Files
clawdbot/src/discord/monitor.ts
Peter Steinberger 7acd26a2fc Move provider to a plugin-architecture (#661)
* refactor: introduce provider plugin registry

* refactor: move provider CLI to plugins

* docs: add provider plugin implementation notes

* refactor: shift provider runtime logic into plugins

* refactor: add plugin defaults and summaries

* docs: update provider plugin notes

* feat(commands): add /commands slash list

* Auto-reply: tidy help message

* Auto-reply: fix status command lint

* Tests: align google shared expectations

* Auto-reply: tidy help message

* Auto-reply: fix status command lint

* refactor: move provider routing into plugins

* test: align agent routing expectations

* docs: update provider plugin notes

* refactor: route replies via provider plugins

* docs: note route-reply plugin hooks

* refactor: extend provider plugin contract

* refactor: derive provider status from plugins

* refactor: unify gateway provider control

* refactor: use plugin metadata in auto-reply

* fix: parenthesize cron target selection

* refactor: derive gateway methods from plugins

* refactor: generalize provider logout

* refactor: route provider logout through plugins

* refactor: move WhatsApp web login methods into plugin

* refactor: generalize provider log prefixes

* refactor: centralize default chat provider

* refactor: derive provider lists from registry

* refactor: move provider reload noops into plugins

* refactor: resolve web login provider via alias

* refactor: derive CLI provider options from plugins

* refactor: derive prompt provider list from plugins

* style: apply biome lint fixes

* fix: resolve provider routing edge cases

* docs: update provider plugin refactor notes

* fix(gateway): harden agent provider routing

* refactor: move provider routing into plugins

* refactor: move provider CLI to plugins

* refactor: derive provider lists from registry

* fix: restore slash command parsing

* refactor: align provider ids for schema

* refactor: unify outbound target resolution

* fix: keep outbound labels stable

* feat: add msteams to cron surfaces

* fix: clean up lint build issues

* refactor: localize chat provider alias normalization

* refactor: drive gateway provider lists from plugins

* docs: update provider plugin notes

* style: format message-provider

* fix: avoid provider registry init cycles

* style: sort message-provider imports

* fix: relax provider alias map typing

* refactor: move provider routing into plugins

* refactor: add plugin pairing/config adapters

* refactor: route pairing and provider removal via plugins

* refactor: align auto-reply provider typing

* test: stabilize telegram media mocks

* docs: update provider plugin refactor notes

* refactor: pluginize outbound targets

* refactor: pluginize provider selection

* refactor: generalize text chunk limits

* docs: update provider plugin notes

* refactor: generalize group session/config

* fix: normalize provider id for room detection

* fix: avoid provider init in system prompt

* style: formatting cleanup

* refactor: normalize agent delivery targets

* test: update outbound delivery labels

* chore: fix lint regressions

* refactor: extend provider plugin adapters

* refactor: move elevated/block streaming defaults to plugins

* refactor: defer outbound send deps to plugins

* docs: note plugin-driven streaming/elevated defaults

* refactor: centralize webchat provider constant

* refactor: add provider setup adapters

* refactor: delegate provider add config to plugins

* docs: document plugin-driven provider add

* refactor: add plugin state/binding metadata

* refactor: build agent provider status from plugins

* docs: note plugin-driven agent bindings

* refactor: centralize internal provider constant usage

* fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing)

* refactor: centralize default chat provider

* refactor: centralize WhatsApp target normalization

* refactor: move provider routing into plugins

* refactor: normalize agent delivery targets

* chore: fix lint regressions

* fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing)

* feat: expand provider plugin adapters

* refactor: route auto-reply via provider plugins

* fix: align WhatsApp target normalization

* fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing)

* refactor: centralize WhatsApp target normalization

* feat: add /config chat config updates

* docs: add /config get alias

* feat(commands): add /commands slash list

* refactor: centralize default chat provider

* style: apply biome lint fixes

* chore: fix lint regressions

* fix: clean up whatsapp allowlist typing

* style: format config command helpers

* refactor: pluginize tool threading context

* refactor: normalize session announce targets

* docs: note new plugin threading and announce hooks

* refactor: pluginize message actions

* docs: update provider plugin actions notes

* fix: align provider action adapters

* refactor: centralize webchat checks

* style: format message provider helpers

* refactor: move provider onboarding into adapters

* docs: note onboarding provider adapters

* feat: add msteams onboarding adapter

* style: organize onboarding imports

* fix: normalize msteams allowFrom types

* feat: add plugin text chunk limits

* refactor: use plugin chunk limit fallbacks

* feat: add provider mention stripping hooks

* style: organize provider plugin type imports

* refactor: generalize health snapshots

* refactor: update macOS health snapshot handling

* docs: refresh health snapshot notes

* style: format health snapshot updates

* refactor: drive security warnings via plugins

* docs: note provider security adapter

* style: format provider security adapters

* refactor: centralize provider account defaults

* refactor: type gateway client identity constants

* chore: regen gateway protocol swift

* fix: degrade health on failed provider probe

* refactor: centralize pairing approve hint

* docs: add plugin CLI command references

* refactor: route auth and tool sends through plugins

* docs: expand provider plugin hooks

* refactor: document provider docking touchpoints

* refactor: normalize internal provider defaults

* refactor: streamline outbound delivery wiring

* refactor: make provider onboarding plugin-owned

* refactor: support provider-owned agent tools

* refactor: move telegram draft chunking into telegram module

* refactor: infer provider tool sends via extractToolSend

* fix: repair plugin onboarding imports

* refactor: de-dup outbound target normalization

* style: tidy plugin and agent imports

* refactor: data-drive provider selection line

* fix: satisfy lint after provider plugin rebase

* test: deflake gateway-cli coverage

* style: format gateway-cli coverage test

* refactor(provider-plugins): simplify provider ids

* test(pairing-cli): avoid provider-specific ternary

* style(macos): swiftformat HealthStore

* refactor(sandbox): derive provider tool denylist

* fix(sandbox): avoid plugin init in defaults

* refactor(provider-plugins): centralize provider aliases

* style(test): satisfy biome

* refactor(protocol): v3 providers.status maps

* refactor(ui): adapt to protocol v3

* refactor(macos): adapt to protocol v3

* test: update providers.status v3 fixtures

* refactor(gateway): map provider runtime snapshot

* test(gateway): update reload runtime snapshot

* refactor(whatsapp): normalize heartbeat provider id

* docs(refactor): update provider plugin notes

* style: satisfy biome after rebase

* fix: describe sandboxed elevated in prompt

* feat(gateway): add agent image attachments + live probe

* refactor: derive CLI provider options from plugins

* fix(gateway): harden agent provider routing

* fix(gateway): harden agent provider routing

* refactor: align provider ids for schema

* fix(protocol): keep agent provider string

* fix(gateway): harden agent provider routing

* fix(protocol): keep agent provider string

* refactor: normalize agent delivery targets

* refactor: support provider-owned agent tools

* refactor(config): provider-keyed elevated allowFrom

* style: satisfy biome

* fix(gateway): appease provider narrowing

* style: satisfy biome

* refactor(reply): move group intro hints into plugin

* fix(reply): avoid plugin registry init cycle

* refactor(providers): add lightweight provider dock

* refactor(gateway): use typed client id in connect

* refactor(providers): document docks and avoid init cycles

* refactor(providers): make media limit helper generic

* fix(providers): break plugin registry import cycles

* style: satisfy biome

* refactor(status-all): build providers table from plugins

* refactor(gateway): delegate web login to provider plugin

* refactor(provider): drop web alias

* refactor(provider): lazy-load monitors

* style: satisfy lint/format

* style: format status-all providers table

* style: swiftformat gateway discovery model

* test: make reload plan plugin-driven

* fix: avoid token stringification in status-all

* refactor: make provider IDs explicit in status

* feat: warn on signal/imessage provider runtime errors

* test: cover gateway provider runtime warnings in status

* fix: add runtime kind to provider status issues

* test: cover health degradation on probe failure

* fix: keep routeReply lightweight

* style: organize routeReply imports

* refactor(web): extract auth-store helpers

* refactor(whatsapp): lazy login imports

* refactor(outbound): route replies via plugin outbound

* docs: update provider plugin notes

* style: format provider status issues

* fix: make sandbox scope warning wrap-safe

* refactor: load outbound adapters from provider plugins

* docs: update provider plugin outbound notes

* style(macos): fix swiftformat lint

* docs: changelog for provider plugins

* fix(macos): satisfy swiftformat

* fix(macos): open settings via menu action

* style: format after rebase

* fix(macos): open Settings via menu action

---------

Co-authored-by: LK <luke@kyohere.com>
Co-authored-by: Luke K (pr-0f3t) <2609441+lc0rp@users.noreply.github.com>
Co-authored-by: Xin <xin@imfing.com>
2026-01-11 11:45:25 +00:00

