fix: add discord mention context history

This commit is contained in:
Peter Steinberger
2026-01-01 23:58:35 +01:00
parent 06e379a239
commit 38d8a669b4
6 changed files with 69 additions and 5 deletions

View File

@@ -32,6 +32,7 @@
### Fixes ### Fixes
- Gateway CLI: read `CLAWDIS_GATEWAY_PASSWORD` from environment in `callGateway()` — allows `doctor`/`health` commands to auth without explicit `--password` flag. - Gateway CLI: read `CLAWDIS_GATEWAY_PASSWORD` from environment in `callGateway()` — allows `doctor`/`health` commands to auth without explicit `--password` flag.
- Auto-reply: suppress stray `HEARTBEAT_OK` acks so they never get delivered as messages. - Auto-reply: suppress stray `HEARTBEAT_OK` acks so they never get delivered as messages.
- Discord: include recent guild context when replying to mentions and add `discord.historyLimit` to tune how many messages are captured.
- Skills: switch imsg installer to brew tap formula. - Skills: switch imsg installer to brew tap formula.
- Skills: gate macOS-only skills by OS and surface block reasons in the Skills UI. - Skills: gate macOS-only skills by OS and surface block reasons in the Skills UI.
- Onboarding: show skill descriptions in the macOS setup flow and surface clearer Gateway/skills error messages. - Onboarding: show skill descriptions in the macOS setup flow and surface clearer Gateway/skills error messages.

View File

@@ -178,7 +178,8 @@ Configure the Discord bot by setting the bot token and optional gating:
users: ["987654321098765432"] // optional user allowlist (ids) users: ["987654321098765432"] // optional user allowlist (ids)
}, },
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
} }
} }
``` ```

View File

