feat(discord): Discord transport
This commit is contained in:
committed by
Peter Steinberger
parent
557f8e5a04
commit
ac659ff5a7
20
README.md
20
README.md
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
||||
case last
|
||||
case whatsapp
|
||||
case telegram
|
||||
case discord
|
||||
case webchat
|
||||
|
||||
init(raw: String?) {
|
||||
|
||||
@@ -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)**
|
||||
|
||||
@@ -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
|
||||
|
||||
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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
54
docs/discord.md
Normal 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 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.
|
||||
@@ -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`.
|
||||
|
||||
@@ -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).
|
||||
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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:<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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@ describe("healthCommand (coverage)", () => {
|
||||
webhook: { url: "https://example.com/h" },
|
||||
},
|
||||
},
|
||||
discord: {
|
||||
configured: false,
|
||||
},
|
||||
heartbeatSeconds: 60,
|
||||
sessions: {
|
||||
path: "/tmp/sessions.json",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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)"));
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
2
src/discord/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { monitorDiscordProvider } from "./monitor.js";
|
||||
export { sendMessageDiscord } from "./send.js";
|
||||
323
src/discord/monitor.ts
Normal file
323
src/discord/monitor.ts
Normal 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
73
src/discord/probe.ts
Normal 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
85
src/discord/send.test.ts
Normal 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
166
src/discord/send.ts
Normal 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
7
src/discord/token.ts
Normal 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, "");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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-"));
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user