feat(discord): Discord transport

This commit is contained in:
Shadow
2025-12-15 10:11:18 -06:00
committed by Peter Steinberger
parent 557f8e5a04
commit ac659ff5a7
44 changed files with 1352 additions and 56 deletions

View File

@@ -15,7 +15,7 @@
</p>
**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)

View File

@@ -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)
}

View File

@@ -9,6 +9,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case last
case whatsapp
case telegram
case discord
case webchat
init(raw: String?) {

View File

@@ -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)**

View File

@@ -7,14 +7,14 @@ read_when:
<!-- {% raw %} -->
# 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
Youre 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).

View File

@@ -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:<id>` (DM) or `channel:<id>` (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)

View File

@@ -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 "<text>"`
- `--message "<agent message>" [--deliver] [--channel last|whatsapp|telegram] [--to <dest>]`
- `--message "<agent message>" [--deliver] [--channel last|whatsapp|telegram|discord] [--to <dest>]`
- `clawdis cron edit <id> ...` (patch-by-flags, non-interactive)
- `clawdis cron rm <id>`

54
docs/discord.md Normal file
View File

@@ -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:<channelId>`.
- 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:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session.
6. Guild channels: use `channel:<channelId>` 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 Discords 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.

View File

@@ -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`.

View File

@@ -13,7 +13,7 @@ read_when:
</p>
<p align="center">
<strong>WhatsApp + Telegram gateway for AI agents (Pi).</strong><br>
<strong>WhatsApp + Telegram + Discord gateway for AI agents (Pi).</strong><br>
Send a message, get an agent response — from your pocket.
</p>
@@ -23,13 +23,13 @@ read_when:
<a href="./clawd">Clawd setup</a>
</p>
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).
Its 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)

View File

@@ -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.

View File

@@ -18,7 +18,7 @@ The macOS menu bar app shows the WebChat UI as a native SwiftUI view and reuses
## How its 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.

View File

@@ -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:<jid>` keys; do not reuse the primary key for groups.

View File

@@ -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 doesnt 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 doesnt 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:<jid>`, so they remain isolated.
- **Session store:** Keys are resolved via `resolveSessionKey(scope, ctx, mainKey)`; the agent JSONL path lives under `~/.clawdis/sessions/<SessionId>.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.

View File

@@ -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

View File

@@ -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",

View File

@@ -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.',
);
});
});

View File

@@ -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"

View File

@@ -155,10 +155,13 @@ export function registerCronCli(program: Command) {
.option("--deliver", "Deliver agent output", false)
.option(
"--channel <channel>",
"Delivery channel (last|whatsapp|telegram)",
"Delivery channel (last|whatsapp|telegram|discord)",
"last",
)
.option("--to <dest>", "Delivery destination (E.164 or Telegram chatId)")
.option(
"--to <dest>",
"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 <channel>",
"Delivery channel (last|whatsapp|telegram)",
"Delivery channel (last|whatsapp|telegram|discord)",
)
.option(
"--to <dest>",
"Delivery destination (E.164, Telegram chatId, or Discord channel/user)",
)
.option("--to <dest>", "Delivery destination")
.option(
"--best-effort-deliver",
"Do not fail job if delivery fails",

View File

@@ -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,
};
}

View File

@@ -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 <number>",
"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 <text>", "Message body")
.option(
@@ -161,7 +161,7 @@ export function buildProgram() {
)
.option(
"--provider <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 <on|off>", "Persist agent verbose level for the session")
.option(
"--provider <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 <ms>", "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) => {

View File

@@ -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 <channelId|user:ID|channel:ID>",
);
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);
}
}
}
}

View File

@@ -46,6 +46,9 @@ describe("healthCommand (coverage)", () => {
webhook: { url: "https://example.com/h" },
},
},
discord: {
configured: false,
},
heartbeatSeconds: 60,
sessions: {
path: "/tmp/sessions.json",

View File

@@ -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",

View File

@@ -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(

View File

@@ -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> = {}): 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();

View File

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

View File

@@ -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)"));
}

View File

@@ -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<string | number>;
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(),

View File

@@ -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;
};

View File

@@ -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 }),
);
});
});
});

View File

@@ -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 <channelId|user:ID>",
};
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 };
}
}
}

View File

@@ -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;
};

2
src/discord/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export { monitorDiscordProvider } from "./monitor.js";
export { sendMessageDiscord } from "./send.js";

323
src/discord/monitor.ts Normal file
View File

@@ -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<string | number>;
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<void>((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<DiscordMediaInfo | null> {
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 "<media:image>";
if (mime.startsWith("video/")) return "<media:video>";
if (mime.startsWith("audio/")) return "<media:audio>";
return "<media:document>";
}
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}`);
}
}

73
src/discord/probe.ts Normal file
View File

@@ -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<Response> {
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<DiscordProbe> {
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,
};
}
}

85
src/discord/send.test.ts Normal file
View File

@@ -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" })],
}),
);
});
});

166
src/discord/send.ts Normal file
View File

@@ -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:<id> 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<DiscordSendResult> {
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),
};
}

7
src/discord/token.ts Normal file
View File

@@ -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, "");
}

View File

@@ -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;

View File

@@ -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()),

View File

@@ -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<string, unknown>;
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-"));

View File

@@ -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<unknown> | null = null;
let telegramTask: Promise<unknown> | null = null;
let discordTask: Promise<unknown> | 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<Client>();
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", {

View File

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