Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/discord.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

</Tab>
Expand Down Expand Up @@ -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

Expand All @@ -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.
15 changes: 11 additions & 4 deletions packages/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
),
Expand All @@ -45,6 +47,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({
Expand Down Expand Up @@ -247,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;
Expand All @@ -259,6 +264,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. */
Expand Down
56 changes: 43 additions & 13 deletions packages/triggers/discord.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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[];
Expand All @@ -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;
}

Expand All @@ -91,6 +94,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);
}

/**
Expand Down Expand Up @@ -205,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;
Expand All @@ -221,13 +240,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;
}
Expand Down
23 changes: 23 additions & 0 deletions packages/triggers/discord_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
inviteUrl,
shouldHandle,
splitMessage,
typingLoop,
} from "./discord.ts";

const BOT_ID = "bot-1";
Expand All @@ -14,6 +15,7 @@ function msg(overrides: Partial<DiscordMessage>): DiscordMessage {
return {
id: "m1",
channel_id: "c-issues",
guild_id: "g1",
content: "the export breaks on big files",
author: { id: "user-1" },
...overrides,
Expand Down Expand Up @@ -42,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
Expand Down Expand Up @@ -70,6 +81,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) =>
Expand Down
1 change: 1 addition & 0 deletions packages/triggers/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export function triggersFromConfig(
fromUsers: t.from_users,
replyChannel: t.reply_channel,
allowSilence: t.allow_silence,
showTyping: t.show_typing,
}),
);
break;
Expand Down
9 changes: 7 additions & 2 deletions schema/agent.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,15 @@
"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",
"minLength": 1
}
},
"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": {
Expand All @@ -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": [
Expand Down
Loading