Add Nextcloud Talk bot integration (Inara)

- New routers/nextcloud_talk.py: webhook handler verifies incoming HMAC,
  calls LLM via BackgroundTasks, posts reply with correctly computed
  signature (random + message_text, not raw body)
- llm_client.py: read Claude OAuth token live from
  ~/.claude/.credentials.json to avoid stale systemd env tokens;
  strip conflicting ANTHROPIC_API_KEY
- config.py: add nextcloud_url, nextcloud_talk_bot_secret,
  nextcloud_talk_timeout settings
- main.py: register nextcloud_talk router, add logging setup
- docs/NEXTCLOUD_TALK_BOT.md: installation guide + HMAC signing gotcha

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-16 23:04:26 -04:00
parent 8add4ffd02
commit fe854ee534
5 changed files with 280 additions and 4 deletions

View File

@@ -64,6 +64,23 @@ async def _dispatch(
return await _claude(system_prompt, messages, model)
def _fresh_claude_token() -> str | None:
"""Read the current OAuth access token from the Claude credentials file.
The token in the systemd .env goes stale (it rotates on each login).
Reading directly from ~/.claude/.credentials.json always gets the latest.
"""
import json as _json
creds_path = os.path.expanduser("~/.claude/.credentials.json")
try:
with open(creds_path) as f:
data = _json.load(f)
return data["claudeAiOauth"]["accessToken"]
except Exception as e:
logger.debug("Could not read Claude credentials file: %s", e)
return None
async def _claude(system_prompt: str, messages: list[dict], model: str | None) -> str:
cmd = [
"claude", "--print",
@@ -75,7 +92,16 @@ async def _claude(system_prompt: str, messages: list[dict], model: str | None) -
if system_prompt:
cmd.extend(["--system-prompt", system_prompt])
cmd.append(_build_conversation(messages))
return await _run(cmd, timeout=settings.timeout_claude)
# Always use the freshest token from the credentials file so the systemd
# service doesn't break when the env-var token rotates after a login.
env = os.environ.copy()
token = _fresh_claude_token()
if token:
env["CLAUDE_CODE_OAUTH_TOKEN"] = token
env.pop("ANTHROPIC_API_KEY", None) # never let a stale API key override OAuth
return await _run(cmd, timeout=settings.timeout_claude, env=env)
async def _gemini(system_prompt: str, messages: list[dict]) -> str:
@@ -148,11 +174,11 @@ def _clean_gemini_output(text: str) -> str:
return "\n".join(lines).strip()
async def _run(cmd: list[str], timeout: int = 60) -> str:
async def _run(cmd: list[str], timeout: int = 60, env: dict | None = None) -> str:
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
None,
lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=timeout),
lambda: subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=env),
)
if result.returncode != 0:
detail = result.stderr.strip() or result.stdout.strip() or f"exit code {result.returncode}"