diff --git a/CHANGELOG.md b/CHANGELOG.md index 189c088d2..b0897c57b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`). - Signal: add `signal-cli` JSON-RPC support for send/receive via the Signal provider. - Chat UI: add recent-session dropdown switcher (main first) in macOS/iOS/Android + Control UI. +- Discord: allow agent-triggered reactions via `clawdis_discord` when enabled, and surface message ids in context. - Tests: add a Z.AI live test gate for smoke validation when keys are present. - macOS Debug: add app log verbosity and rolling file log toggle for swift-log-backed app logs. - CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths. diff --git a/docs/configuration.md b/docs/configuration.md index 7ef6bbd74..c7ca94330 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -179,7 +179,8 @@ Configure the Discord bot by setting the bot token and optional gating: }, requireMention: true, // require @bot mentions in guilds mediaMaxMb: 8, // clamp inbound media size - historyLimit: 20 // include last N guild messages as context + historyLimit: 20, // include last N guild messages as context + enableReactions: false // allow agent-triggered reactions } } ``` diff --git a/docs/discord.md b/docs/discord.md index 5fcb5445f..7bd17715f 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -24,6 +24,7 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa 7. Optional DM allowlist: reuse `discord.allowFrom` with user ids (`1234567890` or `discord:1234567890`). Use `"*"` to allow all DMs. 8. Optional guild allowlist: set `discord.guildAllowFrom` with `guilds` and/or `users` to gate who can invoke the bot in servers. 9. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable. +10. Optional reactions: set `discord.enableReactions = true` to allow the agent to react to Discord messages via the `clawdis_discord` tool. Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets. @@ -47,7 +48,8 @@ Note: Discord does not provide a simple username → id lookup without extra gui }, requireMention: true, mediaMaxMb: 8, - historyLimit: 20 + historyLimit: 20, + enableReactions: false } } ``` @@ -57,6 +59,14 @@ Note: Discord does not provide a simple username → id lookup without extra gui - `requireMention`: when `true`, messages in guild channels must mention the bot. - `mediaMaxMb`: clamp inbound media saved to disk. - `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables). +- `enableReactions`: allow agent-triggered reactions via the `clawdis_discord` tool (default `false`). + +## Reactions +When `discord.enableReactions = true`, the agent can call `clawdis_discord` with: +- `action: "react"` +- `channelId`, `messageId`, `emoji` + +Discord message ids are surfaced in the injected context (`[discord message id: …]` and history lines) so the agent can target them. ## 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. diff --git a/src/agents/clawdis-tools.ts b/src/agents/clawdis-tools.ts index e77a66472..38cf1c32a 100644 --- a/src/agents/clawdis-tools.ts +++ b/src/agents/clawdis-tools.ts @@ -41,6 +41,7 @@ import { } from "../cli/nodes-screen.js"; import { parseDurationMs } from "../cli/parse-duration.js"; import { loadConfig } from "../config/config.js"; +import { reactMessageDiscord } from "../discord/send.js"; import { callGateway } from "../gateway/call.js"; import { detectMime } from "../media/mime.js"; import { sanitizeToolResultImages } from "./tool-images.js"; @@ -1422,6 +1423,48 @@ const GatewayToolSchema = Type.Union([ }), ]); +const DiscordToolSchema = Type.Union([ + Type.Object({ + action: Type.Literal("react"), + channelId: Type.String(), + messageId: Type.String(), + emoji: Type.String(), + }), +]); + +function createDiscordTool(): AnyAgentTool { + return { + label: "Clawdis Discord", + name: "clawdis_discord", + description: + "React to Discord messages. Requires discord.enableReactions=true in config.", + parameters: DiscordToolSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const action = readStringParam(params, "action", { required: true }); + if (action !== "react") throw new Error(`Unknown action: ${action}`); + + const cfg = loadConfig(); + if (!cfg.discord?.enableReactions) { + throw new Error( + "Discord reactions are disabled (set discord.enableReactions=true).", + ); + } + + const channelId = readStringParam(params, "channelId", { + required: true, + }); + const messageId = readStringParam(params, "messageId", { + required: true, + }); + const emoji = readStringParam(params, "emoji", { required: true }); + + await reactMessageDiscord(channelId, messageId, emoji); + return jsonResult({ ok: true }); + }, + }; +} + function createGatewayTool(): AnyAgentTool { return { label: "Clawdis Gateway", @@ -1470,6 +1513,7 @@ export function createClawdisTools(): AnyAgentTool[] { createCanvasTool(), createNodesTool(), createCronTool(), + createDiscordTool(), createGatewayTool(), ]; } diff --git a/src/config/config.ts b/src/config/config.ts index 2539aeb3d..803d16a62 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -171,6 +171,8 @@ export type DiscordConfig = { mediaMaxMb?: number; /** Number of recent guild messages to include for context (default: 20). */ historyLimit?: number; + /** Allow agent-triggered Discord reactions (default: false). */ + enableReactions?: boolean; }; export type SignalConfig = { @@ -879,6 +881,7 @@ const ClawdisSchema = z.object({ requireMention: z.boolean().optional(), mediaMaxMb: z.number().positive().optional(), historyLimit: z.number().int().min(0).optional(), + enableReactions: z.boolean().optional(), }) .optional(), signal: z diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 04e3c9ea5..0ad4e4247 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -44,6 +44,7 @@ type DiscordHistoryEntry = { sender: string; body: string; timestamp?: number; + messageId?: string; }; export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { @@ -122,6 +123,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { sender: message.member?.displayName ?? message.author.tag, body: baseText, timestamp: message.createdTimestamp, + messageId: message.id, }); while (history.length > historyLimit) history.shift(); guildHistories.set(message.channelId, history); @@ -196,11 +198,12 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const fromLabel = isDirectMessage ? buildDirectLabel(message) : buildGuildLabel(message); + const textWithId = `${text}\n[discord message id: ${message.id} channel: ${message.channelId}]`; let combinedBody = formatAgentEnvelope({ surface: "Discord", from: fromLabel, timestamp: message.createdTimestamp, - body: text, + body: textWithId, }); let shouldClearHistory = false; if (!isDirectMessage) { @@ -215,7 +218,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { surface: "Discord", from: fromLabel, timestamp: entry.timestamp, - body: `${entry.sender}: ${entry.body}`, + body: `${entry.sender}: ${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`, }), ) .join("\n"); diff --git a/src/discord/send.ts b/src/discord/send.ts index 430dc38df..1afd90bd0 100644 --- a/src/discord/send.ts +++ b/src/discord/send.ts @@ -29,6 +29,11 @@ export type DiscordSendResult = { channelId: string; }; +export type DiscordReactOpts = { + token?: string; + rest?: REST; +}; + function resolveToken(explicit?: string) { const cfgToken = loadConfig().discord?.token; const token = normalizeDiscordToken( @@ -42,6 +47,16 @@ function resolveToken(explicit?: string) { return token; } +function normalizeReactionEmoji(raw: string) { + const trimmed = raw.trim(); + if (!trimmed) { + throw new Error("emoji required"); + } + const customMatch = trimmed.match(/^]+):(\d+)>$/); + const identifier = customMatch ? `${customMatch[1]}:${customMatch[2]}` : trimmed; + return encodeURIComponent(identifier); +} + function parseRecipient(raw: string): DiscordRecipient { const trimmed = raw.trim(); if (!trimmed) { @@ -164,3 +179,16 @@ export async function sendMessageDiscord( channelId: String(result.channel_id ?? channelId), }; } + +export async function reactMessageDiscord( + channelId: string, + messageId: string, + emoji: string, + opts: DiscordReactOpts = {}, +) { + const token = resolveToken(opts.token); + const rest = opts.rest ?? new REST({ version: "10" }).setToken(token); + const encoded = normalizeReactionEmoji(emoji); + await rest.put(Routes.channelMessageReaction(channelId, messageId, encoded)); + return { ok: true }; +}