2479 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ChannelType,
Client,
Command,
type CommandInteraction,
type CommandOptions,
type Guild,
type Message,
MessageCreateListener,
MessageReactionAddListener,
MessageReactionRemoveListener,
MessageType,
type RequestClient,
type User,
} from "@buape/carbon";
import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
import type { APIAttachment } from "discord-api-types/v10";
import { ApplicationCommandOptionType, Routes } from "discord-api-types/v10";
import {
resolveAckReaction,
resolveEffectiveMessagesConfig,
resolveHumanDelayConfig,
} from "../agents/identity.js";
import { resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import {
buildCommandText,
listNativeCommandSpecsForConfig,
shouldHandleTextCommands,
} from "../auto-reply/commands-registry.js";
import {
formatAgentEnvelope,
formatThreadStarterEnvelope,
} from "../auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
import {
buildHistoryContextFromMap,
clearHistoryEntries,
type HistoryEntry,
} from "../auto-reply/reply/history.js";
import {
buildMentionRegexes,
matchesMentionPatterns,
} from "../auto-reply/reply/mentions.js";
import {
createReplyDispatcher,
createReplyDispatcherWithTyping,
} from "../auto-reply/reply/reply-dispatcher.js";
import { getReplyFromConfig } from "../auto-reply/reply.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import type { ClawdbotConfig, ReplyToMode } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { formatDurationSeconds } from "../infra/format-duration.js";
import { recordProviderActivity } from "../infra/provider-activity.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { getChildLogger } from "../logging.js";
import { fetchRemoteMedia } from "../media/fetch.js";
import { saveMediaBuffer } from "../media/store.js";
import { buildPairingReply } from "../pairing/pairing-messages.js";
import {
readProviderAllowFromStore,
upsertProviderPairingRequest,
} from "../pairing/pairing-store.js";
import {
buildAgentSessionKey,
resolveAgentRoute,
} from "../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { truncateUtf16Safe } from "../utils.js";
import { loadWebMedia } from "../web/media.js";
import { resolveDiscordAccount } from "./accounts.js";
import { chunkDiscordText } from "./chunk.js";
import { attachDiscordGatewayLogging } from "./gateway-logging.js";
import {
getDiscordGatewayEmitter,
waitForDiscordGatewayStop,
} from "./monitor.gateway.js";
import { fetchDiscordApplicationId } from "./probe.js";
import {
reactMessageDiscord,
removeReactionDiscord,
sendMessageDiscord,
} from "./send.js";
import { normalizeDiscordToken } from "./token.js";
export type MonitorDiscordOpts = {
token?: string;
accountId?: string;
config?: ClawdbotConfig;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
mediaMaxMb?: number;
historyLimit?: number;
replyToMode?: ReplyToMode;
};
type DiscordMediaInfo = {
path: string;
contentType?: string;
placeholder: string;
};
type DiscordSnapshotAuthor = {
id?: string | null;
username?: string | null;
discriminator?: string | null;
global_name?: string | null;
name?: string | null;
};
type DiscordSnapshotMessage = {
content?: string | null;
embeds?: Array<{ description?: string | null; title?: string | null }> | null;
attachments?: APIAttachment[] | null;
author?: DiscordSnapshotAuthor | null;
};
type DiscordMessageSnapshot = {
message?: DiscordSnapshotMessage | null;
};
type DiscordReactionEvent = Parameters<MessageReactionAddListener["handle"]>[0];
type DiscordThreadChannel = {
id: string;
name?: string | null;
parentId?: string | null;
parent?: { id?: string; name?: string };
};
type DiscordThreadStarter = {
text: string;
author: string;
timestamp?: number;
};
type DiscordChannelInfo = {
type: ChannelType;
name?: string;
topic?: string;
parentId?: string;
};
const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarter>();
const DISCORD_CHANNEL_INFO_CACHE_TTL_MS = 5 * 60 * 1000;
const DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS = 30 * 1000;
const DISCORD_CHANNEL_INFO_CACHE = new Map<
string,
{ value: DiscordChannelInfo | null; expiresAt: number }
>();
const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 1000;
function logSlowDiscordListener(params: {
logger: ReturnType<typeof getChildLogger> | undefined;
listener: string;
event: string;
durationMs: number;
}) {
if (params.durationMs < DISCORD_SLOW_LISTENER_THRESHOLD_MS) return;
const duration = formatDurationSeconds(params.durationMs, {
decimals: 1,
unit: "seconds",
});
const message = `[EventQueue] Slow listener detected: ${params.listener} took ${duration} for event ${params.event}`;
if (params.logger?.warn) {
params.logger.warn(message);
} else {
console.warn(message);
}
}
async function resolveDiscordThreadStarter(params: {
channel: DiscordThreadChannel;
client: Client;
parentId?: string;
parentType?: ChannelType;
}): Promise<DiscordThreadStarter | null> {
const cacheKey = params.channel.id;
const cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey);
if (cached) return cached;
try {
const parentType = params.parentType;
const isForumParent =
parentType === ChannelType.GuildForum ||
parentType === ChannelType.GuildMedia;
const messageChannelId = isForumParent
? params.channel.id
: params.parentId;
if (!messageChannelId) return null;
const starter = (await params.client.rest.get(
Routes.channelMessage(messageChannelId, params.channel.id),
)) as {
content?: string | null;
embeds?: Array<{ description?: string | null }>;
member?: { nick?: string | null; displayName?: string | null };
author?: {
id?: string | null;
username?: string | null;
discriminator?: string | null;
};
timestamp?: string | null;
};
if (!starter) return null;
const text =
starter.content?.trim() ?? starter.embeds?.[0]?.description?.trim() ?? "";
if (!text) return null;
const author =
starter.member?.nick ??
starter.member?.displayName ??
(starter.author
? starter.author.discriminator && starter.author.discriminator !== "0"
? `${starter.author.username ?? "Unknown"}#${starter.author.discriminator}`
: (starter.author.username ?? starter.author.id ?? "Unknown")
: "Unknown");
const timestamp = resolveTimestampMs(starter.timestamp);
const payload: DiscordThreadStarter = {
text,
author,
timestamp: timestamp ?? undefined,
};
DISCORD_THREAD_STARTER_CACHE.set(cacheKey, payload);
return payload;
} catch {
return null;
}
}
export type DiscordAllowList = {
allowAll: boolean;
ids: Set<string>;
names: Set<string>;
};
export type DiscordGuildEntryResolved = {
id?: string;
slug?: string;
requireMention?: boolean;
reactionNotifications?: "off" | "own" | "all" | "allowlist";
users?: Array<string | number>;
channels?: Record<
string,
{
allow?: boolean;
requireMention?: boolean;
skills?: string[];
enabled?: boolean;
users?: Array<string | number>;
systemPrompt?: string;
}
>;
};
export type DiscordChannelConfigResolved = {
allowed: boolean;
requireMention?: boolean;
skills?: string[];
enabled?: boolean;
users?: Array<string | number>;
systemPrompt?: string;
};
export type DiscordMessageEvent = Parameters<
MessageCreateListener["handle"]
>[0];
export type DiscordMessageHandler = (
data: DiscordMessageEvent,
client: Client,
) => Promise<void>;
function isDiscordThreadType(type: ChannelType | undefined): boolean {
return (
type === ChannelType.PublicThread ||
type === ChannelType.PrivateThread ||
type === ChannelType.AnnouncementThread
);
}
type DiscordThreadParentInfo = {
id?: string;
name?: string;
type?: ChannelType;
};
function resolveDiscordThreadChannel(params: {
isGuildMessage: boolean;
message: DiscordMessageEvent["message"];
channelInfo: DiscordChannelInfo | null;
}): DiscordThreadChannel | null {
if (!params.isGuildMessage) return null;
const { message, channelInfo } = params;
const channel =
"channel" in message
? (message as { channel?: unknown }).channel
: undefined;
const isThreadChannel =
channel &&
typeof channel === "object" &&
"isThread" in channel &&
typeof (channel as { isThread?: unknown }).isThread === "function" &&
(channel as { isThread: () => boolean }).isThread();
if (isThreadChannel) return channel as unknown as DiscordThreadChannel;
if (!isDiscordThreadType(channelInfo?.type)) return null;
return {
id: message.channelId,
name: channelInfo?.name ?? undefined,
parentId: channelInfo?.parentId ?? undefined,
parent: undefined,
};
}
async function resolveDiscordThreadParentInfo(params: {
client: Client;
threadChannel: DiscordThreadChannel;
channelInfo: DiscordChannelInfo | null;
}): Promise<DiscordThreadParentInfo> {
const { threadChannel, channelInfo, client } = params;
const parentId =
threadChannel.parentId ??
threadChannel.parent?.id ??
channelInfo?.parentId ??
undefined;
if (!parentId) return {};
let parentName = threadChannel.parent?.name;
const parentInfo = await resolveDiscordChannelInfo(client, parentId);
parentName = parentName ?? parentInfo?.name;
const parentType = parentInfo?.type;
return { id: parentId, name: parentName, type: parentType };
}
export function resolveDiscordReplyTarget(opts: {
replyToMode: ReplyToMode;
replyToId?: string;
hasReplied: boolean;
}): string | undefined {
if (opts.replyToMode === "off") return undefined;
const replyToId = opts.replyToId?.trim();
if (!replyToId) return undefined;
if (opts.replyToMode === "all") return replyToId;
return opts.hasReplied ? undefined : replyToId;
}
function summarizeAllowList(list?: Array<string | number>) {
if (!list || list.length === 0) return "any";
const sample = list.slice(0, 4).map((entry) => String(entry));
const suffix =
list.length > sample.length ? ` (+${list.length - sample.length})` : "";
return `${sample.join(", ")}${suffix}`;
}
function summarizeGuilds(entries?: Record<string, DiscordGuildEntryResolved>) {
if (!entries || Object.keys(entries).length === 0) return "any";
const keys = Object.keys(entries);
const sample = keys.slice(0, 4);
const suffix =
keys.length > sample.length ? ` (+${keys.length - sample.length})` : "";
return `${sample.join(", ")}${suffix}`;
}
export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const cfg = opts.config ?? loadConfig();
const account = resolveDiscordAccount({
cfg,
accountId: opts.accountId,
});
const token = normalizeDiscordToken(opts.token ?? undefined) ?? account.token;
if (!token) {
throw new Error(
`Discord bot token missing for account "${account.accountId}" (set discord.accounts.${account.accountId}.token or DISCORD_BOT_TOKEN for default).`,
);
}
const runtime: RuntimeEnv = opts.runtime ?? {
log: console.log,
error: console.error,
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
};
const discordCfg = account.config;
const dmConfig = discordCfg.dm;
const guildEntries = discordCfg.guilds;
const groupPolicy = discordCfg.groupPolicy ?? "open";
const allowFrom = dmConfig?.allowFrom;
const mediaMaxBytes =
(opts.mediaMaxMb ?? discordCfg.mediaMaxMb ?? 8) * 1024 * 1024;
const textLimit = resolveTextChunkLimit(cfg, "discord", account.accountId, {
fallbackLimit: 2000,
});
const historyLimit = Math.max(
0,
opts.historyLimit ??
discordCfg.historyLimit ??
cfg.messages?.groupChat?.historyLimit ??
20,
);
const replyToMode = opts.replyToMode ?? discordCfg.replyToMode ?? "off";
const dmEnabled = dmConfig?.enabled ?? true;
const dmPolicy = dmConfig?.policy ?? "pairing";
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
const groupDmChannels = dmConfig?.groupChannels;
const nativeEnabled = cfg.commands?.native === true;
const nativeDisabledExplicit = cfg.commands?.native === false;
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const sessionPrefix = "discord:slash";
const ephemeralDefault = true;
if (shouldLogVerbose()) {
logVerbose(
`discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"}`,
);
}
const applicationId = await fetchDiscordApplicationId(token, 4000);
if (!applicationId) {
throw new Error("Failed to resolve Discord application id");
}
const commandSpecs = nativeEnabled
? listNativeCommandSpecsForConfig(cfg)
: [];
const commands = commandSpecs.map((spec) =>
createDiscordNativeCommand({
command: spec,
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
sessionPrefix,
ephemeralDefault,
}),
);
const client = new Client(
{
baseUrl: "http://localhost",
deploySecret: "a",
clientId: applicationId,
publicKey: "a",
token,
autoDeploy: nativeEnabled,
},
{
commands,
listeners: [],
},
[
new GatewayPlugin({
reconnect: {
maxAttempts: Number.POSITIVE_INFINITY,
},
intents:
GatewayIntents.Guilds |
GatewayIntents.GuildMessages |
GatewayIntents.MessageContent |
GatewayIntents.DirectMessages |
GatewayIntents.GuildMessageReactions |
GatewayIntents.DirectMessageReactions,
autoInteractions: true,
}),
],
);
const logger = getChildLogger({ module: "discord-auto-reply" });
const guildHistories = new Map<string, HistoryEntry[]>();
let botUserId: string | undefined;
if (nativeDisabledExplicit) {
await clearDiscordNativeCommands({
client,
applicationId,
runtime,
});
}
try {
const botUser = await client.fetchUser("@me");
botUserId = botUser?.id;
} catch (err) {
runtime.error?.(
danger(`discord: failed to fetch bot identity: ${String(err)}`),
);
}
const messageHandler = createDiscordMessageHandler({
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
token,
runtime,
botUserId,
guildHistories,
historyLimit,
mediaMaxBytes,
textLimit,
replyToMode,
dmEnabled,
groupDmEnabled,
groupDmChannels,
allowFrom,
guildEntries,
});
client.listeners.push(new DiscordMessageListener(messageHandler, logger));
client.listeners.push(
new DiscordReactionListener({
cfg,
accountId: account.accountId,
runtime,
botUserId,
guildEntries,
logger,
}),
);
client.listeners.push(
new DiscordReactionRemoveListener({
cfg,
accountId: account.accountId,
runtime,
botUserId,
guildEntries,
logger,
}),
);
runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`);
const gateway = client.getPlugin<GatewayPlugin>("gateway");
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
const stopGatewayLogging = attachDiscordGatewayLogging({
emitter: gatewayEmitter,
runtime,
});
// Timeout to detect zombie connections where HELLO is never received.
const HELLO_TIMEOUT_MS = 30000;
let helloTimeoutId: ReturnType<typeof setTimeout> | undefined;
const onGatewayDebug = (msg: unknown) => {
const message = String(msg);
if (!message.includes("WebSocket connection opened")) return;
if (helloTimeoutId) clearTimeout(helloTimeoutId);
helloTimeoutId = setTimeout(() => {
if (!gateway?.isConnected) {
runtime.log?.(
danger(
`[discord] connection stalled: no HELLO received within ${HELLO_TIMEOUT_MS}ms, forcing reconnect`,
),
);
gateway?.disconnect();
gateway?.connect(false);
}
helloTimeoutId = undefined;
}, HELLO_TIMEOUT_MS);
};
gatewayEmitter?.on("debug", onGatewayDebug);
try {
await waitForDiscordGatewayStop({
gateway: gateway
? {
emitter: gatewayEmitter,
disconnect: () => gateway.disconnect(),
}
: undefined,
abortSignal: opts.abortSignal,
onGatewayError: (err) => {
runtime.error?.(danger(`discord gateway error: ${String(err)}`));
},
shouldStopOnError: (err) => {
const message = String(err);
return (
message.includes("Max reconnect attempts") ||
message.includes("Fatal Gateway error")
);
},
});
} finally {
stopGatewayLogging();
stopGatewayLogging();
if (helloTimeoutId) clearTimeout(helloTimeoutId);
gatewayEmitter?.removeListener("debug", onGatewayDebug);
}
}
async function clearDiscordNativeCommands(params: {
client: Client;
applicationId: string;
runtime: RuntimeEnv;
}) {
try {
await params.client.rest.put(
Routes.applicationCommands(params.applicationId),
{
body: [],
},
);
logVerbose("discord: cleared native commands (commands.native=false)");
} catch (err) {
params.runtime.error?.(
danger(`discord: failed to clear native commands: ${String(err)}`),
);
}
}
export function createDiscordMessageHandler(params: {
cfg: ReturnType<typeof loadConfig>;
discordConfig: ClawdbotConfig["discord"];
accountId: string;
token: string;
runtime: RuntimeEnv;
botUserId?: string;
guildHistories: Map<string, HistoryEntry[]>;
historyLimit: number;
mediaMaxBytes: number;
textLimit: number;
replyToMode: ReplyToMode;
dmEnabled: boolean;
groupDmEnabled: boolean;
groupDmChannels?: Array<string | number>;
allowFrom?: Array<string | number>;
guildEntries?: Record<string, DiscordGuildEntryResolved>;
}): DiscordMessageHandler {
const {
cfg,
discordConfig,
accountId,
token,
runtime,
botUserId,
guildHistories,
historyLimit,
mediaMaxBytes,
textLimit,
replyToMode,
dmEnabled,
groupDmEnabled,
groupDmChannels,
allowFrom,
guildEntries,
} = params;
const logger = getChildLogger({ module: "discord-auto-reply" });
const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
const groupPolicy = discordConfig?.groupPolicy ?? "open";
return async (data, client) => {
try {
const message = data.message;
const author = data.author;
if (!author || author.bot) return;
const isGuildMessage = Boolean(data.guild_id);
const channelInfo = await resolveDiscordChannelInfo(
client,
message.channelId,
);
const isDirectMessage = channelInfo?.type === ChannelType.DM;
const isGroupDm = channelInfo?.type === ChannelType.GroupDM;
if (isGroupDm && !groupDmEnabled) {
logVerbose("discord: drop group dm (group dms disabled)");
return;
}
if (isDirectMessage && !dmEnabled) {
logVerbose("discord: drop dm (dms disabled)");
return;
}
const dmPolicy = discordConfig?.dm?.policy ?? "pairing";
let commandAuthorized = true;
if (isDirectMessage) {
if (dmPolicy === "disabled") {
logVerbose("discord: drop dm (dmPolicy: disabled)");
return;
}
if (dmPolicy !== "open") {
const storeAllowFrom = await readProviderAllowFromStore(
"discord",
).catch(() => []);
const effectiveAllowFrom = [...(allowFrom ?? []), ...storeAllowFrom];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, [
"discord:",
"user:",
]);
const permitted = allowList
? allowListMatches(allowList, {
id: author.id,
name: author.username,
tag: formatDiscordUserTag(author),
})
: false;
if (!permitted) {
commandAuthorized = false;
if (dmPolicy === "pairing") {
const { code, created } = await upsertProviderPairingRequest({
provider: "discord",
id: author.id,
meta: {
tag: formatDiscordUserTag(author),
name: author.username ?? undefined,
},
});
if (created) {
logVerbose(
`discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)}`,
);
try {
await sendMessageDiscord(
`user:${author.id}`,
buildPairingReply({
provider: "discord",
idLine: `Your Discord user id: ${author.id}`,
code,
}),
{ token, rest: client.rest, accountId },
);
} catch (err) {
logVerbose(
`discord pairing reply failed for ${author.id}: ${String(err)}`,
);
}
}
} else {
logVerbose(
`Blocked unauthorized discord sender ${author.id} (dmPolicy=${dmPolicy})`,
);
}
return;
}
commandAuthorized = true;
}
}
const botId = botUserId;
const baseText = resolveDiscordMessageText(message, {
includeForwarded: false,
});
const messageText = resolveDiscordMessageText(message, {
includeForwarded: true,
});
recordProviderActivity({
provider: "discord",
accountId,
direction: "inbound",
});
const route = resolveAgentRoute({
cfg,
provider: "discord",
accountId,
guildId: data.guild_id ?? undefined,
peer: {
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
id: isDirectMessage ? author.id : message.channelId,
},
});
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
const wasMentioned =
!isDirectMessage &&
(Boolean(
botId &&
message.mentionedUsers?.some((user: User) => user.id === botId),
) ||
matchesMentionPatterns(baseText, mentionRegexes));
if (shouldLogVerbose()) {
logVerbose(
`discord: inbound id=${message.id} guild=${message.guild?.id ?? "dm"} channel=${message.channelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${messageText ? "yes" : "no"}`,
);
}
if (
isGuildMessage &&
(message.type === MessageType.ChatInputCommand ||
message.type === MessageType.ContextMenuCommand)
) {
logVerbose("discord: drop channel command message");
return;
}
const guildInfo = isGuildMessage
? resolveDiscordGuildEntry({
guild: data.guild ?? undefined,
guildEntries,
})
: null;
if (
isGuildMessage &&
guildEntries &&
Object.keys(guildEntries).length > 0 &&
!guildInfo
) {
logVerbose(
`Blocked discord guild ${data.guild_id ?? "unknown"} (not in discord.guilds)`,
);
return;
}
const channelName =
channelInfo?.name ??
((isGuildMessage || isGroupDm) &&
message.channel &&
"name" in message.channel
? message.channel.name
: undefined);
const threadChannel = resolveDiscordThreadChannel({
isGuildMessage,
message,
channelInfo,
});
let threadParentId: string | undefined;
let threadParentName: string | undefined;
let threadParentType: ChannelType | undefined;
if (threadChannel) {
const parentInfo = await resolveDiscordThreadParentInfo({
client,
threadChannel,
channelInfo,
});
threadParentId = parentInfo.id;
threadParentName = parentInfo.name;
threadParentType = parentInfo.type;
}
const threadName = threadChannel?.name;
const configChannelName = threadParentName ?? channelName;
const configChannelSlug = configChannelName
? normalizeDiscordSlug(configChannelName)
: "";
const displayChannelName = threadName ?? channelName;
const displayChannelSlug = displayChannelName
? normalizeDiscordSlug(displayChannelName)
: "";
const guildSlug =
guildInfo?.slug ||
(data.guild?.name ? normalizeDiscordSlug(data.guild.name) : "");
const baseSessionKey = route.sessionKey;
const channelConfig = isGuildMessage
? resolveDiscordChannelConfig({
guildInfo,
channelId: threadParentId ?? message.channelId,
channelName: configChannelName,
channelSlug: configChannelSlug,
})
: null;
if (isGuildMessage && channelConfig?.enabled === false) {
logVerbose(
`Blocked discord channel ${message.channelId} (channel disabled)`,
);
return;
}
const groupDmAllowed =
isGroupDm &&
resolveGroupDmAllow({
channels: groupDmChannels,
channelId: message.channelId,
channelName: displayChannelName,
channelSlug: displayChannelSlug,
});
if (isGroupDm && !groupDmAllowed) return;
const channelAllowlistConfigured =
Boolean(guildInfo?.channels) &&
Object.keys(guildInfo?.channels ?? {}).length > 0;
const channelAllowed = channelConfig?.allowed !== false;
if (
isGuildMessage &&
!isDiscordGroupAllowedByPolicy({
groupPolicy,
channelAllowlistConfigured,
channelAllowed,
})
) {
if (groupPolicy === "disabled") {
logVerbose("discord: drop guild message (groupPolicy: disabled)");
} else if (!channelAllowlistConfigured) {
logVerbose(
"discord: drop guild message (groupPolicy: allowlist, no channel allowlist)",
);
} else {
logVerbose(
`Blocked discord channel ${message.channelId} not in guild channel allowlist (groupPolicy: allowlist)`,
);
}
return;
}
if (isGuildMessage && channelConfig?.allowed === false) {
logVerbose(
`Blocked discord channel ${message.channelId} not in guild channel allowlist`,
);
return;
}
const textForHistory = resolveDiscordMessageText(message, {
includeForwarded: true,
});
const historyEntry =
isGuildMessage && historyLimit > 0 && textForHistory
? {
sender:
data.member?.nickname ??
author.globalName ??
author.username ??
author.id,
body: textForHistory,
timestamp: resolveTimestampMs(message.timestamp),
messageId: message.id,
}
: undefined;
const shouldRequireMention =
channelConfig?.requireMention ?? guildInfo?.requireMention ?? true;
const hasAnyMention = Boolean(
!isDirectMessage &&
(message.mentionedEveryone ||
(message.mentionedUsers?.length ?? 0) > 0 ||
(message.mentionedRoles?.length ?? 0) > 0),
);
if (!isDirectMessage) {
commandAuthorized = resolveDiscordCommandAuthorized({
isDirectMessage,
allowFrom,
guildInfo,
author,
});
}
const allowTextCommands = shouldHandleTextCommands({
cfg,
surface: "discord",
});
const shouldBypassMention =
allowTextCommands &&
isGuildMessage &&
shouldRequireMention &&
!wasMentioned &&
!hasAnyMention &&
commandAuthorized &&
hasControlCommand(baseText, cfg);
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
const canDetectMention = Boolean(botId) || mentionRegexes.length > 0;
if (isGuildMessage && shouldRequireMention) {
if (botId && !wasMentioned && !shouldBypassMention) {
logVerbose(
`discord: drop guild message (mention required, botId=${botId})`,
);
logger.info(
{
channelId: message.channelId,
reason: "no-mention",
},
"discord: skipping guild message",
);
return;
}
}
if (isGuildMessage) {
const channelUsers = channelConfig?.users ?? guildInfo?.users;
if (Array.isArray(channelUsers) && channelUsers.length > 0) {
const userOk = resolveDiscordUserAllowed({
allowList: channelUsers,
userId: author.id,
userName: author.username,
userTag: formatDiscordUserTag(author),
});
if (!userOk) {
logVerbose(
`Blocked discord guild sender ${author.id} (not in channel users allowlist)`,
);
return;
}
}
}
const systemLocation = resolveDiscordSystemLocation({
isDirectMessage,
isGroupDm,
guild: data.guild ?? undefined,
channelName: channelName ?? message.channelId,
});
const systemText = resolveDiscordSystemEvent(message, systemLocation);
if (systemText) {
enqueueSystemEvent(systemText, {
sessionKey: route.sessionKey,
contextKey: `discord:system:${message.channelId}:${message.id}`,
});
return;
}
const mediaList = await resolveMediaList(message, mediaMaxBytes);
const text = messageText;
if (!text) {
logVerbose(`discord: drop message ${message.id} (empty content)`);
return;
}
const ackReaction = resolveAckReaction(cfg, route.agentId);
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const shouldAckReaction = () => {
if (!ackReaction) return false;
if (ackReactionScope === "all") return true;
if (ackReactionScope === "direct") return isDirectMessage;
const isGroupChat = isGuildMessage || isGroupDm;
if (ackReactionScope === "group-all") return isGroupChat;
if (ackReactionScope === "group-mentions") {
if (!isGuildMessage) return false;
if (!shouldRequireMention) return false;
if (!canDetectMention) return false;
return wasMentioned || shouldBypassMention;
}
return false;
};
const ackReactionPromise = shouldAckReaction()
? reactMessageDiscord(message.channelId, message.id, ackReaction, {
rest: client.rest,
}).then(
() => true,
(err) => {
logVerbose(
`discord react failed for channel ${message.channelId}: ${String(err)}`,
);
return false;
},
)
: null;
const fromLabel = isDirectMessage
? buildDirectLabel(author)
: buildGuildLabel({
guild: data.guild ?? undefined,
channelName: channelName ?? message.channelId,
channelId: message.channelId,
});
const groupRoom =
isGuildMessage && displayChannelSlug
? `#${displayChannelSlug}`
: undefined;
const groupSubject = isDirectMessage ? undefined : groupRoom;
const channelDescription = channelInfo?.topic?.trim();
const systemPromptParts = [
channelDescription ? `Channel topic: ${channelDescription}` : null,
channelConfig?.systemPrompt?.trim() || null,
].filter((entry): entry is string => Boolean(entry));
const groupSystemPrompt =
systemPromptParts.length > 0
? systemPromptParts.join("\n\n")
: undefined;
let combinedBody = formatAgentEnvelope({
provider: "Discord",
from: fromLabel,
timestamp: resolveTimestampMs(message.timestamp),
body: text,
});
let shouldClearHistory = false;
if (!isDirectMessage) {
combinedBody = buildHistoryContextFromMap({
historyMap: guildHistories,
historyKey: message.channelId,
limit: historyLimit,
entry: historyEntry,
currentMessage: combinedBody,
formatEntry: (entry) =>
formatAgentEnvelope({
provider: "Discord",
from: fromLabel,
timestamp: entry.timestamp,
body: `${entry.sender}: ${entry.body} [id:${entry.messageId ?? "unknown"} channel:${message.channelId}]`,
}),
});
const name = formatDiscordUserTag(author);
const id = author.id;
combinedBody = `${combinedBody}\n[from: ${name} user id:${id}]`;
shouldClearHistory = true;
}
const replyContext = resolveReplyContext(message);
if (replyContext) {
combinedBody = `[Replied message - for context]\n${replyContext}\n\n${combinedBody}`;
}
let threadStarterBody: string | undefined;
let threadLabel: string | undefined;
let parentSessionKey: string | undefined;
if (threadChannel) {
const starter = await resolveDiscordThreadStarter({
channel: threadChannel,
client,
parentId: threadParentId,
parentType: threadParentType,
});
if (starter?.text) {
const starterEnvelope = formatThreadStarterEnvelope({
provider: "Discord",
author: starter.author,
timestamp: starter.timestamp,
body: starter.text,
});
threadStarterBody = starterEnvelope;
}
const parentName = threadParentName ?? "parent";
threadLabel = threadName
? `Discord thread #${normalizeDiscordSlug(parentName)} ${threadName}`
: `Discord thread #${normalizeDiscordSlug(parentName)}`;
if (threadParentId) {
parentSessionKey = buildAgentSessionKey({
agentId: route.agentId,
provider: route.provider,
peer: { kind: "channel", id: threadParentId },
});
}
}
const mediaPayload = buildDiscordMediaPayload(mediaList);
const discordTo = `channel:${message.channelId}`;
const threadKeys = resolveThreadSessionKeys({
baseSessionKey,
threadId: threadChannel ? message.channelId : undefined,
parentSessionKey,
useSuffix: false,
});
const ctxPayload = {
Body: combinedBody,
RawBody: baseText,
CommandBody: baseText,
From: isDirectMessage
? `discord:${author.id}`
: `group:${message.channelId}`,
To: discordTo,
SessionKey: threadKeys.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "group",
SenderName:
data.member?.nickname ?? author.globalName ?? author.username,
SenderId: author.id,
SenderUsername: author.username,
SenderTag: formatDiscordUserTag(author),
GroupSubject: groupSubject,
GroupRoom: groupRoom,
GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined,
GroupSpace: isGuildMessage
? (guildInfo?.id ?? guildSlug) || undefined
: undefined,
Provider: "discord" as const,
Surface: "discord" as const,
WasMentioned: effectiveWasMentioned,
MessageSid: message.id,
ParentSessionKey: threadKeys.parentSessionKey,
ThreadStarterBody: threadStarterBody,
ThreadLabel: threadLabel,
Timestamp: resolveTimestampMs(message.timestamp),
...mediaPayload,
CommandAuthorized: commandAuthorized,
CommandSource: "text" as const,
// Originating channel for reply routing.
OriginatingChannel: "discord" as const,
OriginatingTo: discordTo,
};
const replyTarget = ctxPayload.To ?? undefined;
if (!replyTarget) {
runtime.error?.(danger("discord: missing reply target"));
return;
}
if (isDirectMessage) {
const sessionCfg = cfg.session;
const storePath = resolveStorePath(sessionCfg?.store, {
agentId: route.agentId,
});
await updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
provider: "discord",
to: `user:${author.id}`,
accountId: route.accountId,
});
}
if (shouldLogVerbose()) {
const preview = truncateUtf16Safe(combinedBody, 200).replace(
/\n/g,
"\\n",
);
logVerbose(
`discord inbound: channel=${message.channelId} from=${ctxPayload.From} preview="${preview}"`,
);
}
let didSendReply = false;
const { dispatcher, replyOptions, markDispatchIdle } =
createReplyDispatcherWithTyping({
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId)
.responsePrefix,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload) => {
await deliverDiscordReply({
replies: [payload],
target: replyTarget,
token,
accountId,
rest: client.rest,
runtime,
replyToMode,
textLimit,
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
});
didSendReply = true;
},
onError: (err, info) => {
runtime.error?.(
danger(`discord ${info.kind} reply failed: ${String(err)}`),
);
},
onReplyStart: () => sendTyping(message),
});
const { queuedFinal, counts } = await dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
skillFilter: channelConfig?.skills,
disableBlockStreaming:
typeof discordConfig?.blockStreaming === "boolean"
? !discordConfig.blockStreaming
: undefined,
},
});
markDispatchIdle();
if (!queuedFinal) {
if (
isGuildMessage &&
shouldClearHistory &&
historyLimit > 0 &&
didSendReply
) {
clearHistoryEntries({
historyMap: guildHistories,
historyKey: message.channelId,
});
}
return;
}
didSendReply = true;
if (shouldLogVerbose()) {
const finalCount = counts.final;
logVerbose(
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
);
}
if (removeAckAfterReply && ackReactionPromise && ackReaction) {
const ackReactionValue = ackReaction;
void ackReactionPromise.then((didAck) => {
if (!didAck) return;
removeReactionDiscord(
message.channelId,
message.id,
ackReactionValue,
{
rest: client.rest,
},
).catch((err) => {
logVerbose(
`discord: failed to remove ack reaction from ${message.channelId}/${message.id}: ${String(err)}`,
);
});
});
}
if (
isGuildMessage &&
shouldClearHistory &&
historyLimit > 0 &&
didSendReply
) {
clearHistoryEntries({
historyMap: guildHistories,
historyKey: message.channelId,
});
}
} catch (err) {
runtime.error?.(danger(`handler failed: ${String(err)}`));
}
};
}
class DiscordMessageListener extends MessageCreateListener {
constructor(
private handler: DiscordMessageHandler,
private logger?: ReturnType<typeof getChildLogger>,
) {
super();
}
async handle(data: DiscordMessageEvent, client: Client) {
const startedAt = Date.now();
try {
await this.handler(data, client);
} finally {
logSlowDiscordListener({
logger: this.logger,
listener: this.constructor.name,
event: this.type,
durationMs: Date.now() - startedAt,
});
}
}
}
class DiscordReactionListener extends MessageReactionAddListener {
constructor(
private params: {
cfg: ReturnType<typeof loadConfig>;
accountId: string;
runtime: RuntimeEnv;
botUserId?: string;
guildEntries?: Record<string, DiscordGuildEntryResolved>;
logger: ReturnType<typeof getChildLogger>;
},
) {
super();
}
async handle(data: DiscordReactionEvent, client: Client) {
const startedAt = Date.now();
try {
await handleDiscordReactionEvent({
data,
client,
action: "added",
cfg: this.params.cfg,
accountId: this.params.accountId,
botUserId: this.params.botUserId,
guildEntries: this.params.guildEntries,
logger: this.params.logger,
});
} finally {
logSlowDiscordListener({
logger: this.params.logger,
listener: this.constructor.name,
event: this.type,
durationMs: Date.now() - startedAt,
});
}
}
}
class DiscordReactionRemoveListener extends MessageReactionRemoveListener {
constructor(
private params: {
cfg: ReturnType<typeof loadConfig>;
accountId: string;
runtime: RuntimeEnv;
botUserId?: string;
guildEntries?: Record<string, DiscordGuildEntryResolved>;
logger: ReturnType<typeof getChildLogger>;
},
) {
super();
}
async handle(data: DiscordReactionEvent, client: Client) {
const startedAt = Date.now();
try {
await handleDiscordReactionEvent({
data,
client,
action: "removed",
cfg: this.params.cfg,
accountId: this.params.accountId,
botUserId: this.params.botUserId,
guildEntries: this.params.guildEntries,
logger: this.params.logger,
});
} finally {
logSlowDiscordListener({
logger: this.params.logger,
listener: this.constructor.name,
event: this.type,
durationMs: Date.now() - startedAt,
});
}
}
}
async function handleDiscordReactionEvent(params: {
data: DiscordReactionEvent;
client: Client;
action: "added" | "removed";
cfg: ReturnType<typeof loadConfig>;
accountId: string;
botUserId?: string;
guildEntries?: Record<string, DiscordGuildEntryResolved>;
logger: ReturnType<typeof getChildLogger>;
}) {
try {
const { data, client, action, botUserId, guildEntries } = params;
if (!("user" in data)) return;
const user = data.user;
if (!user || user.bot) return;
if (!data.guild_id) return;
const guildInfo = resolveDiscordGuildEntry({
guild: data.guild ?? undefined,
guildEntries,
});
if (guildEntries && Object.keys(guildEntries).length > 0 && !guildInfo) {
return;
}
const channel = await client.fetchChannel(data.channel_id);
if (!channel) return;
const channelName =
"name" in channel ? (channel.name ?? undefined) : undefined;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const channelConfig = resolveDiscordChannelConfig({
guildInfo,
channelId: data.channel_id,
channelName,
channelSlug,
});
if (channelConfig?.allowed === false) return;
if (botUserId && user.id === botUserId) return;
const reactionMode = guildInfo?.reactionNotifications ?? "own";
const message = await data.message.fetch().catch(() => null);
const messageAuthorId = message?.author?.id ?? undefined;
const shouldNotify = shouldEmitDiscordReactionNotification({
mode: reactionMode,
botId: botUserId,
messageAuthorId,
userId: user.id,
userName: user.username,
userTag: formatDiscordUserTag(user),
allowlist: guildInfo?.users,
});
if (!shouldNotify) return;
const emojiLabel = formatDiscordReactionEmoji(data.emoji);
const actorLabel = formatDiscordUserTag(user);
const guildSlug =
guildInfo?.slug ||
(data.guild?.name
? normalizeDiscordSlug(data.guild.name)
: data.guild_id);
const channelLabel = channelSlug
? `#${channelSlug}`
: channelName
? `#${normalizeDiscordSlug(channelName)}`
: `#${data.channel_id}`;
const authorLabel = message?.author
? formatDiscordUserTag(message.author)
: undefined;
const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${data.message_id}`;
const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
const route = resolveAgentRoute({
cfg: params.cfg,
provider: "discord",
accountId: params.accountId,
guildId: data.guild_id ?? undefined,
peer: { kind: "channel", id: data.channel_id },
});
enqueueSystemEvent(text, {
sessionKey: route.sessionKey,
contextKey: `discord:reaction:${action}:${data.message_id}:${user.id}:${emojiLabel}`,
});
} catch (err) {
params.logger.error(
danger(`discord reaction handler failed: ${String(err)}`),
);
}
}
function createDiscordNativeCommand(params: {
command: {
name: string;
description: string;
acceptsArgs: boolean;
};
cfg: ReturnType<typeof loadConfig>;
discordConfig: ClawdbotConfig["discord"];
accountId: string;
sessionPrefix: string;
ephemeralDefault: boolean;
}) {
const {
command,
cfg,
discordConfig,
accountId,
sessionPrefix,
ephemeralDefault,
} = params;
return new (class extends Command {
name = command.name;
description = command.description;
defer = true;
ephemeral = ephemeralDefault;
options = command.acceptsArgs
? ([
{
name: "input",
description: "Command input",
type: ApplicationCommandOptionType.String,
required: false,
},
] satisfies CommandOptions)
: undefined;
async run(interaction: CommandInteraction) {
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const user = interaction.user;
if (!user) return;
const channel = interaction.channel;
const channelType = channel?.type;
const isDirectMessage = channelType === ChannelType.DM;
const isGroupDm = channelType === ChannelType.GroupDM;
const channelName =
channel && "name" in channel ? (channel.name as string) : undefined;
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const prompt = buildCommandText(
this.name,
command.acceptsArgs
? interaction.options.getString("input")
: undefined,
);
const guildInfo = resolveDiscordGuildEntry({
guild: interaction.guild ?? undefined,
guildEntries: discordConfig?.guilds,
});
const channelConfig = interaction.guild
? resolveDiscordChannelConfig({
guildInfo,
channelId: channel?.id ?? "",
channelName,
channelSlug,
})
: null;
if (channelConfig?.enabled === false) {
await interaction.reply({
content: "This channel is disabled.",
});
return;
}
if (interaction.guild && channelConfig?.allowed === false) {
await interaction.reply({
content: "This channel is not allowed.",
});
return;
}
if (useAccessGroups && interaction.guild) {
const channelAllowlistConfigured =
Boolean(guildInfo?.channels) &&
Object.keys(guildInfo?.channels ?? {}).length > 0;
const channelAllowed = channelConfig?.allowed !== false;
const allowByPolicy = isDiscordGroupAllowedByPolicy({
groupPolicy: discordConfig?.groupPolicy ?? "open",
channelAllowlistConfigured,
channelAllowed,
});
if (!allowByPolicy) {
await interaction.reply({
content: "This channel is not allowed.",
});
return;
}
}
const dmEnabled = discordConfig?.dm?.enabled ?? true;
const dmPolicy = discordConfig?.dm?.policy ?? "pairing";
let commandAuthorized = true;
if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") {
await interaction.reply({ content: "Discord DMs are disabled." });
return;
}
if (dmPolicy !== "open") {
const storeAllowFrom = await readProviderAllowFromStore(
"discord",
).catch(() => []);
const effectiveAllowFrom = [
...(discordConfig?.dm?.allowFrom ?? []),
...storeAllowFrom,
];
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, [
"discord:",
"user:",
]);
const permitted = allowList
? allowListMatches(allowList, {
id: user.id,
name: user.username,
tag: formatDiscordUserTag(user),
})
: false;
if (!permitted) {
commandAuthorized = false;
if (dmPolicy === "pairing") {
const { code, created } = await upsertProviderPairingRequest({
provider: "discord",
id: user.id,
meta: {
tag: formatDiscordUserTag(user),
name: user.username ?? undefined,
},
});
if (created) {
await interaction.reply({
content: buildPairingReply({
provider: "discord",
idLine: `Your Discord user id: ${user.id}`,
code,
}),
ephemeral: true,
});
}
} else {
await interaction.reply({
content: "You are not authorized to use this command.",
ephemeral: true,
});
}
return;
}
commandAuthorized = true;
}
}
if (!isDirectMessage) {
const channelUsers = channelConfig?.users ?? guildInfo?.users;
if (Array.isArray(channelUsers) && channelUsers.length > 0) {
const userOk = resolveDiscordUserAllowed({
allowList: channelUsers,
userId: user.id,
userName: user.username,
userTag: formatDiscordUserTag(user),
});
if (!userOk) {
await interaction.reply({
content: "You are not authorized to use this command.",
});
return;
}
}
}
if (isGroupDm && discordConfig?.dm?.groupEnabled === false) {
await interaction.reply({ content: "Discord group DMs are disabled." });
return;
}
const isGuild = Boolean(interaction.guild);
const channelId = channel?.id ?? "unknown";
const interactionId = interaction.rawData.id;
const route = resolveAgentRoute({
cfg,
provider: "discord",
accountId,
guildId: interaction.guild?.id ?? undefined,
peer: {
kind: isDirectMessage ? "dm" : isGroupDm ? "group" : "channel",
id: isDirectMessage ? user.id : channelId,
},
});
const ctxPayload = {
Body: prompt,
CommandBody: prompt,
From: isDirectMessage ? `discord:${user.id}` : `group:${channelId}`,
To: `slash:${user.id}`,
SessionKey: `agent:${route.agentId}:${sessionPrefix}:${user.id}`,
CommandTargetSessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "group",
GroupSubject: isGuild ? interaction.guild?.name : undefined,
GroupSystemPrompt: isGuild
? (() => {
const channelTopic =
channel && "topic" in channel
? (channel.topic ?? undefined)
: undefined;
const channelDescription = channelTopic?.trim();
const systemPromptParts = [
channelDescription
? `Channel topic: ${channelDescription}`
: null,
channelConfig?.systemPrompt?.trim() || null,
].filter((entry): entry is string => Boolean(entry));
return systemPromptParts.length > 0
? systemPromptParts.join("\n\n")
: undefined;
})()
: undefined,
SenderName: user.globalName ?? user.username,
SenderId: user.id,
SenderUsername: user.username,
SenderTag: formatDiscordUserTag(user),
Provider: "discord" as const,
Surface: "discord" as const,
WasMentioned: true,
MessageSid: interactionId,
Timestamp: Date.now(),
CommandAuthorized: commandAuthorized,
CommandSource: "native" as const,
};
let didReply = false;
const dispatcher = createReplyDispatcher({
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId)
.responsePrefix,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload, _info) => {
await deliverDiscordInteractionReply({
interaction,
payload,
textLimit: resolveTextChunkLimit(cfg, "discord", accountId, {
fallbackLimit: 2000,
}),
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
preferFollowUp: didReply,
});
didReply = true;
},
onError: (err) => {
console.error(err);
},
});
const replyResult = await getReplyFromConfig(
ctxPayload,
{ skillFilter: channelConfig?.skills },
cfg,
);
const replies = replyResult
? Array.isArray(replyResult)
? replyResult
: [replyResult]
: [];
for (const reply of replies) {
dispatcher.sendFinalReply(reply);
}
await dispatcher.waitForIdle();
}
})();
}
async function deliverDiscordInteractionReply(params: {
interaction: CommandInteraction;
payload: ReplyPayload;
textLimit: number;
maxLinesPerMessage?: number;
preferFollowUp: boolean;
}) {
const {
interaction,
payload,
textLimit,
maxLinesPerMessage,
preferFollowUp,
} = params;
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
let hasReplied = false;
const sendMessage = async (
content: string,
files?: { name: string; data: Buffer }[],
) => {
const payload =
files && files.length > 0
? {
content,
files: files.map((file) => {
if (file.data instanceof Blob) {
return { name: file.name, data: file.data };
}
const arrayBuffer = Uint8Array.from(file.data).buffer;
return { name: file.name, data: new Blob([arrayBuffer]) };
}),
}
: { content };
if (!preferFollowUp && !hasReplied) {
await interaction.reply(payload);
hasReplied = true;
return;
}
await interaction.followUp(payload);
hasReplied = true;
};
if (mediaList.length > 0) {
const media = await Promise.all(
mediaList.map(async (url) => {
const loaded = await loadWebMedia(url);
return {
name: loaded.fileName ?? "upload",
data: loaded.buffer,
};
}),
);
const chunks = chunkDiscordText(text, {
maxChars: textLimit,
maxLines: maxLinesPerMessage,
});
const caption = chunks[0] ?? "";
await sendMessage(caption, media);
for (const chunk of chunks.slice(1)) {
if (!chunk.trim()) continue;
await interaction.followUp({ content: chunk });
}
return;
}
if (!text.trim()) return;
const chunks = chunkDiscordText(text, {
maxChars: textLimit,
maxLines: maxLinesPerMessage,
});
for (const chunk of chunks) {
if (!chunk.trim()) continue;
await sendMessage(chunk);
}
}
async function deliverDiscordReply(params: {
replies: ReplyPayload[];
target: string;
token: string;
accountId?: string;
rest?: RequestClient;
runtime: RuntimeEnv;
textLimit: number;
maxLinesPerMessage?: number;
replyToMode: ReplyToMode;
}) {
const chunkLimit = Math.min(params.textLimit, 2000);
for (const payload of params.replies) {
const mediaList =
payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
if (!text && mediaList.length === 0) continue;
if (mediaList.length === 0) {
for (const chunk of chunkDiscordText(text, {
maxChars: chunkLimit,
maxLines: params.maxLinesPerMessage,
})) {
const trimmed = chunk.trim();
if (!trimmed) continue;
await sendMessageDiscord(params.target, trimmed, {
token: params.token,
rest: params.rest,
accountId: params.accountId,
});
}
continue;
}
const firstMedia = mediaList[0];
if (!firstMedia) continue;
await sendMessageDiscord(params.target, text, {
token: params.token,
rest: params.rest,
mediaUrl: firstMedia,
accountId: params.accountId,
});
for (const extra of mediaList.slice(1)) {
await sendMessageDiscord(params.target, "", {
token: params.token,
rest: params.rest,
mediaUrl: extra,
accountId: params.accountId,
});
}
}
}
async function resolveDiscordChannelInfo(
client: Client,
channelId: string,
): Promise<DiscordChannelInfo | null> {
const cached = DISCORD_CHANNEL_INFO_CACHE.get(channelId);
if (cached) {
if (cached.expiresAt > Date.now()) return cached.value;
DISCORD_CHANNEL_INFO_CACHE.delete(channelId);
}
try {
const channel = await client.fetchChannel(channelId);
if (!channel) {
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: null,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
});
return null;
}
const name = "name" in channel ? (channel.name ?? undefined) : undefined;
const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined;
const parentId =
"parentId" in channel ? (channel.parentId ?? undefined) : undefined;
const payload: DiscordChannelInfo = {
type: channel.type,
name,
topic,
parentId,
};
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: payload,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_CACHE_TTL_MS,
});
return payload;
} catch (err) {
logVerbose(`discord: failed to fetch channel ${channelId}: ${String(err)}`);
DISCORD_CHANNEL_INFO_CACHE.set(channelId, {
value: null,
expiresAt: Date.now() + DISCORD_CHANNEL_INFO_NEGATIVE_CACHE_TTL_MS,
});
return null;
}
}
async function resolveMediaList(
message: Message,
maxBytes: number,
): Promise<DiscordMediaInfo[]> {
const attachments = message.attachments ?? [];
if (attachments.length === 0) return [];
const out: DiscordMediaInfo[] = [];
for (const attachment of attachments) {
try {
const fetched = await fetchRemoteMedia({
url: attachment.url,
filePathHint: attachment.filename ?? attachment.url,
});
const saved = await saveMediaBuffer(
fetched.buffer,
fetched.contentType ?? attachment.content_type,
"inbound",
maxBytes,
);
out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder(attachment),
});
} catch (err) {
const id = attachment.id ?? attachment.url;
logVerbose(
`discord: failed to download attachment ${id}: ${String(err)}`,
);
}
}
return out;
}
function inferPlaceholder(attachment: APIAttachment): string {
const mime = attachment.content_type ?? "";
if (mime.startsWith("image/")) return "<media:image>";
if (mime.startsWith("video/")) return "<media:video>";
if (mime.startsWith("audio/")) return "<media:audio>";
return "<media:document>";
}
function isImageAttachment(attachment: APIAttachment): boolean {
const mime = attachment.content_type ?? "";
if (mime.startsWith("image/")) return true;
const name = attachment.filename?.toLowerCase() ?? "";
if (!name) return false;
return /\.(avif|bmp|gif|heic|heif|jpe?g|png|tiff?|webp)$/.test(name);
}
function buildDiscordAttachmentPlaceholder(
attachments?: APIAttachment[],
): string {
if (!attachments || attachments.length === 0) return "";
const count = attachments.length;
const allImages = attachments.every(isImageAttachment);
const label = allImages ? "image" : "file";
const suffix = count === 1 ? label : `${label}s`;
const tag = allImages ? "<media:image>" : "<media:document>";
return `${tag} (${count} ${suffix})`;
}
function resolveDiscordMessageText(
message: Message,
options?: { fallbackText?: string; includeForwarded?: boolean },
): string {
const baseText =
message.content?.trim() ||
buildDiscordAttachmentPlaceholder(message.attachments) ||
message.embeds?.[0]?.description ||
options?.fallbackText?.trim() ||
"";
if (!options?.includeForwarded) return baseText;
const forwardedText = resolveDiscordForwardedMessagesText(message);
if (!forwardedText) return baseText;
if (!baseText) return forwardedText;
return `${baseText}\n${forwardedText}`;
}
function resolveDiscordForwardedMessagesText(message: Message): string {
const snapshots = resolveDiscordMessageSnapshots(message);
if (snapshots.length === 0) return "";
const forwardedBlocks = snapshots
.map((snapshot) => {
const snapshotMessage = snapshot.message;
if (!snapshotMessage) return null;
const text = resolveDiscordSnapshotMessageText(snapshotMessage);
if (!text) return null;
const authorLabel = formatDiscordSnapshotAuthor(snapshotMessage.author);
const heading = authorLabel
? `[Forwarded message from ${authorLabel}]`
: "[Forwarded message]";
return `${heading}\n${text}`;
})
.filter((entry): entry is string => Boolean(entry));
if (forwardedBlocks.length === 0) return "";
return forwardedBlocks.join("\n\n");
}
function resolveDiscordMessageSnapshots(
message: Message,
): DiscordMessageSnapshot[] {
const rawData = (message as { rawData?: { message_snapshots?: unknown } })
.rawData;
const snapshots =
rawData?.message_snapshots ??
(message as { message_snapshots?: unknown }).message_snapshots ??
(message as { messageSnapshots?: unknown }).messageSnapshots;
if (!Array.isArray(snapshots)) return [];
return snapshots.filter(
(entry): entry is DiscordMessageSnapshot =>
Boolean(entry) && typeof entry === "object",
);
}
function resolveDiscordSnapshotMessageText(
snapshot: DiscordSnapshotMessage,
): string {
const content = snapshot.content?.trim() ?? "";
const attachmentText = buildDiscordAttachmentPlaceholder(
snapshot.attachments ?? undefined,
);
const embed = snapshot.embeds?.[0];
const embedText = embed?.description?.trim() || embed?.title?.trim() || "";
return content || attachmentText || embedText || "";
}
function formatDiscordSnapshotAuthor(
author: DiscordSnapshotAuthor | null | undefined,
): string | undefined {
if (!author) return undefined;
const globalName = author.global_name ?? undefined;
const username = author.username ?? undefined;
const name = author.name ?? undefined;
const discriminator = author.discriminator ?? undefined;
const base = globalName || username || name;
if (username && discriminator && discriminator !== "0") {
return `@${username}#${discriminator}`;
}
if (base) return `@${base}`;
if (author.id) return `@${author.id}`;
return undefined;
}
export function buildDiscordMediaPayload(
mediaList: Array<{ path: string; contentType?: string }>,
): {
MediaPath?: string;
MediaType?: string;
MediaUrl?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
} {
const first = mediaList[0];
const mediaPaths = mediaList.map((media) => media.path);
const mediaTypes = mediaList
.map((media) => media.contentType)
.filter(Boolean) as string[];
return {
MediaPath: first?.path,
MediaType: first?.contentType,
MediaUrl: first?.path,
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
};
}
function resolveReplyContext(message: Message): string | null {
const referenced = message.referencedMessage;
if (!referenced?.author) return null;
const referencedText = resolveDiscordMessageText(referenced, {
includeForwarded: true,
});
if (!referencedText) return null;
const fromLabel = referenced.author
? buildDirectLabel(referenced.author)
: "Unknown";
const body = `${referencedText}\n[discord message id: ${referenced.id} channel: ${referenced.channelId} from: ${formatDiscordUserTag(referenced.author)} user id:${referenced.author?.id ?? "unknown"}]`;
return formatAgentEnvelope({
provider: "Discord",
from: fromLabel,
timestamp: resolveTimestampMs(referenced.timestamp),
body,
});
}
function buildDirectLabel(author: User) {
const username = formatDiscordUserTag(author);
return `${username} user id:${author.id}`;
}
function buildGuildLabel(params: {
guild?: Guild;
channelName: string;
channelId: string;
}) {
const { guild, channelName, channelId } = params;
return `${guild?.name ?? "Guild"} #${channelName} channel id:${channelId}`;
}
function resolveDiscordSystemEvent(
message: Message,
location: string,
): string | null {
switch (message.type) {
case MessageType.ChannelPinnedMessage:
return buildDiscordSystemEvent(message, location, "pinned a message");
case MessageType.RecipientAdd:
return buildDiscordSystemEvent(message, location, "added a recipient");
case MessageType.RecipientRemove:
return buildDiscordSystemEvent(message, location, "removed a recipient");
case MessageType.UserJoin:
return buildDiscordSystemEvent(message, location, "user joined");
case MessageType.GuildBoost:
return buildDiscordSystemEvent(message, location, "boosted the server");
case MessageType.GuildBoostTier1:
return buildDiscordSystemEvent(
message,
location,
"boosted the server (Tier 1 reached)",
);
case MessageType.GuildBoostTier2:
return buildDiscordSystemEvent(
message,
location,
"boosted the server (Tier 2 reached)",
);
case MessageType.GuildBoostTier3:
return buildDiscordSystemEvent(
message,
location,
"boosted the server (Tier 3 reached)",
);
case MessageType.ThreadCreated:
return buildDiscordSystemEvent(message, location, "created a thread");
case MessageType.AutoModerationAction:
return buildDiscordSystemEvent(
message,
location,
"auto moderation action",
);
case MessageType.GuildIncidentAlertModeEnabled:
return buildDiscordSystemEvent(
message,
location,
"raid protection enabled",
);
case MessageType.GuildIncidentAlertModeDisabled:
return buildDiscordSystemEvent(
message,
location,
"raid protection disabled",
);
case MessageType.GuildIncidentReportRaid:
return buildDiscordSystemEvent(message, location, "raid reported");
case MessageType.GuildIncidentReportFalseAlarm:
return buildDiscordSystemEvent(
message,
location,
"raid report marked false alarm",
);
case MessageType.StageStart:
return buildDiscordSystemEvent(message, location, "stage started");
case MessageType.StageEnd:
return buildDiscordSystemEvent(message, location, "stage ended");
case MessageType.StageSpeaker:
return buildDiscordSystemEvent(
message,
location,
"stage speaker updated",
);
case MessageType.StageTopic:
return buildDiscordSystemEvent(message, location, "stage topic updated");
case MessageType.PollResult:
return buildDiscordSystemEvent(message, location, "poll results posted");
case MessageType.PurchaseNotification:
return buildDiscordSystemEvent(
message,
location,
"purchase notification",
);
default:
return null;
}
}
function buildDiscordSystemEvent(
message: Message,
location: string,
action: string,
) {
const authorLabel = message.author
? formatDiscordUserTag(message.author)
: "";
const actor = authorLabel ? `${authorLabel} ` : "";
return `Discord system: ${actor}${action} in ${location}`;
}
function resolveDiscordSystemLocation(params: {
isDirectMessage: boolean;
isGroupDm: boolean;
guild?: Guild;
channelName: string;
}) {
const { isDirectMessage, isGroupDm, guild, channelName } = params;
if (isDirectMessage) return "DM";
if (isGroupDm) return `Group DM #${channelName}`;
return guild?.name ? `${guild.name} #${channelName}` : `#${channelName}`;
}
function formatDiscordReactionEmoji(emoji: {
id?: string | null;
name?: string | null;
}) {
if (emoji.id && emoji.name) {
return `${emoji.name}:${emoji.id}`;
}
return emoji.name ?? "emoji";
}
function formatDiscordUserTag(user: User) {
const discriminator = (user.discriminator ?? "").trim();
if (discriminator && discriminator !== "0") {
return `${user.username}#${discriminator}`;
}
return user.username ?? user.id;
}
function resolveTimestampMs(timestamp?: string | null) {
if (!timestamp) return undefined;
const parsed = Date.parse(timestamp);
return Number.isNaN(parsed) ? undefined : parsed;
}
export function normalizeDiscordAllowList(
raw: Array<string | number> | undefined,
prefixes: string[],
) {
if (!raw || raw.length === 0) return null;
const ids = new Set<string>();
const names = new Set<string>();
const allowAll = raw.some((entry) => String(entry).trim() === "*");
for (const entry of raw) {
const text = String(entry).trim();
if (!text || text === "*") continue;
const normalized = normalizeDiscordSlug(text);
const maybeId = text.replace(/^<@!?/, "").replace(/>$/, "");
if (/^\d+$/.test(maybeId)) {
ids.add(maybeId);
continue;
}
const prefix = prefixes.find((entry) => text.startsWith(entry));
if (prefix) {
const candidate = text.slice(prefix.length);
if (candidate) ids.add(candidate);
continue;
}
if (normalized) {
names.add(normalized);
}
}
return { allowAll, ids, names } satisfies DiscordAllowList;
}
export function normalizeDiscordSlug(value: string) {
return value
.trim()
.toLowerCase()
.replace(/^#/, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function allowListMatches(
list: DiscordAllowList,
candidate: { id?: string; name?: string; tag?: string },
) {
if (list.allowAll) return true;
if (candidate.id && list.ids.has(candidate.id)) return true;
const slug = candidate.name ? normalizeDiscordSlug(candidate.name) : "";
if (slug && list.names.has(slug)) return true;
if (candidate.tag && list.names.has(normalizeDiscordSlug(candidate.tag)))
return true;
return false;
}
function resolveDiscordUserAllowed(params: {
allowList?: Array<string | number>;
userId: string;
userName?: string;
userTag?: string;
}) {
const allowList = normalizeDiscordAllowList(params.allowList, [
"discord:",
"user:",
]);
if (!allowList) return true;
return allowListMatches(allowList, {
id: params.userId,
name: params.userName,
tag: params.userTag,
});
}
export function resolveDiscordCommandAuthorized(params: {
isDirectMessage: boolean;
allowFrom?: Array<string | number>;
guildInfo?: DiscordGuildEntryResolved | null;
author: User;
}) {
if (!params.isDirectMessage) return true;
const allowList = normalizeDiscordAllowList(params.allowFrom, [
"discord:",
"user:",
]);
if (!allowList) return true;
return allowListMatches(allowList, {
id: params.author.id,
name: params.author.username,
tag: formatDiscordUserTag(params.author),
});
}
export function resolveDiscordGuildEntry(params: {
guild?: Guild<true> | Guild<false> | null;
guildEntries?: Record<string, DiscordGuildEntryResolved>;
}): DiscordGuildEntryResolved | null {
const guild = params.guild;
const entries = params.guildEntries;
if (!guild || !entries) return null;
const byId = entries[guild.id];
if (byId) return { ...byId, id: guild.id };
const slug = normalizeDiscordSlug(guild.name ?? "");
const bySlug = entries[slug];
if (bySlug) return { ...bySlug, id: guild.id, slug: slug || bySlug.slug };
const wildcard = entries["*"];
if (wildcard)
return { ...wildcard, id: guild.id, slug: slug || wildcard.slug };
return null;
}
export function resolveDiscordChannelConfig(params: {
guildInfo?: DiscordGuildEntryResolved | null;
channelId: string;
channelName?: string;
channelSlug: string;
}): DiscordChannelConfigResolved | null {
const { guildInfo, channelId, channelName, channelSlug } = params;
const channels = guildInfo?.channels;
if (!channels) return null;
const byId = channels[channelId];
if (byId)
return {
allowed: byId.allow !== false,
requireMention: byId.requireMention,
skills: byId.skills,
enabled: byId.enabled,
users: byId.users,
systemPrompt: byId.systemPrompt,
};
if (channelSlug && channels[channelSlug]) {
const entry = channels[channelSlug];
return {
allowed: entry.allow !== false,
requireMention: entry.requireMention,
skills: entry.skills,
enabled: entry.enabled,
users: entry.users,
systemPrompt: entry.systemPrompt,
};
}
if (channelName && channels[channelName]) {
const entry = channels[channelName];
return {
allowed: entry.allow !== false,
requireMention: entry.requireMention,
skills: entry.skills,
enabled: entry.enabled,
users: entry.users,
systemPrompt: entry.systemPrompt,
};
}
return { allowed: false };
}
export function isDiscordGroupAllowedByPolicy(params: {
groupPolicy: "open" | "disabled" | "allowlist";
channelAllowlistConfigured: boolean;
channelAllowed: boolean;
}): boolean {
const { groupPolicy, channelAllowlistConfigured, channelAllowed } = params;
if (groupPolicy === "disabled") return false;
if (groupPolicy === "open") return true;
if (!channelAllowlistConfigured) return false;
return channelAllowed;
}
export function resolveGroupDmAllow(params: {
channels?: Array<string | number>;
channelId: string;
channelName?: string;
channelSlug: string;
}) {
const { channels, channelId, channelName, channelSlug } = params;
if (!channels || channels.length === 0) return true;
const allowList = channels.map((entry) =>
normalizeDiscordSlug(String(entry)),
);
const candidates = [
normalizeDiscordSlug(channelId),
channelSlug,
channelName ? normalizeDiscordSlug(channelName) : "",
].filter(Boolean);
return (
allowList.includes("*") ||
candidates.some((candidate) => allowList.includes(candidate))
);
}
export function shouldEmitDiscordReactionNotification(params: {
mode?: "off" | "own" | "all" | "allowlist";
botId?: string;
messageAuthorId?: string;
userId: string;
userName?: string;
userTag?: string;
allowlist?: Array<string | number>;
}) {
const mode = params.mode ?? "own";
if (mode === "off") return false;
if (mode === "all") return true;
if (mode === "own") {
return Boolean(params.botId && params.messageAuthorId === params.botId);
}
if (mode === "allowlist") {
const list = normalizeDiscordAllowList(params.allowlist, [
"discord:",
"user:",
]);
if (!list) return false;
return allowListMatches(list, {
id: params.userId,
name: params.userName,
tag: params.userTag,
});
}
return false;
}
async function sendTyping(params: { client: Client; channelId: string }) {
try {
const channel = await params.client.fetchChannel(params.channelId);
if (!channel) return;
if (
"triggerTyping" in channel &&
typeof channel.triggerTyping === "function"
) {
await channel.triggerTyping();
}
} catch (err) {
logVerbose(
`discord typing cue failed for channel ${params.channelId}: ${String(err)}`,
);
}
}