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>
|
</p>
|
||||||
|
|
||||||
**Clawdis** is a *personal AI assistant* you run on your own devices.
|
**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.
|
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
|
## What Clawdis does
|
||||||
|
|
||||||
- **Personal assistant** — one user, one identity, one memory surface.
|
- **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.
|
- **Voice wake + push-to-talk** — local speech recognition on macOS/iOS.
|
||||||
- **Canvas** — a live visual workspace you can drive from the agent.
|
- **Canvas** — a live visual workspace you can drive from the agent.
|
||||||
- **Automation-ready** — browser control, media handling, and tool streaming.
|
- **Automation-ready** — browser control, media handling, and tool streaming.
|
||||||
@@ -73,7 +73,7 @@ pnpm gateway:watch
|
|||||||
# Send a message
|
# Send a message
|
||||||
pnpm clawdis send --to +1234567890 --message "Hello from Clawdis"
|
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
|
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):
|
Browser control (optional):
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
@@ -191,6 +204,7 @@ Browser control (optional):
|
|||||||
- [`docs/web.md`](docs/web.md)
|
- [`docs/web.md`](docs/web.md)
|
||||||
- [`docs/discovery.md`](docs/discovery.md)
|
- [`docs/discovery.md`](docs/discovery.md)
|
||||||
- [`docs/agent.md`](docs/agent.md)
|
- [`docs/agent.md`](docs/agent.md)
|
||||||
|
- [`docs/discord.md`](docs/discord.md)
|
||||||
- Webhooks + external triggers: [`docs/webhook.md`](docs/webhook.md)
|
- Webhooks + external triggers: [`docs/webhook.md`](docs/webhook.md)
|
||||||
- Gmail hooks (email → wake): [`docs/gmail-pubsub.md`](docs/gmail-pubsub.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."
|
+ "Use an isolated session for agent turns so your main chat stays clean."
|
||||||
static let sessionTargetNote =
|
static let sessionTargetNote =
|
||||||
"Main jobs post a system event into the current main session. "
|
"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 =
|
static let scheduleKindNote =
|
||||||
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
|
||||||
static let isolatedPayloadNote =
|
static let isolatedPayloadNote =
|
||||||
@@ -322,6 +322,7 @@ struct CronJobEditor: View {
|
|||||||
Text("last").tag(GatewayAgentChannel.last)
|
Text("last").tag(GatewayAgentChannel.last)
|
||||||
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
|
Text("whatsapp").tag(GatewayAgentChannel.whatsapp)
|
||||||
Text("telegram").tag(GatewayAgentChannel.telegram)
|
Text("telegram").tag(GatewayAgentChannel.telegram)
|
||||||
|
Text("discord").tag(GatewayAgentChannel.discord)
|
||||||
}
|
}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
@@ -329,7 +330,7 @@ struct CronJobEditor: View {
|
|||||||
}
|
}
|
||||||
GridRow {
|
GridRow {
|
||||||
self.gridLabel("To")
|
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)
|
.textFieldStyle(.roundedBorder)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
|
|||||||
case last
|
case last
|
||||||
case whatsapp
|
case whatsapp
|
||||||
case telegram
|
case telegram
|
||||||
|
case discord
|
||||||
case webchat
|
case webchat
|
||||||
|
|
||||||
init(raw: String?) {
|
init(raw: String?) {
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ read_when:
|
|||||||
Last updated: 2025-12-09
|
Last updated: 2025-12-09
|
||||||
|
|
||||||
## Overview
|
## 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).
|
- 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.
|
- 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`.
|
- 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
|
## Components and flows
|
||||||
- **Gateway (daemon)**
|
- **Gateway (daemon)**
|
||||||
- Maintains Baileys/Telegram connections.
|
- Maintains Baileys/Telegram/Discord connections.
|
||||||
- Exposes a typed WS API (req/resp + server push events).
|
- Exposes a typed WS API (req/resp + server push events).
|
||||||
- Validates every inbound frame against JSON Schema; rejects anything before a mandatory `connect`.
|
- Validates every inbound frame against JSON Schema; rejects anything before a mandatory `connect`.
|
||||||
- **Clients (mac app / CLI / web admin)**
|
- **Clients (mac app / CLI / web admin)**
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ read_when:
|
|||||||
<!-- {% raw %} -->
|
<!-- {% raw %} -->
|
||||||
# Building a personal assistant with CLAWDIS (Clawd-style)
|
# 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
|
## ⚠️ Safety first
|
||||||
|
|
||||||
You’re putting an agent in a position to:
|
You’re putting an agent in a position to:
|
||||||
- run commands on your machine (depending on your Pi tool setup)
|
- run commands on your machine (depending on your Pi tool setup)
|
||||||
- read/write files in your workspace
|
- read/write files in your workspace
|
||||||
- send messages back out via WhatsApp/Telegram
|
- send messages back out via WhatsApp/Telegram/Discord
|
||||||
|
|
||||||
Start conservative:
|
Start conservative:
|
||||||
- Always set `routing.allowFrom` (never run open-to-the-world on your personal Mac).
|
- 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`
|
### `agent.workspace`
|
||||||
|
|
||||||
Sets the **single global workspace directory** used by the agent for file operations.
|
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
|
- `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Omit or set
|
||||||
`0m` to disable.
|
`0m` to disable.
|
||||||
- `model`: optional override model for heartbeat runs (`provider/model`).
|
- `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).
|
- `to`: optional recipient override (E.164 for WhatsApp, chat id for Telegram).
|
||||||
- `prompt`: optional override for the heartbeat body (default: `HEARTBEAT`).
|
- `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) |
|
| `{{GroupMembers}}` | Group members preview (best effort) |
|
||||||
| `{{SenderName}}` | Sender display name (best effort) |
|
| `{{SenderName}}` | Sender display name (best effort) |
|
||||||
| `{{SenderE164}}` | Sender phone number (best effort) |
|
| `{{SenderE164}}` | Sender phone number (best effort) |
|
||||||
| `{{Surface}}` | Surface hint (whatsapp|telegram|webchat|…) |
|
| `{{Surface}}` | Surface hint (whatsapp|telegram|discord|webchat|…) |
|
||||||
|
|
||||||
## Cron (Gateway scheduler)
|
## Cron (Gateway scheduler)
|
||||||
|
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ Add a `cron` command group (all commands should also support `--json` where sens
|
|||||||
- `--wake now|next-heartbeat`
|
- `--wake now|next-heartbeat`
|
||||||
- payload flags (choose one):
|
- payload flags (choose one):
|
||||||
- `--system-event "<text>"`
|
- `--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 edit <id> ...` (patch-by-flags, non-interactive)
|
||||||
- `clawdis cron rm <id>`
|
- `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
|
## Quick checks
|
||||||
- `clawdis status` — local summary: whether creds exist, auth age, session store path + recent sessions.
|
- `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).
|
- `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.
|
- 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`.
|
- 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>
|
||||||
|
|
||||||
<p align="center">
|
<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.
|
Send a message, get an agent response — from your pocket.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -23,13 +23,13 @@ read_when:
|
|||||||
<a href="./clawd">Clawd setup</a>
|
<a href="./clawd">Clawd setup</a>
|
||||||
</p>
|
</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.
|
It’s built for [Clawd](https://clawd.me), a space lobster who needed a TARDIS.
|
||||||
|
|
||||||
## How it works
|
## 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
|
- 📱 **WhatsApp Integration** — Uses Baileys for WhatsApp Web protocol
|
||||||
- ✈️ **Telegram Bot** — DMs + groups via grammY
|
- ✈️ **Telegram Bot** — DMs + groups via grammY
|
||||||
|
- 🎮 **Discord Bot** — DMs + guild channels via discord.js
|
||||||
- 🤖 **Agent bridge** — Pi (RPC mode) with tool streaming
|
- 🤖 **Agent bridge** — Pi (RPC mode) with tool streaming
|
||||||
- 💬 **Sessions** — Direct chats collapse into shared `main` (default); groups are isolated
|
- 💬 **Sessions** — Direct chats collapse into shared `main` (default); groups are isolated
|
||||||
- 👥 **Group Chat Support** — Mention-based by default; owner can toggle `/activation always|mention`
|
- 👥 **Group Chat Support** — Mention-based by default; owner can toggle `/activation always|mention`
|
||||||
@@ -127,6 +128,7 @@ Example:
|
|||||||
- [WebChat](./webchat.md)
|
- [WebChat](./webchat.md)
|
||||||
- [Control UI (browser)](./control-ui.md)
|
- [Control UI (browser)](./control-ui.md)
|
||||||
- [Telegram](./telegram.md)
|
- [Telegram](./telegram.md)
|
||||||
|
- [Discord](./discord.md)
|
||||||
- [Group messages](./group-messages.md)
|
- [Group messages](./group-messages.md)
|
||||||
- [Media: images](./images.md)
|
- [Media: images](./images.md)
|
||||||
- [Media: audio](./audio.md)
|
- [Media: audio](./audio.md)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ Hardening:
|
|||||||
|
|
||||||
## Forwarding behavior
|
## 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).
|
- 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
|
## Forwarding payload
|
||||||
- `VoiceWakeForwarder.prefixedTranscript(_:)` prepends the machine hint before sending. Shared between wake-word and push-to-talk paths.
|
- `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
|
## How it’s wired
|
||||||
- Implementation: `apps/macos/Sources/Clawdis/WebChatSwiftUI.swift` hosts `ClawdisChatUI` and speaks to the Gateway over `GatewayConnection`.
|
- 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`.
|
- 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
|
## Security / surface area
|
||||||
- Remote mode forwards only the Gateway WebSocket control port over SSH.
|
- 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.
|
- Clawdis does **not** read legacy Pi/Tau session folders.
|
||||||
|
|
||||||
## Mapping transports → session keys
|
## 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.
|
- 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.
|
- 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:
|
read_when:
|
||||||
- Changing surface routing or inbox behavior
|
- 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.
|
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.
|
- **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.
|
- **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`.
|
- **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.
|
- **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:**
|
- **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.
|
- 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.
|
- 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
|
```bash
|
||||||
# Check local status (creds, sessions, queued events)
|
# Check local status (creds, sessions, queued events)
|
||||||
clawdis status
|
clawdis status
|
||||||
# Probe the running gateway + providers (WA connect + Telegram API)
|
# Probe the running gateway + providers (WA connect + Telegram + Discord APIs)
|
||||||
clawdis status --deep
|
clawdis status --deep
|
||||||
|
|
||||||
# View recent connection events
|
# View recent connection events
|
||||||
|
|||||||
@@ -81,6 +81,7 @@
|
|||||||
"commander": "^14.0.2",
|
"commander": "^14.0.2",
|
||||||
"croner": "^9.1.0",
|
"croner": "^9.1.0",
|
||||||
"detect-libc": "^2.1.2",
|
"detect-libc": "^2.1.2",
|
||||||
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"file-type": "^21.1.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();
|
defaultGroupActivation();
|
||||||
const subject = sessionCtx.GroupSubject?.trim();
|
const subject = sessionCtx.GroupSubject?.trim();
|
||||||
const members = sessionCtx.GroupMembers?.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
|
const subjectLine = subject
|
||||||
? `You are replying inside the WhatsApp group "${subject}".`
|
? `You are replying inside the ${surfaceLabel} group "${subject}".`
|
||||||
: "You are replying inside a WhatsApp group chat.";
|
: `You are replying inside a ${surfaceLabel} group chat.`;
|
||||||
const membersLine = members ? `Group members: ${members}.` : undefined;
|
const membersLine = members ? `Group members: ${members}.` : undefined;
|
||||||
const activationLine =
|
const activationLine =
|
||||||
activation === "always"
|
activation === "always"
|
||||||
|
|||||||
@@ -155,10 +155,13 @@ export function registerCronCli(program: Command) {
|
|||||||
.option("--deliver", "Deliver agent output", false)
|
.option("--deliver", "Deliver agent output", false)
|
||||||
.option(
|
.option(
|
||||||
"--channel <channel>",
|
"--channel <channel>",
|
||||||
"Delivery channel (last|whatsapp|telegram)",
|
"Delivery channel (last|whatsapp|telegram|discord)",
|
||||||
"last",
|
"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(
|
.option(
|
||||||
"--best-effort-deliver",
|
"--best-effort-deliver",
|
||||||
"Do not fail the job if delivery fails",
|
"Do not fail the job if delivery fails",
|
||||||
@@ -411,9 +414,12 @@ export function registerCronCli(program: Command) {
|
|||||||
.option("--deliver", "Deliver agent output", false)
|
.option("--deliver", "Deliver agent output", false)
|
||||||
.option(
|
.option(
|
||||||
"--channel <channel>",
|
"--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(
|
.option(
|
||||||
"--best-effort-deliver",
|
"--best-effort-deliver",
|
||||||
"Do not fail job if delivery fails",
|
"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 { logWebSelfId, sendMessageWhatsApp } from "../providers/web/index.js";
|
||||||
import { sendMessageTelegram } from "../telegram/send.js";
|
import { sendMessageTelegram } from "../telegram/send.js";
|
||||||
|
|
||||||
export type CliDeps = {
|
export type CliDeps = {
|
||||||
sendMessageWhatsApp: typeof sendMessageWhatsApp;
|
sendMessageWhatsApp: typeof sendMessageWhatsApp;
|
||||||
sendMessageTelegram: typeof sendMessageTelegram;
|
sendMessageTelegram: typeof sendMessageTelegram;
|
||||||
|
sendMessageDiscord: typeof sendMessageDiscord;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createDefaultDeps(): CliDeps {
|
export function createDefaultDeps(): CliDeps {
|
||||||
return {
|
return {
|
||||||
sendMessageWhatsApp,
|
sendMessageWhatsApp,
|
||||||
sendMessageTelegram,
|
sendMessageTelegram,
|
||||||
|
sendMessageDiscord,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,10 +149,10 @@ export function buildProgram() {
|
|||||||
|
|
||||||
program
|
program
|
||||||
.command("send")
|
.command("send")
|
||||||
.description("Send a message (WhatsApp web or Telegram bot)")
|
.description("Send a message (WhatsApp Web, Telegram bot, or Discord)")
|
||||||
.requiredOption(
|
.requiredOption(
|
||||||
"-t, --to <number>",
|
"-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")
|
.requiredOption("-m, --message <text>", "Message body")
|
||||||
.option(
|
.option(
|
||||||
@@ -161,7 +161,7 @@ export function buildProgram() {
|
|||||||
)
|
)
|
||||||
.option(
|
.option(
|
||||||
"--provider <provider>",
|
"--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("--dry-run", "Print payload and skip sending", false)
|
||||||
.option("--json", "Output result as JSON", false)
|
.option("--json", "Output result as JSON", false)
|
||||||
@@ -202,9 +202,13 @@ Examples:
|
|||||||
"Thinking level: off | minimal | low | medium | high",
|
"Thinking level: off | minimal | low | medium | high",
|
||||||
)
|
)
|
||||||
.option("--verbose <on|off>", "Persist agent verbose level for the session")
|
.option("--verbose <on|off>", "Persist agent verbose level for the session")
|
||||||
|
.option(
|
||||||
|
"--provider <provider>",
|
||||||
|
"Delivery provider: whatsapp|telegram|discord (default: whatsapp)",
|
||||||
|
)
|
||||||
.option(
|
.option(
|
||||||
"--deliver",
|
"--deliver",
|
||||||
"Send the agent's reply back to WhatsApp (requires --to)",
|
"Send the agent's reply back to the selected provider (requires --to)",
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
.option("--json", "Output result as JSON", false)
|
.option("--json", "Output result as JSON", false)
|
||||||
@@ -247,7 +251,11 @@ Examples:
|
|||||||
.command("status")
|
.command("status")
|
||||||
.description("Show web session health and recent session recipients")
|
.description("Show web session health and recent session recipients")
|
||||||
.option("--json", "Output JSON instead of text", false)
|
.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("--timeout <ms>", "Probe timeout in milliseconds", "10000")
|
||||||
.option("--verbose", "Verbose logging", false)
|
.option("--verbose", "Verbose logging", false)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
@@ -256,7 +264,7 @@ Examples:
|
|||||||
Examples:
|
Examples:
|
||||||
clawdis status # show linked account + session store summary
|
clawdis status # show linked account + session store summary
|
||||||
clawdis status --json # machine-readable output
|
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`,
|
clawdis status --deep --timeout 5000 # tighten probe timeout`,
|
||||||
)
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
|
|||||||
@@ -414,6 +414,7 @@ export async function agentCommand(
|
|||||||
|
|
||||||
const whatsappTarget = opts.to ? normalizeE164(opts.to) : allowFrom[0];
|
const whatsappTarget = opts.to ? normalizeE164(opts.to) : allowFrom[0];
|
||||||
const telegramTarget = opts.to?.trim() || undefined;
|
const telegramTarget = opts.to?.trim() || undefined;
|
||||||
|
const discordTarget = opts.to?.trim() || undefined;
|
||||||
|
|
||||||
const logDeliveryError = (err: unknown) => {
|
const logDeliveryError = (err: unknown) => {
|
||||||
const deliveryTarget =
|
const deliveryTarget =
|
||||||
@@ -421,7 +422,9 @@ export async function agentCommand(
|
|||||||
? telegramTarget
|
? telegramTarget
|
||||||
: deliveryProvider === "whatsapp"
|
: deliveryProvider === "whatsapp"
|
||||||
? whatsappTarget
|
? whatsappTarget
|
||||||
: undefined;
|
: deliveryProvider === "discord"
|
||||||
|
? discordTarget
|
||||||
|
: undefined;
|
||||||
const message = `Delivery failed (${deliveryProvider}${deliveryTarget ? ` to ${deliveryTarget}` : ""}): ${String(err)}`;
|
const message = `Delivery failed (${deliveryProvider}${deliveryTarget ? ` to ${deliveryTarget}` : ""}): ${String(err)}`;
|
||||||
runtime.error?.(message);
|
runtime.error?.(message);
|
||||||
if (!runtime.error) runtime.log(message);
|
if (!runtime.error) runtime.log(message);
|
||||||
@@ -440,6 +443,13 @@ export async function agentCommand(
|
|||||||
if (!bestEffortDeliver) throw err;
|
if (!bestEffortDeliver) throw err;
|
||||||
logDeliveryError(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") {
|
if (deliveryProvider === "webchat") {
|
||||||
const err = new Error(
|
const err = new Error(
|
||||||
"Delivering to WebChat is not supported via `clawdis agent`; use WhatsApp/Telegram or run with --deliver=false.",
|
"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 (
|
if (
|
||||||
deliveryProvider !== "whatsapp" &&
|
deliveryProvider !== "whatsapp" &&
|
||||||
deliveryProvider !== "telegram" &&
|
deliveryProvider !== "telegram" &&
|
||||||
|
deliveryProvider !== "discord" &&
|
||||||
deliveryProvider !== "webchat"
|
deliveryProvider !== "webchat"
|
||||||
) {
|
) {
|
||||||
const err = new Error(`Unknown provider: ${deliveryProvider}`);
|
const err = new Error(`Unknown provider: ${deliveryProvider}`);
|
||||||
@@ -540,5 +551,28 @@ export async function agentCommand(
|
|||||||
logDeliveryError(err);
|
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" },
|
webhook: { url: "https://example.com/h" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
discord: {
|
||||||
|
configured: false,
|
||||||
|
},
|
||||||
heartbeatSeconds: 60,
|
heartbeatSeconds: 60,
|
||||||
sessions: {
|
sessions: {
|
||||||
path: "/tmp/sessions.json",
|
path: "/tmp/sessions.json",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ describe("getHealthSnapshot", () => {
|
|||||||
foo: { updatedAt: 2000 },
|
foo: { updatedAt: 2000 },
|
||||||
};
|
};
|
||||||
vi.stubEnv("TELEGRAM_BOT_TOKEN", "");
|
vi.stubEnv("TELEGRAM_BOT_TOKEN", "");
|
||||||
|
vi.stubEnv("DISCORD_BOT_TOKEN", "");
|
||||||
const snap = (await getHealthSnapshot(10)) satisfies HealthSummary;
|
const snap = (await getHealthSnapshot(10)) satisfies HealthSummary;
|
||||||
expect(snap.ok).toBe(true);
|
expect(snap.ok).toBe(true);
|
||||||
expect(snap.telegram.configured).toBe(false);
|
expect(snap.telegram.configured).toBe(false);
|
||||||
@@ -51,6 +52,7 @@ describe("getHealthSnapshot", () => {
|
|||||||
it("probes telegram getMe + webhook info when configured", async () => {
|
it("probes telegram getMe + webhook info when configured", async () => {
|
||||||
testConfig = { telegram: { botToken: "t-1" } };
|
testConfig = { telegram: { botToken: "t-1" } };
|
||||||
testStore = {};
|
testStore = {};
|
||||||
|
vi.stubEnv("DISCORD_BOT_TOKEN", "");
|
||||||
|
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
@@ -100,6 +102,7 @@ describe("getHealthSnapshot", () => {
|
|||||||
it("returns a structured telegram probe error when getMe fails", async () => {
|
it("returns a structured telegram probe error when getMe fails", async () => {
|
||||||
testConfig = { telegram: { botToken: "bad-token" } };
|
testConfig = { telegram: { botToken: "bad-token" } };
|
||||||
testStore = {};
|
testStore = {};
|
||||||
|
vi.stubEnv("DISCORD_BOT_TOKEN", "");
|
||||||
|
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
@@ -125,6 +128,7 @@ describe("getHealthSnapshot", () => {
|
|||||||
it("captures unexpected probe exceptions as errors", async () => {
|
it("captures unexpected probe exceptions as errors", async () => {
|
||||||
testConfig = { telegram: { botToken: "t-err" } };
|
testConfig = { telegram: { botToken: "t-err" } };
|
||||||
testStore = {};
|
testStore = {};
|
||||||
|
vi.stubEnv("DISCORD_BOT_TOKEN", "");
|
||||||
|
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
||||||
|
import { probeDiscord, type DiscordProbe } from "../discord/probe.js";
|
||||||
import { callGateway } from "../gateway/call.js";
|
import { callGateway } from "../gateway/call.js";
|
||||||
import { info } from "../globals.js";
|
import { info } from "../globals.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
@@ -34,6 +35,10 @@ export type HealthSummary = {
|
|||||||
configured: boolean;
|
configured: boolean;
|
||||||
probe?: TelegramProbe;
|
probe?: TelegramProbe;
|
||||||
};
|
};
|
||||||
|
discord: {
|
||||||
|
configured: boolean;
|
||||||
|
probe?: DiscordProbe;
|
||||||
|
};
|
||||||
heartbeatSeconds: number;
|
heartbeatSeconds: number;
|
||||||
sessions: {
|
sessions: {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -77,12 +82,19 @@ export async function getHealthSnapshot(
|
|||||||
? await probeTelegram(telegramToken.trim(), cappedTimeout, telegramProxy)
|
? await probeTelegram(telegramToken.trim(), cappedTimeout, telegramProxy)
|
||||||
: undefined;
|
: 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 = {
|
const summary: HealthSummary = {
|
||||||
ok: true,
|
ok: true,
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
durationMs: Date.now() - start,
|
durationMs: Date.now() - start,
|
||||||
web: { linked, authAgeMs },
|
web: { linked, authAgeMs },
|
||||||
telegram: { configured: telegramConfigured, probe: telegramProbe },
|
telegram: { configured: telegramConfigured, probe: telegramProbe },
|
||||||
|
discord: { configured: discordConfigured, probe: discordProbe },
|
||||||
heartbeatSeconds,
|
heartbeatSeconds,
|
||||||
sessions: {
|
sessions: {
|
||||||
path: storePath,
|
path: storePath,
|
||||||
@@ -139,6 +151,15 @@ export async function healthCommand(
|
|||||||
: "Telegram: not configured";
|
: "Telegram: not configured";
|
||||||
runtime.log(tgLabel);
|
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(`Heartbeat interval: ${summary.heartbeatSeconds}s`));
|
||||||
runtime.log(
|
runtime.log(
|
||||||
info(
|
info(
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ vi.mock("../gateway/call.js", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const originalTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
const originalTelegramToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
const originalDiscordToken = process.env.DISCORD_BOT_TOKEN;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
process.env.TELEGRAM_BOT_TOKEN = "token-abc";
|
process.env.TELEGRAM_BOT_TOKEN = "token-abc";
|
||||||
|
process.env.DISCORD_BOT_TOKEN = "token-discord";
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
process.env.TELEGRAM_BOT_TOKEN = originalTelegramToken;
|
process.env.TELEGRAM_BOT_TOKEN = originalTelegramToken;
|
||||||
|
process.env.DISCORD_BOT_TOKEN = originalDiscordToken;
|
||||||
});
|
});
|
||||||
|
|
||||||
const runtime: RuntimeEnv = {
|
const runtime: RuntimeEnv = {
|
||||||
@@ -31,6 +34,7 @@ const runtime: RuntimeEnv = {
|
|||||||
const makeDeps = (overrides: Partial<CliDeps> = {}): CliDeps => ({
|
const makeDeps = (overrides: Partial<CliDeps> = {}): CliDeps => ({
|
||||||
sendMessageWhatsApp: vi.fn(),
|
sendMessageWhatsApp: vi.fn(),
|
||||||
sendMessageTelegram: vi.fn(),
|
sendMessageTelegram: vi.fn(),
|
||||||
|
sendMessageDiscord: vi.fn(),
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,6 +87,25 @@ describe("sendCommand", () => {
|
|||||||
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
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 () => {
|
it("emits json output", async () => {
|
||||||
callGatewayMock.mockResolvedValueOnce({ messageId: "direct2" });
|
callGatewayMock.mockResolvedValueOnce({ messageId: "direct2" });
|
||||||
const deps = makeDeps();
|
const deps = makeDeps();
|
||||||
|
|||||||
@@ -53,6 +53,35 @@ export async function sendCommand(
|
|||||||
return;
|
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.
|
// Always send via gateway over WS to avoid multi-session corruption.
|
||||||
const sendViaGateway = async () =>
|
const sendViaGateway = async () =>
|
||||||
callGateway<{
|
callGateway<{
|
||||||
|
|||||||
@@ -235,6 +235,15 @@ export async function statusCommand(
|
|||||||
: `Telegram: failed (${health.telegram.probe?.status ?? "unknown"})${health.telegram.probe?.error ? ` - ${health.telegram.probe.error}` : ""}`
|
: `Telegram: failed (${health.telegram.probe?.status ?? "unknown"})${health.telegram.probe?.error ? ` - ${health.telegram.probe.error}` : ""}`
|
||||||
: info("Telegram: not configured");
|
: info("Telegram: not configured");
|
||||||
runtime.log(tgLine);
|
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 {
|
} else {
|
||||||
runtime.log(info("Provider probes: skipped (use --deep)"));
|
runtime.log(info("Provider probes: skipped (use --deep)"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export type HookMappingConfig = {
|
|||||||
messageTemplate?: string;
|
messageTemplate?: string;
|
||||||
textTemplate?: string;
|
textTemplate?: string;
|
||||||
deliver?: boolean;
|
deliver?: boolean;
|
||||||
channel?: "last" | "whatsapp" | "telegram";
|
channel?: "last" | "whatsapp" | "telegram" | "discord";
|
||||||
to?: string;
|
to?: string;
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
@@ -136,6 +136,13 @@ export type TelegramConfig = {
|
|||||||
webhookPath?: string;
|
webhookPath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DiscordConfig = {
|
||||||
|
token?: string;
|
||||||
|
allowFrom?: Array<string | number>;
|
||||||
|
requireMention?: boolean;
|
||||||
|
mediaMaxMb?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type GroupChatConfig = {
|
export type GroupChatConfig = {
|
||||||
requireMention?: boolean;
|
requireMention?: boolean;
|
||||||
mentionPatterns?: string[];
|
mentionPatterns?: string[];
|
||||||
@@ -329,8 +336,8 @@ export type ClawdisConfig = {
|
|||||||
every?: string;
|
every?: string;
|
||||||
/** Heartbeat model override (provider/model). */
|
/** Heartbeat model override (provider/model). */
|
||||||
model?: string;
|
model?: string;
|
||||||
/** Delivery target (last|whatsapp|telegram|none). */
|
/** Delivery target (last|whatsapp|telegram|discord|none). */
|
||||||
target?: "last" | "whatsapp" | "telegram" | "none";
|
target?: "last" | "whatsapp" | "telegram" | "discord" | "none";
|
||||||
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
|
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
|
||||||
to?: string;
|
to?: string;
|
||||||
/** Override the heartbeat prompt body (default: "HEARTBEAT"). */
|
/** Override the heartbeat prompt body (default: "HEARTBEAT"). */
|
||||||
@@ -353,6 +360,7 @@ export type ClawdisConfig = {
|
|||||||
session?: SessionConfig;
|
session?: SessionConfig;
|
||||||
web?: WebConfig;
|
web?: WebConfig;
|
||||||
telegram?: TelegramConfig;
|
telegram?: TelegramConfig;
|
||||||
|
discord?: DiscordConfig;
|
||||||
cron?: CronConfig;
|
cron?: CronConfig;
|
||||||
hooks?: HooksConfig;
|
hooks?: HooksConfig;
|
||||||
bridge?: BridgeConfig;
|
bridge?: BridgeConfig;
|
||||||
@@ -512,7 +520,12 @@ const HookMappingSchema = z
|
|||||||
textTemplate: z.string().optional(),
|
textTemplate: z.string().optional(),
|
||||||
deliver: z.boolean().optional(),
|
deliver: z.boolean().optional(),
|
||||||
channel: z
|
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(),
|
.optional(),
|
||||||
to: z.string().optional(),
|
to: z.string().optional(),
|
||||||
thinking: z.string().optional(),
|
thinking: z.string().optional(),
|
||||||
@@ -681,6 +694,14 @@ const ClawdisSchema = z.object({
|
|||||||
webhookPath: z.string().optional(),
|
webhookPath: z.string().optional(),
|
||||||
})
|
})
|
||||||
.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
|
bridge: z
|
||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export type SessionEntry = {
|
|||||||
totalTokens?: number;
|
totalTokens?: number;
|
||||||
model?: string;
|
model?: string;
|
||||||
contextTokens?: number;
|
contextTokens?: number;
|
||||||
lastChannel?: "whatsapp" | "telegram" | "webchat";
|
lastChannel?: "whatsapp" | "telegram" | "discord" | "webchat";
|
||||||
lastTo?: string;
|
lastTo?: string;
|
||||||
skillsSnapshot?: SessionSkillSnapshot;
|
skillsSnapshot?: SessionSkillSnapshot;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
const deps: CliDeps = {
|
const deps: CliDeps = {
|
||||||
sendMessageWhatsApp: vi.fn(),
|
sendMessageWhatsApp: vi.fn(),
|
||||||
sendMessageTelegram: vi.fn(),
|
sendMessageTelegram: vi.fn(),
|
||||||
|
sendMessageDiscord: vi.fn(),
|
||||||
};
|
};
|
||||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
payloads: [{ text: "first" }, { text: " " }, { text: " last " }],
|
payloads: [{ text: "first" }, { text: " " }, { text: " last " }],
|
||||||
@@ -116,6 +117,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
const deps: CliDeps = {
|
const deps: CliDeps = {
|
||||||
sendMessageWhatsApp: vi.fn(),
|
sendMessageWhatsApp: vi.fn(),
|
||||||
sendMessageTelegram: vi.fn(),
|
sendMessageTelegram: vi.fn(),
|
||||||
|
sendMessageDiscord: vi.fn(),
|
||||||
};
|
};
|
||||||
const long = "a".repeat(2001);
|
const long = "a".repeat(2001);
|
||||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
@@ -146,6 +148,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
const deps: CliDeps = {
|
const deps: CliDeps = {
|
||||||
sendMessageWhatsApp: vi.fn(),
|
sendMessageWhatsApp: vi.fn(),
|
||||||
sendMessageTelegram: vi.fn(),
|
sendMessageTelegram: vi.fn(),
|
||||||
|
sendMessageDiscord: vi.fn(),
|
||||||
};
|
};
|
||||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
payloads: [{ text: "hello" }],
|
payloads: [{ text: "hello" }],
|
||||||
@@ -183,6 +186,7 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
const deps: CliDeps = {
|
const deps: CliDeps = {
|
||||||
sendMessageWhatsApp: vi.fn(),
|
sendMessageWhatsApp: vi.fn(),
|
||||||
sendMessageTelegram: vi.fn(),
|
sendMessageTelegram: vi.fn(),
|
||||||
|
sendMessageDiscord: vi.fn(),
|
||||||
};
|
};
|
||||||
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
|
||||||
payloads: [{ text: "hello" }],
|
payloads: [{ text: "hello" }],
|
||||||
@@ -212,4 +216,47 @@ describe("runCronIsolatedAgentTurn", () => {
|
|||||||
expect(deps.sendMessageWhatsApp).not.toHaveBeenCalled();
|
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(
|
function resolveDeliveryTarget(
|
||||||
cfg: ClawdisConfig,
|
cfg: ClawdisConfig,
|
||||||
jobPayload: {
|
jobPayload: {
|
||||||
channel?: "last" | "whatsapp" | "telegram";
|
channel?: "last" | "whatsapp" | "telegram" | "discord";
|
||||||
to?: string;
|
to?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -76,7 +76,11 @@ function resolveDeliveryTarget(
|
|||||||
const lastTo = typeof main?.lastTo === "string" ? main.lastTo.trim() : "";
|
const lastTo = typeof main?.lastTo === "string" ? main.lastTo.trim() : "";
|
||||||
|
|
||||||
const channel = (() => {
|
const channel = (() => {
|
||||||
if (requestedChannel === "whatsapp" || requestedChannel === "telegram") {
|
if (
|
||||||
|
requestedChannel === "whatsapp" ||
|
||||||
|
requestedChannel === "telegram" ||
|
||||||
|
requestedChannel === "discord"
|
||||||
|
) {
|
||||||
return requestedChannel;
|
return requestedChannel;
|
||||||
}
|
}
|
||||||
return lastChannel ?? "whatsapp";
|
return lastChannel ?? "whatsapp";
|
||||||
@@ -366,6 +370,50 @@ export async function runCronIsolatedAgentTurn(params: {
|
|||||||
return { status: "error", summary, error: String(err) };
|
return { status: "error", summary, error: String(err) };
|
||||||
return { status: "ok", summary };
|
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;
|
thinking?: string;
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
deliver?: boolean;
|
deliver?: boolean;
|
||||||
channel?: "last" | "whatsapp" | "telegram";
|
channel?: "last" | "whatsapp" | "telegram" | "discord";
|
||||||
to?: string;
|
to?: string;
|
||||||
bestEffortDeliver?: boolean;
|
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;
|
messageTemplate?: string;
|
||||||
textTemplate?: string;
|
textTemplate?: string;
|
||||||
deliver?: boolean;
|
deliver?: boolean;
|
||||||
channel?: "last" | "whatsapp" | "telegram";
|
channel?: "last" | "whatsapp" | "telegram" | "discord";
|
||||||
to?: string;
|
to?: string;
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
@@ -50,7 +50,7 @@ export type HookAction =
|
|||||||
wakeMode: "now" | "next-heartbeat";
|
wakeMode: "now" | "next-heartbeat";
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
deliver?: boolean;
|
deliver?: boolean;
|
||||||
channel?: "last" | "whatsapp" | "telegram";
|
channel?: "last" | "whatsapp" | "telegram" | "discord";
|
||||||
to?: string;
|
to?: string;
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
timeoutSeconds?: number;
|
timeoutSeconds?: number;
|
||||||
@@ -86,7 +86,7 @@ type HookTransformResult = Partial<{
|
|||||||
name: string;
|
name: string;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
deliver: boolean;
|
deliver: boolean;
|
||||||
channel: "last" | "whatsapp" | "telegram";
|
channel: "last" | "whatsapp" | "telegram" | "discord";
|
||||||
to: string;
|
to: string;
|
||||||
thinking: string;
|
thinking: string;
|
||||||
timeoutSeconds: number;
|
timeoutSeconds: number;
|
||||||
|
|||||||
@@ -450,6 +450,7 @@ export const CronPayloadSchema = Type.Union([
|
|||||||
Type.Literal("last"),
|
Type.Literal("last"),
|
||||||
Type.Literal("whatsapp"),
|
Type.Literal("whatsapp"),
|
||||||
Type.Literal("telegram"),
|
Type.Literal("telegram"),
|
||||||
|
Type.Literal("discord"),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
to: Type.Optional(Type.String()),
|
to: Type.Optional(Type.String()),
|
||||||
|
|||||||
@@ -1793,6 +1793,61 @@ describe("gateway server", () => {
|
|||||||
await server.close();
|
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 () => {
|
test("agent ignores webchat last-channel for routing", async () => {
|
||||||
testAllowFrom = ["+1555"];
|
testAllowFrom = ["+1555"];
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ import {
|
|||||||
import { CronService } from "../cron/service.js";
|
import { CronService } from "../cron/service.js";
|
||||||
import { resolveCronStorePath } from "../cron/store.js";
|
import { resolveCronStorePath } from "../cron/store.js";
|
||||||
import type { CronJob, CronJobCreate, CronJobPatch } from "../cron/types.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 { isVerbose } from "../globals.js";
|
||||||
import { onAgentEvent } from "../infra/agent-events.js";
|
import { onAgentEvent } from "../infra/agent-events.js";
|
||||||
import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js";
|
import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js";
|
||||||
@@ -273,9 +275,11 @@ const logHooks = log.child("hooks");
|
|||||||
const logWsControl = log.child("ws");
|
const logWsControl = log.child("ws");
|
||||||
const logWhatsApp = logProviders.child("whatsapp");
|
const logWhatsApp = logProviders.child("whatsapp");
|
||||||
const logTelegram = logProviders.child("telegram");
|
const logTelegram = logProviders.child("telegram");
|
||||||
|
const logDiscord = logProviders.child("discord");
|
||||||
const canvasRuntime = runtimeForLogger(logCanvas);
|
const canvasRuntime = runtimeForLogger(logCanvas);
|
||||||
const whatsappRuntimeEnv = runtimeForLogger(logWhatsApp);
|
const whatsappRuntimeEnv = runtimeForLogger(logWhatsApp);
|
||||||
const telegramRuntimeEnv = runtimeForLogger(logTelegram);
|
const telegramRuntimeEnv = runtimeForLogger(logTelegram);
|
||||||
|
const discordRuntimeEnv = runtimeForLogger(logDiscord);
|
||||||
|
|
||||||
function resolveBonjourCliPath(): string | undefined {
|
function resolveBonjourCliPath(): string | undefined {
|
||||||
const envPath = process.env.CLAWDIS_CLI_PATH?.trim();
|
const envPath = process.env.CLAWDIS_CLI_PATH?.trim();
|
||||||
@@ -1378,13 +1382,17 @@ export async function startGatewayServer(
|
|||||||
const channel =
|
const channel =
|
||||||
channelRaw === "whatsapp" ||
|
channelRaw === "whatsapp" ||
|
||||||
channelRaw === "telegram" ||
|
channelRaw === "telegram" ||
|
||||||
|
channelRaw === "discord" ||
|
||||||
channelRaw === "last"
|
channelRaw === "last"
|
||||||
? channelRaw
|
? channelRaw
|
||||||
: channelRaw === undefined
|
: channelRaw === undefined
|
||||||
? "last"
|
? "last"
|
||||||
: null;
|
: null;
|
||||||
if (channel === 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 toRaw = payload.to;
|
||||||
const to =
|
const to =
|
||||||
@@ -1703,8 +1711,10 @@ export async function startGatewayServer(
|
|||||||
});
|
});
|
||||||
let whatsappAbort: AbortController | null = null;
|
let whatsappAbort: AbortController | null = null;
|
||||||
let telegramAbort: AbortController | null = null;
|
let telegramAbort: AbortController | null = null;
|
||||||
|
let discordAbort: AbortController | null = null;
|
||||||
let whatsappTask: Promise<unknown> | null = null;
|
let whatsappTask: Promise<unknown> | null = null;
|
||||||
let telegramTask: Promise<unknown> | null = null;
|
let telegramTask: Promise<unknown> | null = null;
|
||||||
|
let discordTask: Promise<unknown> | null = null;
|
||||||
let whatsappRuntime: WebProviderStatus = {
|
let whatsappRuntime: WebProviderStatus = {
|
||||||
running: false,
|
running: false,
|
||||||
connected: false,
|
connected: false,
|
||||||
@@ -1728,6 +1738,17 @@ export async function startGatewayServer(
|
|||||||
lastError: null,
|
lastError: null,
|
||||||
mode: 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>();
|
const clients = new Set<Client>();
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
// Track per-run sequence to detect out-of-order/lost agent events.
|
// 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 () => {
|
const startProviders = async () => {
|
||||||
await startWhatsAppProvider();
|
await startWhatsAppProvider();
|
||||||
await startTelegramProvider();
|
await startTelegramProvider();
|
||||||
|
await startDiscordProvider();
|
||||||
};
|
};
|
||||||
|
|
||||||
const broadcast = (
|
const broadcast = (
|
||||||
@@ -3784,6 +3884,21 @@ export async function startGatewayServer(
|
|||||||
lastProbeAt = Date.now();
|
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 linked = await webAuthExists();
|
||||||
const authAgeMs = getWebAuthAgeMs();
|
const authAgeMs = getWebAuthAgeMs();
|
||||||
const self = readWebSelfId();
|
const self = readWebSelfId();
|
||||||
@@ -3817,6 +3932,16 @@ export async function startGatewayServer(
|
|||||||
probe: telegramProbe,
|
probe: telegramProbe,
|
||||||
lastProbeAt,
|
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,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -5588,6 +5713,23 @@ export async function startGatewayServer(
|
|||||||
payload,
|
payload,
|
||||||
});
|
});
|
||||||
respond(true, payload, undefined, { provider });
|
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 {
|
} else {
|
||||||
const result = await sendMessageWhatsApp(to, message, {
|
const result = await sendMessageWhatsApp(to, message, {
|
||||||
mediaUrl: params.mediaUrl,
|
mediaUrl: params.mediaUrl,
|
||||||
@@ -5723,6 +5865,7 @@ export async function startGatewayServer(
|
|||||||
if (
|
if (
|
||||||
requestedChannel === "whatsapp" ||
|
requestedChannel === "whatsapp" ||
|
||||||
requestedChannel === "telegram" ||
|
requestedChannel === "telegram" ||
|
||||||
|
requestedChannel === "discord" ||
|
||||||
requestedChannel === "webchat"
|
requestedChannel === "webchat"
|
||||||
) {
|
) {
|
||||||
return requestedChannel;
|
return requestedChannel;
|
||||||
@@ -5740,7 +5883,8 @@ export async function startGatewayServer(
|
|||||||
if (explicit) return explicit;
|
if (explicit) return explicit;
|
||||||
if (
|
if (
|
||||||
resolvedChannel === "whatsapp" ||
|
resolvedChannel === "whatsapp" ||
|
||||||
resolvedChannel === "telegram"
|
resolvedChannel === "telegram" ||
|
||||||
|
resolvedChannel === "discord"
|
||||||
) {
|
) {
|
||||||
return lastTo || undefined;
|
return lastTo || undefined;
|
||||||
}
|
}
|
||||||
@@ -5975,6 +6119,7 @@ export async function startGatewayServer(
|
|||||||
}
|
}
|
||||||
await stopWhatsAppProvider();
|
await stopWhatsAppProvider();
|
||||||
await stopTelegramProvider();
|
await stopTelegramProvider();
|
||||||
|
await stopDiscordProvider();
|
||||||
cron.stop();
|
cron.stop();
|
||||||
heartbeatRunner.stop();
|
heartbeatRunner.stop();
|
||||||
broadcast("shutdown", {
|
broadcast("shutdown", {
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ export function isVerbose() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function logVerbose(message: string) {
|
export function logVerbose(message: string) {
|
||||||
if (globalVerbose) {
|
// if (globalVerbose) {
|
||||||
console.log(chalk.gray(message));
|
console.log(chalk.gray(message));
|
||||||
try {
|
try {
|
||||||
getLogger().debug({ message }, "verbose");
|
getLogger().debug({ message }, "verbose");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore logger failures to avoid breaking verbose printing
|
// ignore logger failures to avoid breaking verbose printing
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setYes(v: boolean) {
|
export function setYes(v: boolean) {
|
||||||
|
|||||||
Reference in New Issue
Block a user