From ac659ff5a7478f281b952aaae1c45f335d837e52 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 15 Dec 2025 10:11:18 -0600 Subject: [PATCH] feat(discord): Discord transport --- README.md | 20 +- .../macos/Sources/Clawdis/CronJobEditor.swift | 5 +- .../Sources/Clawdis/GatewayConnection.swift | 1 + docs/architecture.md | 4 +- docs/clawd.md | 4 +- docs/configuration.md | 21 +- docs/cron.md | 2 +- docs/discord.md | 54 +++ docs/health.md | 2 +- docs/index.md | 8 +- docs/mac/voicewake.md | 2 +- docs/mac/webchat.md | 2 +- docs/session.md | 2 +- docs/surface.md | 6 +- docs/troubleshooting.md | 2 +- package.json | 1 + src/auto-reply/reply.triggers.test.ts | 84 +++++ src/auto-reply/reply.ts | 13 +- src/cli/cron-cli.ts | 14 +- src/cli/deps.ts | 3 + src/cli/program.ts | 20 +- src/commands/agent.ts | 36 +- src/commands/health.command.coverage.test.ts | 3 + src/commands/health.snapshot.test.ts | 4 + src/commands/health.ts | 21 ++ src/commands/send.test.ts | 23 ++ src/commands/send.ts | 29 ++ src/commands/status.ts | 9 + src/config/config.ts | 29 +- src/config/sessions.ts | 2 +- src/cron/isolated-agent.test.ts | 47 +++ src/cron/isolated-agent.ts | 52 ++- src/cron/types.ts | 2 +- src/discord/index.ts | 2 + src/discord/monitor.ts | 323 ++++++++++++++++++ src/discord/probe.ts | 73 ++++ src/discord/send.test.ts | 85 +++++ src/discord/send.ts | 166 +++++++++ src/discord/token.ts | 7 + src/gateway/hooks-mapping.ts | 6 +- src/gateway/protocol/schema.ts | 1 + src/gateway/server.test.ts | 55 +++ src/gateway/server.ts | 149 +++++++- src/globals.ts | 14 +- 44 files changed, 1352 insertions(+), 56 deletions(-) create mode 100644 docs/discord.md create mode 100644 src/discord/index.ts create mode 100644 src/discord/monitor.ts create mode 100644 src/discord/probe.ts create mode 100644 src/discord/send.test.ts create mode 100644 src/discord/send.ts create mode 100644 src/discord/token.ts diff --git a/README.md b/README.md index 342fd2cf6..2cff9633b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@

