feat: add discord reaction tool
This commit is contained in:
@@ -16,6 +16,7 @@
|
|||||||
- Agent runtime: accept legacy `Z_AI_API_KEY` for Z.AI provider auth (maps to `ZAI_API_KEY`).
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- CLI: add onboarding wizard (gateway + workspace + skills) with daemon installers and Anthropic/Minimax setup paths.
|
||||||
|
|||||||
@@ -179,7 +179,8 @@ Configure the Discord bot by setting the bot token and optional gating:
|
|||||||
},
|
},
|
||||||
requireMention: true, // require @bot mentions in guilds
|
requireMention: true, // require @bot mentions in guilds
|
||||||
mediaMaxMb: 8, // clamp inbound media size
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
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.
|
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,
|
requireMention: true,
|
||||||
mediaMaxMb: 8,
|
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.
|
- `requireMention`: when `true`, messages in guild channels must mention the bot.
|
||||||
- `mediaMaxMb`: clamp inbound media saved to disk.
|
- `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).
|
- `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
|
## 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.
|
- Treat the bot token like a password; prefer the `DISCORD_BOT_TOKEN` env var on supervised hosts or lock down the config file permissions.
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
} from "../cli/nodes-screen.js";
|
} from "../cli/nodes-screen.js";
|
||||||
import { parseDurationMs } from "../cli/parse-duration.js";
|
import { parseDurationMs } from "../cli/parse-duration.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import { reactMessageDiscord } from "../discord/send.js";
|
||||||
import { callGateway } from "../gateway/call.js";
|
import { callGateway } from "../gateway/call.js";
|
||||||
import { detectMime } from "../media/mime.js";
|
import { detectMime } from "../media/mime.js";
|
||||||
import { sanitizeToolResultImages } from "./tool-images.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<string, unknown>;
|
||||||
|
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 {
|
function createGatewayTool(): AnyAgentTool {
|
||||||
return {
|
return {
|
||||||
label: "Clawdis Gateway",
|
label: "Clawdis Gateway",
|
||||||
@@ -1470,6 +1513,7 @@ export function createClawdisTools(): AnyAgentTool[] {
|
|||||||
createCanvasTool(),
|
createCanvasTool(),
|
||||||
createNodesTool(),
|
createNodesTool(),
|
||||||
createCronTool(),
|
createCronTool(),
|
||||||
|
createDiscordTool(),
|
||||||
createGatewayTool(),
|
createGatewayTool(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ export type DiscordConfig = {
|
|||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
/** Number of recent guild messages to include for context (default: 20). */
|
/** Number of recent guild messages to include for context (default: 20). */
|
||||||
historyLimit?: number;
|
historyLimit?: number;
|
||||||
|
/** Allow agent-triggered Discord reactions (default: false). */
|
||||||
|
enableReactions?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SignalConfig = {
|
export type SignalConfig = {
|
||||||
@@ -879,6 +881,7 @@ const ClawdisSchema = z.object({
|
|||||||
requireMention: z.boolean().optional(),
|
requireMention: z.boolean().optional(),
|
||||||
mediaMaxMb: z.number().positive().optional(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
historyLimit: z.number().int().min(0).optional(),
|
historyLimit: z.number().int().min(0).optional(),
|
||||||
|
enableReactions: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
signal: z
|
signal: z
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type DiscordHistoryEntry = {
|
|||||||
sender: string;
|
sender: string;
|
||||||
body: string;
|
body: string;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
|
messageId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||||
@@ -122,6 +123,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
sender: message.member?.displayName ?? message.author.tag,
|
sender: message.member?.displayName ?? message.author.tag,
|
||||||
body: baseText,
|
body: baseText,
|
||||||
timestamp: message.createdTimestamp,
|
timestamp: message.createdTimestamp,
|
||||||
|
messageId: message.id,
|
||||||
});
|
});
|
||||||
while (history.length > historyLimit) history.shift();
|
while (history.length > historyLimit) history.shift();
|
||||||
guildHistories.set(message.channelId, history);
|
guildHistories.set(message.channelId, history);
|
||||||
@@ -196,11 +198,12 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
const fromLabel = isDirectMessage
|
const fromLabel = isDirectMessage
|
||||||
? buildDirectLabel(message)
|
? buildDirectLabel(message)
|
||||||
: buildGuildLabel(message);
|
: buildGuildLabel(message);
|
||||||
|
const textWithId = `${text}\n[discord message id: ${message.id} channel: ${message.channelId}]`;
|
||||||
let combinedBody = formatAgentEnvelope({
|
let combinedBody = formatAgentEnvelope({
|
||||||
surface: "Discord",
|
surface: "Discord",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: message.createdTimestamp,
|
timestamp: message.createdTimestamp,
|
||||||
body: text,
|
body: textWithId,
|
||||||
});
|
});
|
||||||
let shouldClearHistory = false;
|
let shouldClearHistory = false;
|
||||||
if (!isDirectMessage) {
|
if (!isDirectMessage) {
|
||||||
@@ -215,7 +218,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
surface: "Discord",
|
surface: "Discord",
|
||||||
from: fromLabel,
|
from: fromLabel,
|
||||||
timestamp: entry.timestamp,
|
timestamp: entry.timestamp,
|
||||||
body: `${entry.sender}: ${entry.body}`,
|
body: `${entry.sender}: ${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ export type DiscordSendResult = {
|
|||||||
channelId: string;
|
channelId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DiscordReactOpts = {
|
||||||
|
token?: string;
|
||||||
|
rest?: REST;
|
||||||
|
};
|
||||||
|
|
||||||
function resolveToken(explicit?: string) {
|
function resolveToken(explicit?: string) {
|
||||||
const cfgToken = loadConfig().discord?.token;
|
const cfgToken = loadConfig().discord?.token;
|
||||||
const token = normalizeDiscordToken(
|
const token = normalizeDiscordToken(
|
||||||
@@ -42,6 +47,16 @@ function resolveToken(explicit?: string) {
|
|||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeReactionEmoji(raw: string) {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new Error("emoji required");
|
||||||
|
}
|
||||||
|
const customMatch = trimmed.match(/^<a?:([^:>]+):(\d+)>$/);
|
||||||
|
const identifier = customMatch ? `${customMatch[1]}:${customMatch[2]}` : trimmed;
|
||||||
|
return encodeURIComponent(identifier);
|
||||||
|
}
|
||||||
|
|
||||||
function parseRecipient(raw: string): DiscordRecipient {
|
function parseRecipient(raw: string): DiscordRecipient {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -164,3 +179,16 @@ export async function sendMessageDiscord(
|
|||||||
channelId: String(result.channel_id ?? channelId),
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user