Commit 2c1c176e01ee

Vincent Demeester <vincent@sbr.pm>
2026-01-14 12:45:21
feat(xmpp): add research bot and fix certificate reloading
- Add xmpp-research-bot module with slixmpp and Claude API integration - Implement /research command for automated research via XMPP - Results saved to inbox.org as TODOs - Add reloadServices to ACME cert config for proper Prosody reload - Add secrets for bot password and Anthropic API key Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 5be6ce8
Changed files (8)
modules/harmonia/default.nix
@@ -126,9 +126,9 @@ in
         signKeyPaths = [ cfg.signKeyPath ];
         settings = {
           bind = "[::]:${toString cfg.port}";
-          workers = cfg.workers;
+          inherit (cfg) workers;
           max_connection_rate = cfg.maxConnectionRate;
-          priority = cfg.priority;
+          inherit (cfg) priority;
         };
       };
 
modules/xmpp-research-bot/bot.py
@@ -0,0 +1,197 @@
+#!/usr/bin/env python3
+"""
+XMPP Research Bot - Automated research assistant via XMPP
+
+Listens for /research commands and uses Claude API to generate research summaries.
+Results are saved to inbox.org for later review.
+"""
+
+import asyncio
+import logging
+import os
+import sys
+from datetime import datetime
+from pathlib import Path
+
+import slixmpp
+from anthropic import Anthropic
+
+# Configure logging
+logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
+log = logging.getLogger(__name__)
+
+
+class ResearchBot(slixmpp.ClientXMPP):
+    """XMPP bot that performs research using Claude API"""
+
+    def __init__(self, jid, password, owner_jid, api_key, inbox_path):
+        super().__init__(jid, password)
+        self.owner_jid = owner_jid
+        self.inbox_path = Path(inbox_path)
+        self.client = Anthropic(api_key=api_key)
+
+        # Register event handlers
+        self.add_event_handler("session_start", self.on_session_start)
+        self.add_event_handler("message", self.on_message)
+
+        log.info(f"Bot initialized: {jid}")
+        log.info(f"Owner: {owner_jid}")
+        log.info(f"Inbox: {inbox_path}")
+
+    async def on_session_start(self, event):
+        """Called when XMPP session starts"""
+        self.send_presence()
+        await self.get_roster()
+        log.info("Session started, presence sent")
+
+    async def on_message(self, msg):
+        """Handle incoming messages"""
+        # Only respond to chat messages from owner
+        if msg["type"] not in ("chat", "normal"):
+            return
+
+        sender = str(msg["from"]).split("/")[0]  # Remove resource
+        if sender != self.owner_jid:
+            log.warning(f"Ignoring message from non-owner: {sender}")
+            return
+
+        body = msg["body"].strip()
+        log.info(f"Received from {sender}: {body}")
+
+        # Handle /research command
+        if body.startswith("/research "):
+            query = body[len("/research "):].strip()
+            if not query:
+                msg.reply("Usage: /research <your research question>").send()
+                return
+
+            # Acknowledge receipt
+            msg.reply(f"๐Ÿ” Researching: {query}").send()
+
+            try:
+                # Perform research
+                result = await self.research(query)
+
+                # Save to inbox
+                await self.save_to_inbox(query, result)
+
+                # Send confirmation
+                msg.reply(f"โœ… Research complete! Saved to inbox.org\n\nPreview:\n{result[:200]}...").send()
+                log.info(f"Research completed for: {query}")
+
+            except Exception as e:
+                log.error(f"Research failed: {e}", exc_info=True)
+                msg.reply(f"โŒ Research failed: {str(e)}").send()
+
+        elif body == "/help":
+            help_text = """Available commands:
+/research <question> - Perform research on a topic
+/help - Show this help message
+/ping - Check if bot is alive"""
+            msg.reply(help_text).send()
+
+        elif body == "/ping":
+            msg.reply("๐Ÿค– Pong! Bot is alive.").send()
+
+    async def research(self, query: str) -> str:
+        """
+        Perform research using Claude API with prompt caching.
+
+        Uses cached system prompt for efficiency.
+        """
+        log.info(f"Starting research for: {query}")
+
+        # System prompt (will be cached)
+        system_prompt = [
+            {
+                "type": "text",
+                "text": """You are a research assistant helping with quick, accurate research queries.
+
+Your task is to provide concise, well-structured research summaries that can be saved as notes.
+
+Guidelines:
+- Provide factual, accurate information
+- Structure responses with clear headings
+- Include relevant sources or references when possible
+- Keep responses focused and actionable
+- Use markdown formatting
+- Aim for 200-500 words unless more detail is requested""",
+                "cache_control": {"type": "ephemeral"},
+            }
+        ]
+
+        # Call Claude API
+        response = self.client.messages.create(
+            model="claude-sonnet-4-5-20251101",
+            max_tokens=2000,
+            system=system_prompt,
+            messages=[{"role": "user", "content": query}],
+        )
+
+        result = response.content[0].text
+
+        # Log cache usage
+        usage = response.usage
+        log.info(
+            f"API usage - Input: {usage.input_tokens}, "
+            f"Cache creation: {getattr(usage, 'cache_creation_input_tokens', 0)}, "
+            f"Cache read: {getattr(usage, 'cache_read_input_tokens', 0)}, "
+            f"Output: {usage.output_tokens}"
+        )
+
+        return result
+
+    async def save_to_inbox(self, query: str, result: str):
+        """Save research result to inbox.org"""
+        timestamp = datetime.now().strftime("%Y-%m-%d %a %H:%M")
+
+        entry = f"""* TODO Review research: {query}
+:PROPERTIES:
+:CREATED: [{timestamp}]
+:END:
+
+** Query
+{query}
+
+** Result
+{result}
+
+"""
+
+        # Append to inbox file
+        self.inbox_path.parent.mkdir(parents=True, exist_ok=True)
+        with open(self.inbox_path, "a", encoding="utf-8") as f:
+            f.write(entry)
+
+        log.info(f"Saved to {self.inbox_path}")
+
+
+async def main():
+    """Main entry point"""
+    # Read configuration from environment
+    jid = os.getenv("XMPP_JID")
+    password = os.getenv("XMPP_PASSWORD")
+    owner_jid = os.getenv("XMPP_OWNER_JID")
+    api_key = os.getenv("ANTHROPIC_API_KEY")
+    inbox_path = os.getenv("INBOX_PATH", "/home/vincent/desktop/org/inbox.org")
+
+    if not all([jid, password, owner_jid, api_key]):
+        log.error("Missing required environment variables:")
+        log.error("  XMPP_JID, XMPP_PASSWORD, XMPP_OWNER_JID, ANTHROPIC_API_KEY")
+        sys.exit(1)
+
+    # Create and start bot
+    bot = ResearchBot(jid, password, owner_jid, api_key, inbox_path)
+
+    log.info("Connecting to XMPP server...")
+    bot.connect()
+
+    try:
+        await bot.process(forever=True)
+    except KeyboardInterrupt:
+        log.info("Shutting down...")
+        bot.disconnect()
+
+
+if __name__ == "__main__":
+    asyncio.run(main())
modules/xmpp-research-bot/default.nix
@@ -0,0 +1,113 @@
+{
+  config,
+  lib,
+  pkgs,
+  ...
+}:
+let
+  cfg = config.services.xmpp-research-bot;
+
+  pythonEnv = pkgs.python3.withPackages (
+    ps: with ps; [
+      slixmpp
+      anthropic
+    ]
+  );
+
+  botScript = pkgs.writeShellScript "xmpp-research-bot" ''
+    export XMPP_JID="${cfg.jid}"
+    export XMPP_PASSWORD="$(cat ${cfg.passwordFile})"
+    export XMPP_OWNER_JID="${cfg.ownerJid}"
+    export ANTHROPIC_API_KEY="$(cat ${cfg.apiKeyFile})"
+    export INBOX_PATH="${cfg.inboxPath}"
+
+    exec ${pythonEnv}/bin/python3 ${./bot.py}
+  '';
+in
+{
+  options.services.xmpp-research-bot = {
+    enable = lib.mkEnableOption "XMPP Research Bot";
+
+    jid = lib.mkOption {
+      type = lib.types.str;
+      default = "researchbot@xmpp.sbr.pm";
+      description = "XMPP JID (Jabber ID) for the bot";
+    };
+
+    passwordFile = lib.mkOption {
+      type = lib.types.path;
+      description = "Path to file containing XMPP password";
+    };
+
+    ownerJid = lib.mkOption {
+      type = lib.types.str;
+      default = "vincent@xmpp.sbr.pm";
+      description = "XMPP JID of the bot owner (only responds to this user)";
+    };
+
+    apiKeyFile = lib.mkOption {
+      type = lib.types.path;
+      description = "Path to file containing Anthropic API key";
+    };
+
+    inboxPath = lib.mkOption {
+      type = lib.types.path;
+      default = "/home/vincent/desktop/org/inbox.org";
+      description = "Path to inbox.org file for saving research results";
+    };
+
+    user = lib.mkOption {
+      type = lib.types.str;
+      default = "vincent";
+      description = "User to run the bot as";
+    };
+
+    group = lib.mkOption {
+      type = lib.types.str;
+      default = "users";
+      description = "Group to run the bot as";
+    };
+  };
+
+  config = lib.mkIf cfg.enable {
+    systemd.services.xmpp-research-bot = {
+      description = "XMPP Research Bot";
+      wantedBy = [ "multi-user.target" ];
+      after = [
+        "network-online.target"
+        "prosody.service"
+      ];
+      wants = [ "network-online.target" ];
+
+      serviceConfig = {
+        Type = "simple";
+        User = cfg.user;
+        Group = cfg.group;
+        ExecStart = "${botScript}";
+        Restart = "always";
+        RestartSec = "10s";
+
+        # Security hardening
+        PrivateTmp = true;
+        ProtectSystem = "strict";
+        ProtectHome = false; # Need access to inbox.org
+        ReadWritePaths = [ (builtins.dirOf cfg.inboxPath) ];
+        NoNewPrivileges = true;
+        ProtectKernelTunables = true;
+        ProtectKernelModules = true;
+        ProtectControlGroups = true;
+        RestrictAddressFamilies = [
+          "AF_INET"
+          "AF_INET6"
+        ];
+        RestrictNamespaces = true;
+        LockPersonality = true;
+        MemoryDenyWriteExecute = false; # Python needs this
+        RestrictRealtime = true;
+        RestrictSUIDSGID = true;
+        RemoveIPC = true;
+        PrivateMounts = true;
+      };
+    };
+  };
+}
overlays/default.nix
@@ -33,7 +33,7 @@ in
 
     # Use feishin from master to get mpv propagatedBuildInputs fix
     # https://github.com/NixOS/nixpkgs/pull/459155