**Clawdis** is a *personal AI assistant* you run on your own devices. -It answers you on the surfaces you already use (WhatsApp, Telegram, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant. +It answers you on the surfaces you already use (WhatsApp, Telegram, Discord, WebChat), can speak and listen on macOS/iOS, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant. If you want a private, single-user assistant that feels local, fast, and always-on, this is it. @@ -38,7 +38,7 @@ Your surfaces ## What Clawdis does - **Personal assistant** — one user, one identity, one memory surface. -- **Multi-surface inbox** — WhatsApp, Telegram, WebChat, macOS, iOS. +- **Multi-surface inbox** — WhatsApp, Telegram, Discord, WebChat, macOS, iOS. - **Voice wake + push-to-talk** — local speech recognition on macOS/iOS. - **Canvas** — a live visual workspace you can drive from the agent. - **Automation-ready** — browser control, media handling, and tool streaming. @@ -73,7 +73,7 @@ pnpm gateway:watch # Send a message pnpm clawdis send --to +1234567890 --message "Hello from Clawdis" -# Talk to the assistant (optionally deliver back to WhatsApp/Telegram) +# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Discord) pnpm clawdis agent --message "Ship checklist" --thinking high ``` @@ -170,6 +170,19 @@ Minimal `~/.clawdis/clawdis.json`: } ``` +### Discord + +- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins). +- Optional: set `discord.requireMention`, `discord.allowFrom`, or `discord.mediaMaxMb` as needed. + +```json5 +{ + discord: { + token: "1234abcd" + } +} +``` + Browser control (optional): ```json5 @@ -191,6 +204,7 @@ Browser control (optional): - [`docs/web.md`](docs/web.md) - [`docs/discovery.md`](docs/discovery.md) - [`docs/agent.md`](docs/agent.md) +- [`docs/discord.md`](docs/discord.md) - Webhooks + external triggers: [`docs/webhook.md`](docs/webhook.md) - Gmail hooks (email → wake): [`docs/gmail-pubsub.md`](docs/gmail-pubsub.md) diff --git a/apps/macos/Sources/Clawdis/CronJobEditor.swift b/apps/macos/Sources/Clawdis/CronJobEditor.swift index 579064df8..ea40e9303 100644 --- a/apps/macos/Sources/Clawdis/CronJobEditor.swift +++ b/apps/macos/Sources/Clawdis/CronJobEditor.swift @@ -13,7 +13,7 @@ struct CronJobEditor: View { + "Use an isolated session for agent turns so your main chat stays clean." static let sessionTargetNote = "Main jobs post a system event into the current main session. " - + "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/etc)." + + "Isolated jobs run clawd in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)." static let scheduleKindNote = "“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression." static let isolatedPayloadNote = @@ -322,6 +322,7 @@ struct CronJobEditor: View { Text("last").tag(GatewayAgentChannel.last) Text("whatsapp").tag(GatewayAgentChannel.whatsapp) Text("telegram").tag(GatewayAgentChannel.telegram) + Text("discord").tag(GatewayAgentChannel.discord) } .labelsHidden() .pickerStyle(.segmented) @@ -329,7 +330,7 @@ struct CronJobEditor: View { } GridRow { self.gridLabel("To") - TextField("Optional override (phone number / chat id)", text: self.$to) + TextField("Optional override (phone number / chat id / Discord channel)", text: self.$to) .textFieldStyle(.roundedBorder) .frame(maxWidth: .infinity) } diff --git a/apps/macos/Sources/Clawdis/GatewayConnection.swift b/apps/macos/Sources/Clawdis/GatewayConnection.swift index 3bafd4184..f255f69b9 100644 --- a/apps/macos/Sources/Clawdis/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdis/GatewayConnection.swift @@ -9,6 +9,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable { case last case whatsapp case telegram + case discord case webchat init(raw: String?) { diff --git a/docs/architecture.md b/docs/architecture.md index 34f5c51a1..a2a7d3c0f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,14 +8,14 @@ read_when: Last updated: 2025-12-09 ## Overview -- A single long-lived **Gateway** process owns all messaging surfaces (WhatsApp via Baileys, Telegram when enabled) and the control/event plane. +- A single long-lived **Gateway** process owns all messaging surfaces (WhatsApp via Baileys, Telegram via grammY, Discord via discord.js) and the control/event plane. - All clients (macOS app, CLI, web UI, automations) connect to the Gateway over one transport: **WebSocket on 127.0.0.1:18789** (tunnel or VPN for remote). - One Gateway per host; it is the only place that is allowed to open a WhatsApp session. All sends/agent runs go through it. - By default: the Gateway exposes a Canvas host on `canvasHost.port` (default `18793`), serving `~/clawd/canvas` at `/__clawdis__/canvas/` with live-reload; disable via `canvasHost.enabled=false` or `CLAWDIS_SKIP_CANVAS_HOST=1`. ## Components and flows - **Gateway (daemon)** - - Maintains Baileys/Telegram connections. + - Maintains Baileys/Telegram/Discord connections. - Exposes a typed WS API (req/resp + server push events). - Validates every inbound frame against JSON Schema; rejects anything before a mandatory `connect`. - **Clients (mac app / CLI / web admin)** diff --git a/docs/clawd.md b/docs/clawd.md index b6979948f..4d87403bf 100644 --- a/docs/clawd.md +++ b/docs/clawd.md @@ -7,14 +7,14 @@ read_when: # Building a personal assistant with CLAWDIS (Clawd-style) -CLAWDIS is a WhatsApp + Telegram gateway for **Pi** agents. This guide is the “personal assistant” setup: one dedicated WhatsApp number that behaves like your always-on agent. +CLAWDIS is a WhatsApp + Telegram + Discord gateway for **Pi** agents. This guide is the “personal assistant” setup: one dedicated WhatsApp number that behaves like your always-on agent. ## ⚠️ Safety first You’re putting an agent in a position to: - run commands on your machine (depending on your Pi tool setup) - read/write files in your workspace -- send messages back out via WhatsApp/Telegram +- send messages back out via WhatsApp/Telegram/Discord Start conservative: - Always set `routing.allowFrom` (never run open-to-the-world on your personal Mac). diff --git a/docs/configuration.md b/docs/configuration.md index 18a3b3d9b..181a31f13 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -85,6 +85,23 @@ Group messages default to **require mention** (either metadata mention or regex } ``` +### `discord` (bot transport) + +Configure the Discord bot by setting the bot token and optional gating: + +```json5 +{ + discord: { + token: "your-bot-token", + allowFrom: ["discord:1234567890", "*"], // optional DM allowlist (user ids) + requireMention: true, // require @bot mentions in guilds + mediaMaxMb: 8 // clamp inbound media size + } +} +``` + +Clawdis reads `DISCORD_BOT_TOKEN` or `discord.token` to start the provider. Use `user:` (DM) or `channel:` (guild channel) when specifying delivery targets for cron/CLI commands. + ### `agent.workspace` Sets the **single global workspace directory** used by the agent for file operations. @@ -152,7 +169,7 @@ deprecation fallback. - `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Omit or set `0m` to disable. - `model`: optional override model for heartbeat runs (`provider/model`). -- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `none`). Default: `last`. +- `target`: optional delivery channel (`last`, `whatsapp`, `telegram`, `discord`, `none`). Default: `last`. - `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram). - `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`). @@ -510,7 +527,7 @@ Template placeholders are expanded in `routing.transcribeAudio.command` (and any | `{{GroupMembers}}` | Group members preview (best effort) | | `{{SenderName}}` | Sender display name (best effort) | | `{{SenderE164}}` | Sender phone number (best effort) | -| `{{Surface}}` | Surface hint (whatsapp|telegram|webchat|…) | +| `{{Surface}}` | Surface hint (whatsapp|telegram|discord|webchat|…) | ## Cron (Gateway scheduler) diff --git a/docs/cron.md b/docs/cron.md index f17b0a023..eeb7b152a 100644 --- a/docs/cron.md +++ b/docs/cron.md @@ -264,7 +264,7 @@ Add a `cron` command group (all commands should also support `--json` where sens - `--wake now|next-heartbeat` - payload flags (choose one): - `--system-event ""` - - `--message "" [--deliver] [--channel last|whatsapp|telegram] [--to ]` + - `--message "" [--deliver] [--channel last|whatsapp|telegram|discord] [--to ]` - `clawdis cron edit ...` (patch-by-flags, non-interactive) - `clawdis cron rm ` diff --git a/docs/discord.md b/docs/discord.md new file mode 100644 index 000000000..34a109340 --- /dev/null +++ b/docs/discord.md @@ -0,0 +1,54 @@ +--- +summary: "Discord bot support status, capabilities, and configuration" +read_when: + - Working on Discord surface features +--- +# Discord (Bot API) + +Updated: 2025-12-07 + +Status: ready for DM and guild text channels via the official Discord bot gateway. + +## Goals +- Talk to Clawdis via Discord DMs or guild channels. +- Share the same `main` session used by WhatsApp/Telegram/WebChat; guild channels stay isolated as `group:`. +- Keep routing deterministic: replies always go back to the surface they arrived on. + +## How it works +1. Create a Discord application → Bot, enable the intents you need (DMs + guild messages + message content), and grab the bot token. +2. Invite the bot to your server with the permissions required to read/send messages where you want to use it. +3. Configure Clawdis with `DISCORD_BOT_TOKEN` (or `discord.token` in `~/.clawdis/clawdis.json`). +4. Run the gateway; it auto-starts the Discord provider when the token is set. +5. Direct chats: use `user:` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session. +6. Guild channels: use `channel:` for delivery. Mentions are required by default; disable with `discord.requireMention = false`. +7. Optional DM allowlist: reuse `discord.allowFrom` with user ids (`1234567890` or `discord:1234567890`). Use `"*"` to allow all DMs. + +Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets. + +## Capabilities & limits +- DMs and guild text channels (threads are treated as separate channels; voice not supported). +- Typing indicators sent best-effort; message chunking honors Discord’s 2k character limit. +- File uploads supported up to the configured `discord.mediaMaxMb` (default 8 MB). +- Mention-gated guild replies by default to avoid noisy bots. + +## Config + +```json5 +{ + discord: { + token: "abc.123", + allowFrom: ["123456789012345678"], + requireMention: true, + mediaMaxMb: 8 + } +} +``` + +- `allowFrom`: DM allowlist (user ids). Omit or set to `["*"]` to allow any DM sender. +- `requireMention`: when `true`, messages in guild channels must mention the bot. +- `mediaMaxMb`: clamp inbound media saved to disk. + +## Safety & ops +- Treat the bot token like a password; prefer the `DISCORD_BOT_TOKEN` env var on supervised hosts or lock down the config file permissions. +- Only grant the bot permissions it needs (typically Read/Send Messages). +- If the bot is stuck or rate limited, restart the gateway (`clawdis gateway --force`) after confirming no other processes own the Discord session. diff --git a/docs/health.md b/docs/health.md index 0581dcf18..5d2ec90dd 100644 --- a/docs/health.md +++ b/docs/health.md @@ -9,7 +9,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing. ## Quick checks - `clawdis status` — local summary: whether creds exist, auth age, session store path + recent sessions. -- `clawdis status --deep` — also probes the running Gateway (WA connect + Telegram API). +- `clawdis status --deep` — also probes the running Gateway (WhatsApp connect + Telegram + Discord APIs). - `clawdis health --json` — asks the running Gateway for a full health snapshot (WS-only; no direct Baileys socket). - Send `/status` in WhatsApp/WebChat to get a status reply without invoking the agent. - Logs: tail `/tmp/clawdis/clawdis-*.log` and filter for `web-heartbeat`, `web-reconnect`, `web-auto-reply`, `web-inbound`. diff --git a/docs/index.md b/docs/index.md index 841179915..18f0009c5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,7 +13,7 @@ read_when:

- WhatsApp + Telegram gateway for AI agents (Pi).
+ WhatsApp + Telegram + Discord gateway for AI agents (Pi).
Send a message, get an agent response — from your pocket.

@@ -23,13 +23,13 @@ read_when: Clawd setup

-CLAWDIS bridges WhatsApp (via WhatsApp Web / Baileys) and Telegram (Bot API / grammY) to coding agents like [Pi](https://github.com/badlogic/pi-mono). +CLAWDIS bridges WhatsApp (via WhatsApp Web / Baileys), Telegram (Bot API / grammY), and Discord (Bot API / discord.js) to coding agents like [Pi](https://github.com/badlogic/pi-mono). It’s built for [Clawd](https://clawd.me), a space lobster who needed a TARDIS. ## How it works ``` -WhatsApp / Telegram +WhatsApp / Telegram / Discord │ ▼ ┌──────────────────────────┐ @@ -60,6 +60,7 @@ Most operations flow through the **Gateway** (`clawdis gateway`), a single long- - 📱 **WhatsApp Integration** — Uses Baileys for WhatsApp Web protocol - ✈️ **Telegram Bot** — DMs + groups via grammY +- 🎮 **Discord Bot** — DMs + guild channels via discord.js - 🤖 **Agent bridge** — Pi (RPC mode) with tool streaming - 💬 **Sessions** — Direct chats collapse into shared `main` (default); groups are isolated - 👥 **Group Chat Support** — Mention-based by default; owner can toggle `/activation always|mention` @@ -127,6 +128,7 @@ Example: - [WebChat](./webchat.md) - [Control UI (browser)](./control-ui.md) - [Telegram](./telegram.md) + - [Discord](./discord.md) - [Group messages](./group-messages.md) - [Media: images](./images.md) - [Media: audio](./audio.md) diff --git a/docs/mac/voicewake.md b/docs/mac/voicewake.md index ff289ba1d..36afae944 100644 --- a/docs/mac/voicewake.md +++ b/docs/mac/voicewake.md @@ -46,7 +46,7 @@ Hardening: ## Forwarding behavior - When Voice Wake is enabled, transcripts are forwarded to the active gateway/agent (the same local vs remote mode used by the rest of the mac app). -- Replies are delivered to the **last-used main surface** (WhatsApp/Telegram/WebChat). If delivery fails, the error is logged and the run is still visible via WebChat/session logs. +- Replies are delivered to the **last-used main surface** (WhatsApp/Telegram/Discord/WebChat). If delivery fails, the error is logged and the run is still visible via WebChat/session logs. ## Forwarding payload - `VoiceWakeForwarder.prefixedTranscript(_:)` prepends the machine hint before sending. Shared between wake-word and push-to-talk paths. diff --git a/docs/mac/webchat.md b/docs/mac/webchat.md index 3c2b5b332..d2b825473 100644 --- a/docs/mac/webchat.md +++ b/docs/mac/webchat.md @@ -18,7 +18,7 @@ The macOS menu bar app shows the WebChat UI as a native SwiftUI view and reuses ## How it’s wired - Implementation: `apps/macos/Sources/Clawdis/WebChatSwiftUI.swift` hosts `ClawdisChatUI` and speaks to the Gateway over `GatewayConnection`. - Data plane: Gateway WebSocket methods `chat.history`, `chat.send`, `chat.abort`; events `chat`, `agent`, `presence`, `tick`, `health`. -- Session: usually primary (`main`). The onboarding flow uses a dedicated `onboarding` session to keep first-run setup separate. +- Session: usually primary (`main`); multiple transports (WhatsApp/Telegram/Discord/Desktop) share the same key. The onboarding flow uses a dedicated `onboarding` session to keep first-run setup separate. ## Security / surface area - Remote mode forwards only the Gateway WebSocket control port over SSH. diff --git a/docs/session.md b/docs/session.md index f9f6264a2..756bd5181 100644 --- a/docs/session.md +++ b/docs/session.md @@ -21,7 +21,7 @@ All session state is **owned by the gateway** (the “master” Clawdis). UI cli - Clawdis does **not** read legacy Pi/Tau session folders. ## Mapping transports → session keys -- Direct chats (WhatsApp, Telegram, desktop Web Chat) all collapse to the **primary key** so they share context. +- Direct chats (WhatsApp, Telegram, Discord, desktop Web Chat) all collapse to the **primary key** so they share context. - Multiple phone numbers can map to that same key; they act as transports into the same conversation. - Group chats still isolate state with `group:` keys; do not reuse the primary key for groups. diff --git a/docs/surface.md b/docs/surface.md index 26827ebc2..2967d9afc 100644 --- a/docs/surface.md +++ b/docs/surface.md @@ -1,5 +1,5 @@ --- -summary: "Routing rules per surface (WhatsApp, Telegram, web) and shared context" +summary: "Routing rules per surface (WhatsApp, Telegram, Discord, web) and shared context" read_when: - Changing surface routing or inbox behavior --- @@ -9,12 +9,12 @@ Updated: 2025-12-07 Goal: make replies deterministic per channel while keeping one shared context for direct chats. -- **Surfaces** (channel labels): `whatsapp`, `webchat`, `telegram`, `voice`, etc. Add `Surface` to inbound `MsgContext` so templates/agents can log which channel a turn came from. Routing is fixed: replies go back to the origin surface; the model doesn’t choose. +- **Surfaces** (channel labels): `whatsapp`, `webchat`, `telegram`, `discord`, `voice`, etc. Add `Surface` to inbound `MsgContext` so templates/agents can log which channel a turn came from. Routing is fixed: replies go back to the origin surface; the model doesn’t choose. - **Reply context:** inbound replies include `ReplyToId`, `ReplyToBody`, and `ReplyToSender`, and the quoted context is appended to `Body` as a `[Replying to ...]` block. - **Canonical direct session:** All direct chats collapse into the single `main` session by default (no config needed). Groups stay `group:`, so they remain isolated. - **Session store:** Keys are resolved via `resolveSessionKey(scope, ctx, mainKey)`; the agent JSONL path lives under `~/.clawdis/sessions/.jsonl`. - **WebChat:** Always attaches to `main`, loads the full session transcript so desktop reflects cross-surface history, and writes new turns back to the same session. - **Implementation hints:** - - Set `Surface` in each ingress (WhatsApp gateway, WebChat bridge, future Telegram). + - Set `Surface` in each ingress (WhatsApp gateway, WebChat bridge, Telegram, Discord). - Keep routing deterministic: originate → same surface. Use the gateway WebSocket for sends; avoid side channels. - Do not let the agent emit “send to X” decisions; keep that policy in the host code. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e4e1b0df5..cc8c01dfa 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -83,7 +83,7 @@ Or use the `process` tool to background long commands. ```bash # Check local status (creds, sessions, queued events) clawdis status -# Probe the running gateway + providers (WA connect + Telegram API) +# Probe the running gateway + providers (WA connect + Telegram + Discord APIs) clawdis status --deep # View recent connection events diff --git a/package.json b/package.json index ab015740e..0b90d4142 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "commander": "^14.0.2", "croner": "^9.1.0", "detect-libc": "^2.1.2", + "discord.js": "^14.25.1", "dotenv": "^17.2.3", "express": "^5.2.1", "file-type": "^21.1.1", diff --git a/src/auto-reply/reply.triggers.test.ts b/src/auto-reply/reply.triggers.test.ts index 53f7e0dbd..a5e177b6c 100644 --- a/src/auto-reply/reply.triggers.test.ts +++ b/src/auto-reply/reply.triggers.test.ts @@ -386,3 +386,87 @@ describe("trigger handling", () => { }); }); }); + +describe("group intro prompts", () => { + it("labels Discord groups using the surface metadata", async () => { + const commandSpy = vi + .spyOn(commandReply, "runCommandReply") + .mockResolvedValue({ payloads: [{ text: "ok" }], meta: { durationMs: 1 } }); + + await getReplyFromConfig( + { + Body: "status update", + From: "group:dev", + To: "+1888", + ChatType: "group", + GroupSubject: "Release Squad", + GroupMembers: "Alice, Bob", + Surface: "discord", + }, + {}, + baseCfg, + ); + + expect(commandSpy).toHaveBeenCalledOnce(); + const body = + commandSpy.mock.calls.at(-1)?.[0]?.templatingCtx.Body ?? ""; + const intro = body.split("\n\n")[0]; + expect(intro).toBe( + 'You are replying inside the Discord group "Release Squad". Group members: Alice, Bob. Address the specific sender noted in the message context.', + ); + }); + + it("keeps WhatsApp labeling for WhatsApp group chats", async () => { + const commandSpy = vi + .spyOn(commandReply, "runCommandReply") + .mockResolvedValue({ payloads: [{ text: "ok" }], meta: { durationMs: 1 } }); + + await getReplyFromConfig( + { + Body: "ping", + From: "123@g.us", + To: "+1999", + ChatType: "group", + GroupSubject: "Ops", + Surface: "whatsapp", + }, + {}, + baseCfg, + ); + + expect(commandSpy).toHaveBeenCalledOnce(); + const body = + commandSpy.mock.calls.at(-1)?.[0]?.templatingCtx.Body ?? ""; + const intro = body.split("\n\n")[0]; + expect(intro).toBe( + 'You are replying inside the WhatsApp group "Ops". Address the specific sender noted in the message context.', + ); + }); + + it("labels Telegram groups using their own surface", async () => { + const commandSpy = vi + .spyOn(commandReply, "runCommandReply") + .mockResolvedValue({ payloads: [{ text: "ok" }], meta: { durationMs: 1 } }); + + await getReplyFromConfig( + { + Body: "ping", + From: "group:tg", + To: "+1777", + ChatType: "group", + GroupSubject: "Dev Chat", + Surface: "telegram", + }, + {}, + baseCfg, + ); + + expect(commandSpy).toHaveBeenCalledOnce(); + const body = + commandSpy.mock.calls.at(-1)?.[0]?.templatingCtx.Body ?? ""; + const intro = body.split("\n\n")[0]; + expect(intro).toBe( + 'You are replying inside the Telegram group "Dev Chat". Address the specific sender noted in the message context.', + ); + }); +}); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 4655d40f2..b7756f65f 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -790,9 +790,18 @@ export async function getReplyFromConfig( defaultGroupActivation(); const subject = sessionCtx.GroupSubject?.trim(); const members = sessionCtx.GroupMembers?.trim(); + const surface = sessionCtx.Surface?.trim().toLowerCase(); + const surfaceLabel = (() => { + if (!surface) return "chat"; + if (surface === "whatsapp") return "WhatsApp"; + if (surface === "telegram") return "Telegram"; + if (surface === "discord") return "Discord"; + if (surface === "webchat") return "WebChat"; + return `${surface.at(0)?.toUpperCase() ?? ""}${surface.slice(1)}`; + })(); const subjectLine = subject - ? `You are replying inside the WhatsApp group "${subject}".` - : "You are replying inside a WhatsApp group chat."; + ? `You are replying inside the ${surfaceLabel} group "${subject}".` + : `You are replying inside a ${surfaceLabel} group chat.`; const membersLine = members ? `Group members: ${members}.` : undefined; const activationLine = activation === "always" diff --git a/src/cli/cron-cli.ts b/src/cli/cron-cli.ts index c35869e85..980c672d8 100644 --- a/src/cli/cron-cli.ts +++ b/src/cli/cron-cli.ts @@ -155,10 +155,13 @@ export function registerCronCli(program: Command) { .option("--deliver", "Deliver agent output", false) .option( "--channel ", - "Delivery channel (last|whatsapp|telegram)", + "Delivery channel (last|whatsapp|telegram|discord)", "last", ) - .option("--to ", "Delivery destination (E.164 or Telegram chatId)") + .option( + "--to ", + "Delivery destination (E.164, Telegram chatId, or Discord channel/user)", + ) .option( "--best-effort-deliver", "Do not fail the job if delivery fails", @@ -411,9 +414,12 @@ export function registerCronCli(program: Command) { .option("--deliver", "Deliver agent output", false) .option( "--channel ", - "Delivery channel (last|whatsapp|telegram)", + "Delivery channel (last|whatsapp|telegram|discord)", + ) + .option( + "--to ", + "Delivery destination (E.164, Telegram chatId, or Discord channel/user)", ) - .option("--to ", "Delivery destination") .option( "--best-effort-deliver", "Do not fail job if delivery fails", diff --git a/src/cli/deps.ts b/src/cli/deps.ts index 9e2e88aae..76f309c87 100644 --- a/src/cli/deps.ts +++ b/src/cli/deps.ts @@ -1,15 +1,18 @@ +import { sendMessageDiscord } from "../discord/send.js"; import { logWebSelfId, sendMessageWhatsApp } from "../providers/web/index.js"; import { sendMessageTelegram } from "../telegram/send.js"; export type CliDeps = { sendMessageWhatsApp: typeof sendMessageWhatsApp; sendMessageTelegram: typeof sendMessageTelegram; + sendMessageDiscord: typeof sendMessageDiscord; }; export function createDefaultDeps(): CliDeps { return { sendMessageWhatsApp, sendMessageTelegram, + sendMessageDiscord, }; } diff --git a/src/cli/program.ts b/src/cli/program.ts index e4e0b4423..4c39ec22b 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -149,10 +149,10 @@ export function buildProgram() { program .command("send") - .description("Send a message (WhatsApp web or Telegram bot)") + .description("Send a message (WhatsApp Web, Telegram bot, or Discord)") .requiredOption( "-t, --to ", - "Recipient: E.164 for WhatsApp (e.g. +15555550123) or Telegram chat id/@username", + "Recipient: E.164 for WhatsApp, Telegram chat id/@username, or Discord channel/user", ) .requiredOption("-m, --message ", "Message body") .option( @@ -161,7 +161,7 @@ export function buildProgram() { ) .option( "--provider ", - "Delivery provider: whatsapp|telegram (default: whatsapp)", + "Delivery provider: whatsapp|telegram|discord (default: whatsapp)", ) .option("--dry-run", "Print payload and skip sending", false) .option("--json", "Output result as JSON", false) @@ -202,9 +202,13 @@ Examples: "Thinking level: off | minimal | low | medium | high", ) .option("--verbose ", "Persist agent verbose level for the session") + .option( + "--provider ", + "Delivery provider: whatsapp|telegram|discord (default: whatsapp)", + ) .option( "--deliver", - "Send the agent's reply back to WhatsApp (requires --to)", + "Send the agent's reply back to the selected provider (requires --to)", false, ) .option("--json", "Output result as JSON", false) @@ -247,7 +251,11 @@ Examples: .command("status") .description("Show web session health and recent session recipients") .option("--json", "Output JSON instead of text", false) - .option("--deep", "Probe providers (WA connect + Telegram API)", false) + .option( + "--deep", + "Probe providers (WhatsApp Web + Telegram + Discord)", + false, + ) .option("--timeout ", "Probe timeout in milliseconds", "10000") .option("--verbose", "Verbose logging", false) .addHelpText( @@ -256,7 +264,7 @@ Examples: Examples: clawdis status # show linked account + session store summary clawdis status --json # machine-readable output - clawdis status --deep # run provider probes (WA + Telegram) + clawdis status --deep # run provider probes (WA + Telegram + Discord) clawdis status --deep --timeout 5000 # tighten probe timeout`, ) .action(async (opts) => { diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 4051e61dd..1be671cda 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -414,6 +414,7 @@ export async function agentCommand( const whatsappTarget = opts.to ? normalizeE164(opts.to) : allowFrom[0]; const telegramTarget = opts.to?.trim() || undefined; + const discordTarget = opts.to?.trim() || undefined; const logDeliveryError = (err: unknown) => { const deliveryTarget = @@ -421,7 +422,9 @@ export async function agentCommand( ? telegramTarget : deliveryProvider === "whatsapp" ? whatsappTarget - : undefined; + : deliveryProvider === "discord" + ? discordTarget + : undefined; const message = `Delivery failed (${deliveryProvider}${deliveryTarget ? ` to ${deliveryTarget}` : ""}): ${String(err)}`; runtime.error?.(message); if (!runtime.error) runtime.log(message); @@ -440,6 +443,13 @@ export async function agentCommand( if (!bestEffortDeliver) throw err; logDeliveryError(err); } + if (deliveryProvider === "discord" && !discordTarget) { + const err = new Error( + "Delivering to Discord requires --to ", + ); + if (!bestEffortDeliver) throw err; + logDeliveryError(err); + } if (deliveryProvider === "webchat") { const err = new Error( "Delivering to WebChat is not supported via `clawdis agent`; use WhatsApp/Telegram or run with --deliver=false.", @@ -450,6 +460,7 @@ export async function agentCommand( if ( deliveryProvider !== "whatsapp" && deliveryProvider !== "telegram" && + deliveryProvider !== "discord" && deliveryProvider !== "webchat" ) { const err = new Error(`Unknown provider: ${deliveryProvider}`); @@ -540,5 +551,28 @@ export async function agentCommand( logDeliveryError(err); } } + + if (deliveryProvider === "discord" && discordTarget) { + try { + if (media.length === 0) { + await deps.sendMessageDiscord(discordTarget, text, { + token: process.env.DISCORD_BOT_TOKEN, + }); + } else { + let first = true; + for (const url of media) { + const caption = first ? text : ""; + first = false; + await deps.sendMessageDiscord(discordTarget, caption, { + token: process.env.DISCORD_BOT_TOKEN, + mediaUrl: url, + }); + } + } + } catch (err) { + if (!bestEffortDeliver) throw err; + logDeliveryError(err); + } + } } } diff --git a/src/commands/health.command.coverage.test.ts b/src/commands/health.command.coverage.test.ts index c8046edf8..37f2d21d6 100644 --- a/src/commands/health.command.coverage.test.ts +++ b/src/commands/health.command.coverage.test.ts @@ -46,6 +46,9 @@ describe("healthCommand (coverage)", () => { webhook: { url: "https://example.com/h" }, }, }, + discord: { + configured: false, + }, heartbeatSeconds: 60, sessions: { path: "/tmp/sessions.json", diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index c31123404..661ea382b 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -40,6 +40,7 @@ describe("getHealthSnapshot", () => { foo: { updatedAt: 2000 }, }; vi.stubEnv("TELEGRAM_BOT_TOKEN", ""); + vi.stubEnv("DISCORD_BOT_TOKEN", ""); const snap = (await getHealthSnapshot(10)) satisfies HealthSummary; expect(snap.ok).toBe(true); expect(snap.telegram.configured).toBe(false); @@ -51,6 +52,7 @@ describe("getHealthSnapshot", () => { it("probes telegram getMe + webhook info when configured", async () => { testConfig = { telegram: { botToken: "t-1" } }; testStore = {}; + vi.stubEnv("DISCORD_BOT_TOKEN", ""); const calls: string[] = []; vi.stubGlobal( @@ -100,6 +102,7 @@ describe("getHealthSnapshot", () => { it("returns a structured telegram probe error when getMe fails", async () => { testConfig = { telegram: { botToken: "bad-token" } }; testStore = {}; + vi.stubEnv("DISCORD_BOT_TOKEN", ""); vi.stubGlobal( "fetch", @@ -125,6 +128,7 @@ describe("getHealthSnapshot", () => { it("captures unexpected probe exceptions as errors", async () => { testConfig = { telegram: { botToken: "t-err" } }; testStore = {}; + vi.stubEnv("DISCORD_BOT_TOKEN", ""); vi.stubGlobal( "fetch", diff --git a/src/commands/health.ts b/src/commands/health.ts index cd1b1aec6..5aeefb371 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -1,5 +1,6 @@ import { loadConfig } from "../config/config.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; +import { probeDiscord, type DiscordProbe } from "../discord/probe.js"; import { callGateway } from "../gateway/call.js"; import { info } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -34,6 +35,10 @@ export type HealthSummary = { configured: boolean; probe?: TelegramProbe; }; + discord: { + configured: boolean; + probe?: DiscordProbe; + }; heartbeatSeconds: number; sessions: { path: string; @@ -77,12 +82,19 @@ export async function getHealthSnapshot( ? await probeTelegram(telegramToken.trim(), cappedTimeout, telegramProxy) : undefined; + const discordToken = process.env.DISCORD_BOT_TOKEN ?? cfg.discord?.token ?? ""; + const discordConfigured = discordToken.trim().length > 0; + const discordProbe = discordConfigured + ? await probeDiscord(discordToken.trim(), cappedTimeout) + : undefined; + const summary: HealthSummary = { ok: true, ts: Date.now(), durationMs: Date.now() - start, web: { linked, authAgeMs }, telegram: { configured: telegramConfigured, probe: telegramProbe }, + discord: { configured: discordConfigured, probe: discordProbe }, heartbeatSeconds, sessions: { path: storePath, @@ -139,6 +151,15 @@ export async function healthCommand( : "Telegram: not configured"; runtime.log(tgLabel); + const discordLabel = summary.discord.configured + ? summary.discord.probe?.ok + ? info( + `Discord: ok${summary.discord.probe.bot?.username ? ` (@${summary.discord.probe.bot.username})` : ""} (${summary.discord.probe.elapsedMs}ms)`, + ) + : `Discord: failed (${summary.discord.probe?.status ?? "unknown"})${summary.discord.probe?.error ? ` - ${summary.discord.probe.error}` : ""}` + : "Discord: not configured"; + runtime.log(discordLabel); + runtime.log(info(`Heartbeat interval: ${summary.heartbeatSeconds}s`)); runtime.log( info( diff --git a/src/commands/send.test.ts b/src/commands/send.test.ts index eac42e05f..413b0c1bc 100644 --- a/src/commands/send.test.ts +++ b/src/commands/send.test.ts @@ -11,13 +11,16 @@ vi.mock("../gateway/call.js", () => ({ })); const originalTelegramToken = process.env.TELEGRAM_BOT_TOKEN; +const originalDiscordToken = process.env.DISCORD_BOT_TOKEN; beforeEach(() => { process.env.TELEGRAM_BOT_TOKEN = "token-abc"; + process.env.DISCORD_BOT_TOKEN = "token-discord"; }); afterAll(() => { process.env.TELEGRAM_BOT_TOKEN = originalTelegramToken; + process.env.DISCORD_BOT_TOKEN = originalDiscordToken; }); const runtime: RuntimeEnv = { @@ -31,6 +34,7 @@ const runtime: RuntimeEnv = { const makeDeps = (overrides: Partial = {}): CliDeps => ({ sendMessageWhatsApp: vi.fn(), sendMessageTelegram: vi.fn(), + sendMessageDiscord: vi.fn(), ...overrides, }); @@ -83,6 +87,25 @@ describe("sendCommand", () => { expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); }); + it("routes to discord provider", async () => { + const deps = makeDeps({ + sendMessageDiscord: vi + .fn() + .mockResolvedValue({ messageId: "d1", channelId: "chan" }), + }); + await sendCommand( + { to: "channel:chan", message: "hi", provider: "discord" }, + deps, + runtime, + ); + expect(deps.sendMessageDiscord).toHaveBeenCalledWith( + "channel:chan", + "hi", + expect.objectContaining({ token: "token-discord" }), + ); + expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); + }); + it("emits json output", async () => { callGatewayMock.mockResolvedValueOnce({ messageId: "direct2" }); const deps = makeDeps(); diff --git a/src/commands/send.ts b/src/commands/send.ts index e43db0b95..35e259746 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -53,6 +53,35 @@ export async function sendCommand( return; } + if (provider === "discord") { + const result = await deps.sendMessageDiscord(opts.to, opts.message, { + token: process.env.DISCORD_BOT_TOKEN, + mediaUrl: opts.media, + }); + runtime.log( + success( + `✅ Sent via discord. Message ID: ${result.messageId} (channel ${result.channelId})`, + ), + ); + if (opts.json) { + runtime.log( + JSON.stringify( + { + provider: "discord", + via: "direct", + to: opts.to, + channelId: result.channelId, + messageId: result.messageId, + mediaUrl: opts.media ?? null, + }, + null, + 2, + ), + ); + } + return; + } + // Always send via gateway over WS to avoid multi-session corruption. const sendViaGateway = async () => callGateway<{ diff --git a/src/commands/status.ts b/src/commands/status.ts index 4ae5b97bf..456249495 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -235,6 +235,15 @@ export async function statusCommand( : `Telegram: failed (${health.telegram.probe?.status ?? "unknown"})${health.telegram.probe?.error ? ` - ${health.telegram.probe.error}` : ""}` : info("Telegram: not configured"); runtime.log(tgLine); + + const discordLine = health.discord.configured + ? health.discord.probe?.ok + ? info( + `Discord: ok${health.discord.probe.bot?.username ? ` (@${health.discord.probe.bot.username})` : ""} (${health.discord.probe.elapsedMs}ms)`, + ) + : `Discord: failed (${health.discord.probe?.status ?? "unknown"})${health.discord.probe?.error ? ` - ${health.discord.probe.error}` : ""}` + : info("Discord: not configured"); + runtime.log(discordLine); } else { runtime.log(info("Provider probes: skipped (use --deep)")); } diff --git a/src/config/config.ts b/src/config/config.ts index b305d00ac..c2d2ce0e1 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -84,7 +84,7 @@ export type HookMappingConfig = { messageTemplate?: string; textTemplate?: string; deliver?: boolean; - channel?: "last" | "whatsapp" | "telegram"; + channel?: "last" | "whatsapp" | "telegram" | "discord"; to?: string; thinking?: string; timeoutSeconds?: number; @@ -136,6 +136,13 @@ export type TelegramConfig = { webhookPath?: string; }; +export type DiscordConfig = { + token?: string; + allowFrom?: Array; + requireMention?: boolean; + mediaMaxMb?: number; +}; + export type GroupChatConfig = { requireMention?: boolean; mentionPatterns?: string[]; @@ -329,8 +336,8 @@ export type ClawdisConfig = { every?: string; /** Heartbeat model override (provider/model). */ model?: string; - /** Delivery target (last|whatsapp|telegram|none). */ - target?: "last" | "whatsapp" | "telegram" | "none"; + /** Delivery target (last|whatsapp|telegram|discord|none). */ + target?: "last" | "whatsapp" | "telegram" | "discord" | "none"; /** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */ to?: string; /** Override the heartbeat prompt body (default: "HEARTBEAT"). */ @@ -353,6 +360,7 @@ export type ClawdisConfig = { session?: SessionConfig; web?: WebConfig; telegram?: TelegramConfig; + discord?: DiscordConfig; cron?: CronConfig; hooks?: HooksConfig; bridge?: BridgeConfig; @@ -512,7 +520,12 @@ const HookMappingSchema = z textTemplate: z.string().optional(), deliver: z.boolean().optional(), channel: z - .union([z.literal("last"), z.literal("whatsapp"), z.literal("telegram")]) + .union([ + z.literal("last"), + z.literal("whatsapp"), + z.literal("telegram"), + z.literal("discord"), + ]) .optional(), to: z.string().optional(), thinking: z.string().optional(), @@ -681,6 +694,14 @@ const ClawdisSchema = z.object({ webhookPath: z.string().optional(), }) .optional(), + discord: z + .object({ + token: z.string().optional(), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + requireMention: z.boolean().optional(), + mediaMaxMb: z.number().positive().optional(), + }) + .optional(), bridge: z .object({ enabled: z.boolean().optional(), diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 4616014b4..ab5593054 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -26,7 +26,7 @@ export type SessionEntry = { totalTokens?: number; model?: string; contextTokens?: number; - lastChannel?: "whatsapp" | "telegram" | "webchat"; + lastChannel?: "whatsapp" | "telegram" | "discord" | "webchat"; lastTo?: string; skillsSnapshot?: SessionSkillSnapshot; }; diff --git a/src/cron/isolated-agent.test.ts b/src/cron/isolated-agent.test.ts index f4bf2f36f..16b268147 100644 --- a/src/cron/isolated-agent.test.ts +++ b/src/cron/isolated-agent.test.ts @@ -87,6 +87,7 @@ describe("runCronIsolatedAgentTurn", () => { const deps: CliDeps = { sendMessageWhatsApp: vi.fn(), sendMessageTelegram: vi.fn(), + sendMessageDiscord: vi.fn(), }; vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ payloads: [{ text: "first" }, { text: " " }, { text: " last " }], @@ -116,6 +117,7 @@ describe("runCronIsolatedAgentTurn", () => { const deps: CliDeps = { sendMessageWhatsApp: vi.fn(), sendMessageTelegram: vi.fn(), + sendMessageDiscord: vi.fn(), }; const long = "a".repeat(2001); vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ @@ -146,6 +148,7 @@ describe("runCronIsolatedAgentTurn", () => { const deps: CliDeps = { sendMessageWhatsApp: vi.fn(), sendMessageTelegram: vi.fn(), + sendMessageDiscord: vi.fn(), }; vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ payloads: [{ text: "hello" }], @@ -183,6 +186,7 @@ describe("runCronIsolatedAgentTurn", () => { const deps: CliDeps = { sendMessageWhatsApp: vi.fn(), sendMessageTelegram: vi.fn(), + sendMessageDiscord: vi.fn(), }; vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ payloads: [{ text: "hello" }], @@ -212,4 +216,47 @@ describe("runCronIsolatedAgentTurn", () => { expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled(); }); }); + + it("delivers via discord when configured", async () => { + await withTempHome(async (home) => { + const storePath = await writeSessionStore(home); + const deps: CliDeps = { + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn(), + sendMessageDiscord: vi.fn().mockResolvedValue({ + messageId: "d1", + channelId: "chan", + }), + }; + vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ + payloads: [{ text: "hello from cron" }], + meta: { + durationMs: 5, + agentMeta: { sessionId: "s", provider: "p", model: "m" }, + }, + }); + + const res = await runCronIsolatedAgentTurn({ + cfg: makeCfg(home, storePath), + deps, + job: makeJob({ + kind: "agentTurn", + message: "do it", + deliver: true, + channel: "discord", + to: "channel:1122", + }), + message: "do it", + sessionKey: "cron:job-1", + lane: "cron", + }); + + expect(res.status).toBe("ok"); + expect(deps.sendMessageDiscord).toHaveBeenCalledWith( + "channel:1122", + "hello from cron", + expect.objectContaining({ token: process.env.DISCORD_BOT_TOKEN }), + ); + }); + }); }); diff --git a/src/cron/isolated-agent.ts b/src/cron/isolated-agent.ts index a96e93c46..3e2fa0d6a 100644 --- a/src/cron/isolated-agent.ts +++ b/src/cron/isolated-agent.ts @@ -53,7 +53,7 @@ function pickSummaryFromPayloads( function resolveDeliveryTarget( cfg: ClawdisConfig, jobPayload: { - channel?: "last" | "whatsapp" | "telegram"; + channel?: "last" | "whatsapp" | "telegram" | "discord"; to?: string; }, ) { @@ -76,7 +76,11 @@ function resolveDeliveryTarget( const lastTo = typeof main?.lastTo === "string" ? main.lastTo.trim() : ""; const channel = (() => { - if (requestedChannel === "whatsapp" || requestedChannel === "telegram") { + if ( + requestedChannel === "whatsapp" || + requestedChannel === "telegram" || + requestedChannel === "discord" + ) { return requestedChannel; } return lastChannel ?? "whatsapp"; @@ -366,6 +370,50 @@ export async function runCronIsolatedAgentTurn(params: { return { status: "error", summary, error: String(err) }; return { status: "ok", summary }; } + } else if (resolvedDelivery.channel === "discord") { + if (!resolvedDelivery.to) { + if (!bestEffortDeliver) + return { + status: "error", + summary, + error: + "Cron delivery to Discord requires --channel discord and --to ", + }; + return { + status: "skipped", + summary: "Delivery skipped (no Discord destination).", + }; + } + const discordTarget = resolvedDelivery.to; + try { + for (const payload of payloads) { + const mediaList = + payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + if (mediaList.length === 0) { + await params.deps.sendMessageDiscord( + discordTarget, + payload.text ?? "", + { + token: process.env.DISCORD_BOT_TOKEN, + }, + ); + } else { + let first = true; + for (const url of mediaList) { + const caption = first ? (payload.text ?? "") : ""; + first = false; + await params.deps.sendMessageDiscord(discordTarget, caption, { + token: process.env.DISCORD_BOT_TOKEN, + mediaUrl: url, + }); + } + } + } + } catch (err) { + if (!bestEffortDeliver) + return { status: "error", summary, error: String(err) }; + return { status: "ok", summary }; + } } } diff --git a/src/cron/types.ts b/src/cron/types.ts index 43b26f204..c02d1d183 100644 --- a/src/cron/types.ts +++ b/src/cron/types.ts @@ -14,7 +14,7 @@ export type CronPayload = thinking?: string; timeoutSeconds?: number; deliver?: boolean; - channel?: "last" | "whatsapp" | "telegram"; + channel?: "last" | "whatsapp" | "telegram" | "discord"; to?: string; bestEffortDeliver?: boolean; }; diff --git a/src/discord/index.ts b/src/discord/index.ts new file mode 100644 index 000000000..4bd4018e3 --- /dev/null +++ b/src/discord/index.ts @@ -0,0 +1,2 @@ +export { monitorDiscordProvider } from "./monitor.js"; +export { sendMessageDiscord } from "./send.js"; diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts new file mode 100644 index 000000000..88c6f890b --- /dev/null +++ b/src/discord/monitor.ts @@ -0,0 +1,323 @@ +import { + Client, + Events, + GatewayIntentBits, + type Message, + Partials, +} from "discord.js"; + +import { chunkText } from "../auto-reply/chunk.js"; +import { formatAgentEnvelope } from "../auto-reply/envelope.js"; +import { getReplyFromConfig } from "../auto-reply/reply.js"; +import type { ReplyPayload } from "../auto-reply/types.js"; +import { loadConfig } from "../config/config.js"; +import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; +import { danger, isVerbose, logVerbose } from "../globals.js"; +import { getChildLogger } from "../logging.js"; +import { detectMime } from "../media/mime.js"; +import { saveMediaBuffer } from "../media/store.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { sendMessageDiscord } from "./send.js"; +import { normalizeDiscordToken } from "./token.js"; + +export type MonitorDiscordOpts = { + token?: string; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + allowFrom?: Array; + requireMention?: boolean; + mediaMaxMb?: number; +}; + +type DiscordMediaInfo = { + path: string; + contentType?: string; + placeholder: string; +}; + +export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { + const cfg = loadConfig(); + const token = normalizeDiscordToken( + opts.token ?? process.env.DISCORD_BOT_TOKEN ?? cfg.discord?.token ?? undefined, + ); + if (!token) { + throw new Error( + "DISCORD_BOT_TOKEN or discord.token is required for Discord gateway", + ); + } + + const runtime: RuntimeEnv = opts.runtime ?? { + log: console.log, + error: console.error, + exit: (code: number): never => { + throw new Error(`exit ${code}`); + }, + }; + + const allowFrom = opts.allowFrom ?? cfg.discord?.allowFrom; + const requireMention = + opts.requireMention ?? cfg.discord?.requireMention ?? true; + const mediaMaxBytes = + (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; + + const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + ], + partials: [Partials.Channel], + }); + + const logger = getChildLogger({ module: "discord-auto-reply" }); + + client.once(Events.ClientReady, () => { + runtime.log?.(`discord: logged in as ${client.user?.tag ?? "unknown"}`); + }); + + client.on(Events.Error, (err) => { + runtime.error?.(danger(`discord client error: ${String(err)}`)); + }); + + client.on(Events.MessageCreate, async (message) => { + try { + if (message.author?.bot) return; + if (!message.author) return; + + const isDirectMessage = !message.guild; + if (!isDirectMessage && requireMention) { + const botId = client.user?.id; + if (botId && !message.mentions.has(botId)) { + logger.info( + { + channelId: message.channelId, + reason: "no-mention", + }, + "discord: skipping guild message", + ); + return; + } + } + + if (isDirectMessage && Array.isArray(allowFrom) && allowFrom.length > 0) { + const allowed = allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean); + const candidate = message.author.id; + const normalized = new Set( + allowed + .filter((entry) => entry !== "*") + .map((entry) => entry.replace(/^discord:/i, "")), + ); + const permitted = + allowed.includes("*") || + normalized.has(candidate) || + allowed.includes(candidate); + if (!permitted) { + logVerbose( + `Blocked unauthorized discord sender ${candidate} (not in allowFrom)`, + ); + return; + } + } + + const media = await resolveMedia(message, mediaMaxBytes); + const text = + message.content?.trim() ?? + media?.placeholder ?? + message.embeds[0]?.description ?? + ""; + if (!text) return; + + const fromLabel = isDirectMessage + ? buildDirectLabel(message) + : buildGuildLabel(message); + const body = formatAgentEnvelope({ + surface: "Discord", + from: fromLabel, + timestamp: message.createdTimestamp, + body: text, + }); + + const ctxPayload = { + Body: body, + From: isDirectMessage + ? `discord:${message.author.id}` + : `group:${message.channelId}`, + To: isDirectMessage + ? `user:${message.author.id}` + : `channel:${message.channelId}`, + ChatType: isDirectMessage ? "direct" : "group", + SenderName: message.member?.displayName ?? message.author.tag, + GroupSubject: + !isDirectMessage && "name" in message.channel + ? message.channel.name + : undefined, + Surface: "discord" as const, + MessageSid: message.id, + Timestamp: message.createdTimestamp, + MediaPath: media?.path, + MediaType: media?.contentType, + MediaUrl: media?.path, + }; + + if (isDirectMessage) { + const sessionCfg = cfg.inbound?.reply?.session; + const mainKey = (sessionCfg?.mainKey ?? "main").trim() || "main"; + const storePath = resolveStorePath(sessionCfg?.store); + await updateLastRoute({ + storePath, + sessionKey: mainKey, + channel: "discord", + to: `user:${message.author.id}`, + }); + } + + if (isVerbose()) { + const preview = body.slice(0, 200).replace(/\n/g, "\\n"); + logVerbose( + `discord inbound: channel=${message.channelId} from=${ctxPayload.From} preview="${preview}"`, + ); + } + + const replyResult = await getReplyFromConfig( + ctxPayload, + { + onReplyStart: () => sendTyping(message), + }, + cfg, + ); + const replies = replyResult + ? Array.isArray(replyResult) + ? replyResult + : [replyResult] + : []; + if (replies.length === 0) return; + + await deliverReplies({ + replies, + target: ctxPayload.To, + token, + runtime, + }); + } catch (err) { + runtime.error?.(danger(`Discord handler failed: ${String(err)}`)); + } + }); + + await client.login(token); + + await new Promise((resolve, reject) => { + const onAbort = () => { + cleanup(); + client.destroy(); + resolve(); + }; + const onError = (err: Error) => { + cleanup(); + reject(err); + }; + const cleanup = () => { + opts.abortSignal?.removeEventListener("abort", onAbort); + client.off(Events.Error, onError); + }; + opts.abortSignal?.addEventListener("abort", onAbort, { once: true }); + client.on(Events.Error, onError); + }); +} + +async function resolveMedia( + message: import("discord.js").Message, + maxBytes: number, +): Promise { + const attachment = message.attachments.first(); + if (!attachment) return null; + const res = await fetch(attachment.url); + if (!res.ok) { + throw new Error( + `Failed to download discord attachment: HTTP ${res.status}`, + ); + } + const buffer = Buffer.from(await res.arrayBuffer()); + const saved = await saveMediaBuffer( + buffer, + detectMime({ + buffer, + headerMime: attachment.contentType ?? res.headers.get("content-type"), + filePath: attachment.name ?? attachment.url, + }), + "inbound", + maxBytes, + ); + return { + path: saved.path, + contentType: saved.contentType, + placeholder: inferPlaceholder(attachment), + }; +} + +function inferPlaceholder(attachment: import("discord.js").Attachment): string { + const mime = attachment.contentType ?? ""; + if (mime.startsWith("image/")) return ""; + if (mime.startsWith("video/")) return ""; + if (mime.startsWith("audio/")) return ""; + return ""; +} + +function buildDirectLabel(message: import("discord.js").Message) { + const username = message.author.tag; + return `${username} id:${message.author.id}`; +} + +function buildGuildLabel(message: import("discord.js").Message) { + const channelName = + "name" in message.channel ? message.channel.name : message.channelId; + return `${message.guild?.name ?? "Guild"} #${channelName} id:${message.channelId}`; +} + +async function sendTyping(message: Message) { + try { + const channel = message.channel; + if (channel.isSendable()) { + await channel.sendTyping(); + } + } catch { + /* ignore */ + } +} + +async function deliverReplies({ + replies, + target, + token, + runtime, +}: { + replies: ReplyPayload[]; + target: string; + token: string; + runtime: RuntimeEnv; +}) { + for (const payload of replies) { + const mediaList = + payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const text = payload.text ?? ""; + if (!text && mediaList.length === 0) continue; + if (mediaList.length === 0) { + for (const chunk of chunkText(text, 2000)) { + await sendMessageDiscord(target, chunk, { token }); + } + } else { + let first = true; + for (const mediaUrl of mediaList) { + const caption = first ? text : ""; + first = false; + await sendMessageDiscord(target, caption, { + token, + mediaUrl, + }); + } + } + runtime.log?.(`discord: delivered reply to ${target}`); + } +} diff --git a/src/discord/probe.ts b/src/discord/probe.ts new file mode 100644 index 000000000..ae1b56143 --- /dev/null +++ b/src/discord/probe.ts @@ -0,0 +1,73 @@ +import { normalizeDiscordToken } from "./token.js"; + +const DISCORD_API_BASE = "https://discord.com/api/v10"; + +export type DiscordProbe = { + ok: boolean; + status?: number | null; + error?: string | null; + elapsedMs: number; + bot?: { id?: string | null; username?: string | null }; +}; + +async function fetchWithTimeout( + url: string, + timeoutMs: number, + fetcher: typeof fetch, + headers?: HeadersInit, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetcher(url, { signal: controller.signal, headers }); + } finally { + clearTimeout(timer); + } +} + +export async function probeDiscord( + token: string, + timeoutMs: number, +): Promise { + const started = Date.now(); + const normalized = normalizeDiscordToken(token); + const result: DiscordProbe = { + ok: false, + status: null, + error: null, + elapsedMs: 0, + }; + if (!normalized) { + return { ...result, error: "missing token", elapsedMs: Date.now() - started }; + } + try { + const res = await fetchWithTimeout( + `${DISCORD_API_BASE}/users/@me`, + timeoutMs, + fetch, + { + Authorization: `Bot ${normalized}`, + }, + ); + if (!res.ok) { + result.status = res.status; + result.error = `getMe failed (${res.status})`; + return { ...result, elapsedMs: Date.now() - started }; + } + const json = (await res.json()) as { id?: string; username?: string }; + result.ok = true; + result.bot = { + id: json.id ?? null, + username: json.username ?? null, + }; + return { ...result, elapsedMs: Date.now() - started }; + } catch (err) { + return { + ...result, + status: err instanceof Response ? err.status : result.status, + error: err instanceof Error ? err.message : String(err), + elapsedMs: Date.now() - started, + }; + } +} + diff --git a/src/discord/send.test.ts b/src/discord/send.test.ts new file mode 100644 index 000000000..7de4dd0c4 --- /dev/null +++ b/src/discord/send.test.ts @@ -0,0 +1,85 @@ +import { Routes } from "discord.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { sendMessageDiscord } from "./send.js"; + +vi.mock("../web/media.js", () => ({ + loadWebMedia: vi.fn().mockResolvedValue({ + buffer: Buffer.from("img"), + fileName: "photo.jpg", + contentType: "image/jpeg", + kind: "image", + }), +})); + +const makeRest = () => { + const postMock = vi.fn(); + return { + rest: { + post: postMock, + } as unknown as import("discord.js").REST, + postMock, + }; +}; + +describe("sendMessageDiscord", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("sends basic channel messages", async () => { + const { rest, postMock } = makeRest(); + postMock.mockResolvedValue({ + id: "msg1", + channel_id: "789", + }); + const res = await sendMessageDiscord("channel:789", "hello world", { + rest, + token: "t", + }); + expect(res).toEqual({ messageId: "msg1", channelId: "789" }); + expect(postMock).toHaveBeenCalledWith( + Routes.channelMessages("789"), + expect.objectContaining({ body: { content: "hello world" } }), + ); + }); + + it("starts DM when recipient is a user", async () => { + const { rest, postMock } = makeRest(); + postMock + .mockResolvedValueOnce({ id: "chan1" }) + .mockResolvedValueOnce({ id: "msg1", channel_id: "chan1" }); + const res = await sendMessageDiscord("user:123", "hiya", { + rest, + token: "t", + }); + expect(postMock).toHaveBeenNthCalledWith( + 1, + Routes.userChannels(), + expect.objectContaining({ body: { recipient_id: "123" } }), + ); + expect(postMock).toHaveBeenNthCalledWith( + 2, + Routes.channelMessages("chan1"), + expect.objectContaining({ body: { content: "hiya" } }), + ); + expect(res.channelId).toBe("chan1"); + }); + + it("uploads media attachments", async () => { + const { rest, postMock } = makeRest(); + postMock.mockResolvedValue({ id: "msg", channel_id: "789" }); + const res = await sendMessageDiscord("channel:789", "photo", { + rest, + token: "t", + mediaUrl: "file:///tmp/photo.jpg", + }); + expect(res.messageId).toBe("msg"); + expect(postMock).toHaveBeenCalledWith( + Routes.channelMessages("789"), + expect.objectContaining({ + files: [expect.objectContaining({ name: "photo.jpg" })], + }), + ); + }); +}); diff --git a/src/discord/send.ts b/src/discord/send.ts new file mode 100644 index 000000000..430dc38df --- /dev/null +++ b/src/discord/send.ts @@ -0,0 +1,166 @@ +import { REST, Routes } from "discord.js"; + +import { chunkText } from "../auto-reply/chunk.js"; +import { loadConfig } from "../config/config.js"; +import { loadWebMedia } from "../web/media.js"; +import { normalizeDiscordToken } from "./token.js"; + +const DISCORD_TEXT_LIMIT = 2000; + +type DiscordRecipient = + | { + kind: "user"; + id: string; + } + | { + kind: "channel"; + id: string; + }; + +type DiscordSendOpts = { + token?: string; + mediaUrl?: string; + verbose?: boolean; + rest?: REST; +}; + +export type DiscordSendResult = { + messageId: string; + channelId: string; +}; + +function resolveToken(explicit?: string) { + const cfgToken = loadConfig().discord?.token; + const token = normalizeDiscordToken( + explicit ?? process.env.DISCORD_BOT_TOKEN ?? cfgToken ?? undefined, + ); + if (!token) { + throw new Error( + "DISCORD_BOT_TOKEN or discord.token is required for Discord sends", + ); + } + return token; +} + +function parseRecipient(raw: string): DiscordRecipient { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error("Recipient is required for Discord sends"); + } + const mentionMatch = trimmed.match(/^<@!?(\d+)>$/); + if (mentionMatch) { + return { kind: "user", id: mentionMatch[1] }; + } + if (trimmed.startsWith("user:")) { + return { kind: "user", id: trimmed.slice("user:".length) }; + } + if (trimmed.startsWith("channel:")) { + return { kind: "channel", id: trimmed.slice("channel:".length) }; + } + if (trimmed.startsWith("discord:")) { + return { kind: "user", id: trimmed.slice("discord:".length) }; + } + if (trimmed.startsWith("@")) { + const candidate = trimmed.slice(1); + if (!/^\d+$/.test(candidate)) { + throw new Error( + "Discord DMs require a user id (use user: or a <@id> mention)", + ); + } + return { kind: "user", id: candidate }; + } + return { kind: "channel", id: trimmed }; +} + +async function resolveChannelId( + rest: REST, + recipient: DiscordRecipient, +): Promise<{ channelId: string; dm?: boolean }> { + if (recipient.kind === "channel") { + return { channelId: recipient.id }; + } + const dmChannel = (await rest.post(Routes.userChannels(), { + body: { recipient_id: recipient.id }, + })) as { id: string }; + if (!dmChannel?.id) { + throw new Error("Failed to create Discord DM channel"); + } + return { channelId: dmChannel.id, dm: true }; +} + +async function sendDiscordText(rest: REST, channelId: string, text: string) { + if (!text.trim()) { + throw new Error("Message must be non-empty for Discord sends"); + } + if (text.length <= DISCORD_TEXT_LIMIT) { + const res = (await rest.post(Routes.channelMessages(channelId), { + body: { content: text }, + })) as { id: string; channel_id: string }; + return res; + } + const chunks = chunkText(text, DISCORD_TEXT_LIMIT); + let last: { id: string; channel_id: string } | null = null; + for (const chunk of chunks) { + last = (await rest.post(Routes.channelMessages(channelId), { + body: { content: chunk }, + })) as { id: string; channel_id: string }; + } + if (!last) { + throw new Error("Discord send failed (empty chunk result)"); + } + return last; +} + +async function sendDiscordMedia( + rest: REST, + channelId: string, + text: string, + mediaUrl: string, +) { + const media = await loadWebMedia(mediaUrl); + const caption = + text.length > DISCORD_TEXT_LIMIT ? text.slice(0, DISCORD_TEXT_LIMIT) : text; + const res = (await rest.post(Routes.channelMessages(channelId), { + body: { + content: caption || undefined, + }, + files: [ + { + data: media.buffer, + name: media.fileName ?? "upload", + }, + ], + })) as { id: string; channel_id: string }; + if (text.length > DISCORD_TEXT_LIMIT) { + const remaining = text.slice(DISCORD_TEXT_LIMIT).trim(); + if (remaining) { + await sendDiscordText(rest, channelId, remaining); + } + } + return res; +} + +export async function sendMessageDiscord( + to: string, + text: string, + opts: DiscordSendOpts = {}, +): Promise { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const recipient = parseRecipient(to); + const { channelId } = await resolveChannelId(rest, recipient); + let result: + | { id: string; channel_id: string } + | { id: string | null; channel_id: string }; + + if (opts.mediaUrl) { + result = await sendDiscordMedia(rest, channelId, text, opts.mediaUrl); + } else { + result = await sendDiscordText(rest, channelId, text); + } + + return { + messageId: result.id ? String(result.id) : "unknown", + channelId: String(result.channel_id ?? channelId), + }; +} diff --git a/src/discord/token.ts b/src/discord/token.ts new file mode 100644 index 000000000..9f98dc405 --- /dev/null +++ b/src/discord/token.ts @@ -0,0 +1,7 @@ +export function normalizeDiscordToken(raw?: string | null): string | undefined { + if (!raw) return undefined; + const trimmed = raw.trim(); + if (!trimmed) return undefined; + return trimmed.replace(/^Bot\s+/i, ""); +} + diff --git a/src/gateway/hooks-mapping.ts b/src/gateway/hooks-mapping.ts index 6553d27fc..1d29d786b 100644 --- a/src/gateway/hooks-mapping.ts +++ b/src/gateway/hooks-mapping.ts @@ -18,7 +18,7 @@ export type HookMappingResolved = { messageTemplate?: string; textTemplate?: string; deliver?: boolean; - channel?: "last" | "whatsapp" | "telegram"; + channel?: "last" | "whatsapp" | "telegram" | "discord"; to?: string; thinking?: string; timeoutSeconds?: number; @@ -50,7 +50,7 @@ export type HookAction = wakeMode: "now" | "next-heartbeat"; sessionKey?: string; deliver?: boolean; - channel?: "last" | "whatsapp" | "telegram"; + channel?: "last" | "whatsapp" | "telegram" | "discord"; to?: string; thinking?: string; timeoutSeconds?: number; @@ -86,7 +86,7 @@ type HookTransformResult = Partial<{ name: string; sessionKey: string; deliver: boolean; - channel: "last" | "whatsapp" | "telegram"; + channel: "last" | "whatsapp" | "telegram" | "discord"; to: string; thinking: string; timeoutSeconds: number; diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 7e581bbad..00df8da62 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -450,6 +450,7 @@ export const CronPayloadSchema = Type.Union([ Type.Literal("last"), Type.Literal("whatsapp"), Type.Literal("telegram"), + Type.Literal("discord"), ]), ), to: Type.Optional(Type.String()), diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 92fd22fe1..dec83329d 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -1793,6 +1793,61 @@ describe("gateway server", () => { await server.close(); }); + test("agent routes main last-channel discord", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); + testSessionStorePath = path.join(dir, "sessions.json"); + await fs.writeFile( + testSessionStorePath, + JSON.stringify( + { + main: { + sessionId: "sess-discord", + updatedAt: Date.now(), + lastChannel: "discord", + lastTo: "channel:discord-123", + }, + }, + null, + 2, + ), + "utf-8", + ); + + const { server, ws } = await startServerWithClient(); + await connectOk(ws); + + ws.send( + JSON.stringify({ + type: "req", + id: "agent-last-discord", + method: "agent", + params: { + message: "hi", + sessionKey: "main", + channel: "last", + deliver: true, + idempotencyKey: "idem-agent-last-discord", + }, + }), + ); + await onceMessage( + ws, + (o) => o.type === "res" && o.id === "agent-last-discord", + ); + + const spy = vi.mocked(agentCommand); + expect(spy).toHaveBeenCalled(); + const call = spy.mock.calls.at(-1)?.[0] as Record; + expect(call.provider).toBe("discord"); + expect(call.to).toBe("channel:discord-123"); + expect(call.deliver).toBe(true); + expect(call.bestEffortDeliver).toBe(true); + expect(call.sessionId).toBe("sess-discord"); + + ws.close(); + await server.close(); + }); + test("agent ignores webchat last-channel for routing", async () => { testAllowFrom = ["+1555"]; const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-")); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index eee23568c..ef7d72985 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -67,6 +67,8 @@ import { import { CronService } from "../cron/service.js"; import { resolveCronStorePath } from "../cron/store.js"; import type { CronJob, CronJobCreate, CronJobPatch } from "../cron/types.js"; +import { monitorDiscordProvider, sendMessageDiscord } from "../discord/index.js"; +import { probeDiscord, type DiscordProbe } from "../discord/probe.js"; import { isVerbose } from "../globals.js"; import { onAgentEvent } from "../infra/agent-events.js"; import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js"; @@ -273,9 +275,11 @@ const logHooks = log.child("hooks"); const logWsControl = log.child("ws"); const logWhatsApp = logProviders.child("whatsapp"); const logTelegram = logProviders.child("telegram"); +const logDiscord = logProviders.child("discord"); const canvasRuntime = runtimeForLogger(logCanvas); const whatsappRuntimeEnv = runtimeForLogger(logWhatsApp); const telegramRuntimeEnv = runtimeForLogger(logTelegram); +const discordRuntimeEnv = runtimeForLogger(logDiscord); function resolveBonjourCliPath(): string | undefined { const envPath = process.env.CLAWDIS_CLI_PATH?.trim(); @@ -1378,13 +1382,17 @@ export async function startGatewayServer( const channel = channelRaw === "whatsapp" || channelRaw === "telegram" || + channelRaw === "discord" || channelRaw === "last" ? channelRaw : channelRaw === undefined ? "last" : null; if (channel === null) { - return { ok: false, error: "channel must be last|whatsapp|telegram" }; + return { + ok: false, + error: "channel must be last|whatsapp|telegram|discord", + }; } const toRaw = payload.to; const to = @@ -1703,8 +1711,10 @@ export async function startGatewayServer( }); let whatsappAbort: AbortController | null = null; let telegramAbort: AbortController | null = null; + let discordAbort: AbortController | null = null; let whatsappTask: Promise | null = null; let telegramTask: Promise | null = null; + let discordTask: Promise | null = null; let whatsappRuntime: WebProviderStatus = { running: false, connected: false, @@ -1728,6 +1738,17 @@ export async function startGatewayServer( lastError: null, mode: null, }; + let discordRuntime: { + running: boolean; + lastStartAt?: number | null; + lastStopAt?: number | null; + lastError?: string | null; + } = { + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }; const clients = new Set(); let seq = 0; // Track per-run sequence to detect out-of-order/lost agent events. @@ -1954,9 +1975,88 @@ export async function startGatewayServer( }; }; + const startDiscordProvider = async () => { + if (discordTask) return; + const cfg = loadConfig(); + const discordToken = + process.env.DISCORD_BOT_TOKEN ?? cfg.discord?.token ?? ""; + if (!discordToken.trim()) { + discordRuntime = { + ...discordRuntime, + running: false, + lastError: "not configured", + }; + logDiscord.info( + "skipping provider start (no DISCORD_BOT_TOKEN/config)", + ); + return; + } + let discordBotLabel = ""; + try { + const probe = await probeDiscord(discordToken.trim(), 2500); + const username = probe.ok ? probe.bot?.username?.trim() : null; + if (username) discordBotLabel = ` (@${username})`; + } catch (err) { + if (isVerbose()) { + logDiscord.debug(`bot probe failed: ${String(err)}`); + } + } + logDiscord.info(`starting provider${discordBotLabel}`); + discordAbort = new AbortController(); + discordRuntime = { + ...discordRuntime, + running: true, + lastStartAt: Date.now(), + lastError: null, + }; + const task = monitorDiscordProvider({ + token: discordToken.trim(), + runtime: discordRuntimeEnv, + abortSignal: discordAbort.signal, + allowFrom: cfg.discord?.allowFrom, + requireMention: cfg.discord?.requireMention, + mediaMaxMb: cfg.discord?.mediaMaxMb, + }) + .catch((err) => { + discordRuntime = { + ...discordRuntime, + lastError: formatError(err), + }; + logDiscord.error(`provider exited: ${formatError(err)}`); + }) + .finally(() => { + discordAbort = null; + discordTask = null; + discordRuntime = { + ...discordRuntime, + running: false, + lastStopAt: Date.now(), + }; + }); + discordTask = task; + }; + + const stopDiscordProvider = async () => { + if (!discordAbort && !discordTask) return; + discordAbort?.abort(); + try { + await discordTask; + } catch { + // ignore + } + discordAbort = null; + discordTask = null; + discordRuntime = { + ...discordRuntime, + running: false, + lastStopAt: Date.now(), + }; + }; + const startProviders = async () => { await startWhatsAppProvider(); await startTelegramProvider(); + await startDiscordProvider(); }; const broadcast = ( @@ -3784,6 +3884,21 @@ export async function startGatewayServer( lastProbeAt = Date.now(); } + const discordEnvToken = process.env.DISCORD_BOT_TOKEN?.trim(); + const discordConfigToken = cfg.discord?.token?.trim(); + const discordToken = discordEnvToken || discordConfigToken || ""; + const discordTokenSource = discordEnvToken + ? "env" + : discordConfigToken + ? "config" + : "none"; + let discordProbe: DiscordProbe | undefined; + let discordLastProbeAt: number | null = null; + if (probe && discordToken) { + discordProbe = await probeDiscord(discordToken, timeoutMs); + discordLastProbeAt = Date.now(); + } + const linked = await webAuthExists(); const authAgeMs = getWebAuthAgeMs(); const self = readWebSelfId(); @@ -3817,6 +3932,16 @@ export async function startGatewayServer( probe: telegramProbe, lastProbeAt, }, + discord: { + configured: Boolean(discordToken), + tokenSource: discordTokenSource, + running: discordRuntime.running, + lastStartAt: discordRuntime.lastStartAt ?? null, + lastStopAt: discordRuntime.lastStopAt ?? null, + lastError: discordRuntime.lastError ?? null, + probe: discordProbe, + lastProbeAt: discordLastProbeAt, + }, }, undefined, ); @@ -5588,6 +5713,23 @@ export async function startGatewayServer( payload, }); respond(true, payload, undefined, { provider }); + } else if (provider === "discord") { + const result = await sendMessageDiscord(to, message, { + mediaUrl: params.mediaUrl, + token: process.env.DISCORD_BOT_TOKEN, + }); + const payload = { + runId: idem, + messageId: result.messageId, + channelId: result.channelId, + provider, + }; + dedupe.set(`send:${idem}`, { + ts: Date.now(), + ok: true, + payload, + }); + respond(true, payload, undefined, { provider }); } else { const result = await sendMessageWhatsApp(to, message, { mediaUrl: params.mediaUrl, @@ -5723,6 +5865,7 @@ export async function startGatewayServer( if ( requestedChannel === "whatsapp" || requestedChannel === "telegram" || + requestedChannel === "discord" || requestedChannel === "webchat" ) { return requestedChannel; @@ -5740,7 +5883,8 @@ export async function startGatewayServer( if (explicit) return explicit; if ( resolvedChannel === "whatsapp" || - resolvedChannel === "telegram" + resolvedChannel === "telegram" || + resolvedChannel === "discord" ) { return lastTo || undefined; } @@ -5975,6 +6119,7 @@ export async function startGatewayServer( } await stopWhatsAppProvider(); await stopTelegramProvider(); + await stopDiscordProvider(); cron.stop(); heartbeatRunner.stop(); broadcast("shutdown", { diff --git a/src/globals.ts b/src/globals.ts index c422dc9d9..a837db32e 100644 --- a/src/globals.ts +++ b/src/globals.ts @@ -13,14 +13,14 @@ export function isVerbose() { } export function logVerbose(message: string) { - if (globalVerbose) { - console.log(chalk.gray(message)); - try { - getLogger().debug({ message }, "verbose"); - } catch { - // ignore logger failures to avoid breaking verbose printing - } + // if (globalVerbose) { + console.log(chalk.gray(message)); + try { + getLogger().debug({ message }, "verbose"); + } catch { + // ignore logger failures to avoid breaking verbose printing } + // } } export function setYes(v: boolean) {