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:
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user