-    feishin = final.master.feishin;
+    inherit (final.master) feishin;
     lazytree = final.master.lazetree;
   };
 
systems/aion/extra.nix
@@ -45,6 +45,7 @@ in
     ../../modules/audible-sync
     ../../modules/music-playlist-dl
     ../../modules/harmonia
+    ../../modules/xmpp-research-bot
     ./xmpp.nix
   ];
 
@@ -85,6 +86,18 @@ in
       owner = "root";
       group = "root";
     };
+    "xmpp-research-bot-password" = {
+      file = ../../secrets/aion/xmpp-research-bot-password.age;
+      mode = "400";
+      owner = "vincent";
+      group = "users";
+    };
+    "anthropic-api-key" = {
+      file = ../../secrets/aion/anthropic-api-key.age;
+      mode = "400";
+      owner = "vincent";
+      group = "users";
+    };
   };
 
   services = {
@@ -265,6 +278,18 @@ in
       };
     };
 
+    # XMPP Research Bot
+    xmpp-research-bot = {
+      enable = true;
+      jid = "researchbot@xmpp.sbr.pm";
+      ownerJid = "vincent@xmpp.sbr.pm";
+      passwordFile = config.age.secrets."xmpp-research-bot-password".path;
+      apiKeyFile = config.age.secrets."anthropic-api-key".path;
+      inboxPath = "/home/vincent/desktop/org/inbox.org";
+      user = "vincent";
+      group = "users";
+    };
+
     navidrome = {
       enable = true;
       settings = {
systems/aion/xmpp.nix
@@ -44,6 +44,7 @@
     dnsProvider = "gandiv5";
     credentialsFile = config.age.secrets."gandi.env".path;
     group = "prosody"; # Allow prosody to read certificates
+    reloadServices = [ "prosody.service" ]; # Reload prosody when certificates are renewed
   };
 
   # Age secret for Gandi API (shared with rhea for DNS-01 challenge)
systems/nagoya/home.nix
@@ -1,5 +1,4 @@
-{ ... }:
-{
+_: {
   # Syncthing will be configured here via home-manager
   # For now, just enable the user service
   services.syncthing = {
secrets.nix
@@ -143,6 +143,8 @@ in
   "secrets/sakhalin/homeassistant-prometheus-token.age".publicKeys = users ++ [ sakhalin ];
   "secrets/demeter/mosquitto-homeassistant-password.age".publicKeys = users ++ [ demeter ];
   "secrets/aion/restic-aix-password.age".publicKeys = users ++ [ aion ];
+  "secrets/aion/xmpp-research-bot-password.age".publicKeys = users ++ [ aion ];
+  "secrets/aion/anthropic-api-key.age".publicKeys = users ++ [ aion ];
   "secrets/rhea/restic-aix-password.age".publicKeys = users ++ [ rhea ];
 
   # Harmonia binary cache signing keys