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) {