@@ -23,6 +23,7 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default; disable with `discord.requireMention = false`. 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. 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.
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.
@@ -45,7 +46,8 @@ Note: Discord does not provide a simple username → id lookup without extra gui
users: ["987654321098765432"] users: ["987654321098765432"]
}, },
requireMention: true, requireMention: true,
mediaMaxMb: 8 mediaMaxMb: 8,
historyLimit: 20
} }
} }
``` ```
@@ -54,6 +56,7 @@ Note: Discord does not provide a simple username → id lookup without extra gui
- `guildAllowFrom`: Optional allowlist for guild messages. Set `guilds` and/or `users` (ids). When both are set, both must match. - `guildAllowFrom`: Optional allowlist for guild messages. Set `guilds` and/or `users` (ids). When both are set, both must match.
- `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).
## 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.

View File

@@ -169,6 +169,8 @@ export type DiscordConfig = {
}; };
requireMention?: boolean; requireMention?: boolean;
mediaMaxMb?: number; mediaMaxMb?: number;
/** Number of recent guild messages to include for context (default: 20). */
historyLimit?: number;
}; };
export type SignalConfig = { export type SignalConfig = {
@@ -874,6 +876,7 @@ const ClawdisSchema = z.object({
.optional(), .optional(),
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(),
}) })
.optional(), .optional(),
signal: z signal: z

View File

@@ -31,6 +31,7 @@ export type MonitorDiscordOpts = {
}; };
requireMention?: boolean; requireMention?: boolean;
mediaMaxMb?: number; mediaMaxMb?: number;
historyLimit?: number;
}; };
type DiscordMediaInfo = { type DiscordMediaInfo = {
@@ -39,6 +40,12 @@ type DiscordMediaInfo = {
placeholder: string; placeholder: string;
}; };
type DiscordHistoryEntry = {
sender: string;
body: string;
timestamp?: number;
};
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const cfg = loadConfig(); const cfg = loadConfig();
const token = normalizeDiscordToken( const token = normalizeDiscordToken(
@@ -67,6 +74,10 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
opts.requireMention ?? cfg.discord?.requireMention ?? true; opts.requireMention ?? cfg.discord?.requireMention ?? true;
const mediaMaxBytes = const mediaMaxBytes =
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
const historyLimit = Math.max(
0,
opts.historyLimit ?? cfg.discord?.historyLimit ?? 20,
);
const client = new Client({ const client = new Client({
intents: [ intents: [
@@ -79,6 +90,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
}); });
const logger = getChildLogger({ module: "discord-auto-reply" }); const logger = getChildLogger({ module: "discord-auto-reply" });
const guildHistories = new Map<string, DiscordHistoryEntry[]>();
client.once(Events.ClientReady, () => { client.once(Events.ClientReady, () => {
runtime.log?.(`discord: logged in as ${client.user?.tag ?? "unknown"}`); runtime.log?.(`discord: logged in as ${client.user?.tag ?? "unknown"}`);
@@ -97,6 +109,24 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const botId = client.user?.id; const botId = client.user?.id;
const wasMentioned = const wasMentioned =
!isDirectMessage && Boolean(botId && message.mentions.has(botId)); !isDirectMessage && Boolean(botId && message.mentions.has(botId));
const attachment = message.attachments.first();
const baseText =
message.content?.trim() ||
(attachment ? inferPlaceholder(attachment) : "") ||
message.embeds[0]?.description ||
"";
if (!isDirectMessage && historyLimit > 0 && baseText) {
const history = guildHistories.get(message.channelId) ?? [];
history.push({
sender: message.member?.displayName ?? message.author.tag,
body: baseText,
timestamp: message.createdTimestamp,
});
while (history.length > historyLimit) history.shift();
guildHistories.set(message.channelId, history);
}
if (!isDirectMessage && requireMention) { if (!isDirectMessage && requireMention) {
if (botId && !wasMentioned) { if (botId && !wasMentioned) {
logger.info( logger.info(
@@ -166,15 +196,37 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const fromLabel = isDirectMessage const fromLabel = isDirectMessage
? buildDirectLabel(message) ? buildDirectLabel(message)
: buildGuildLabel(message); : buildGuildLabel(message);
const body = formatAgentEnvelope({ let combinedBody = formatAgentEnvelope({
surface: "Discord", surface: "Discord",
from: fromLabel, from: fromLabel,
timestamp: message.createdTimestamp, timestamp: message.createdTimestamp,
body: text, body: text,
}); });
let shouldClearHistory = false;
if (!isDirectMessage) {
const history =
historyLimit > 0 ? (guildHistories.get(message.channelId) ?? []) : [];
const historyWithoutCurrent =
history.length > 0 ? history.slice(0, -1) : [];
if (historyWithoutCurrent.length > 0) {
const historyText = historyWithoutCurrent
.map((entry) =>
formatAgentEnvelope({
surface: "Discord",
from: fromLabel,
timestamp: entry.timestamp,
body: `${entry.sender}: ${entry.body}`,
}),
)
.join("\n");
combinedBody = `[Chat messages since your last reply - for context]\n${historyText}\n\n[Current message - respond to this]\n${combinedBody}`;
}
combinedBody = `${combinedBody}\n[from: ${message.member?.displayName ?? message.author.tag}]`;
shouldClearHistory = true;
}
const ctxPayload = { const ctxPayload = {
Body: body, Body: combinedBody,
From: isDirectMessage From: isDirectMessage
? `discord:${message.author.id}` ? `discord:${message.author.id}`
: `group:${message.channelId}`, : `group:${message.channelId}`,
@@ -209,7 +261,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
} }
if (isVerbose()) { if (isVerbose()) {
const preview = body.slice(0, 200).replace(/\n/g, "\\n"); const preview = combinedBody.slice(0, 200).replace(/\n/g, "\\n");
logVerbose( logVerbose(
`discord inbound: channel=${message.channelId} from=${ctxPayload.From} preview="${preview}"`, `discord inbound: channel=${message.channelId} from=${ctxPayload.From} preview="${preview}"`,
); );
@@ -235,6 +287,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
token, token,
runtime, runtime,
}); });
if (!isDirectMessage && shouldClearHistory && historyLimit > 0) {
guildHistories.set(message.channelId, []);
}
} catch (err) { } catch (err) {
runtime.error?.(danger(`Discord handler failed: ${String(err)}`)); runtime.error?.(danger(`Discord handler failed: ${String(err)}`));
} }

View File

@@ -2098,6 +2098,7 @@ export async function startGatewayServer(
guildAllowFrom: cfg.discord?.guildAllowFrom, guildAllowFrom: cfg.discord?.guildAllowFrom,
requireMention: cfg.discord?.requireMention, requireMention: cfg.discord?.requireMention,
mediaMaxMb: cfg.discord?.mediaMaxMb, mediaMaxMb: cfg.discord?.mediaMaxMb,
historyLimit: cfg.discord?.historyLimit,
}) })
.catch((err) => { .catch((err) => {
discordRuntime = { discordRuntime = {