From 2dac82ae1c6c3655bf62d5ad119003e7e0de53c9 Mon Sep 17 00:00:00 2001 From: Ratul Maharaj <56479869+RatulMaharaj@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:15:13 +0200 Subject: [PATCH 1/2] feat(discord): show_typing option keeps the typing indicator alive while the agent works Co-Authored-By: Claude Fable 5 --- packages/core/config/schema.ts | 5 ++++ packages/triggers/discord.ts | 38 +++++++++++++++++++++++++------ packages/triggers/discord_test.ts | 13 +++++++++++ packages/triggers/mod.ts | 1 + schema/agent.json | 5 ++++ 5 files changed, 55 insertions(+), 7 deletions(-) diff --git a/packages/core/config/schema.ts b/packages/core/config/schema.ts index c5d22b5..c21f360 100644 --- a/packages/core/config/schema.ts +++ b/packages/core/config/schema.ts @@ -45,6 +45,9 @@ const DiscordTriggerSchema = z.strictObject({ allow_silence: z.boolean().default(false).describe( "Post nothing when the agent replies with exactly __NO_REPLY__ (or nothing). Instruct the sentinel in purpose.", ), + show_typing: z.boolean().default(false).describe( + "Show the typing indicator in the source channel while the agent works. Looks odd with allow_silence: typing may end in no message.", + ), }).describe("Listen to Discord messages via the gateway; replies go in-channel by default."); const SlackTriggerSchema = z.strictObject({ @@ -259,6 +262,8 @@ export interface DiscordTriggerConfig { reply_channel?: string; /** Post nothing when the agent replies with exactly __NO_REPLY__ (or nothing). */ allow_silence: boolean; + /** Show the typing indicator in the source channel while the agent works. */ + show_typing: boolean; } /** A Slack trigger: listen to messages via Socket Mode. */ diff --git a/packages/triggers/discord.ts b/packages/triggers/discord.ts index aa0c0ab..3fbbc48 100644 --- a/packages/triggers/discord.ts +++ b/packages/triggers/discord.ts @@ -91,6 +91,19 @@ export interface DiscordTriggerOptions extends DiscordFilterOptions { replyChannel?: string; /** Suppress the reply when the agent answers with the NO_REPLY sentinel (or nothing). */ allowSilence?: boolean; + /** Show the typing indicator in the source channel while the agent works. */ + showTyping?: boolean; +} + +/** + * Fire `send` now and again every `intervalMs` until the returned stop + * function is called. Discord's typing indicator expires after ~10 s, so the + * default interval keeps it alive for the whole run. + */ +export function typingLoop(send: () => void, intervalMs = 8_000): () => void { + send(); + const timer = setInterval(send, intervalMs); + return () => clearInterval(timer); } /** @@ -221,13 +234,24 @@ export class DiscordTrigger implements Trigger { ? await this.#channelName(msg.channel_id) : undefined; if (!shouldHandle(msg, this.#botUserId, channelName, this.#opts)) break; - const result = await emit({ - id: msg.id, - trigger: this.name, - input: msg.content, - conversationKey: `discord:${msg.channel_id}`, - }); - await this.#reply(msg, result); + const stopTyping = this.#opts.showTyping + ? typingLoop(() => { + this.#api(`/channels/${msg.channel_id}/typing`, { method: "POST" }) + .then((res) => res.body?.cancel()) + .catch(() => {}); // typing is cosmetic — never let it break the run + }) + : undefined; + try { + const result = await emit({ + id: msg.id, + trigger: this.name, + input: msg.content, + conversationKey: `discord:${msg.channel_id}`, + }); + await this.#reply(msg, result); + } finally { + stopTyping?.(); + } } break; } diff --git a/packages/triggers/discord_test.ts b/packages/triggers/discord_test.ts index f9fab28..f2024e6 100644 --- a/packages/triggers/discord_test.ts +++ b/packages/triggers/discord_test.ts @@ -6,6 +6,7 @@ import { inviteUrl, shouldHandle, splitMessage, + typingLoop, } from "./discord.ts"; const BOT_ID = "bot-1"; @@ -70,6 +71,18 @@ Deno.test("inviteUrl: correct client_id, scope, and permissions integer", () => ); }); +Deno.test("typingLoop: fires immediately, keeps firing, and stops cleanly", async () => { + let fired = 0; + const stop = typingLoop(() => fired++, 5); + assertEquals(fired, 1); // immediate first fire + await new Promise((r) => setTimeout(r, 20)); + assert(fired > 1); // kept alive on the interval + stop(); + const atStop = fired; + await new Promise((r) => setTimeout(r, 20)); + assertEquals(fired, atStop); // nothing after stop +}); + Deno.test("fetchApplicationId: returns id, throws readable error on bad token", async () => { const ok = ((_url: RequestInfo | URL, _init?: RequestInit) => diff --git a/packages/triggers/mod.ts b/packages/triggers/mod.ts index 89f28a3..42fa341 100644 --- a/packages/triggers/mod.ts +++ b/packages/triggers/mod.ts @@ -91,6 +91,7 @@ export function triggersFromConfig( fromUsers: t.from_users, replyChannel: t.reply_channel, allowSilence: t.allow_silence, + showTyping: t.show_typing, }), ); break; diff --git a/schema/agent.json b/schema/agent.json index 85dcd16..bb781a0 100644 --- a/schema/agent.json +++ b/schema/agent.json @@ -111,6 +111,11 @@ "default": false, "description": "Post nothing when the agent replies with exactly __NO_REPLY__ (or nothing). Instruct the sentinel in purpose.", "type": "boolean" + }, + "show_typing": { + "default": false, + "description": "Show the typing indicator in the source channel while the agent works. Looks odd with allow_silence: typing may end in no message.", + "type": "boolean" } }, "required": [ From e4dbb0aaf433b5e502164065068a5fe41f6a8d79 Mon Sep 17 00:00:00 2001 From: Ratul Maharaj <56479869+RatulMaharaj@users.noreply.github.com> Date: Sun, 5 Jul 2026 23:20:09 +0200 Subject: [PATCH 2/2] feat(discord): DM support and explicit online presence Co-Authored-By: Claude Fable 5 --- docs/discord.mdx | 9 ++++++++- packages/core/config/schema.ts | 10 ++++++---- packages/triggers/discord.ts | 18 ++++++++++++------ packages/triggers/discord_test.ts | 10 ++++++++++ schema/agent.json | 4 ++-- 5 files changed, 38 insertions(+), 13 deletions(-) diff --git a/docs/discord.mdx b/docs/discord.mdx index c4bdf2e..988b944 100644 --- a/docs/discord.mdx +++ b/docs/discord.mdx @@ -19,6 +19,7 @@ triggers: # from_users: ["amin", "ratul"] # only handle these authors (user ids or usernames) # reply_channel: "1522..." # post replies here instead of the source channel # allow_silence: true # a reply of exactly __NO_REPLY__ posts nothing + # show_typing: true # show "typing…" in the channel while the agent works ``` @@ -69,7 +70,11 @@ Setting up a Discord bot takes about 15 minutes: 4. Run `af discord-invite agent.yaml` to print a ready-made invite URL with the correct scopes and permissions, then open it and invite the bot to your server. 5. Run the agent: `af run agent.yaml` -The agent replies in-channel to the triggering message; conversations are keyed per channel or thread, and `memory.scope: thread` continues them ([Memory](agent-file.md#memory)). It ignores bots, itself, and empty messages; long replies split at Discord's 2000-char limit. +The agent replies in-channel to the triggering message; conversations are keyed per channel or thread, and `memory.scope: thread` continues them ([Memory](agent-file.md#memory)). It ignores bots, itself, and empty messages; long replies split at Discord's 2000-char limit. While connected the bot shows as online, and with `show_typing: true` it also shows the "typing…" indicator in the source channel for as long as a run is in flight. A run can take a while on a slow model, and the indicator is what tells the person who asked that something is happening. + +## Direct messages + +The bot answers DMs too. A DM addresses the bot by definition, so the `channels` filter and `require_mention` don't apply there. `from_users` still does, and it's the knob to reach for if you don't want the bot talking to everyone who can see it in a server. Each DM is its own conversation, with history kept separate from every channel the bot watches. ## Observer agents @@ -79,4 +84,6 @@ Three optional keys together turn the trigger from a chatbot into an observer - `reply_channel` — deliver replies to a dedicated channel instead of the source. Out-of-channel replies quote the triggering message and link back to it. - `allow_silence` — let the agent say nothing. Instruct it in `purpose` to answer with exactly `__NO_REPLY__` when it has no feedback; the trigger then posts nothing instead of a "looks fine" reply on every message. +Leave `show_typing` off for an observer. A typing indicator that ends in no message reads as the bot changing its mind, and an observer stays silent most of the time. + Every run — replied or silent — lands in the agent's [run history](docker-run.md#persistence-the-data-volume) with its status, steps and tokens. diff --git a/packages/core/config/schema.ts b/packages/core/config/schema.ts index c21f360..9c7146f 100644 --- a/packages/core/config/schema.ts +++ b/packages/core/config/schema.ts @@ -30,9 +30,11 @@ const ModelConfigSchema = z.strictObject({ const DiscordTriggerSchema = z.strictObject({ type: z.literal("discord"), channels: z.array(z.string().min(1)).optional().describe( - "Channel names or ids to listen in; omit for all channels the bot can see.", + "Channel names or ids to listen in; omit for all channels the bot can see. DMs always pass.", + ), + require_mention: z.boolean().optional().describe( + "Only respond when the bot is @-mentioned. DMs always address the bot.", ), - require_mention: z.boolean().optional().describe("Only respond when the bot is @-mentioned."), token_env: z.string().min(1).default("DISCORD_BOT_TOKEN").describe( "Env var holding the Discord bot token.", ), @@ -250,9 +252,9 @@ export interface ModelConfig { export interface DiscordTriggerConfig { /** Discriminant for TriggerConfig. */ type: "discord"; - /** Channel names or ids to listen in; omit for all channels the bot can see. */ + /** Channel names or ids to listen in; omit for all channels the bot can see. DMs always pass. */ channels?: string[]; - /** Only respond when the bot is @-mentioned. */ + /** Only respond when the bot is @-mentioned. DMs always address the bot. */ require_mention?: boolean; /** Env var holding the Discord bot token. */ token_env: string; diff --git a/packages/triggers/discord.ts b/packages/triggers/discord.ts index 3fbbc48..9bec9c9 100644 --- a/packages/triggers/discord.ts +++ b/packages/triggers/discord.ts @@ -7,8 +7,8 @@ import { NO_REPLY, splitMessage } from "./text.ts"; // ~200 lines keeps the container minimal (Plan 0, principle 8). const API = "https://discord.com/api/v10"; -// GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT -const INTENTS = (1 << 0) | (1 << 9) | (1 << 15); +// GUILDS | GUILD_MESSAGES | DIRECT_MESSAGES | MESSAGE_CONTENT +const INTENTS = (1 << 0) | (1 << 9) | (1 << 12) | (1 << 15); export { NO_REPLY, splitMessage }; @@ -51,9 +51,9 @@ export interface DiscordMessage { /** Message filters shared by {@linkcode shouldHandle} and {@linkcode DiscordTriggerOptions}. */ export interface DiscordFilterOptions { - /** Channel names or ids to listen in; empty/undefined = all channels. */ + /** Channel names or ids to listen in; empty/undefined = all channels. DMs always pass. */ channels?: string[]; - /** Only handle messages that @-mention the bot. */ + /** Only handle messages that @-mention the bot (DMs always pass). */ requireMention?: boolean; /** Only handle messages from these authors (user ids or usernames); empty/undefined = anyone. */ fromUsers?: string[]; @@ -74,12 +74,15 @@ export function shouldHandle( !opts.fromUsers.some((u) => u === msg.author.id || u === msg.author.username) ) return false; if (!msg.content.trim()) return false; - if (opts.channels?.length) { + // A DM addresses the bot by definition: the channel filter and the mention + // gate only apply to guild messages (DMs carry no guild_id). + const isDM = !msg.guild_id; + if (!isDM && opts.channels?.length) { const match = opts.channels.includes(msg.channel_id) || (channelName !== undefined && opts.channels.includes(channelName)); if (!match) return false; } - if (opts.requireMention && !msg.mentions?.some((m) => m.id === botUserId)) return false; + if (!isDM && opts.requireMention && !msg.mentions?.some((m) => m.id === botUserId)) return false; return true; } @@ -218,6 +221,9 @@ export class DiscordTrigger implements Trigger { token: this.#opts.token, intents: INTENTS, properties: { os: "linux", browser: "looped-af", device: "looped-af" }, + // Explicit presence so the bot reads as online for as long as + // the gateway connection is up. + presence: { since: null, activities: [], status: "online", afk: false }, }, })); break; diff --git a/packages/triggers/discord_test.ts b/packages/triggers/discord_test.ts index f2024e6..d9aa916 100644 --- a/packages/triggers/discord_test.ts +++ b/packages/triggers/discord_test.ts @@ -15,6 +15,7 @@ function msg(overrides: Partial): DiscordMessage { return { id: "m1", channel_id: "c-issues", + guild_id: "g1", content: "the export breaks on big files", author: { id: "user-1" }, ...overrides, @@ -43,6 +44,15 @@ Deno.test("shouldHandle: require_mention", () => { assert(shouldHandle(msg({ mentions: [{ id: BOT_ID }] }), BOT_ID, "issues", opts)); }); +Deno.test("shouldHandle: DMs skip the channel filter and the mention gate", () => { + const dm = msg({ guild_id: undefined, channel_id: "dm-1" }); + assert(shouldHandle(dm, BOT_ID, undefined, { channels: ["issues"], requireMention: true })); + // from_users still applies in DMs + assert(!shouldHandle(dm, BOT_ID, undefined, { fromUsers: ["someone-else"] })); + // and the bot's own DMs are still ignored + assert(!shouldHandle(msg({ guild_id: undefined, author: { id: BOT_ID } }), BOT_ID, undefined, {})); +}); + Deno.test("shouldHandle: from_users matches by id or username, drops everyone else", () => { const opts = { fromUsers: ["user-1", "amin"] }; assert(shouldHandle(msg({}), BOT_ID, "issues", opts)); // by id diff --git a/schema/agent.json b/schema/agent.json index bb781a0..406b1d3 100644 --- a/schema/agent.json +++ b/schema/agent.json @@ -77,7 +77,7 @@ "const": "discord" }, "channels": { - "description": "Channel names or ids to listen in; omit for all channels the bot can see.", + "description": "Channel names or ids to listen in; omit for all channels the bot can see. DMs always pass.", "type": "array", "items": { "type": "string", @@ -85,7 +85,7 @@ } }, "require_mention": { - "description": "Only respond when the bot is @-mentioned.", + "description": "Only respond when the bot is @-mentioned. DMs always address the bot.", "type": "boolean" }, "token_env": {