refactor: move Discord channel implementation to extensions/ (#45660)

* refactor: move Discord channel implementation to extensions/discord/src/

Move all Discord source files from src/discord/ to extensions/discord/src/,
following the extension migration pattern. Source files in src/discord/ are
replaced with re-export shims. Channel-plugin files from
src/channels/plugins/*/discord* are similarly moved and shimmed.

- Copy all .ts source files preserving subdirectory structure (monitor/, voice/)
- Move channel-plugin files (actions, normalize, onboarding, outbound, status-issues)
- Fix all relative imports to use correct paths from new location
- Create re-export shims at original locations for backward compatibility
- Delete test files from shim locations (tests live in extension now)
- Update tsconfig.plugin-sdk.dts.json rootDir from "src" to "." to accommodate
  extension files outside src/
- Update write-plugin-sdk-entry-dts.ts to match new declaration output paths

* fix: add importOriginal to thread-bindings session-meta mock for extensions test

* style: fix formatting in thread-bindings lifecycle test
This commit is contained in:
scoootscooob
2026-03-14 02:53:57 -07:00
committed by GitHub
parent e5bca0832f
commit 5682ec37fa
283 changed files with 26180 additions and 26016 deletions

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { inspectDiscordAccount } from "./account-inspect.js";
function asConfig(value: unknown): OpenClawConfig {

View File

@@ -0,0 +1,132 @@
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { DiscordAccountConfig } from "../../../src/config/types.discord.js";
import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "../../../src/config/types.secrets.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
import {
mergeDiscordAccountConfig,
resolveDefaultDiscordAccountId,
resolveDiscordAccountConfig,
} from "./accounts.js";
export type DiscordCredentialStatus = "available" | "configured_unavailable" | "missing";
export type InspectedDiscordAccount = {
accountId: string;
enabled: boolean;
name?: string;
token: string;
tokenSource: "env" | "config" | "none";
tokenStatus: DiscordCredentialStatus;
configured: boolean;
config: DiscordAccountConfig;
};
function inspectDiscordTokenValue(value: unknown): {
token: string;
tokenSource: "config";
tokenStatus: Exclude<DiscordCredentialStatus, "missing">;
} | null {
const normalized = normalizeSecretInputString(value);
if (normalized) {
return {
token: normalized.replace(/^Bot\s+/i, ""),
tokenSource: "config",
tokenStatus: "available",
};
}
if (hasConfiguredSecretInput(value)) {
return {
token: "",
tokenSource: "config",
tokenStatus: "configured_unavailable",
};
}
return null;
}
export function inspectDiscordAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
envToken?: string | null;
}): InspectedDiscordAccount {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultDiscordAccountId(params.cfg),
);
const merged = mergeDiscordAccountConfig(params.cfg, accountId);
const enabled = params.cfg.channels?.discord?.enabled !== false && merged.enabled !== false;
const accountConfig = resolveDiscordAccountConfig(params.cfg, accountId);
const hasAccountToken = Boolean(
accountConfig &&
Object.prototype.hasOwnProperty.call(accountConfig as Record<string, unknown>, "token"),
);
const accountToken = inspectDiscordTokenValue(accountConfig?.token);
if (accountToken) {
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
token: accountToken.token,
tokenSource: accountToken.tokenSource,
tokenStatus: accountToken.tokenStatus,
configured: true,
config: merged,
};
}
if (hasAccountToken) {
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
token: "",
tokenSource: "none",
tokenStatus: "missing",
configured: false,
config: merged,
};
}
const channelToken = inspectDiscordTokenValue(params.cfg.channels?.discord?.token);
if (channelToken) {
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
token: channelToken.token,
tokenSource: channelToken.tokenSource,
tokenStatus: channelToken.tokenStatus,
configured: true,
config: merged,
};
}
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const envToken = allowEnv
? normalizeSecretInputString(params.envToken ?? process.env.DISCORD_BOT_TOKEN)
: undefined;
if (envToken) {
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
token: envToken.replace(/^Bot\s+/i, ""),
tokenSource: "env",
tokenStatus: "available",
configured: true,
config: merged,
};
}
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
token: "",
tokenSource: "none",
tokenStatus: "missing",
configured: false,
config: merged,
};
}

View File

@@ -0,0 +1,89 @@
import { createAccountActionGate } from "../../../src/channels/plugins/account-action-gate.js";
import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { DiscordAccountConfig, DiscordActionConfig } from "../../../src/config/types.js";
import { resolveAccountEntry } from "../../../src/routing/account-lookup.js";
import { normalizeAccountId } from "../../../src/routing/session-key.js";
import { resolveDiscordToken } from "./token.js";
export type ResolvedDiscordAccount = {
accountId: string;
enabled: boolean;
name?: string;
token: string;
tokenSource: "env" | "config" | "none";
config: DiscordAccountConfig;
};
const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("discord");
export const listDiscordAccountIds = listAccountIds;
export const resolveDefaultDiscordAccountId = resolveDefaultAccountId;
export function resolveDiscordAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): DiscordAccountConfig | undefined {
return resolveAccountEntry(cfg.channels?.discord?.accounts, accountId);
}
export function mergeDiscordAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): DiscordAccountConfig {
const { accounts: _ignored, ...base } = (cfg.channels?.discord ?? {}) as DiscordAccountConfig & {
accounts?: unknown;
};
const account = resolveDiscordAccountConfig(cfg, accountId) ?? {};
return { ...base, ...account };
}
export function createDiscordActionGate(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): (key: keyof DiscordActionConfig, defaultValue?: boolean) => boolean {
const accountId = normalizeAccountId(params.accountId);
return createAccountActionGate({
baseActions: params.cfg.channels?.discord?.actions,
accountActions: resolveDiscordAccountConfig(params.cfg, accountId)?.actions,
});
}
export function resolveDiscordAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedDiscordAccount {
const accountId = normalizeAccountId(params.accountId);
const baseEnabled = params.cfg.channels?.discord?.enabled !== false;
const merged = mergeDiscordAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const tokenResolution = resolveDiscordToken(params.cfg, { accountId });
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
token: tokenResolution.token,
tokenSource: tokenResolution.source,
config: merged,
};
}
export function resolveDiscordMaxLinesPerMessage(params: {
cfg: OpenClawConfig;
discordConfig?: DiscordAccountConfig | null;
accountId?: string | null;
}): number | undefined {
if (typeof params.discordConfig?.maxLinesPerMessage === "number") {
return params.discordConfig.maxLinesPerMessage;
}
return resolveDiscordAccount({
cfg: params.cfg,
accountId: params.accountId,
}).config.maxLinesPerMessage;
}
export function listEnabledDiscordAccounts(cfg: OpenClawConfig): ResolvedDiscordAccount[] {
return listDiscordAccountIds(cfg)
.map((accountId) => resolveDiscordAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}

View File

@@ -0,0 +1,451 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import {
parseAvailableTags,
readNumberParam,
readStringArrayParam,
readStringParam,
} from "../../../../src/agents/tools/common.js";
import {
isDiscordModerationAction,
readDiscordModerationCommand,
} from "../../../../src/agents/tools/discord-actions-moderation-shared.js";
import { handleDiscordAction } from "../../../../src/agents/tools/discord-actions.js";
import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js";
type Ctx = Pick<
ChannelMessageActionContext,
"action" | "params" | "cfg" | "accountId" | "requesterSenderId"
>;
export async function tryHandleDiscordMessageActionGuildAdmin(params: {
ctx: Ctx;
resolveChannelId: () => string;
readParentIdParam: (params: Record<string, unknown>) => string | null | undefined;
}): Promise<AgentToolResult<unknown> | undefined> {
const { ctx, resolveChannelId, readParentIdParam } = params;
const { action, params: actionParams, cfg } = ctx;
const accountId = ctx.accountId ?? readStringParam(actionParams, "accountId");
if (action === "member-info") {
const userId = readStringParam(actionParams, "userId", { required: true });
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
return await handleDiscordAction(
{ action: "memberInfo", accountId: accountId ?? undefined, guildId, userId },
cfg,
);
}
if (action === "role-info") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
return await handleDiscordAction(
{ action: "roleInfo", accountId: accountId ?? undefined, guildId },
cfg,
);
}
if (action === "emoji-list") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
return await handleDiscordAction(
{ action: "emojiList", accountId: accountId ?? undefined, guildId },
cfg,
);
}
if (action === "emoji-upload") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
const name = readStringParam(actionParams, "emojiName", { required: true });
const mediaUrl = readStringParam(actionParams, "media", {
required: true,
trim: false,
});
const roleIds = readStringArrayParam(actionParams, "roleIds");
return await handleDiscordAction(
{
action: "emojiUpload",
accountId: accountId ?? undefined,
guildId,
name,
mediaUrl,
roleIds,
},
cfg,
);
}
if (action === "sticker-upload") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
const name = readStringParam(actionParams, "stickerName", {
required: true,
});
const description = readStringParam(actionParams, "stickerDesc", {
required: true,
});
const tags = readStringParam(actionParams, "stickerTags", {
required: true,
});
const mediaUrl = readStringParam(actionParams, "media", {
required: true,
trim: false,
});
return await handleDiscordAction(
{
action: "stickerUpload",
accountId: accountId ?? undefined,
guildId,
name,
description,
tags,
mediaUrl,
},
cfg,
);
}
if (action === "role-add" || action === "role-remove") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
const userId = readStringParam(actionParams, "userId", { required: true });
const roleId = readStringParam(actionParams, "roleId", { required: true });
return await handleDiscordAction(
{
action: action === "role-add" ? "roleAdd" : "roleRemove",
accountId: accountId ?? undefined,
guildId,
userId,
roleId,
},
cfg,
);
}
if (action === "channel-info") {
const channelId = readStringParam(actionParams, "channelId", {
required: true,
});
return await handleDiscordAction(
{ action: "channelInfo", accountId: accountId ?? undefined, channelId },
cfg,
);
}
if (action === "channel-list") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
return await handleDiscordAction(
{ action: "channelList", accountId: accountId ?? undefined, guildId },
cfg,
);
}
if (action === "channel-create") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
const name = readStringParam(actionParams, "name", { required: true });
const type = readNumberParam(actionParams, "type", { integer: true });
const parentId = readParentIdParam(actionParams);
const topic = readStringParam(actionParams, "topic");
const position = readNumberParam(actionParams, "position", {
integer: true,
});
const nsfw = typeof actionParams.nsfw === "boolean" ? actionParams.nsfw : undefined;
return await handleDiscordAction(
{
action: "channelCreate",
accountId: accountId ?? undefined,
guildId,
name,
type: type ?? undefined,
parentId: parentId ?? undefined,
topic: topic ?? undefined,
position: position ?? undefined,
nsfw,
},
cfg,
);
}
if (action === "channel-edit") {
const channelId = readStringParam(actionParams, "channelId", {
required: true,
});
const name = readStringParam(actionParams, "name");
const topic = readStringParam(actionParams, "topic");
const position = readNumberParam(actionParams, "position", {
integer: true,
});
const parentId = readParentIdParam(actionParams);
const nsfw = typeof actionParams.nsfw === "boolean" ? actionParams.nsfw : undefined;
const rateLimitPerUser = readNumberParam(actionParams, "rateLimitPerUser", {
integer: true,
});
const archived = typeof actionParams.archived === "boolean" ? actionParams.archived : undefined;
const locked = typeof actionParams.locked === "boolean" ? actionParams.locked : undefined;
const autoArchiveDuration = readNumberParam(actionParams, "autoArchiveDuration", {
integer: true,
});
const availableTags = parseAvailableTags(actionParams.availableTags);
return await handleDiscordAction(
{
action: "channelEdit",
accountId: accountId ?? undefined,
channelId,
name: name ?? undefined,
topic: topic ?? undefined,
position: position ?? undefined,
parentId: parentId === undefined ? undefined : parentId,
nsfw,
rateLimitPerUser: rateLimitPerUser ?? undefined,
archived,
locked,
autoArchiveDuration: autoArchiveDuration ?? undefined,
availableTags,
},
cfg,
);
}
if (action === "channel-delete") {
const channelId = readStringParam(actionParams, "channelId", {
required: true,
});
return await handleDiscordAction(
{ action: "channelDelete", accountId: accountId ?? undefined, channelId },
cfg,
);
}
if (action === "channel-move") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
const channelId = readStringParam(actionParams, "channelId", {
required: true,
});
const parentId = readParentIdParam(actionParams);
const position = readNumberParam(actionParams, "position", {
integer: true,
});
return await handleDiscordAction(
{
action: "channelMove",
accountId: accountId ?? undefined,
guildId,
channelId,
parentId: parentId === undefined ? undefined : parentId,
position: position ?? undefined,
},
cfg,
);
}
if (action === "category-create") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
const name = readStringParam(actionParams, "name", { required: true });
const position = readNumberParam(actionParams, "position", {
integer: true,
});
return await handleDiscordAction(
{
action: "categoryCreate",
accountId: accountId ?? undefined,
guildId,
name,
position: position ?? undefined,
},
cfg,
);
}
if (action === "category-edit") {
const categoryId = readStringParam(actionParams, "categoryId", {
required: true,
});
const name = readStringParam(actionParams, "name");
const position = readNumberParam(actionParams, "position", {
integer: true,
});
return await handleDiscordAction(
{
action: "categoryEdit",
accountId: accountId ?? undefined,
categoryId,
name: name ?? undefined,
position: position ?? undefined,
},
cfg,
);
}
if (action === "category-delete") {
const categoryId = readStringParam(actionParams, "categoryId", {
required: true,
});
return await handleDiscordAction(
{ action: "categoryDelete", accountId: accountId ?? undefined, categoryId },
cfg,
);
}
if (action === "voice-status") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
const userId = readStringParam(actionParams, "userId", { required: true });
return await handleDiscordAction(
{ action: "voiceStatus", accountId: accountId ?? undefined, guildId, userId },
cfg,
);
}
if (action === "event-list") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
return await handleDiscordAction(
{ action: "eventList", accountId: accountId ?? undefined, guildId },
cfg,
);
}
if (action === "event-create") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
const name = readStringParam(actionParams, "eventName", { required: true });
const startTime = readStringParam(actionParams, "startTime", {
required: true,
});
const endTime = readStringParam(actionParams, "endTime");
const description = readStringParam(actionParams, "desc");
const channelId = readStringParam(actionParams, "channelId");
const location = readStringParam(actionParams, "location");
const entityType = readStringParam(actionParams, "eventType");
return await handleDiscordAction(
{
action: "eventCreate",
accountId: accountId ?? undefined,
guildId,
name,
startTime,
endTime,
description,
channelId,
location,
entityType,
},
cfg,
);
}
if (isDiscordModerationAction(action)) {
const moderation = readDiscordModerationCommand(action, {
...actionParams,
durationMinutes: readNumberParam(actionParams, "durationMin", { integer: true }),
deleteMessageDays: readNumberParam(actionParams, "deleteDays", {
integer: true,
}),
});
const senderUserId = ctx.requesterSenderId?.trim() || undefined;
return await handleDiscordAction(
{
action: moderation.action,
accountId: accountId ?? undefined,
guildId: moderation.guildId,
userId: moderation.userId,
durationMinutes: moderation.durationMinutes,
until: moderation.until,
reason: moderation.reason,
deleteMessageDays: moderation.deleteMessageDays,
senderUserId,
},
cfg,
);
}
// Some actions are conceptually "admin", but still act on a resolved channel.
if (action === "thread-list") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
const channelId = readStringParam(actionParams, "channelId");
const includeArchived =
typeof actionParams.includeArchived === "boolean" ? actionParams.includeArchived : undefined;
const before = readStringParam(actionParams, "before");
const limit = readNumberParam(actionParams, "limit", { integer: true });
return await handleDiscordAction(
{
action: "threadList",
accountId: accountId ?? undefined,
guildId,
channelId,
includeArchived,
before,
limit,
},
cfg,
);
}
if (action === "thread-reply") {
const content = readStringParam(actionParams, "message", {
required: true,
});
const mediaUrl = readStringParam(actionParams, "media", { trim: false });
const replyTo = readStringParam(actionParams, "replyTo");
// `message.thread-reply` (tool) uses `threadId`, while the CLI historically used `to`/`channelId`.
// Prefer `threadId` when present to avoid accidentally replying in the parent channel.
const threadId = readStringParam(actionParams, "threadId");
const channelId = threadId ?? resolveChannelId();
return await handleDiscordAction(
{
action: "threadReply",
accountId: accountId ?? undefined,
channelId,
content,
mediaUrl: mediaUrl ?? undefined,
replyTo: replyTo ?? undefined,
},
cfg,
);
}
if (action === "search") {
const guildId = readStringParam(actionParams, "guildId", {
required: true,
});
const query = readStringParam(actionParams, "query", { required: true });
return await handleDiscordAction(
{
action: "searchMessages",
accountId: accountId ?? undefined,
guildId,
content: query,
channelId: readStringParam(actionParams, "channelId"),
channelIds: readStringArrayParam(actionParams, "channelIds"),
authorId: readStringParam(actionParams, "authorId"),
authorIds: readStringArrayParam(actionParams, "authorIds"),
limit: readNumberParam(actionParams, "limit", { integer: true }),
},
cfg,
);
}
return undefined;
}

View File

@@ -0,0 +1,295 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import {
readNumberParam,
readStringArrayParam,
readStringParam,
} from "../../../../src/agents/tools/common.js";
import { readDiscordParentIdParam } from "../../../../src/agents/tools/discord-actions-shared.js";
import { handleDiscordAction } from "../../../../src/agents/tools/discord-actions.js";
import { resolveReactionMessageId } from "../../../../src/channels/plugins/actions/reaction-message-id.js";
import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js";
import { readBooleanParam } from "../../../../src/plugin-sdk/boolean-param.js";
import { resolveDiscordChannelId } from "../targets.js";
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";
const providerId = "discord";
export async function handleDiscordMessageAction(
ctx: Pick<
ChannelMessageActionContext,
| "action"
| "params"
| "cfg"
| "accountId"
| "requesterSenderId"
| "toolContext"
| "mediaLocalRoots"
>,
): Promise<AgentToolResult<unknown>> {
const { action, params, cfg } = ctx;
const accountId = ctx.accountId ?? readStringParam(params, "accountId");
const actionOptions = {
mediaLocalRoots: ctx.mediaLocalRoots,
} as const;
const resolveChannelId = () =>
resolveDiscordChannelId(
readStringParam(params, "channelId") ?? readStringParam(params, "to", { required: true }),
);
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const asVoice = readBooleanParam(params, "asVoice") === true;
const rawComponents = params.components;
const hasComponents =
Boolean(rawComponents) &&
(typeof rawComponents === "function" || typeof rawComponents === "object");
const components = hasComponents ? rawComponents : undefined;
const content = readStringParam(params, "message", {
required: !asVoice && !hasComponents,
allowEmpty: true,
});
// Support media, path, and filePath for media URL
const mediaUrl =
readStringParam(params, "media", { trim: false }) ??
readStringParam(params, "path", { trim: false }) ??
readStringParam(params, "filePath", { trim: false });
const filename = readStringParam(params, "filename");
const replyTo = readStringParam(params, "replyTo");
const rawEmbeds = params.embeds;
const embeds = Array.isArray(rawEmbeds) ? rawEmbeds : undefined;
const silent = readBooleanParam(params, "silent") === true;
const sessionKey = readStringParam(params, "__sessionKey");
const agentId = readStringParam(params, "__agentId");
return await handleDiscordAction(
{
action: "sendMessage",
accountId: accountId ?? undefined,
to,
content,
mediaUrl: mediaUrl ?? undefined,
filename: filename ?? undefined,
replyTo: replyTo ?? undefined,
components,
embeds,
asVoice,
silent,
__sessionKey: sessionKey ?? undefined,
__agentId: agentId ?? undefined,
},
cfg,
actionOptions,
);
}
if (action === "poll") {
const to = readStringParam(params, "to", { required: true });
const question = readStringParam(params, "pollQuestion", {
required: true,
});
const answers = readStringArrayParam(params, "pollOption", { required: true });
const allowMultiselect = readBooleanParam(params, "pollMulti");
const durationHours = readNumberParam(params, "pollDurationHours", {
integer: true,
strict: true,
});
return await handleDiscordAction(
{
action: "poll",
accountId: accountId ?? undefined,
to,
question,
answers,
allowMultiselect,
durationHours: durationHours ?? undefined,
content: readStringParam(params, "message"),
},
cfg,
actionOptions,
);
}
if (action === "react") {
const messageIdRaw = resolveReactionMessageId({ args: params, toolContext: ctx.toolContext });
const messageId = messageIdRaw != null ? String(messageIdRaw).trim() : "";
if (!messageId) {
throw new Error(
"messageId required. Provide messageId explicitly or react to the current inbound message.",
);
}
const emoji = readStringParam(params, "emoji", { allowEmpty: true });
const remove = readBooleanParam(params, "remove");
return await handleDiscordAction(
{
action: "react",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
messageId,
emoji,
remove,
},
cfg,
actionOptions,
);
}
if (action === "reactions") {
const messageId = readStringParam(params, "messageId", { required: true });
const limit = readNumberParam(params, "limit", { integer: true });
return await handleDiscordAction(
{
action: "reactions",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
messageId,
limit,
},
cfg,
actionOptions,
);
}
if (action === "read") {
const limit = readNumberParam(params, "limit", { integer: true });
return await handleDiscordAction(
{
action: "readMessages",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
limit,
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
around: readStringParam(params, "around"),
},
cfg,
actionOptions,
);
}
if (action === "edit") {
const messageId = readStringParam(params, "messageId", { required: true });
const content = readStringParam(params, "message", { required: true });
return await handleDiscordAction(
{
action: "editMessage",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
messageId,
content,
},
cfg,
actionOptions,
);
}
if (action === "delete") {
const messageId = readStringParam(params, "messageId", { required: true });
return await handleDiscordAction(
{
action: "deleteMessage",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
messageId,
},
cfg,
actionOptions,
);
}
if (action === "pin" || action === "unpin" || action === "list-pins") {
const messageId =
action === "list-pins" ? undefined : readStringParam(params, "messageId", { required: true });
return await handleDiscordAction(
{
action: action === "pin" ? "pinMessage" : action === "unpin" ? "unpinMessage" : "listPins",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
messageId,
},
cfg,
actionOptions,
);
}
if (action === "permissions") {
return await handleDiscordAction(
{
action: "permissions",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
},
cfg,
actionOptions,
);
}
if (action === "thread-create") {
const name = readStringParam(params, "threadName", { required: true });
const messageId = readStringParam(params, "messageId");
const content = readStringParam(params, "message");
const autoArchiveMinutes = readNumberParam(params, "autoArchiveMin", {
integer: true,
});
const appliedTags = readStringArrayParam(params, "appliedTags");
return await handleDiscordAction(
{
action: "threadCreate",
accountId: accountId ?? undefined,
channelId: resolveChannelId(),
name,
messageId,
content,
autoArchiveMinutes,
appliedTags: appliedTags ?? undefined,
},
cfg,
actionOptions,
);
}
if (action === "sticker") {
const stickerIds =
readStringArrayParam(params, "stickerId", {
required: true,
label: "sticker-id",
}) ?? [];
return await handleDiscordAction(
{
action: "sticker",
accountId: accountId ?? undefined,
to: readStringParam(params, "to", { required: true }),
stickerIds,
content: readStringParam(params, "message"),
},
cfg,
actionOptions,
);
}
if (action === "set-presence") {
return await handleDiscordAction(
{
action: "setPresence",
accountId: accountId ?? undefined,
status: readStringParam(params, "status"),
activityType: readStringParam(params, "activityType"),
activityName: readStringParam(params, "activityName"),
activityUrl: readStringParam(params, "activityUrl"),
activityState: readStringParam(params, "activityState"),
},
cfg,
actionOptions,
);
}
const adminResult = await tryHandleDiscordMessageActionGuildAdmin({
ctx,
resolveChannelId,
readParentIdParam: readDiscordParentIdParam,
});
if (adminResult !== undefined) {
return adminResult;
}
throw new Error(`Action ${String(action)} is not supported for provider ${providerId}.`);
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { withFetchPreconnect } from "../../../src/test-utils/fetch-mock.js";
import { fetchDiscord } from "./api.js";
import { jsonResponse } from "./test-http-helpers.js";

View File

@@ -0,0 +1,136 @@
import { resolveFetch } from "../../../src/infra/fetch.js";
import { resolveRetryConfig, retryAsync, type RetryConfig } from "../../../src/infra/retry.js";
const DISCORD_API_BASE = "https://discord.com/api/v10";
const DISCORD_API_RETRY_DEFAULTS = {
attempts: 3,
minDelayMs: 500,
maxDelayMs: 30_000,
jitter: 0.1,
};
type DiscordApiErrorPayload = {
message?: string;
retry_after?: number;
code?: number;
global?: boolean;
};
function parseDiscordApiErrorPayload(text: string): DiscordApiErrorPayload | null {
const trimmed = text.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
return null;
}
try {
const payload = JSON.parse(trimmed);
if (payload && typeof payload === "object") {
return payload as DiscordApiErrorPayload;
}
} catch {
return null;
}
return null;
}
function parseRetryAfterSeconds(text: string, response: Response): number | undefined {
const payload = parseDiscordApiErrorPayload(text);
const retryAfter =
payload && typeof payload.retry_after === "number" && Number.isFinite(payload.retry_after)
? payload.retry_after
: undefined;
if (retryAfter !== undefined) {
return retryAfter;
}
const header = response.headers.get("Retry-After");
if (!header) {
return undefined;
}
const parsed = Number(header);
return Number.isFinite(parsed) ? parsed : undefined;
}
function formatRetryAfterSeconds(value: number | undefined): string | undefined {
if (value === undefined || !Number.isFinite(value) || value < 0) {
return undefined;
}
const rounded = value < 10 ? value.toFixed(1) : Math.round(value).toString();
return `${rounded}s`;
}
function formatDiscordApiErrorText(text: string): string | undefined {
const trimmed = text.trim();
if (!trimmed) {
return undefined;
}
const payload = parseDiscordApiErrorPayload(trimmed);
if (!payload) {
const looksJson = trimmed.startsWith("{") && trimmed.endsWith("}");
return looksJson ? "unknown error" : trimmed;
}
const message =
typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: "unknown error";
const retryAfter = formatRetryAfterSeconds(
typeof payload.retry_after === "number" ? payload.retry_after : undefined,
);
return retryAfter ? `${message} (retry after ${retryAfter})` : message;
}
export class DiscordApiError extends Error {
status: number;
retryAfter?: number;
constructor(message: string, status: number, retryAfter?: number) {
super(message);
this.status = status;
this.retryAfter = retryAfter;
}
}
export type DiscordFetchOptions = {
retry?: RetryConfig;
label?: string;
};
export async function fetchDiscord<T>(
path: string,
token: string,
fetcher: typeof fetch = fetch,
options?: DiscordFetchOptions,
): Promise<T> {
const fetchImpl = resolveFetch(fetcher);
if (!fetchImpl) {
throw new Error("fetch is not available");
}
const retryConfig = resolveRetryConfig(DISCORD_API_RETRY_DEFAULTS, options?.retry);
return retryAsync(
async () => {
const res = await fetchImpl(`${DISCORD_API_BASE}${path}`, {
headers: { Authorization: `Bot ${token}` },
});
if (!res.ok) {
const text = await res.text().catch(() => "");
const detail = formatDiscordApiErrorText(text);
const suffix = detail ? `: ${detail}` : "";
const retryAfter = res.status === 429 ? parseRetryAfterSeconds(text, res) : undefined;
throw new DiscordApiError(
`Discord API ${path} failed (${res.status})${suffix}`,
res.status,
retryAfter,
);
}
return (await res.json()) as T;
},
{
...retryConfig,
label: options?.label ?? path,
shouldRetry: (err) => err instanceof DiscordApiError && err.status === 429,
retryAfterMs: (err) =>
err instanceof DiscordApiError && typeof err.retryAfter === "number"
? err.retryAfter * 1000
: undefined,
},
);
}

View File

@@ -27,7 +27,7 @@ describe("discord audit", () => {
},
},
},
} as unknown as import("../config/config.js").OpenClawConfig;
} as unknown as import("../../../src/config/config.js").OpenClawConfig;
const collected = collectDiscordAuditChannelIds({
cfg,
@@ -73,7 +73,7 @@ describe("discord audit", () => {
},
},
},
} as unknown as import("../config/config.js").OpenClawConfig;
} as unknown as import("../../../src/config/config.js").OpenClawConfig;
const collected = collectDiscordAuditChannelIds({ cfg, accountId: "default" });
expect(collected.channelIds).toEqual(["111"]);
@@ -98,7 +98,7 @@ describe("discord audit", () => {
},
},
},
} as unknown as import("../config/config.js").OpenClawConfig;
} as unknown as import("../../../src/config/config.js").OpenClawConfig;
const collected = collectDiscordAuditChannelIds({ cfg, accountId: "default" });
expect(collected.channelIds).toEqual([]);
@@ -127,7 +127,7 @@ describe("discord audit", () => {
},
},
},
} as unknown as import("../config/config.js").OpenClawConfig;
} as unknown as import("../../../src/config/config.js").OpenClawConfig;
const collected = collectDiscordAuditChannelIds({ cfg, accountId: "default" });
expect(collected.channelIds).toEqual(["111"]);

View File

@@ -0,0 +1,141 @@
import type { OpenClawConfig } from "../../../src/config/config.js";
import type { DiscordGuildChannelConfig, DiscordGuildEntry } from "../../../src/config/types.js";
import { isRecord } from "../../../src/utils.js";
import { inspectDiscordAccount } from "./account-inspect.js";
import { fetchChannelPermissionsDiscord } from "./send.js";
export type DiscordChannelPermissionsAuditEntry = {
channelId: string;
ok: boolean;
missing?: string[];
error?: string | null;
matchKey?: string;
matchSource?: "id";
};
export type DiscordChannelPermissionsAudit = {
ok: boolean;
checkedChannels: number;
unresolvedChannels: number;
channels: DiscordChannelPermissionsAuditEntry[];
elapsedMs: number;
};
const REQUIRED_CHANNEL_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
function shouldAuditChannelConfig(config: DiscordGuildChannelConfig | undefined) {
if (!config) {
return true;
}
if (config.allow === false) {
return false;
}
if (config.enabled === false) {
return false;
}
return true;
}
function listConfiguredGuildChannelKeys(
guilds: Record<string, DiscordGuildEntry> | undefined,
): string[] {
if (!guilds) {
return [];
}
const ids = new Set<string>();
for (const entry of Object.values(guilds)) {
if (!entry || typeof entry !== "object") {
continue;
}
const channelsRaw = (entry as { channels?: unknown }).channels;
if (!isRecord(channelsRaw)) {
continue;
}
for (const [key, value] of Object.entries(channelsRaw)) {
const channelId = String(key).trim();
if (!channelId) {
continue;
}
// Skip wildcard keys (e.g. "*" meaning "all channels") — they are valid
// config but are not real channel IDs and should not be audited.
if (channelId === "*") {
continue;
}
if (!shouldAuditChannelConfig(value as DiscordGuildChannelConfig | undefined)) {
continue;
}
ids.add(channelId);
}
}
return [...ids].toSorted((a, b) => a.localeCompare(b));
}
export function collectDiscordAuditChannelIds(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}) {
const account = inspectDiscordAccount({
cfg: params.cfg,
accountId: params.accountId,
});
const keys = listConfiguredGuildChannelKeys(account.config.guilds);
const channelIds = keys.filter((key) => /^\d+$/.test(key));
const unresolvedChannels = keys.length - channelIds.length;
return { channelIds, unresolvedChannels };
}
export async function auditDiscordChannelPermissions(params: {
token: string;
accountId?: string | null;
channelIds: string[];
timeoutMs: number;
}): Promise<DiscordChannelPermissionsAudit> {
const started = Date.now();
const token = params.token?.trim() ?? "";
if (!token || params.channelIds.length === 0) {
return {
ok: true,
checkedChannels: 0,
unresolvedChannels: 0,
channels: [],
elapsedMs: Date.now() - started,
};
}
const required = [...REQUIRED_CHANNEL_PERMISSIONS];
const channels: DiscordChannelPermissionsAuditEntry[] = [];
for (const channelId of params.channelIds) {
try {
const perms = await fetchChannelPermissionsDiscord(channelId, {
token,
accountId: params.accountId ?? undefined,
});
const missing = required.filter((p) => !perms.permissions.includes(p));
channels.push({
channelId,
ok: missing.length === 0,
missing: missing.length ? missing : undefined,
error: null,
matchKey: channelId,
matchSource: "id",
});
} catch (err) {
channels.push({
channelId,
ok: false,
error: err instanceof Error ? err.message : String(err),
matchKey: channelId,
matchSource: "id",
});
}
}
return {
ok: channels.every((c) => c.ok),
checkedChannels: channels.length,
unresolvedChannels: 0,
channels,
elapsedMs: Date.now() - started,
};
}

View File

@@ -0,0 +1,140 @@
import {
createUnionActionGate,
listTokenSourcedAccounts,
} from "../../../src/channels/plugins/actions/shared.js";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
} from "../../../src/channels/plugins/types.js";
import type { DiscordActionConfig } from "../../../src/config/types.discord.js";
import { createDiscordActionGate, listEnabledDiscordAccounts } from "./accounts.js";
import { handleDiscordMessageAction } from "./actions/handle-action.js";
export const discordMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const accounts = listTokenSourcedAccounts(listEnabledDiscordAccounts(cfg));
if (accounts.length === 0) {
return [];
}
// Union of all accounts' action gates (any account enabling an action makes it available)
const gate = createUnionActionGate(accounts, (account) =>
createDiscordActionGate({
cfg,
accountId: account.accountId,
}),
);
const isEnabled = (key: keyof DiscordActionConfig, defaultValue = true) =>
gate(key, defaultValue);
const actions = new Set<ChannelMessageActionName>(["send"]);
if (isEnabled("polls")) {
actions.add("poll");
}
if (isEnabled("reactions")) {
actions.add("react");
actions.add("reactions");
}
if (isEnabled("messages")) {
actions.add("read");
actions.add("edit");
actions.add("delete");
}
if (isEnabled("pins")) {
actions.add("pin");
actions.add("unpin");
actions.add("list-pins");
}
if (isEnabled("permissions")) {
actions.add("permissions");
}
if (isEnabled("threads")) {
actions.add("thread-create");
actions.add("thread-list");
actions.add("thread-reply");
}
if (isEnabled("search")) {
actions.add("search");
}
if (isEnabled("stickers")) {
actions.add("sticker");
}
if (isEnabled("memberInfo")) {
actions.add("member-info");
}
if (isEnabled("roleInfo")) {
actions.add("role-info");
}
if (isEnabled("reactions")) {
actions.add("emoji-list");
}
if (isEnabled("emojiUploads")) {
actions.add("emoji-upload");
}
if (isEnabled("stickerUploads")) {
actions.add("sticker-upload");
}
if (isEnabled("roles", false)) {
actions.add("role-add");
actions.add("role-remove");
}
if (isEnabled("channelInfo")) {
actions.add("channel-info");
actions.add("channel-list");
}
if (isEnabled("channels")) {
actions.add("channel-create");
actions.add("channel-edit");
actions.add("channel-delete");
actions.add("channel-move");
actions.add("category-create");
actions.add("category-edit");
actions.add("category-delete");
}
if (isEnabled("voiceStatus")) {
actions.add("voice-status");
}
if (isEnabled("events")) {
actions.add("event-list");
actions.add("event-create");
}
if (isEnabled("moderation", false)) {
actions.add("timeout");
actions.add("kick");
actions.add("ban");
}
if (isEnabled("presence", false)) {
actions.add("set-presence");
}
return Array.from(actions);
},
extractToolSend: ({ args }) => {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action === "sendMessage") {
const to = typeof args.to === "string" ? args.to : undefined;
return to ? { to } : null;
}
if (action === "threadReply") {
const channelId = typeof args.channelId === "string" ? args.channelId.trim() : "";
return channelId ? { to: `channel:${channelId}` } : null;
}
return null;
},
handleAction: async ({
action,
params,
cfg,
accountId,
requesterSenderId,
toolContext,
mediaLocalRoots,
}) => {
return await handleDiscordMessageAction({
action,
params,
cfg,
accountId,
requesterSenderId,
toolContext,
mediaLocalRoots,
});
},
};

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { countLines, hasBalancedFences } from "../test-utils/chunk-test-helpers.js";
import { countLines, hasBalancedFences } from "../../../src/test-utils/chunk-test-helpers.js";
import { chunkDiscordText, chunkDiscordTextWithMode } from "./chunk.js";
describe("chunkDiscordText", () => {

View File

@@ -0,0 +1,277 @@
import { chunkMarkdownTextWithMode, type ChunkMode } from "../../../src/auto-reply/chunk.js";
export type ChunkDiscordTextOpts = {
/** Max characters per Discord message. Default: 2000. */
maxChars?: number;
/**
* Soft max line count per message. Default: 17.
*
* Discord clients can clip/collapse very tall messages in the UI; splitting
* by lines keeps long multi-paragraph replies readable.
*/
maxLines?: number;
};
type OpenFence = {
indent: string;
markerChar: string;
markerLen: number;
openLine: string;
};
const DEFAULT_MAX_CHARS = 2000;
const DEFAULT_MAX_LINES = 17;
const FENCE_RE = /^( {0,3})(`{3,}|~{3,})(.*)$/;
function countLines(text: string) {
if (!text) {
return 0;
}
return text.split("\n").length;
}
function parseFenceLine(line: string): OpenFence | null {
const match = line.match(FENCE_RE);
if (!match) {
return null;
}
const indent = match[1] ?? "";
const marker = match[2] ?? "";
return {
indent,
markerChar: marker[0] ?? "`",
markerLen: marker.length,
openLine: line,
};
}
function closeFenceLine(openFence: OpenFence) {
return `${openFence.indent}${openFence.markerChar.repeat(openFence.markerLen)}`;
}
function closeFenceIfNeeded(text: string, openFence: OpenFence | null) {
if (!openFence) {
return text;
}
const closeLine = closeFenceLine(openFence);
if (!text) {
return closeLine;
}
if (!text.endsWith("\n")) {
return `${text}\n${closeLine}`;
}
return `${text}${closeLine}`;
}
function splitLongLine(
line: string,
maxChars: number,
opts: { preserveWhitespace: boolean },
): string[] {
const limit = Math.max(1, Math.floor(maxChars));
if (line.length <= limit) {
return [line];
}
const out: string[] = [];
let remaining = line;
while (remaining.length > limit) {
if (opts.preserveWhitespace) {
out.push(remaining.slice(0, limit));
remaining = remaining.slice(limit);
continue;
}
const window = remaining.slice(0, limit);
let breakIdx = -1;
for (let i = window.length - 1; i >= 0; i--) {
if (/\s/.test(window[i])) {
breakIdx = i;
break;
}
}
if (breakIdx <= 0) {
breakIdx = limit;
}
out.push(remaining.slice(0, breakIdx));
// Keep the separator for the next segment so words don't get glued together.
remaining = remaining.slice(breakIdx);
}
if (remaining.length) {
out.push(remaining);
}
return out;
}
/**
* Chunks outbound Discord text by both character count and (soft) line count,
* while keeping fenced code blocks balanced across chunks.
*/
export function chunkDiscordText(text: string, opts: ChunkDiscordTextOpts = {}): string[] {
const maxChars = Math.max(1, Math.floor(opts.maxChars ?? DEFAULT_MAX_CHARS));
const maxLines = Math.max(1, Math.floor(opts.maxLines ?? DEFAULT_MAX_LINES));
const body = text ?? "";
if (!body) {
return [];
}
const alreadyOk = body.length <= maxChars && countLines(body) <= maxLines;
if (alreadyOk) {
return [body];
}
const lines = body.split("\n");
const chunks: string[] = [];
let current = "";
let currentLines = 0;
let openFence: OpenFence | null = null;
const flush = () => {
if (!current) {
return;
}
const payload = closeFenceIfNeeded(current, openFence);
if (payload.trim().length) {
chunks.push(payload);
}
current = "";
currentLines = 0;
if (openFence) {
current = openFence.openLine;
currentLines = 1;
}
};
for (const originalLine of lines) {
const fenceInfo = parseFenceLine(originalLine);
const wasInsideFence = openFence !== null;
let nextOpenFence: OpenFence | null = openFence;
if (fenceInfo) {
if (!openFence) {
nextOpenFence = fenceInfo;
} else if (
openFence.markerChar === fenceInfo.markerChar &&
fenceInfo.markerLen >= openFence.markerLen
) {
nextOpenFence = null;
}
}
const reserveChars = nextOpenFence ? closeFenceLine(nextOpenFence).length + 1 : 0;
const reserveLines = nextOpenFence ? 1 : 0;
const effectiveMaxChars = maxChars - reserveChars;
const effectiveMaxLines = maxLines - reserveLines;
const charLimit = effectiveMaxChars > 0 ? effectiveMaxChars : maxChars;
const lineLimit = effectiveMaxLines > 0 ? effectiveMaxLines : maxLines;
const prefixLen = current.length > 0 ? current.length + 1 : 0;
const segmentLimit = Math.max(1, charLimit - prefixLen);
const segments = splitLongLine(originalLine, segmentLimit, {
preserveWhitespace: wasInsideFence,
});
for (let segIndex = 0; segIndex < segments.length; segIndex++) {
const segment = segments[segIndex];
const isLineContinuation = segIndex > 0;
const delimiter = isLineContinuation ? "" : current.length > 0 ? "\n" : "";
const addition = `${delimiter}${segment}`;
const nextLen = current.length + addition.length;
const nextLines = currentLines + (isLineContinuation ? 0 : 1);
const wouldExceedChars = nextLen > charLimit;
const wouldExceedLines = nextLines > lineLimit;
if ((wouldExceedChars || wouldExceedLines) && current.length > 0) {
flush();
}
if (current.length > 0) {
current += addition;
if (!isLineContinuation) {
currentLines += 1;
}
} else {
current = segment;
currentLines = 1;
}
}
openFence = nextOpenFence;
}
if (current.length) {
const payload = closeFenceIfNeeded(current, openFence);
if (payload.trim().length) {
chunks.push(payload);
}
}
return rebalanceReasoningItalics(text, chunks);
}
export function chunkDiscordTextWithMode(
text: string,
opts: ChunkDiscordTextOpts & { chunkMode?: ChunkMode },
): string[] {
const chunkMode = opts.chunkMode ?? "length";
if (chunkMode !== "newline") {
return chunkDiscordText(text, opts);
}
const lineChunks = chunkMarkdownTextWithMode(
text,
Math.max(1, Math.floor(opts.maxChars ?? DEFAULT_MAX_CHARS)),
"newline",
);
const chunks: string[] = [];
for (const line of lineChunks) {
const nested = chunkDiscordText(line, opts);
if (!nested.length && line) {
chunks.push(line);
continue;
}
chunks.push(...nested);
}
return chunks;
}
// Keep italics intact for reasoning payloads that are wrapped once with `_…_`.
// When Discord chunking splits the message, we close italics at the end of
// each chunk and reopen at the start of the next so every chunk renders
// consistently.
function rebalanceReasoningItalics(source: string, chunks: string[]): string[] {
if (chunks.length <= 1) {
return chunks;
}
const opensWithReasoningItalics =
source.startsWith("Reasoning:\n_") && source.trimEnd().endsWith("_");
if (!opensWithReasoningItalics) {
return chunks;
}
const adjusted = [...chunks];
for (let i = 0; i < adjusted.length; i++) {
const isLast = i === adjusted.length - 1;
const current = adjusted[i];
// Ensure current chunk closes italics so Discord renders it italicized.
const needsClosing = !current.trimEnd().endsWith("_");
if (needsClosing) {
adjusted[i] = `${current}_`;
}
if (isLast) {
break;
}
// Re-open italics on the next chunk if needed.
const next = adjusted[i + 1];
const leadingWhitespaceLen = next.length - next.trimStart().length;
const leadingWhitespace = next.slice(0, leadingWhitespaceLen);
const nextBody = next.slice(leadingWhitespaceLen);
if (!nextBody.startsWith("_")) {
adjusted[i + 1] = `${leadingWhitespace}_${nextBody}`;
}
}
return adjusted;
}

View File

@@ -1,6 +1,6 @@
import type { RequestClient } from "@buape/carbon";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { createDiscordRestClient } from "./client.js";
describe("createDiscordRestClient", () => {

View File

@@ -0,0 +1,88 @@
import { RequestClient } from "@buape/carbon";
import { loadConfig } from "../../../src/config/config.js";
import { createDiscordRetryRunner, type RetryRunner } from "../../../src/infra/retry-policy.js";
import type { RetryConfig } from "../../../src/infra/retry.js";
import { normalizeAccountId } from "../../../src/routing/session-key.js";
import {
mergeDiscordAccountConfig,
resolveDiscordAccount,
type ResolvedDiscordAccount,
} from "./accounts.js";
import { normalizeDiscordToken } from "./token.js";
export type DiscordClientOpts = {
cfg?: ReturnType<typeof loadConfig>;
token?: string;
accountId?: string;
rest?: RequestClient;
retry?: RetryConfig;
verbose?: boolean;
};
function resolveToken(params: { accountId: string; fallbackToken?: string }) {
const fallback = normalizeDiscordToken(params.fallbackToken, "channels.discord.token");
if (!fallback) {
throw new Error(
`Discord bot token missing for account "${params.accountId}" (set discord.accounts.${params.accountId}.token or DISCORD_BOT_TOKEN for default).`,
);
}
return fallback;
}
function resolveRest(token: string, rest?: RequestClient) {
return rest ?? new RequestClient(token);
}
function resolveAccountWithoutToken(params: {
cfg: ReturnType<typeof loadConfig>;
accountId?: string;
}): ResolvedDiscordAccount {
const accountId = normalizeAccountId(params.accountId);
const merged = mergeDiscordAccountConfig(params.cfg, accountId);
const baseEnabled = params.cfg.channels?.discord?.enabled !== false;
const accountEnabled = merged.enabled !== false;
return {
accountId,
enabled: baseEnabled && accountEnabled,
name: merged.name?.trim() || undefined,
token: "",
tokenSource: "none",
config: merged,
};
}
export function createDiscordRestClient(
opts: DiscordClientOpts,
cfg?: ReturnType<typeof loadConfig>,
) {
const resolvedCfg = opts.cfg ?? cfg ?? loadConfig();
const explicitToken = normalizeDiscordToken(opts.token, "channels.discord.token");
const account = explicitToken
? resolveAccountWithoutToken({ cfg: resolvedCfg, accountId: opts.accountId })
: resolveDiscordAccount({ cfg: resolvedCfg, accountId: opts.accountId });
const token =
explicitToken ??
resolveToken({
accountId: account.accountId,
fallbackToken: account.token,
});
const rest = resolveRest(token, opts.rest);
return { token, rest, account };
}
export function createDiscordClient(
opts: DiscordClientOpts,
cfg?: ReturnType<typeof loadConfig>,
): { token: string; rest: RequestClient; request: RetryRunner } {
const { token, rest, account } = createDiscordRestClient(opts, opts.cfg ?? cfg);
const request = createDiscordRetryRunner({
retry: opts.retry,
configRetry: account.config.retry,
verbose: opts.verbose,
});
return { token, rest, request };
}
export function resolveDiscordRest(opts: DiscordClientOpts) {
return createDiscordRestClient(opts, opts.cfg).rest;
}

View File

@@ -0,0 +1,89 @@
import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js";
const DEFAULT_COMPONENT_TTL_MS = 30 * 60 * 1000;
const componentEntries = new Map<string, DiscordComponentEntry>();
const modalEntries = new Map<string, DiscordModalEntry>();
function isExpired(entry: { expiresAt?: number }, now: number) {
return typeof entry.expiresAt === "number" && entry.expiresAt <= now;
}
function normalizeEntryTimestamps<T extends { createdAt?: number; expiresAt?: number }>(
entry: T,
now: number,
ttlMs: number,
): T {
const createdAt = entry.createdAt ?? now;
const expiresAt = entry.expiresAt ?? createdAt + ttlMs;
return { ...entry, createdAt, expiresAt };
}
export function registerDiscordComponentEntries(params: {
entries: DiscordComponentEntry[];
modals: DiscordModalEntry[];
ttlMs?: number;
messageId?: string;
}): void {
const now = Date.now();
const ttlMs = params.ttlMs ?? DEFAULT_COMPONENT_TTL_MS;
for (const entry of params.entries) {
const normalized = normalizeEntryTimestamps(
{ ...entry, messageId: params.messageId ?? entry.messageId },
now,
ttlMs,
);
componentEntries.set(entry.id, normalized);
}
for (const modal of params.modals) {
const normalized = normalizeEntryTimestamps(
{ ...modal, messageId: params.messageId ?? modal.messageId },
now,
ttlMs,
);
modalEntries.set(modal.id, normalized);
}
}
export function resolveDiscordComponentEntry(params: {
id: string;
consume?: boolean;
}): DiscordComponentEntry | null {
const entry = componentEntries.get(params.id);
if (!entry) {
return null;
}
const now = Date.now();
if (isExpired(entry, now)) {
componentEntries.delete(params.id);
return null;
}
if (params.consume !== false) {
componentEntries.delete(params.id);
}
return entry;
}
export function resolveDiscordModalEntry(params: {
id: string;
consume?: boolean;
}): DiscordModalEntry | null {
const entry = modalEntries.get(params.id);
if (!entry) {
return null;
}
const now = Date.now();
if (isExpired(entry, now)) {
modalEntries.delete(params.id);
return null;
}
if (params.consume !== false) {
modalEntries.delete(params.id);
}
return entry;
}
export function clearDiscordComponentEntries(): void {
componentEntries.clear();
modalEntries.clear();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,111 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/account-id.js";
const DISCORD_DIRECTORY_CACHE_MAX_ENTRIES = 4000;
const DISCORD_DISCRIMINATOR_SUFFIX = /#\d{4}$/;
const DIRECTORY_HANDLE_CACHE = new Map<string, Map<string, string>>();
function normalizeAccountCacheKey(accountId?: string | null): string {
const normalized = normalizeAccountId(accountId ?? DEFAULT_ACCOUNT_ID);
return normalized || DEFAULT_ACCOUNT_ID;
}
function normalizeSnowflake(value: string | number | bigint): string | null {
const text = String(value ?? "").trim();
if (!/^\d+$/.test(text)) {
return null;
}
return text;
}
function normalizeHandleKey(raw: string): string | null {
let handle = raw.trim();
if (!handle) {
return null;
}
if (handle.startsWith("@")) {
handle = handle.slice(1).trim();
}
if (!handle || /\s/.test(handle)) {
return null;
}
return handle.toLowerCase();
}
function ensureAccountCache(accountId?: string | null): Map<string, string> {
const cacheKey = normalizeAccountCacheKey(accountId);
const existing = DIRECTORY_HANDLE_CACHE.get(cacheKey);
if (existing) {
return existing;
}
const created = new Map<string, string>();
DIRECTORY_HANDLE_CACHE.set(cacheKey, created);
return created;
}
function setCacheEntry(cache: Map<string, string>, key: string, userId: string): void {
if (cache.has(key)) {
cache.delete(key);
}
cache.set(key, userId);
if (cache.size <= DISCORD_DIRECTORY_CACHE_MAX_ENTRIES) {
return;
}
const oldest = cache.keys().next();
if (!oldest.done) {
cache.delete(oldest.value);
}
}
export function rememberDiscordDirectoryUser(params: {
accountId?: string | null;
userId: string | number | bigint;
handles: Array<string | null | undefined>;
}): void {
const userId = normalizeSnowflake(params.userId);
if (!userId) {
return;
}
const cache = ensureAccountCache(params.accountId);
for (const candidate of params.handles) {
if (typeof candidate !== "string") {
continue;
}
const handle = normalizeHandleKey(candidate);
if (!handle) {
continue;
}
setCacheEntry(cache, handle, userId);
const withoutDiscriminator = handle.replace(DISCORD_DISCRIMINATOR_SUFFIX, "");
if (withoutDiscriminator && withoutDiscriminator !== handle) {
setCacheEntry(cache, withoutDiscriminator, userId);
}
}
}
export function resolveDiscordDirectoryUserId(params: {
accountId?: string | null;
handle: string;
}): string | undefined {
const cache = DIRECTORY_HANDLE_CACHE.get(normalizeAccountCacheKey(params.accountId));
if (!cache) {
return undefined;
}
const handle = normalizeHandleKey(params.handle);
if (!handle) {
return undefined;
}
const direct = cache.get(handle);
if (direct) {
return direct;
}
const withoutDiscriminator = handle.replace(DISCORD_DISCRIMINATOR_SUFFIX, "");
if (!withoutDiscriminator || withoutDiscriminator === handle) {
return undefined;
}
return cache.get(withoutDiscriminator);
}
export function __resetDiscordDirectoryCacheForTest(): void {
DIRECTORY_HANDLE_CACHE.clear();
}

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DirectoryConfigParams } from "../channels/plugins/directory-config.js";
import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js";
const mocks = vi.hoisted(() => ({
fetchDiscord: vi.fn(),

View File

@@ -0,0 +1,132 @@
import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js";
import type { ChannelDirectoryEntry } from "../../../src/channels/plugins/types.js";
import { resolveDiscordAccount } from "./accounts.js";
import { fetchDiscord } from "./api.js";
import { rememberDiscordDirectoryUser } from "./directory-cache.js";
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
import { normalizeDiscordToken } from "./token.js";
type DiscordGuild = { id: string; name: string };
type DiscordUser = { id: string; username: string; global_name?: string; bot?: boolean };
type DiscordMember = { user: DiscordUser; nick?: string | null };
type DiscordChannel = { id: string; name?: string | null };
type DiscordDirectoryAccess = { token: string; query: string };
function normalizeQuery(value?: string | null): string {
return value?.trim().toLowerCase() ?? "";
}
function buildUserRank(user: DiscordUser): number {
return user.bot ? 0 : 1;
}
function resolveDiscordDirectoryAccess(
params: DirectoryConfigParams,
): DiscordDirectoryAccess | null {
const account = resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId });
const token = normalizeDiscordToken(account.token, "channels.discord.token");
if (!token) {
return null;
}
return { token, query: normalizeQuery(params.query) };
}
async function listDiscordGuilds(token: string): Promise<DiscordGuild[]> {
const rawGuilds = await fetchDiscord<DiscordGuild[]>("/users/@me/guilds", token);
return rawGuilds.filter((guild) => guild.id && guild.name);
}
export async function listDiscordDirectoryGroupsLive(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const access = resolveDiscordDirectoryAccess(params);
if (!access) {
return [];
}
const { token, query } = access;
const guilds = await listDiscordGuilds(token);
const rows: ChannelDirectoryEntry[] = [];
for (const guild of guilds) {
const channels = await fetchDiscord<DiscordChannel[]>(`/guilds/${guild.id}/channels`, token);
for (const channel of channels) {
const name = channel.name?.trim();
if (!name) {
continue;
}
if (query && !normalizeDiscordSlug(name).includes(normalizeDiscordSlug(query))) {
continue;
}
rows.push({
kind: "group",
id: `channel:${channel.id}`,
name,
handle: `#${name}`,
raw: channel,
});
if (typeof params.limit === "number" && params.limit > 0 && rows.length >= params.limit) {
return rows;
}
}
}
return rows;
}
export async function listDiscordDirectoryPeersLive(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const access = resolveDiscordDirectoryAccess(params);
if (!access) {
return [];
}
const { token, query } = access;
if (!query) {
return [];
}
const guilds = await listDiscordGuilds(token);
const rows: ChannelDirectoryEntry[] = [];
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 25;
for (const guild of guilds) {
const paramsObj = new URLSearchParams({
query,
limit: String(Math.min(limit, 100)),
});
const members = await fetchDiscord<DiscordMember[]>(
`/guilds/${guild.id}/members/search?${paramsObj.toString()}`,
token,
);
for (const member of members) {
const user = member.user;
if (!user?.id) {
continue;
}
rememberDiscordDirectoryUser({
accountId: params.accountId,
userId: user.id,
handles: [
user.username,
user.global_name,
member.nick,
user.username ? `@${user.username}` : null,
],
});
const name = member.nick?.trim() || user.global_name?.trim() || user.username?.trim();
rows.push({
kind: "user",
id: `user:${user.id}`,
name: name || undefined,
handle: user.username ? `@${user.username}` : undefined,
rank: buildUserRank(user),
raw: member,
});
if (rows.length >= limit) {
return rows;
}
}
}
return rows;
}

View File

@@ -0,0 +1,41 @@
import { resolveTextChunkLimit } from "../../../src/auto-reply/chunk.js";
import { getChannelDock } from "../../../src/channels/dock.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { resolveAccountEntry } from "../../../src/routing/account-lookup.js";
import { normalizeAccountId } from "../../../src/routing/session-key.js";
const DEFAULT_DISCORD_DRAFT_STREAM_MIN = 200;
const DEFAULT_DISCORD_DRAFT_STREAM_MAX = 800;
export function resolveDiscordDraftStreamingChunking(
cfg: OpenClawConfig | undefined,
accountId?: string | null,
): {
minChars: number;
maxChars: number;
breakPreference: "paragraph" | "newline" | "sentence";
} {
const providerChunkLimit = getChannelDock("discord")?.outbound?.textChunkLimit;
const textLimit = resolveTextChunkLimit(cfg, "discord", accountId, {
fallbackLimit: providerChunkLimit,
});
const normalizedAccountId = normalizeAccountId(accountId);
const accountCfg = resolveAccountEntry(cfg?.channels?.discord?.accounts, normalizedAccountId);
const draftCfg = accountCfg?.draftChunk ?? cfg?.channels?.discord?.draftChunk;
const maxRequested = Math.max(
1,
Math.floor(draftCfg?.maxChars ?? DEFAULT_DISCORD_DRAFT_STREAM_MAX),
);
const maxChars = Math.max(1, Math.min(maxRequested, textLimit));
const minRequested = Math.max(
1,
Math.floor(draftCfg?.minChars ?? DEFAULT_DISCORD_DRAFT_STREAM_MIN),
);
const minChars = Math.min(minRequested, maxChars);
const breakPreference =
draftCfg?.breakPreference === "newline" || draftCfg?.breakPreference === "sentence"
? draftCfg.breakPreference
: "paragraph";
return { minChars, maxChars, breakPreference };
}

View File

@@ -0,0 +1,145 @@
import type { RequestClient } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { createFinalizableDraftLifecycle } from "../../../src/channels/draft-stream-controls.js";
/** Discord messages cap at 2000 characters. */
const DISCORD_STREAM_MAX_CHARS = 2000;
const DEFAULT_THROTTLE_MS = 1200;
export type DiscordDraftStream = {
update: (text: string) => void;
flush: () => Promise<void>;
messageId: () => string | undefined;
clear: () => Promise<void>;
stop: () => Promise<void>;
/** Reset internal state so the next update creates a new message instead of editing. */
forceNewMessage: () => void;
};
export function createDiscordDraftStream(params: {
rest: RequestClient;
channelId: string;
maxChars?: number;
replyToMessageId?: string | (() => string | undefined);
throttleMs?: number;
/** Minimum chars before sending first message (debounce for push notifications) */
minInitialChars?: number;
log?: (message: string) => void;
warn?: (message: string) => void;
}): DiscordDraftStream {
const maxChars = Math.min(params.maxChars ?? DISCORD_STREAM_MAX_CHARS, DISCORD_STREAM_MAX_CHARS);
const throttleMs = Math.max(250, params.throttleMs ?? DEFAULT_THROTTLE_MS);
const minInitialChars = params.minInitialChars;
const channelId = params.channelId;
const rest = params.rest;
const resolveReplyToMessageId = () =>
typeof params.replyToMessageId === "function"
? params.replyToMessageId()
: params.replyToMessageId;
const streamState = { stopped: false, final: false };
let streamMessageId: string | undefined;
let lastSentText = "";
const sendOrEditStreamMessage = async (text: string): Promise<boolean> => {
// Allow final flush even if stopped (e.g., after clear()).
if (streamState.stopped && !streamState.final) {
return false;
}
const trimmed = text.trimEnd();
if (!trimmed) {
return false;
}
if (trimmed.length > maxChars) {
// Discord messages cap at 2000 chars.
// Stop streaming once we exceed the cap to avoid repeated API failures.
streamState.stopped = true;
params.warn?.(`discord stream preview stopped (text length ${trimmed.length} > ${maxChars})`);
return false;
}
if (trimmed === lastSentText) {
return true;
}
// Debounce first preview send for better push notification quality.
if (streamMessageId === undefined && minInitialChars != null && !streamState.final) {
if (trimmed.length < minInitialChars) {
return false;
}
}
lastSentText = trimmed;
try {
if (streamMessageId !== undefined) {
// Edit existing message
await rest.patch(Routes.channelMessage(channelId, streamMessageId), {
body: { content: trimmed },
});
return true;
}
// Send new message
const replyToMessageId = resolveReplyToMessageId()?.trim();
const messageReference = replyToMessageId
? { message_id: replyToMessageId, fail_if_not_exists: false }
: undefined;
const sent = (await rest.post(Routes.channelMessages(channelId), {
body: {
content: trimmed,
...(messageReference ? { message_reference: messageReference } : {}),
},
})) as { id?: string } | undefined;
const sentMessageId = sent?.id;
if (typeof sentMessageId !== "string" || !sentMessageId) {
streamState.stopped = true;
params.warn?.("discord stream preview stopped (missing message id from send)");
return false;
}
streamMessageId = sentMessageId;
return true;
} catch (err) {
streamState.stopped = true;
params.warn?.(
`discord stream preview failed: ${err instanceof Error ? err.message : String(err)}`,
);
return false;
}
};
const readMessageId = () => streamMessageId;
const clearMessageId = () => {
streamMessageId = undefined;
};
const isValidStreamMessageId = (value: unknown): value is string => typeof value === "string";
const deleteStreamMessage = async (messageId: string) => {
await rest.delete(Routes.channelMessage(channelId, messageId));
};
const { loop, update, stop, clear } = createFinalizableDraftLifecycle({
throttleMs,
state: streamState,
sendOrEditStreamMessage,
readMessageId,
clearMessageId,
isValidMessageId: isValidStreamMessageId,
deleteMessage: deleteStreamMessage,
warn: params.warn,
warnPrefix: "discord stream preview cleanup failed",
});
const forceNewMessage = () => {
streamMessageId = undefined;
lastSentText = "";
loop.resetPending();
};
params.log?.(`discord stream preview ready (maxChars=${maxChars}, throttleMs=${throttleMs})`);
return {
update,
flush: loop.flush,
messageId: () => streamMessageId,
clear,
stop,
forceNewMessage,
};
}

View File

@@ -0,0 +1,23 @@
import type { ReplyPayload } from "../../../src/auto-reply/types.js";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { getExecApprovalReplyMetadata } from "../../../src/infra/exec-approval-reply.js";
import { resolveDiscordAccount } from "./accounts.js";
export function isDiscordExecApprovalClientEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
const config = resolveDiscordAccount(params).config.execApprovals;
return Boolean(config?.enabled && (config.approvers?.length ?? 0) > 0);
}
export function shouldSuppressLocalDiscordExecApprovalPrompt(params: {
cfg: OpenClawConfig;
accountId?: string | null;
payload: ReplyPayload;
}): boolean {
return (
isDiscordExecApprovalClientEnabled(params) &&
getExecApprovalReplyMetadata(params.payload) !== null
);
}

View File

@@ -1,11 +1,11 @@
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("../globals.js", () => ({
vi.mock("../../../src/globals.js", () => ({
logVerbose: vi.fn(),
}));
import { logVerbose } from "../globals.js";
import { logVerbose } from "../../../src/globals.js";
import { attachDiscordGatewayLogging } from "./gateway-logging.js";
const makeRuntime = () => ({

View File

@@ -0,0 +1,67 @@
import type { EventEmitter } from "node:events";
import { logVerbose } from "../../../src/globals.js";
import type { RuntimeEnv } from "../../../src/runtime.js";
type GatewayEmitter = Pick<EventEmitter, "on" | "removeListener">;
const INFO_DEBUG_MARKERS = [
"WebSocket connection closed",
"Reconnecting with backoff",
"Attempting resume with backoff",
];
const shouldPromoteGatewayDebug = (message: string) =>
INFO_DEBUG_MARKERS.some((marker) => message.includes(marker));
const formatGatewayMetrics = (metrics: unknown) => {
if (metrics === null || metrics === undefined) {
return String(metrics);
}
if (typeof metrics === "string") {
return metrics;
}
if (typeof metrics === "number" || typeof metrics === "boolean" || typeof metrics === "bigint") {
return String(metrics);
}
try {
return JSON.stringify(metrics);
} catch {
return "[unserializable metrics]";
}
};
export function attachDiscordGatewayLogging(params: {
emitter?: GatewayEmitter;
runtime: RuntimeEnv;
}) {
const { emitter, runtime } = params;
if (!emitter) {
return () => {};
}
const onGatewayDebug = (msg: unknown) => {
const message = String(msg);
logVerbose(`discord gateway: ${message}`);
if (shouldPromoteGatewayDebug(message)) {
runtime.log?.(`discord gateway: ${message}`);
}
};
const onGatewayWarning = (warning: unknown) => {
logVerbose(`discord gateway warning: ${String(warning)}`);
};
const onGatewayMetrics = (metrics: unknown) => {
logVerbose(`discord gateway metrics: ${formatGatewayMetrics(metrics)}`);
};
emitter.on("debug", onGatewayDebug);
emitter.on("warning", onGatewayWarning);
emitter.on("metrics", onGatewayMetrics);
return () => {
emitter.removeListener("debug", onGatewayDebug);
emitter.removeListener("warning", onGatewayWarning);
emitter.removeListener("metrics", onGatewayMetrics);
};
}

View File

@@ -0,0 +1,29 @@
import { fetchDiscord } from "./api.js";
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
export type DiscordGuildSummary = {
id: string;
name: string;
slug: string;
};
export async function listGuilds(
token: string,
fetcher: typeof fetch,
): Promise<DiscordGuildSummary[]> {
const raw = await fetchDiscord<Array<{ id?: string; name?: string }>>(
"/users/@me/guilds",
token,
fetcher,
);
return raw
.filter(
(guild): guild is { id: string; name: string } =>
typeof guild.id === "string" && typeof guild.name === "string",
)
.map((guild) => ({
id: guild.id,
name: guild.name,
slug: normalizeDiscordSlug(guild.name),
}));
}

View File

@@ -0,0 +1,83 @@
import { resolveDiscordDirectoryUserId } from "./directory-cache.js";
const MARKDOWN_CODE_SEGMENT_PATTERN = /```[\s\S]*?```|`[^`\n]*`/g;
const MENTION_CANDIDATE_PATTERN = /(^|[\s([{"'.,;:!?])@([a-z0-9_.-]{2,32}(?:#[0-9]{4})?)/gi;
const DISCORD_RESERVED_MENTIONS = new Set(["everyone", "here"]);
function normalizeSnowflake(value: string | number | bigint): string | null {
const text = String(value ?? "").trim();
if (!/^\d+$/.test(text)) {
return null;
}
return text;
}
export function formatMention(params: {
userId?: string | number | bigint | null;
roleId?: string | number | bigint | null;
channelId?: string | number | bigint | null;
}): string {
const userId = params.userId == null ? null : normalizeSnowflake(params.userId);
const roleId = params.roleId == null ? null : normalizeSnowflake(params.roleId);
const channelId = params.channelId == null ? null : normalizeSnowflake(params.channelId);
const values = [
userId ? { kind: "user" as const, id: userId } : null,
roleId ? { kind: "role" as const, id: roleId } : null,
channelId ? { kind: "channel" as const, id: channelId } : null,
].filter((entry): entry is { kind: "user" | "role" | "channel"; id: string } => Boolean(entry));
if (values.length !== 1) {
throw new Error("formatMention requires exactly one of userId, roleId, or channelId");
}
const target = values[0];
if (target.kind === "user") {
return `<@${target.id}>`;
}
if (target.kind === "role") {
return `<@&${target.id}>`;
}
return `<#${target.id}>`;
}
function rewritePlainTextMentions(text: string, accountId?: string | null): string {
if (!text.includes("@")) {
return text;
}
return text.replace(MENTION_CANDIDATE_PATTERN, (match, prefix, rawHandle) => {
const handle = String(rawHandle ?? "").trim();
if (!handle) {
return match;
}
const lookup = handle.toLowerCase();
if (DISCORD_RESERVED_MENTIONS.has(lookup)) {
return match;
}
const userId = resolveDiscordDirectoryUserId({
accountId,
handle,
});
if (!userId) {
return match;
}
return `${String(prefix ?? "")}${formatMention({ userId })}`;
});
}
export function rewriteDiscordKnownMentions(
text: string,
params: { accountId?: string | null },
): string {
if (!text.includes("@")) {
return text;
}
let rewritten = "";
let offset = 0;
MARKDOWN_CODE_SEGMENT_PATTERN.lastIndex = 0;
for (const match of text.matchAll(MARKDOWN_CODE_SEGMENT_PATTERN)) {
const matchIndex = match.index ?? 0;
rewritten += rewritePlainTextMentions(text.slice(offset, matchIndex), params.accountId);
rewritten += match[0];
offset = matchIndex + match[0].length;
}
rewritten += rewritePlainTextMentions(text.slice(offset), params.accountId);
return rewritten;
}

View File

@@ -0,0 +1,78 @@
import type { EventEmitter } from "node:events";
export type DiscordGatewayHandle = {
emitter?: Pick<EventEmitter, "on" | "removeListener">;
disconnect?: () => void;
};
export type WaitForDiscordGatewayStopParams = {
gateway?: DiscordGatewayHandle;
abortSignal?: AbortSignal;
onGatewayError?: (err: unknown) => void;
shouldStopOnError?: (err: unknown) => boolean;
registerForceStop?: (forceStop: (err: unknown) => void) => void;
};
export function getDiscordGatewayEmitter(gateway?: unknown): EventEmitter | undefined {
return (gateway as { emitter?: EventEmitter } | undefined)?.emitter;
}
export async function waitForDiscordGatewayStop(
params: WaitForDiscordGatewayStopParams,
): Promise<void> {
const { gateway, abortSignal, onGatewayError, shouldStopOnError } = params;
const emitter = gateway?.emitter;
return await new Promise<void>((resolve, reject) => {
let settled = false;
const cleanup = () => {
abortSignal?.removeEventListener("abort", onAbort);
emitter?.removeListener("error", onGatewayErrorEvent);
};
const finishResolve = () => {
if (settled) {
return;
}
settled = true;
cleanup();
try {
gateway?.disconnect?.();
} finally {
resolve();
}
};
const finishReject = (err: unknown) => {
if (settled) {
return;
}
settled = true;
cleanup();
try {
gateway?.disconnect?.();
} finally {
reject(err);
}
};
const onAbort = () => {
finishResolve();
};
const onGatewayErrorEvent = (err: unknown) => {
onGatewayError?.(err);
const shouldStop = shouldStopOnError?.(err) ?? true;
if (shouldStop) {
finishReject(err);
}
};
const onForceStop = (err: unknown) => {
finishReject(err);
};
if (abortSignal?.aborted) {
onAbort();
return;
}
abortSignal?.addEventListener("abort", onAbort, { once: true });
emitter?.on("error", onGatewayErrorEvent);
params.registerForceStop?.(onForceStop);
});
}

View File

@@ -1,6 +1,6 @@
import { ChannelType, type Guild } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { typedCases } from "../test-utils/typed-cases.js";
import { typedCases } from "../../../src/test-utils/typed-cases.js";
import {
allowListMatches,
buildDiscordMediaPayload,
@@ -22,7 +22,7 @@ import { DiscordMessageListener, DiscordReactionListener } from "./monitor/liste
const readAllowFromStoreMock = vi.hoisted(() => vi.fn());
vi.mock("../pairing/pairing-store.js", () => ({
vi.mock("../../../src/pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
}));
@@ -157,7 +157,9 @@ describe("DiscordMessageListener", () => {
const logger = {
warn: vi.fn(),
error: vi.fn(),
} as unknown as ReturnType<typeof import("../logging/subsystem.js").createSubsystemLogger>;
} as unknown as ReturnType<
typeof import("../../../src/logging/subsystem.js").createSubsystemLogger
>;
const handler = vi.fn(async () => {
throw new Error("boom");
});
@@ -178,7 +180,9 @@ describe("DiscordMessageListener", () => {
const logger = {
warn: vi.fn(),
error: vi.fn(),
} as unknown as ReturnType<typeof import("../logging/subsystem.js").createSubsystemLogger>;
} as unknown as ReturnType<
typeof import("../../../src/logging/subsystem.js").createSubsystemLogger
>;
const listener = new DiscordMessageListener(handler, logger);
const handlePromise = listener.handle(
@@ -888,11 +892,11 @@ const { enqueueSystemEventSpy, resolveAgentRouteMock } = vi.hoisted(() => ({
})),
}));
vi.mock("../infra/system-events.js", () => ({
vi.mock("../../../src/infra/system-events.js", () => ({
enqueueSystemEvent: enqueueSystemEventSpy,
}));
vi.mock("../routing/resolve-route.js", () => ({
vi.mock("../../../src/routing/resolve-route.js", () => ({
resolveAgentRoute: resolveAgentRouteMock,
}));
@@ -973,9 +977,9 @@ function makeReactionListenerParams(overrides?: {
guildEntries?: Record<string, DiscordGuildEntryResolved>;
}) {
return {
cfg: {} as ReturnType<typeof import("../config/config.js").loadConfig>,
cfg: {} as ReturnType<typeof import("../../../src/config/config.js").loadConfig>,
accountId: "acc-1",
runtime: {} as import("../runtime.js").RuntimeEnv,
runtime: {} as import("../../../src/runtime.js").RuntimeEnv,
botUserId: overrides?.botUserId ?? "bot-1",
dmEnabled: overrides?.dmEnabled ?? true,
groupDmEnabled: overrides?.groupDmEnabled ?? true,
@@ -990,7 +994,9 @@ function makeReactionListenerParams(overrides?: {
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as unknown as ReturnType<typeof import("../logging/subsystem.js").createSubsystemLogger>,
} as unknown as ReturnType<
typeof import("../../../src/logging/subsystem.js").createSubsystemLogger
>,
};
}

View File

@@ -2,7 +2,7 @@ import type { Client } from "@buape/carbon";
import { ChannelType, MessageType } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
import { createReplyDispatcherWithTyping } from "../../../src/auto-reply/reply/reply-dispatcher.js";
import {
dispatchMock,
readAllowFromStoreMock,
@@ -14,8 +14,8 @@ import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.j
import { createNoopThreadBindingManager } from "./monitor/thread-bindings.js";
const loadConfigMock = vi.fn();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
vi.mock("../../../src/config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/config/config.js")>();
return {
...actual,
loadConfig: (...args: unknown[]) => loadConfigMock(...args),
@@ -63,7 +63,7 @@ beforeEach(() => {
const MENTION_PATTERNS_TEST_TIMEOUT_MS = process.platform === "win32" ? 90_000 : 60_000;
type LoadedConfig = ReturnType<(typeof import("../config/config.js"))["loadConfig"]>;
type LoadedConfig = ReturnType<(typeof import("../../../src/config/config.js"))["loadConfig"]>;
let createDiscordMessageHandler: typeof import("./monitor.js").createDiscordMessageHandler;
let createDiscordNativeCommand: typeof import("./monitor.js").createDiscordNativeCommand;
@@ -322,7 +322,7 @@ describe("discord tool result dispatch", () => {
channels: {
discord: { dm: { enabled: true, policy: "open" } },
},
} as ReturnType<typeof import("../config/config.js").loadConfig>;
} as ReturnType<typeof import("../../../src/config/config.js").loadConfig>;
const command = createDiscordNativeCommand({
command: {
@@ -451,7 +451,7 @@ describe("discord tool result dispatch", () => {
const cfg = {
...createDefaultThreadConfig(),
routing: { allowFrom: [] },
} as ReturnType<typeof import("../config/config.js").loadConfig>;
} as ReturnType<typeof import("../../../src/config/config.js").loadConfig>;
const handler = await createHandler(cfg);

View File

@@ -12,7 +12,7 @@ import { createDiscordMessageHandler } from "./monitor/message-handler.js";
import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js";
import { createNoopThreadBindingManager } from "./monitor/thread-bindings.js";
type Config = ReturnType<typeof import("../config/config.js").loadConfig>;
type Config = ReturnType<typeof import("../../../src/config/config.js").loadConfig>;
beforeEach(() => {
__resetDiscordChannelInfoCacheForTest();

View File

@@ -1,5 +1,5 @@
import { vi } from "vitest";
import type { MockFn } from "../test-utils/vitest-mock-fn.js";
import type { MockFn } from "../../../src/test-utils/vitest-mock-fn.js";
export const sendMock: MockFn = vi.fn();
export const reactMock: MockFn = vi.fn();
@@ -15,8 +15,8 @@ vi.mock("./send.js", () => ({
},
}));
vi.mock("../auto-reply/dispatch.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../auto-reply/dispatch.js")>();
vi.mock("../../../src/auto-reply/dispatch.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/auto-reply/dispatch.js")>();
return {
...actual,
dispatchInboundMessage: (...args: unknown[]) => dispatchMock(...args),
@@ -36,10 +36,10 @@ function createPairingStoreMocks() {
};
}
vi.mock("../pairing/pairing-store.js", () => createPairingStoreMocks());
vi.mock("../../../src/pairing/pairing-store.js", () => createPairingStoreMocks());
vi.mock("../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/sessions.js")>();
vi.mock("../../../src/config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../src/config/sessions.js")>();
return {
...actual,
resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"),

View File

@@ -0,0 +1,28 @@
export type {
DiscordAllowList,
DiscordChannelConfigResolved,
DiscordGuildEntryResolved,
} from "./monitor/allow-list.js";
export {
allowListMatches,
isDiscordGroupAllowedByPolicy,
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveDiscordChannelConfig,
resolveDiscordChannelConfigWithFallback,
resolveDiscordCommandAuthorized,
resolveDiscordGuildEntry,
resolveDiscordShouldRequireMention,
resolveGroupDmAllow,
shouldEmitDiscordReactionNotification,
} from "./monitor/allow-list.js";
export type { DiscordMessageEvent, DiscordMessageHandler } from "./monitor/listeners.js";
export { registerDiscordListener } from "./monitor/listeners.js";
export { createDiscordMessageHandler } from "./monitor/message-handler.js";
export { buildDiscordMediaPayload } from "./monitor/message-utils.js";
export { createDiscordNativeCommand } from "./monitor/native-command.js";
export type { MonitorDiscordOpts } from "./monitor/provider.js";
export { monitorDiscordProvider } from "./monitor/provider.js";
export { resolveDiscordReplyTarget, sanitizeDiscordThreadName } from "./monitor/threading.js";

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,596 @@
import type { Guild, User } from "@buape/carbon";
import type { AllowlistMatch } from "../../../../src/channels/allowlist-match.js";
import {
buildChannelKeyCandidates,
resolveChannelEntryMatchWithFallback,
resolveChannelMatchConfig,
type ChannelMatchSource,
} from "../../../../src/channels/channel-config.js";
import { evaluateGroupRouteAccessForPolicy } from "../../../../src/plugin-sdk/group-access.js";
import { formatDiscordUserTag } from "./format.js";
export type DiscordAllowList = {
allowAll: boolean;
ids: Set<string>;
names: Set<string>;
};
export type DiscordAllowListMatch = AllowlistMatch<"wildcard" | "id" | "name" | "tag">;
const DISCORD_OWNER_ALLOWLIST_PREFIXES = ["discord:", "user:", "pk:"];
type DiscordChannelOverrideConfig = {
requireMention?: boolean;
ignoreOtherMentions?: boolean;
skills?: string[];
enabled?: boolean;
users?: string[];
roles?: string[];
systemPrompt?: string;
includeThreadStarter?: boolean;
autoThread?: boolean;
autoArchiveDuration?: "60" | "1440" | "4320" | "10080" | 60 | 1440 | 4320 | 10080;
};
export type DiscordGuildEntryResolved = {
id?: string;
slug?: string;
requireMention?: boolean;
ignoreOtherMentions?: boolean;
reactionNotifications?: "off" | "own" | "all" | "allowlist";
users?: string[];
roles?: string[];
channels?: Record<string, { allow?: boolean } & DiscordChannelOverrideConfig>;
};
export type DiscordChannelConfigResolved = DiscordChannelOverrideConfig & {
allowed: boolean;
matchKey?: string;
matchSource?: ChannelMatchSource;
};
export function normalizeDiscordAllowList(raw: string[] | 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, "");
}
function resolveDiscordAllowListNameMatch(
list: DiscordAllowList,
candidate: { name?: string; tag?: string },
): { matchKey: string; matchSource: "name" | "tag" } | null {
const nameSlug = candidate.name ? normalizeDiscordSlug(candidate.name) : "";
if (nameSlug && list.names.has(nameSlug)) {
return { matchKey: nameSlug, matchSource: "name" };
}
const tagSlug = candidate.tag ? normalizeDiscordSlug(candidate.tag) : "";
if (tagSlug && list.names.has(tagSlug)) {
return { matchKey: tagSlug, matchSource: "tag" };
}
return null;
}
export function allowListMatches(
list: DiscordAllowList,
candidate: { id?: string; name?: string; tag?: string },
params?: { allowNameMatching?: boolean },
) {
if (list.allowAll) {
return true;
}
if (candidate.id && list.ids.has(candidate.id)) {
return true;
}
if (params?.allowNameMatching === true) {
if (resolveDiscordAllowListNameMatch(list, candidate)) {
return true;
}
}
return false;
}
export function resolveDiscordAllowListMatch(params: {
allowList: DiscordAllowList;
candidate: { id?: string; name?: string; tag?: string };
allowNameMatching?: boolean;
}): DiscordAllowListMatch {
const { allowList, candidate } = params;
if (allowList.allowAll) {
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
}
if (candidate.id && allowList.ids.has(candidate.id)) {
return { allowed: true, matchKey: candidate.id, matchSource: "id" };
}
if (params.allowNameMatching === true) {
const namedMatch = resolveDiscordAllowListNameMatch(allowList, candidate);
if (namedMatch) {
return { allowed: true, ...namedMatch };
}
}
return { allowed: false };
}
export function resolveDiscordUserAllowed(params: {
allowList?: string[];
userId: string;
userName?: string;
userTag?: string;
allowNameMatching?: boolean;
}) {
const allowList = normalizeDiscordAllowList(params.allowList, ["discord:", "user:", "pk:"]);
if (!allowList) {
return true;
}
return allowListMatches(
allowList,
{
id: params.userId,
name: params.userName,
tag: params.userTag,
},
{ allowNameMatching: params.allowNameMatching },
);
}
export function resolveDiscordRoleAllowed(params: {
allowList?: string[];
memberRoleIds: string[];
}) {
// Role allowlists accept role IDs only. Names are ignored.
const allowList = normalizeDiscordAllowList(params.allowList, ["role:"]);
if (!allowList) {
return true;
}
if (allowList.allowAll) {
return true;
}
return params.memberRoleIds.some((roleId) => allowList.ids.has(roleId));
}
export function resolveDiscordMemberAllowed(params: {
userAllowList?: string[];
roleAllowList?: string[];
memberRoleIds: string[];
userId: string;
userName?: string;
userTag?: string;
allowNameMatching?: boolean;
}) {
const hasUserRestriction = Array.isArray(params.userAllowList) && params.userAllowList.length > 0;
const hasRoleRestriction = Array.isArray(params.roleAllowList) && params.roleAllowList.length > 0;
if (!hasUserRestriction && !hasRoleRestriction) {
return true;
}
const userOk = hasUserRestriction
? resolveDiscordUserAllowed({
allowList: params.userAllowList,
userId: params.userId,
userName: params.userName,
userTag: params.userTag,
allowNameMatching: params.allowNameMatching,
})
: false;
const roleOk = hasRoleRestriction
? resolveDiscordRoleAllowed({
allowList: params.roleAllowList,
memberRoleIds: params.memberRoleIds,
})
: false;
return userOk || roleOk;
}
export function resolveDiscordMemberAccessState(params: {
channelConfig?: DiscordChannelConfigResolved | null;
guildInfo?: DiscordGuildEntryResolved | null;
memberRoleIds: string[];
sender: { id: string; name?: string; tag?: string };
allowNameMatching?: boolean;
}) {
const channelUsers = params.channelConfig?.users ?? params.guildInfo?.users;
const channelRoles = params.channelConfig?.roles ?? params.guildInfo?.roles;
const hasAccessRestrictions =
(Array.isArray(channelUsers) && channelUsers.length > 0) ||
(Array.isArray(channelRoles) && channelRoles.length > 0);
const memberAllowed = resolveDiscordMemberAllowed({
userAllowList: channelUsers,
roleAllowList: channelRoles,
memberRoleIds: params.memberRoleIds,
userId: params.sender.id,
userName: params.sender.name,
userTag: params.sender.tag,
allowNameMatching: params.allowNameMatching,
});
return { channelUsers, channelRoles, hasAccessRestrictions, memberAllowed } as const;
}
export function resolveDiscordOwnerAllowFrom(params: {
channelConfig?: DiscordChannelConfigResolved | null;
guildInfo?: DiscordGuildEntryResolved | null;
sender: { id: string; name?: string; tag?: string };
allowNameMatching?: boolean;
}): string[] | undefined {
const rawAllowList = params.channelConfig?.users ?? params.guildInfo?.users;
if (!Array.isArray(rawAllowList) || rawAllowList.length === 0) {
return undefined;
}
const allowList = normalizeDiscordAllowList(rawAllowList, ["discord:", "user:", "pk:"]);
if (!allowList) {
return undefined;
}
const match = resolveDiscordAllowListMatch({
allowList,
candidate: {
id: params.sender.id,
name: params.sender.name,
tag: params.sender.tag,
},
allowNameMatching: params.allowNameMatching,
});
if (!match.allowed || !match.matchKey || match.matchKey === "*") {
return undefined;
}
return [match.matchKey];
}
export function resolveDiscordOwnerAccess(params: {
allowFrom?: string[];
sender: { id: string; name?: string; tag?: string };
allowNameMatching?: boolean;
}): {
ownerAllowList: DiscordAllowList | null;
ownerAllowed: boolean;
} {
const ownerAllowList = normalizeDiscordAllowList(
params.allowFrom,
DISCORD_OWNER_ALLOWLIST_PREFIXES,
);
const ownerAllowed = ownerAllowList
? allowListMatches(
ownerAllowList,
{
id: params.sender.id,
name: params.sender.name,
tag: params.sender.tag,
},
{ allowNameMatching: params.allowNameMatching },
)
: false;
return { ownerAllowList, ownerAllowed };
}
export function resolveDiscordCommandAuthorized(params: {
isDirectMessage: boolean;
allowFrom?: string[];
guildInfo?: DiscordGuildEntryResolved | null;
author: User;
allowNameMatching?: boolean;
}) {
if (!params.isDirectMessage) {
return true;
}
const allowList = normalizeDiscordAllowList(params.allowFrom, ["discord:", "user:", "pk:"]);
if (!allowList) {
return true;
}
return allowListMatches(
allowList,
{
id: params.author.id,
name: params.author.username,
tag: formatDiscordUserTag(params.author),
},
{ allowNameMatching: params.allowNameMatching },
);
}
export function resolveDiscordGuildEntry(params: {
guild?: Guild<true> | Guild | null;
guildId?: string | null;
guildEntries?: Record<string, DiscordGuildEntryResolved>;
}): DiscordGuildEntryResolved | null {
const guild = params.guild;
const entries = params.guildEntries;
const guildId = params.guildId?.trim() || guild?.id;
if (!entries) {
return null;
}
const byId = guildId ? entries[guildId] : undefined;
if (byId) {
return { ...byId, id: guildId };
}
if (!guild) {
return null;
}
const slug = normalizeDiscordSlug(guild.name ?? "");
const bySlug = entries[slug];
if (bySlug) {
return { ...bySlug, id: guildId ?? guild.id, slug: slug || bySlug.slug };
}
const wildcard = entries["*"];
if (wildcard) {
return { ...wildcard, id: guildId ?? guild.id, slug: slug || wildcard.slug };
}
return null;
}
type DiscordChannelEntry = NonNullable<DiscordGuildEntryResolved["channels"]>[string];
type DiscordChannelLookup = {
id: string;
name?: string;
slug?: string;
};
type DiscordChannelScope = "channel" | "thread";
function buildDiscordChannelKeys(
params: DiscordChannelLookup & { allowNameMatch?: boolean },
): string[] {
const allowNameMatch = params.allowNameMatch !== false;
return buildChannelKeyCandidates(
params.id,
allowNameMatch ? params.slug : undefined,
allowNameMatch ? params.name : undefined,
);
}
function resolveDiscordChannelEntryMatch(
channels: NonNullable<DiscordGuildEntryResolved["channels"]>,
params: DiscordChannelLookup & { allowNameMatch?: boolean },
parentParams?: DiscordChannelLookup,
) {
const keys = buildDiscordChannelKeys(params);
const parentKeys = parentParams ? buildDiscordChannelKeys(parentParams) : undefined;
return resolveChannelEntryMatchWithFallback({
entries: channels,
keys,
parentKeys,
wildcardKey: "*",
});
}
function hasConfiguredDiscordChannels(
channels: DiscordGuildEntryResolved["channels"] | undefined,
): channels is NonNullable<DiscordGuildEntryResolved["channels"]> {
return Boolean(channels && Object.keys(channels).length > 0);
}
function resolveDiscordChannelConfigEntry(
entry: DiscordChannelEntry,
): DiscordChannelConfigResolved {
const resolved: DiscordChannelConfigResolved = {
allowed: entry.allow !== false,
requireMention: entry.requireMention,
ignoreOtherMentions: entry.ignoreOtherMentions,
skills: entry.skills,
enabled: entry.enabled,
users: entry.users,
roles: entry.roles,
systemPrompt: entry.systemPrompt,
includeThreadStarter: entry.includeThreadStarter,
autoThread: entry.autoThread,
autoArchiveDuration: entry.autoArchiveDuration,
};
return resolved;
}
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 (!hasConfiguredDiscordChannels(channels)) {
return null;
}
const match = resolveDiscordChannelEntryMatch(channels, {
id: channelId,
name: channelName,
slug: channelSlug,
});
const resolved = resolveChannelMatchConfig(match, resolveDiscordChannelConfigEntry);
return resolved ?? { allowed: false };
}
export function resolveDiscordChannelConfigWithFallback(params: {
guildInfo?: DiscordGuildEntryResolved | null;
channelId: string;
channelName?: string;
channelSlug: string;
parentId?: string;
parentName?: string;
parentSlug?: string;
scope?: DiscordChannelScope;
}): DiscordChannelConfigResolved | null {
const {
guildInfo,
channelId,
channelName,
channelSlug,
parentId,
parentName,
parentSlug,
scope,
} = params;
const channels = guildInfo?.channels;
if (!hasConfiguredDiscordChannels(channels)) {
return null;
}
const resolvedParentSlug = parentSlug ?? (parentName ? normalizeDiscordSlug(parentName) : "");
const match = resolveDiscordChannelEntryMatch(
channels,
{
id: channelId,
name: channelName,
slug: channelSlug,
allowNameMatch: scope !== "thread",
},
parentId || parentName || parentSlug
? {
id: parentId ?? "",
name: parentName,
slug: resolvedParentSlug,
}
: undefined,
);
return resolveChannelMatchConfig(match, resolveDiscordChannelConfigEntry) ?? { allowed: false };
}
export function resolveDiscordShouldRequireMention(params: {
isGuildMessage: boolean;
isThread: boolean;
botId?: string | null;
threadOwnerId?: string | null;
channelConfig?: DiscordChannelConfigResolved | null;
guildInfo?: DiscordGuildEntryResolved | null;
/** Pass pre-computed value to avoid redundant checks. */
isAutoThreadOwnedByBot?: boolean;
}): boolean {
if (!params.isGuildMessage) {
return false;
}
// Only skip mention requirement in threads created by the bot (when autoThread is enabled).
const isBotThread = params.isAutoThreadOwnedByBot ?? isDiscordAutoThreadOwnedByBot(params);
if (isBotThread) {
return false;
}
return params.channelConfig?.requireMention ?? params.guildInfo?.requireMention ?? true;
}
export function isDiscordAutoThreadOwnedByBot(params: {
isThread: boolean;
channelConfig?: DiscordChannelConfigResolved | null;
botId?: string | null;
threadOwnerId?: string | null;
}): boolean {
if (!params.isThread) {
return false;
}
if (!params.channelConfig?.autoThread) {
return false;
}
const botId = params.botId?.trim();
const threadOwnerId = params.threadOwnerId?.trim();
return Boolean(botId && threadOwnerId && botId === threadOwnerId);
}
export function isDiscordGroupAllowedByPolicy(params: {
groupPolicy: "open" | "disabled" | "allowlist";
guildAllowlisted: boolean;
channelAllowlistConfigured: boolean;
channelAllowed: boolean;
}): boolean {
if (params.groupPolicy === "allowlist" && !params.guildAllowlisted) {
return false;
}
return evaluateGroupRouteAccessForPolicy({
groupPolicy:
params.groupPolicy === "allowlist" && !params.channelAllowlistConfigured
? "open"
: params.groupPolicy,
routeAllowlistConfigured: params.channelAllowlistConfigured,
routeMatched: params.channelAllowed,
}).allowed;
}
export function resolveGroupDmAllow(params: {
channels?: string[];
channelId: string;
channelName?: string;
channelSlug: string;
}) {
const { channels, channelId, channelName, channelSlug } = params;
if (!channels || channels.length === 0) {
return true;
}
const allowList = new Set(channels.map((entry) => normalizeDiscordSlug(String(entry))));
const candidates = [
normalizeDiscordSlug(channelId),
channelSlug,
channelName ? normalizeDiscordSlug(channelName) : "",
].filter(Boolean);
return allowList.has("*") || candidates.some((candidate) => allowList.has(candidate));
}
export function shouldEmitDiscordReactionNotification(params: {
mode?: "off" | "own" | "all" | "allowlist";
botId?: string;
messageAuthorId?: string;
userId: string;
userName?: string;
userTag?: string;
channelConfig?: DiscordChannelConfigResolved | null;
guildInfo?: DiscordGuildEntryResolved | null;
memberRoleIds?: string[];
allowlist?: string[];
allowNameMatching?: boolean;
}) {
const mode = params.mode ?? "own";
if (mode === "off") {
return false;
}
const accessGuildInfo =
params.guildInfo ??
(params.allowlist ? ({ users: params.allowlist } satisfies DiscordGuildEntryResolved) : null);
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
channelConfig: params.channelConfig,
guildInfo: accessGuildInfo,
memberRoleIds: params.memberRoleIds ?? [],
sender: {
id: params.userId,
name: params.userName,
tag: params.userTag,
},
allowNameMatching: params.allowNameMatching,
});
if (mode === "allowlist") {
return hasAccessRestrictions && memberAllowed;
}
if (hasAccessRestrictions && !memberAllowed) {
return false;
}
if (mode === "all") {
return true;
}
if (mode === "own") {
return Boolean(params.botId && params.messageAuthorId === params.botId);
}
return false;
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { AuthProfileStore } from "../../agents/auth-profiles.js";
import type { AuthProfileStore } from "../../../../src/agents/auth-profiles.js";
import {
createDiscordAutoPresenceController,
resolveDiscordAutoPresenceDecision,

View File

@@ -0,0 +1,362 @@
import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway";
import {
clearExpiredCooldowns,
ensureAuthProfileStore,
isProfileInCooldown,
resolveProfilesUnavailableReason,
type AuthProfileFailureReason,
type AuthProfileStore,
} from "../../../../src/agents/auth-profiles.js";
import type {
DiscordAccountConfig,
DiscordAutoPresenceConfig,
} from "../../../../src/config/config.js";
import { warn } from "../../../../src/globals.js";
import { resolveDiscordPresenceUpdate } from "./presence.js";
const DEFAULT_CUSTOM_ACTIVITY_TYPE = 4;
const CUSTOM_STATUS_NAME = "Custom Status";
const DEFAULT_INTERVAL_MS = 30_000;
const DEFAULT_MIN_UPDATE_INTERVAL_MS = 15_000;
const MIN_INTERVAL_MS = 5_000;
const MIN_UPDATE_INTERVAL_MS = 1_000;
export type DiscordAutoPresenceState = "healthy" | "degraded" | "exhausted";
type ResolvedDiscordAutoPresenceConfig = {
enabled: boolean;
intervalMs: number;
minUpdateIntervalMs: number;
healthyText?: string;
degradedText?: string;
exhaustedText?: string;
};
export type DiscordAutoPresenceDecision = {
state: DiscordAutoPresenceState;
unavailableReason?: AuthProfileFailureReason | null;
presence: UpdatePresenceData;
};
type PresenceGateway = {
isConnected: boolean;
updatePresence: (payload: UpdatePresenceData) => void;
};
function normalizeOptionalText(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function clampPositiveInt(value: unknown, fallback: number, minValue: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
const rounded = Math.round(value);
if (rounded <= 0) {
return fallback;
}
return Math.max(minValue, rounded);
}
function resolveAutoPresenceConfig(
config?: DiscordAutoPresenceConfig,
): ResolvedDiscordAutoPresenceConfig {
const intervalMs = clampPositiveInt(config?.intervalMs, DEFAULT_INTERVAL_MS, MIN_INTERVAL_MS);
const minUpdateIntervalMs = clampPositiveInt(
config?.minUpdateIntervalMs,
DEFAULT_MIN_UPDATE_INTERVAL_MS,
MIN_UPDATE_INTERVAL_MS,
);
return {
enabled: config?.enabled === true,
intervalMs,
minUpdateIntervalMs,
healthyText: normalizeOptionalText(config?.healthyText),
degradedText: normalizeOptionalText(config?.degradedText),
exhaustedText: normalizeOptionalText(config?.exhaustedText),
};
}
function buildCustomStatusActivity(text: string): Activity {
return {
name: CUSTOM_STATUS_NAME,
type: DEFAULT_CUSTOM_ACTIVITY_TYPE,
state: text,
};
}
function renderTemplate(
template: string,
vars: Record<string, string | undefined>,
): string | undefined {
const rendered = template
.replace(/\{([a-zA-Z0-9_]+)\}/g, (_full, key: string) => vars[key] ?? "")
.replace(/\s+/g, " ")
.trim();
return rendered.length > 0 ? rendered : undefined;
}
function isExhaustedUnavailableReason(reason: AuthProfileFailureReason | null): boolean {
if (!reason) {
return false;
}
return (
reason === "rate_limit" ||
reason === "overloaded" ||
reason === "billing" ||
reason === "auth" ||
reason === "auth_permanent"
);
}
function formatUnavailableReason(reason: AuthProfileFailureReason | null): string {
if (!reason) {
return "unknown";
}
return reason.replace(/_/g, " ");
}
function resolveAuthAvailability(params: { store: AuthProfileStore; now: number }): {
state: DiscordAutoPresenceState;
unavailableReason?: AuthProfileFailureReason | null;
} {
const profileIds = Object.keys(params.store.profiles);
if (profileIds.length === 0) {
return { state: "degraded", unavailableReason: null };
}
clearExpiredCooldowns(params.store, params.now);
const hasUsableProfile = profileIds.some(
(profileId) => !isProfileInCooldown(params.store, profileId, params.now),
);
if (hasUsableProfile) {
return { state: "healthy", unavailableReason: null };
}
const unavailableReason = resolveProfilesUnavailableReason({
store: params.store,
profileIds,
now: params.now,
});
if (isExhaustedUnavailableReason(unavailableReason)) {
return {
state: "exhausted",
unavailableReason,
};
}
return {
state: "degraded",
unavailableReason,
};
}
function resolvePresenceActivities(params: {
state: DiscordAutoPresenceState;
cfg: ResolvedDiscordAutoPresenceConfig;
basePresence: UpdatePresenceData | null;
unavailableReason?: AuthProfileFailureReason | null;
}): Activity[] {
const reasonLabel = formatUnavailableReason(params.unavailableReason ?? null);
if (params.state === "healthy") {
if (params.cfg.healthyText) {
return [buildCustomStatusActivity(params.cfg.healthyText)];
}
return params.basePresence?.activities ?? [];
}
if (params.state === "degraded") {
const template = params.cfg.degradedText ?? "runtime degraded";
const text = renderTemplate(template, { reason: reasonLabel });
return text ? [buildCustomStatusActivity(text)] : [];
}
const defaultTemplate = isExhaustedUnavailableReason(params.unavailableReason ?? null)
? "token exhausted"
: "model unavailable ({reason})";
const template = params.cfg.exhaustedText ?? defaultTemplate;
const text = renderTemplate(template, { reason: reasonLabel });
return text ? [buildCustomStatusActivity(text)] : [];
}
function resolvePresenceStatus(state: DiscordAutoPresenceState): UpdatePresenceData["status"] {
if (state === "healthy") {
return "online";
}
if (state === "exhausted") {
return "dnd";
}
return "idle";
}
export function resolveDiscordAutoPresenceDecision(params: {
discordConfig: Pick<
DiscordAccountConfig,
"autoPresence" | "activity" | "status" | "activityType" | "activityUrl"
>;
authStore: AuthProfileStore;
gatewayConnected: boolean;
now?: number;
}): DiscordAutoPresenceDecision | null {
const autoPresence = resolveAutoPresenceConfig(params.discordConfig.autoPresence);
if (!autoPresence.enabled) {
return null;
}
const now = params.now ?? Date.now();
const basePresence = resolveDiscordPresenceUpdate(params.discordConfig);
const availability = resolveAuthAvailability({
store: params.authStore,
now,
});
const state = params.gatewayConnected ? availability.state : "degraded";
const unavailableReason = params.gatewayConnected
? availability.unavailableReason
: (availability.unavailableReason ?? "unknown");
const activities = resolvePresenceActivities({
state,
cfg: autoPresence,
basePresence,
unavailableReason,
});
return {
state,
unavailableReason,
presence: {
since: null,
activities,
status: resolvePresenceStatus(state),
afk: false,
},
};
}
function stablePresenceSignature(payload: UpdatePresenceData): string {
return JSON.stringify({
status: payload.status,
afk: payload.afk,
since: payload.since,
activities: payload.activities.map((activity) => ({
type: activity.type,
name: activity.name,
state: activity.state,
url: activity.url,
})),
});
}
export type DiscordAutoPresenceController = {
start: () => void;
stop: () => void;
refresh: () => void;
runNow: () => void;
enabled: boolean;
};
export function createDiscordAutoPresenceController(params: {
accountId: string;
discordConfig: Pick<
DiscordAccountConfig,
"autoPresence" | "activity" | "status" | "activityType" | "activityUrl"
>;
gateway: PresenceGateway;
loadAuthStore?: () => AuthProfileStore;
now?: () => number;
setIntervalFn?: typeof setInterval;
clearIntervalFn?: typeof clearInterval;
log?: (message: string) => void;
}): DiscordAutoPresenceController {
const autoCfg = resolveAutoPresenceConfig(params.discordConfig.autoPresence);
if (!autoCfg.enabled) {
return {
enabled: false,
start: () => undefined,
stop: () => undefined,
refresh: () => undefined,
runNow: () => undefined,
};
}
const loadAuthStore = params.loadAuthStore ?? (() => ensureAuthProfileStore());
const now = params.now ?? (() => Date.now());
const setIntervalFn = params.setIntervalFn ?? setInterval;
const clearIntervalFn = params.clearIntervalFn ?? clearInterval;
let timer: ReturnType<typeof setInterval> | undefined;
let lastAppliedSignature: string | null = null;
let lastAppliedAt = 0;
const runEvaluation = (options?: { force?: boolean }) => {
let decision: DiscordAutoPresenceDecision | null = null;
try {
decision = resolveDiscordAutoPresenceDecision({
discordConfig: params.discordConfig,
authStore: loadAuthStore(),
gatewayConnected: params.gateway.isConnected,
now: now(),
});
} catch (err) {
params.log?.(
warn(
`discord: auto-presence evaluation failed for account ${params.accountId}: ${String(err)}`,
),
);
return;
}
if (!decision || !params.gateway.isConnected) {
return;
}
const forceApply = options?.force === true;
const ts = now();
const signature = stablePresenceSignature(decision.presence);
if (!forceApply && signature === lastAppliedSignature) {
return;
}
if (!forceApply && lastAppliedAt > 0 && ts - lastAppliedAt < autoCfg.minUpdateIntervalMs) {
return;
}
params.gateway.updatePresence(decision.presence);
lastAppliedSignature = signature;
lastAppliedAt = ts;
};
return {
enabled: true,
runNow: () => runEvaluation(),
refresh: () => runEvaluation({ force: true }),
start: () => {
if (timer) {
return;
}
runEvaluation({ force: true });
timer = setIntervalFn(() => runEvaluation(), autoCfg.intervalMs);
},
stop: () => {
if (!timer) {
return;
}
clearIntervalFn(timer);
timer = undefined;
},
};
}
export const __testing = {
resolveAutoPresenceConfig,
resolveAuthAvailability,
stablePresenceSignature,
};

View File

@@ -0,0 +1,9 @@
import type { DiscordSlashCommandConfig } from "../../../../src/config/types.discord.js";
export function resolveDiscordSlashCommandConfig(
raw?: DiscordSlashCommandConfig,
): Required<DiscordSlashCommandConfig> {
return {
ephemeral: raw?.ephemeral !== false,
};
}

View File

@@ -0,0 +1,104 @@
import { resolveCommandAuthorizedFromAuthorizers } from "../../../../src/channels/command-gating.js";
import {
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
type DmGroupAccessDecision,
} from "../../../../src/security/dm-policy-shared.js";
import { normalizeDiscordAllowList, resolveDiscordAllowListMatch } from "./allow-list.js";
const DISCORD_ALLOW_LIST_PREFIXES = ["discord:", "user:", "pk:"];
export type DiscordDmPolicy = "open" | "pairing" | "allowlist" | "disabled";
export type DiscordDmCommandAccess = {
decision: DmGroupAccessDecision;
reason: string;
commandAuthorized: boolean;
allowMatch: ReturnType<typeof resolveDiscordAllowListMatch> | { allowed: false };
};
function resolveSenderAllowMatch(params: {
allowEntries: string[];
sender: { id: string; name?: string; tag?: string };
allowNameMatching: boolean;
}) {
const allowList = normalizeDiscordAllowList(params.allowEntries, DISCORD_ALLOW_LIST_PREFIXES);
return allowList
? resolveDiscordAllowListMatch({
allowList,
candidate: params.sender,
allowNameMatching: params.allowNameMatching,
})
: ({ allowed: false } as const);
}
function resolveDmPolicyCommandAuthorization(params: {
dmPolicy: DiscordDmPolicy;
decision: DmGroupAccessDecision;
commandAuthorized: boolean;
}) {
if (params.dmPolicy === "open" && params.decision === "allow") {
return true;
}
return params.commandAuthorized;
}
export async function resolveDiscordDmCommandAccess(params: {
accountId: string;
dmPolicy: DiscordDmPolicy;
configuredAllowFrom: string[];
sender: { id: string; name?: string; tag?: string };
allowNameMatching: boolean;
useAccessGroups: boolean;
readStoreAllowFrom?: () => Promise<string[]>;
}): Promise<DiscordDmCommandAccess> {
const storeAllowFrom = params.readStoreAllowFrom
? await params.readStoreAllowFrom().catch(() => [])
: await readStoreAllowFromForDmPolicy({
provider: "discord",
accountId: params.accountId,
dmPolicy: params.dmPolicy,
});
const access = resolveDmGroupAccessWithLists({
isGroup: false,
dmPolicy: params.dmPolicy,
allowFrom: params.configuredAllowFrom,
groupAllowFrom: [],
storeAllowFrom,
isSenderAllowed: (allowEntries) =>
resolveSenderAllowMatch({
allowEntries,
sender: params.sender,
allowNameMatching: params.allowNameMatching,
}).allowed,
});
const allowMatch = resolveSenderAllowMatch({
allowEntries: access.effectiveAllowFrom,
sender: params.sender,
allowNameMatching: params.allowNameMatching,
});
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups: params.useAccessGroups,
authorizers: [
{
configured: access.effectiveAllowFrom.length > 0,
allowed: allowMatch.allowed,
},
],
modeWhenAccessGroupsOff: "configured",
});
return {
decision: access.decision,
reason: access.reason,
commandAuthorized: resolveDmPolicyCommandAuthorization({
dmPolicy: params.dmPolicy,
decision: access.decision,
commandAuthorized,
}),
allowMatch,
};
}

View File

@@ -0,0 +1,48 @@
import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js";
import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js";
import type { DiscordDmCommandAccess } from "./dm-command-auth.js";
export async function handleDiscordDmCommandDecision(params: {
dmAccess: DiscordDmCommandAccess;
accountId: string;
sender: {
id: string;
tag?: string;
name?: string;
};
onPairingCreated: (code: string) => Promise<void>;
onUnauthorized: () => Promise<void>;
upsertPairingRequest?: typeof upsertChannelPairingRequest;
}): Promise<boolean> {
if (params.dmAccess.decision === "allow") {
return true;
}
if (params.dmAccess.decision === "pairing") {
const upsertPairingRequest = params.upsertPairingRequest ?? upsertChannelPairingRequest;
const result = await issuePairingChallenge({
channel: "discord",
senderId: params.sender.id,
senderIdLine: `Your Discord user id: ${params.sender.id}`,
meta: {
tag: params.sender.tag,
name: params.sender.name,
},
upsertPairingRequest: async ({ id, meta }) =>
await upsertPairingRequest({
channel: "discord",
id,
accountId: params.accountId,
meta,
}),
sendPairingReply: async () => {},
});
if (result.created && result.code) {
await params.onPairingCreated(result.code);
}
return false;
}
await params.onUnauthorized();
return false;
}

View File

@@ -4,8 +4,8 @@ import path from "node:path";
import type { ButtonInteraction, ComponentData } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { clearSessionStoreCacheForTest } from "../../config/sessions.js";
import type { DiscordExecApprovalConfig } from "../../config/types.discord.js";
import { clearSessionStoreCacheForTest } from "../../../../src/config/sessions.js";
import type { DiscordExecApprovalConfig } from "../../../../src/config/types.discord.js";
import {
buildExecApprovalCustomId,
extractDiscordChannelId,
@@ -76,7 +76,7 @@ vi.mock("../send.shared.js", async (importOriginal) => {
};
});
vi.mock("../../gateway/client.js", () => ({
vi.mock("../../../../src/gateway/client.js", () => ({
GatewayClient: class {
private params: Record<string, unknown>;
constructor(params: Record<string, unknown>) {
@@ -96,11 +96,11 @@ vi.mock("../../gateway/client.js", () => ({
},
}));
vi.mock("../../gateway/connection-auth.js", () => ({
vi.mock("../../../../src/gateway/connection-auth.js", () => ({
resolveGatewayConnectionAuth: mockResolveGatewayConnectionAuth,
}));
vi.mock("../../logger.js", () => ({
vi.mock("../../../../src/logger.js", () => ({
logDebug: vi.fn(),
logError: vi.fn(),
}));

View File

@@ -0,0 +1,877 @@
import {
Button,
Row,
Separator,
TextDisplay,
serializePayload,
type ButtonInteraction,
type ComponentData,
type MessagePayloadObject,
type TopLevelComponents,
} from "@buape/carbon";
import { ButtonStyle, Routes } from "discord-api-types/v10";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import { loadSessionStore, resolveStorePath } from "../../../../src/config/sessions.js";
import type { DiscordExecApprovalConfig } from "../../../../src/config/types.discord.js";
import { GatewayClient } from "../../../../src/gateway/client.js";
import { createOperatorApprovalsGatewayClient } from "../../../../src/gateway/operator-approvals-client.js";
import type { EventFrame } from "../../../../src/gateway/protocol/index.js";
import { resolveExecApprovalCommandDisplay } from "../../../../src/infra/exec-approval-command-display.js";
import { getExecApprovalApproverDmNoticeText } from "../../../../src/infra/exec-approval-reply.js";
import type {
ExecApprovalDecision,
ExecApprovalRequest,
ExecApprovalResolved,
} from "../../../../src/infra/exec-approvals.js";
import { logDebug, logError } from "../../../../src/logger.js";
import {
normalizeAccountId,
resolveAgentIdFromSessionKey,
} from "../../../../src/routing/session-key.js";
import type { RuntimeEnv } from "../../../../src/runtime.js";
import {
compileSafeRegex,
testRegexWithBoundedInput,
} from "../../../../src/security/safe-regex.js";
import { normalizeMessageChannel } from "../../../../src/utils/message-channel.js";
import { createDiscordClient, stripUndefinedFields } from "../send.shared.js";
import { DiscordUiContainer } from "../ui.js";
const EXEC_APPROVAL_KEY = "execapproval";
export type { ExecApprovalRequest, ExecApprovalResolved };
/** Extract Discord channel ID from a session key like "agent:main:discord:channel:123456789" */
export function extractDiscordChannelId(sessionKey?: string | null): string | null {
if (!sessionKey) {
return null;
}
// Session key format: agent:<id>:discord:channel:<channelId> or agent:<id>:discord:group:<channelId>
const match = sessionKey.match(/discord:(?:channel|group):(\d+)/);
return match ? match[1] : null;
}
function buildDiscordApprovalDmRedirectNotice(): { content: string } {
return {
content: getExecApprovalApproverDmNoticeText(),
};
}
type PendingApproval = {
discordMessageId: string;
discordChannelId: string;
timeoutId: NodeJS.Timeout;
};
function encodeCustomIdValue(value: string): string {
return encodeURIComponent(value);
}
function decodeCustomIdValue(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
export function buildExecApprovalCustomId(
approvalId: string,
action: ExecApprovalDecision,
): string {
return [`${EXEC_APPROVAL_KEY}:id=${encodeCustomIdValue(approvalId)}`, `action=${action}`].join(
";",
);
}
export function parseExecApprovalData(
data: ComponentData,
): { approvalId: string; action: ExecApprovalDecision } | null {
if (!data || typeof data !== "object") {
return null;
}
const coerce = (value: unknown) =>
typeof value === "string" || typeof value === "number" ? String(value) : "";
const rawId = coerce(data.id);
const rawAction = coerce(data.action);
if (!rawId || !rawAction) {
return null;
}
const action = rawAction as ExecApprovalDecision;
if (action !== "allow-once" && action !== "allow-always" && action !== "deny") {
return null;
}
return {
approvalId: decodeCustomIdValue(rawId),
action,
};
}
type ExecApprovalContainerParams = {
cfg: OpenClawConfig;
accountId: string;
title: string;
description?: string;
commandPreview: string;
commandSecondaryPreview?: string | null;
metadataLines?: string[];
actionRow?: Row<Button>;
footer?: string;
accentColor?: string;
};
class ExecApprovalContainer extends DiscordUiContainer {
constructor(params: ExecApprovalContainerParams) {
const components: Array<TextDisplay | Separator | Row<Button>> = [
new TextDisplay(`## ${params.title}`),
];
if (params.description) {
components.push(new TextDisplay(params.description));
}
components.push(new Separator({ divider: true, spacing: "small" }));
components.push(new TextDisplay(`### Command\n\`\`\`\n${params.commandPreview}\n\`\`\``));
if (params.commandSecondaryPreview) {
components.push(
new TextDisplay(`### Shell Preview\n\`\`\`\n${params.commandSecondaryPreview}\n\`\`\``),
);
}
if (params.metadataLines?.length) {
components.push(new TextDisplay(params.metadataLines.join("\n")));
}
if (params.actionRow) {
components.push(params.actionRow);
}
if (params.footer) {
components.push(new Separator({ divider: false, spacing: "small" }));
components.push(new TextDisplay(`-# ${params.footer}`));
}
super({
cfg: params.cfg,
accountId: params.accountId,
components,
accentColor: params.accentColor,
});
}
}
class ExecApprovalActionButton extends Button {
customId: string;
label: string;
style: ButtonStyle;
constructor(params: {
approvalId: string;
action: ExecApprovalDecision;
label: string;
style: ButtonStyle;
}) {
super();
this.customId = buildExecApprovalCustomId(params.approvalId, params.action);
this.label = params.label;
this.style = params.style;
}
}
class ExecApprovalActionRow extends Row<Button> {
constructor(approvalId: string) {
super([
new ExecApprovalActionButton({
approvalId,
action: "allow-once",
label: "Allow once",
style: ButtonStyle.Success,
}),
new ExecApprovalActionButton({
approvalId,
action: "allow-always",
label: "Always allow",
style: ButtonStyle.Primary,
}),
new ExecApprovalActionButton({
approvalId,
action: "deny",
label: "Deny",
style: ButtonStyle.Danger,
}),
]);
}
}
function resolveExecApprovalAccountId(params: {
cfg: OpenClawConfig;
request: ExecApprovalRequest;
}): string | null {
const sessionKey = params.request.request.sessionKey?.trim();
if (!sessionKey) {
return null;
}
try {
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const storePath = resolveStorePath(params.cfg.session?.store, { agentId });
const store = loadSessionStore(storePath);
const entry = store[sessionKey];
const channel = normalizeMessageChannel(entry?.origin?.provider ?? entry?.lastChannel);
if (channel && channel !== "discord") {
return null;
}
const accountId = entry?.origin?.accountId ?? entry?.lastAccountId;
return accountId?.trim() || null;
} catch {
return null;
}
}
function buildExecApprovalMetadataLines(request: ExecApprovalRequest): string[] {
const lines: string[] = [];
if (request.request.cwd) {
lines.push(`- Working Directory: ${request.request.cwd}`);
}
if (request.request.host) {
lines.push(`- Host: ${request.request.host}`);
}
if (Array.isArray(request.request.envKeys) && request.request.envKeys.length > 0) {
lines.push(`- Env Overrides: ${request.request.envKeys.join(", ")}`);
}
if (request.request.agentId) {
lines.push(`- Agent: ${request.request.agentId}`);
}
return lines;
}
function buildExecApprovalPayload(container: DiscordUiContainer): MessagePayloadObject {
const components: TopLevelComponents[] = [container];
return { components };
}
function formatCommandPreview(commandText: string, maxChars: number): string {
const commandRaw =
commandText.length > maxChars ? `${commandText.slice(0, maxChars)}...` : commandText;
return commandRaw.replace(/`/g, "\u200b`");
}
function formatOptionalCommandPreview(
commandText: string | null | undefined,
maxChars: number,
): string | null {
if (!commandText) {
return null;
}
return formatCommandPreview(commandText, maxChars);
}
function resolveExecApprovalPreviews(
request: ExecApprovalRequest["request"],
maxChars: number,
secondaryMaxChars: number,
): { commandPreview: string; commandSecondaryPreview: string | null } {
const { commandText, commandPreview: secondaryPreview } =
resolveExecApprovalCommandDisplay(request);
return {
commandPreview: formatCommandPreview(commandText, maxChars),
commandSecondaryPreview: formatOptionalCommandPreview(secondaryPreview, secondaryMaxChars),
};
}
function createExecApprovalRequestContainer(params: {
request: ExecApprovalRequest;
cfg: OpenClawConfig;
accountId: string;
actionRow?: Row<Button>;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveExecApprovalPreviews(
params.request.request,
1000,
500,
);
const expiresAtSeconds = Math.max(0, Math.floor(params.request.expiresAtMs / 1000));
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Exec Approval Required",
description: "A command needs your approval.",
commandPreview,
commandSecondaryPreview,
metadataLines: buildExecApprovalMetadataLines(params.request),
actionRow: params.actionRow,
footer: `Expires <t:${expiresAtSeconds}:R> · ID: ${params.request.id}`,
accentColor: "#FFA500",
});
}
function createResolvedContainer(params: {
request: ExecApprovalRequest;
decision: ExecApprovalDecision;
resolvedBy?: string | null;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveExecApprovalPreviews(
params.request.request,
500,
300,
);
const decisionLabel =
params.decision === "allow-once"
? "Allowed (once)"
: params.decision === "allow-always"
? "Allowed (always)"
: "Denied";
const accentColor =
params.decision === "deny"
? "#ED4245"
: params.decision === "allow-always"
? "#5865F2"
: "#57F287";
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: `Exec Approval: ${decisionLabel}`,
description: params.resolvedBy ? `Resolved by ${params.resolvedBy}` : "Resolved",
commandPreview,
commandSecondaryPreview,
footer: `ID: ${params.request.id}`,
accentColor,
});
}
function createExpiredContainer(params: {
request: ExecApprovalRequest;
cfg: OpenClawConfig;
accountId: string;
}): ExecApprovalContainer {
const { commandPreview, commandSecondaryPreview } = resolveExecApprovalPreviews(
params.request.request,
500,
300,
);
return new ExecApprovalContainer({
cfg: params.cfg,
accountId: params.accountId,
title: "Exec Approval: Expired",
description: "This approval request has expired.",
commandPreview,
commandSecondaryPreview,
footer: `ID: ${params.request.id}`,
accentColor: "#99AAB5",
});
}
export type DiscordExecApprovalHandlerOpts = {
token: string;
accountId: string;
config: DiscordExecApprovalConfig;
gatewayUrl?: string;
cfg: OpenClawConfig;
runtime?: RuntimeEnv;
onResolve?: (id: string, decision: ExecApprovalDecision) => Promise<void>;
};
export class DiscordExecApprovalHandler {
private gatewayClient: GatewayClient | null = null;
private pending = new Map<string, PendingApproval>();
private requestCache = new Map<string, ExecApprovalRequest>();
private opts: DiscordExecApprovalHandlerOpts;
private started = false;
constructor(opts: DiscordExecApprovalHandlerOpts) {
this.opts = opts;
}
shouldHandle(request: ExecApprovalRequest): boolean {
const config = this.opts.config;
if (!config.enabled) {
return false;
}
if (!config.approvers || config.approvers.length === 0) {
return false;
}
const requestAccountId = resolveExecApprovalAccountId({
cfg: this.opts.cfg,
request,
});
if (requestAccountId) {
const handlerAccountId = normalizeAccountId(this.opts.accountId);
if (normalizeAccountId(requestAccountId) !== handlerAccountId) {
return false;
}
}
// Check agent filter
if (config.agentFilter?.length) {
if (!request.request.agentId) {
return false;
}
if (!config.agentFilter.includes(request.request.agentId)) {
return false;
}
}
// Check session filter (substring match)
if (config.sessionFilter?.length) {
const session = request.request.sessionKey;
if (!session) {
return false;
}
const matches = config.sessionFilter.some((p) => {
if (session.includes(p)) {
return true;
}
const regex = compileSafeRegex(p);
return regex ? testRegexWithBoundedInput(regex, session) : false;
});
if (!matches) {
return false;
}
}
return true;
}
async start(): Promise<void> {
if (this.started) {
return;
}
this.started = true;
const config = this.opts.config;
if (!config.enabled) {
logDebug("discord exec approvals: disabled");
return;
}
if (!config.approvers || config.approvers.length === 0) {
logDebug("discord exec approvals: no approvers configured");
return;
}
logDebug("discord exec approvals: starting handler");
this.gatewayClient = await createOperatorApprovalsGatewayClient({
config: this.opts.cfg,
gatewayUrl: this.opts.gatewayUrl,
clientDisplayName: "Discord Exec Approvals",
onEvent: (evt) => this.handleGatewayEvent(evt),
onHelloOk: () => {
logDebug("discord exec approvals: connected to gateway");
},
onConnectError: (err) => {
logError(`discord exec approvals: connect error: ${err.message}`);
},
onClose: (code, reason) => {
logDebug(`discord exec approvals: gateway closed: ${code} ${reason}`);
},
});
this.gatewayClient.start();
}
async stop(): Promise<void> {
if (!this.started) {
return;
}
this.started = false;
// Clear all pending timeouts
for (const pending of this.pending.values()) {
clearTimeout(pending.timeoutId);
}
this.pending.clear();
this.requestCache.clear();
this.gatewayClient?.stop();
this.gatewayClient = null;
logDebug("discord exec approvals: stopped");
}
private handleGatewayEvent(evt: EventFrame): void {
if (evt.event === "exec.approval.requested") {
const request = evt.payload as ExecApprovalRequest;
void this.handleApprovalRequested(request);
} else if (evt.event === "exec.approval.resolved") {
const resolved = evt.payload as ExecApprovalResolved;
void this.handleApprovalResolved(resolved);
}
}
private async handleApprovalRequested(request: ExecApprovalRequest): Promise<void> {
if (!this.shouldHandle(request)) {
return;
}
logDebug(`discord exec approvals: received request ${request.id}`);
this.requestCache.set(request.id, request);
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
const actionRow = new ExecApprovalActionRow(request.id);
const container = createExecApprovalRequestContainer({
request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
actionRow,
});
const payload = buildExecApprovalPayload(container);
const body = stripUndefinedFields(serializePayload(payload));
const target = this.opts.config.target ?? "dm";
const sendToDm = target === "dm" || target === "both";
const sendToChannel = target === "channel" || target === "both";
let fallbackToDm = false;
const originatingChannelId =
request.request.sessionKey && target === "dm"
? extractDiscordChannelId(request.request.sessionKey)
: null;
if (target === "dm" && originatingChannelId) {
try {
await discordRequest(
() =>
rest.post(Routes.channelMessages(originatingChannelId), {
body: buildDiscordApprovalDmRedirectNotice(),
}) as Promise<{ id: string; channel_id: string }>,
"send-approval-dm-redirect-notice",
);
} catch (err) {
logError(`discord exec approvals: failed to send DM redirect notice: ${String(err)}`);
}
}
// Send to originating channel if configured
if (sendToChannel) {
const channelId = extractDiscordChannelId(request.request.sessionKey);
if (channelId) {
try {
const message = (await discordRequest(
() =>
rest.post(Routes.channelMessages(channelId), {
body,
}) as Promise<{ id: string; channel_id: string }>,
"send-approval-channel",
)) as { id: string; channel_id: string };
if (message?.id) {
const timeoutMs = Math.max(0, request.expiresAtMs - Date.now());
const timeoutId = setTimeout(() => {
void this.handleApprovalTimeout(request.id, "channel");
}, timeoutMs);
this.pending.set(`${request.id}:channel`, {
discordMessageId: message.id,
discordChannelId: channelId,
timeoutId,
});
logDebug(`discord exec approvals: sent approval ${request.id} to channel ${channelId}`);
}
} catch (err) {
logError(`discord exec approvals: failed to send to channel: ${String(err)}`);
}
} else {
if (!sendToDm) {
logError(
`discord exec approvals: target is "channel" but could not extract channel id from session key "${request.request.sessionKey ?? "(none)"}" — falling back to DM delivery for approval ${request.id}`,
);
fallbackToDm = true;
} else {
logDebug("discord exec approvals: could not extract channel id from session key");
}
}
}
// Send to approver DMs if configured (or as fallback when channel extraction fails)
if (sendToDm || fallbackToDm) {
const approvers = this.opts.config.approvers ?? [];
for (const approver of approvers) {
const userId = String(approver);
try {
// Create DM channel
const dmChannel = (await discordRequest(
() =>
rest.post(Routes.userChannels(), {
body: { recipient_id: userId },
}) as Promise<{ id: string }>,
"dm-channel",
)) as { id: string };
if (!dmChannel?.id) {
logError(`discord exec approvals: failed to create DM for user ${userId}`);
continue;
}
// Send message with components v2 + buttons
const message = (await discordRequest(
() =>
rest.post(Routes.channelMessages(dmChannel.id), {
body,
}) as Promise<{ id: string; channel_id: string }>,
"send-approval",
)) as { id: string; channel_id: string };
if (!message?.id) {
logError(`discord exec approvals: failed to send message to user ${userId}`);
continue;
}
// Clear any existing pending DM entry to avoid timeout leaks
const existingDm = this.pending.get(`${request.id}:dm`);
if (existingDm) {
clearTimeout(existingDm.timeoutId);
}
// Set up timeout
const timeoutMs = Math.max(0, request.expiresAtMs - Date.now());
const timeoutId = setTimeout(() => {
void this.handleApprovalTimeout(request.id, "dm");
}, timeoutMs);
this.pending.set(`${request.id}:dm`, {
discordMessageId: message.id,
discordChannelId: dmChannel.id,
timeoutId,
});
logDebug(`discord exec approvals: sent approval ${request.id} to user ${userId}`);
} catch (err) {
logError(`discord exec approvals: failed to notify user ${userId}: ${String(err)}`);
}
}
}
}
private async handleApprovalResolved(resolved: ExecApprovalResolved): Promise<void> {
// Clean up all pending entries for this approval (channel + dm)
const request = this.requestCache.get(resolved.id);
this.requestCache.delete(resolved.id);
if (!request) {
return;
}
logDebug(`discord exec approvals: resolved ${resolved.id} with ${resolved.decision}`);
const container = createResolvedContainer({
request,
decision: resolved.decision,
resolvedBy: resolved.resolvedBy,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
});
for (const suffix of [":channel", ":dm", ""]) {
const key = `${resolved.id}${suffix}`;
const pending = this.pending.get(key);
if (!pending) {
continue;
}
clearTimeout(pending.timeoutId);
this.pending.delete(key);
await this.finalizeMessage(pending.discordChannelId, pending.discordMessageId, container);
}
}
private async handleApprovalTimeout(
approvalId: string,
source?: "channel" | "dm",
): Promise<void> {
const key = source ? `${approvalId}:${source}` : approvalId;
const pending = this.pending.get(key);
if (!pending) {
return;
}
this.pending.delete(key);
const request = this.requestCache.get(approvalId);
// Only clean up requestCache if no other pending entries exist for this approval
const hasOtherPending =
this.pending.has(`${approvalId}:channel`) ||
this.pending.has(`${approvalId}:dm`) ||
this.pending.has(approvalId);
if (!hasOtherPending) {
this.requestCache.delete(approvalId);
}
if (!request) {
return;
}
logDebug(`discord exec approvals: timeout for ${approvalId} (${source ?? "default"})`);
const container = createExpiredContainer({
request,
cfg: this.opts.cfg,
accountId: this.opts.accountId,
});
await this.finalizeMessage(pending.discordChannelId, pending.discordMessageId, container);
}
private async finalizeMessage(
channelId: string,
messageId: string,
container: DiscordUiContainer,
): Promise<void> {
if (!this.opts.config.cleanupAfterResolve) {
await this.updateMessage(channelId, messageId, container);
return;
}
try {
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
await discordRequest(
() => rest.delete(Routes.channelMessage(channelId, messageId)) as Promise<void>,
"delete-approval",
);
} catch (err) {
logError(`discord exec approvals: failed to delete message: ${String(err)}`);
await this.updateMessage(channelId, messageId, container);
}
}
private async updateMessage(
channelId: string,
messageId: string,
container: DiscordUiContainer,
): Promise<void> {
try {
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
const payload = buildExecApprovalPayload(container);
await discordRequest(
() =>
rest.patch(Routes.channelMessage(channelId, messageId), {
body: stripUndefinedFields(serializePayload(payload)),
}),
"update-approval",
);
} catch (err) {
logError(`discord exec approvals: failed to update message: ${String(err)}`);
}
}
async resolveApproval(approvalId: string, decision: ExecApprovalDecision): Promise<boolean> {
if (!this.gatewayClient) {
logError("discord exec approvals: gateway client not connected");
return false;
}
logDebug(`discord exec approvals: resolving ${approvalId} with ${decision}`);
try {
await this.gatewayClient.request("exec.approval.resolve", {
id: approvalId,
decision,
});
logDebug(`discord exec approvals: resolved ${approvalId} successfully`);
return true;
} catch (err) {
logError(`discord exec approvals: resolve failed: ${String(err)}`);
return false;
}
}
/** Return the list of configured approver IDs. */
getApprovers(): string[] {
return this.opts.config.approvers ?? [];
}
}
export type ExecApprovalButtonContext = {
handler: DiscordExecApprovalHandler;
};
export class ExecApprovalButton extends Button {
label = "execapproval";
customId = `${EXEC_APPROVAL_KEY}:seed=1`;
style = ButtonStyle.Primary;
private ctx: ExecApprovalButtonContext;
constructor(ctx: ExecApprovalButtonContext) {
super();
this.ctx = ctx;
}
async run(interaction: ButtonInteraction, data: ComponentData): Promise<void> {
const parsed = parseExecApprovalData(data);
if (!parsed) {
try {
await interaction.reply({
content: "This approval is no longer valid.",
ephemeral: true,
});
} catch {
// Interaction may have expired
}
return;
}
// Verify the user is an authorized approver
const approvers = this.ctx.handler.getApprovers();
const userId = interaction.userId;
if (!approvers.some((id) => String(id) === userId)) {
try {
await interaction.reply({
content: "⛔ You are not authorized to approve exec requests.",
ephemeral: true,
});
} catch {
// Interaction may have expired
}
return;
}
const decisionLabel =
parsed.action === "allow-once"
? "Allowed (once)"
: parsed.action === "allow-always"
? "Allowed (always)"
: "Denied";
// Acknowledge immediately so Discord does not fail the interaction while
// the gateway resolve roundtrip completes. The resolved event will update
// the approval card in-place with the final state.
try {
await interaction.acknowledge();
} catch {
// Interaction may have expired, try to continue anyway
}
const ok = await this.ctx.handler.resolveApproval(parsed.approvalId, parsed.action);
if (!ok) {
try {
await interaction.followUp({
content: `Failed to submit approval decision for **${decisionLabel}**. The request may have expired or already been resolved.`,
ephemeral: true,
});
} catch {
// Interaction may have expired
}
}
// On success, the handleApprovalResolved event will update the message with the final result
}
}
export function createExecApprovalButton(ctx: ExecApprovalButtonContext): Button {
return new ExecApprovalButton(ctx);
}

View File

@@ -0,0 +1,45 @@
import type { Guild, User } from "@buape/carbon";
export 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}`;
}
export function formatDiscordReactionEmoji(emoji: { id?: string | null; name?: string | null }) {
if (emoji.id && emoji.name) {
// Custom guild emoji in Discord-renderable form.
return `<:${emoji.name}:${emoji.id}>`;
}
if (emoji.id) {
// Keep id visible even when name is missing (instead of opaque "emoji").
return `emoji:${emoji.id}`;
}
return emoji.name ?? "emoji";
}
export function formatDiscordUserTag(user: User) {
const discriminator = (user.discriminator ?? "").trim();
if (discriminator && discriminator !== "0") {
return `${user.username}#${discriminator}`;
}
return user.username ?? user.id;
}
export function resolveTimestampMs(timestamp?: string | null) {
if (!timestamp) {
return undefined;
}
const parsed = Date.parse(timestamp);
return Number.isNaN(parsed) ? undefined : parsed;
}

View File

@@ -0,0 +1,36 @@
import type { Client } from "@buape/carbon";
import { getDiscordGatewayEmitter } from "../monitor.gateway.js";
export type EarlyGatewayErrorGuard = {
pendingErrors: unknown[];
release: () => void;
};
export function attachEarlyGatewayErrorGuard(client: Client): EarlyGatewayErrorGuard {
const pendingErrors: unknown[] = [];
const gateway = client.getPlugin("gateway");
const emitter = getDiscordGatewayEmitter(gateway);
if (!emitter) {
return {
pendingErrors,
release: () => {},
};
}
let released = false;
const onGatewayError = (err: unknown) => {
pendingErrors.push(err);
};
emitter.on("error", onGatewayError);
return {
pendingErrors,
release: () => {
if (released) {
return;
}
released = true;
emitter.removeListener("error", onGatewayError);
},
};
}

View File

@@ -0,0 +1,212 @@
import { GatewayIntents, GatewayPlugin } from "@buape/carbon/gateway";
import type { APIGatewayBotInfo } from "discord-api-types/v10";
import { HttpsProxyAgent } from "https-proxy-agent";
import { ProxyAgent, fetch as undiciFetch } from "undici";
import WebSocket from "ws";
import type { DiscordAccountConfig } from "../../../../src/config/types.js";
import { danger } from "../../../../src/globals.js";
import type { RuntimeEnv } from "../../../../src/runtime.js";
const DISCORD_GATEWAY_BOT_URL = "https://discord.com/api/v10/gateway/bot";
const DEFAULT_DISCORD_GATEWAY_URL = "wss://gateway.discord.gg/";
type DiscordGatewayMetadataResponse = Pick<Response, "ok" | "status" | "text">;
type DiscordGatewayFetchInit = Record<string, unknown> & {
headers?: Record<string, string>;
};
type DiscordGatewayFetch = (
input: string,
init?: DiscordGatewayFetchInit,
) => Promise<DiscordGatewayMetadataResponse>;
export function resolveDiscordGatewayIntents(
intentsConfig?: import("../../../../src/config/types.discord.js").DiscordIntentsConfig,
): number {
let intents =
GatewayIntents.Guilds |
GatewayIntents.GuildMessages |
GatewayIntents.MessageContent |
GatewayIntents.DirectMessages |
GatewayIntents.GuildMessageReactions |
GatewayIntents.DirectMessageReactions |
GatewayIntents.GuildVoiceStates;
if (intentsConfig?.presence) {
intents |= GatewayIntents.GuildPresences;
}
if (intentsConfig?.guildMembers) {
intents |= GatewayIntents.GuildMembers;
}
return intents;
}
function summarizeGatewayResponseBody(body: string): string {
const normalized = body.trim().replace(/\s+/g, " ");
if (!normalized) {
return "<empty>";
}
return normalized.slice(0, 240);
}
function isTransientDiscordGatewayResponse(status: number, body: string): boolean {
if (status >= 500) {
return true;
}
const normalized = body.toLowerCase();
return (
normalized.includes("upstream connect error") ||
normalized.includes("disconnect/reset before headers") ||
normalized.includes("reset reason:")
);
}
function createGatewayMetadataError(params: {
detail: string;
transient: boolean;
cause?: unknown;
}): Error {
if (params.transient) {
return new Error("Failed to get gateway information from Discord: fetch failed", {
cause: params.cause ?? new Error(params.detail),
});
}
return new Error(`Failed to get gateway information from Discord: ${params.detail}`, {
cause: params.cause,
});
}
async function fetchDiscordGatewayInfo(params: {
token: string;
fetchImpl: DiscordGatewayFetch;
fetchInit?: DiscordGatewayFetchInit;
}): Promise<APIGatewayBotInfo> {
let response: DiscordGatewayMetadataResponse;
try {
response = await params.fetchImpl(DISCORD_GATEWAY_BOT_URL, {
...params.fetchInit,
headers: {
...params.fetchInit?.headers,
Authorization: `Bot ${params.token}`,
},
});
} catch (error) {
throw createGatewayMetadataError({
detail: error instanceof Error ? error.message : String(error),
transient: true,
cause: error,
});
}
let body: string;
try {
body = await response.text();
} catch (error) {
throw createGatewayMetadataError({
detail: error instanceof Error ? error.message : String(error),
transient: true,
cause: error,
});
}
const summary = summarizeGatewayResponseBody(body);
const transient = isTransientDiscordGatewayResponse(response.status, body);
if (!response.ok) {
throw createGatewayMetadataError({
detail: `Discord API /gateway/bot failed (${response.status}): ${summary}`,
transient,
});
}
try {
const parsed = JSON.parse(body) as Partial<APIGatewayBotInfo>;
return {
...parsed,
url:
typeof parsed.url === "string" && parsed.url.trim()
? parsed.url
: DEFAULT_DISCORD_GATEWAY_URL,
} as APIGatewayBotInfo;
} catch (error) {
throw createGatewayMetadataError({
detail: `Discord API /gateway/bot returned invalid JSON: ${summary}`,
transient,
cause: error,
});
}
}
function createGatewayPlugin(params: {
options: {
reconnect: { maxAttempts: number };
intents: number;
autoInteractions: boolean;
};
fetchImpl: DiscordGatewayFetch;
fetchInit?: DiscordGatewayFetchInit;
wsAgent?: HttpsProxyAgent<string>;
}): GatewayPlugin {
class SafeGatewayPlugin extends GatewayPlugin {
constructor() {
super(params.options);
}
override async registerClient(client: Parameters<GatewayPlugin["registerClient"]>[0]) {
if (!this.gatewayInfo) {
this.gatewayInfo = await fetchDiscordGatewayInfo({
token: client.options.token,
fetchImpl: params.fetchImpl,
fetchInit: params.fetchInit,
});
}
return super.registerClient(client);
}
override createWebSocket(url: string) {
if (!params.wsAgent) {
return super.createWebSocket(url);
}
return new WebSocket(url, { agent: params.wsAgent });
}
}
return new SafeGatewayPlugin();
}
export function createDiscordGatewayPlugin(params: {
discordConfig: DiscordAccountConfig;
runtime: RuntimeEnv;
}): GatewayPlugin {
const intents = resolveDiscordGatewayIntents(params.discordConfig?.intents);
const proxy = params.discordConfig?.proxy?.trim();
const options = {
reconnect: { maxAttempts: 50 },
intents,
autoInteractions: true,
};
if (!proxy) {
return createGatewayPlugin({
options,
fetchImpl: (input, init) => fetch(input, init as RequestInit),
});
}
try {
const wsAgent = new HttpsProxyAgent<string>(proxy);
const fetchAgent = new ProxyAgent(proxy);
params.runtime.log?.("discord: gateway proxy enabled");
return createGatewayPlugin({
options,
fetchImpl: (input, init) => undiciFetch(input, init),
fetchInit: { dispatcher: fetchAgent },
wsAgent,
});
} catch (err) {
params.runtime.error?.(danger(`discord: invalid gateway proxy: ${String(err)}`));
return createGatewayPlugin({
options,
fetchImpl: (input, init) => fetch(input, init as RequestInit),
});
}
}

View File

@@ -0,0 +1,37 @@
import type { GatewayPlugin } from "@buape/carbon/gateway";
/**
* Module-level registry of active Discord GatewayPlugin instances.
* Bridges the gap between agent tool handlers (which only have REST access)
* and the gateway WebSocket (needed for operations like updatePresence).
* Follows the same pattern as presence-cache.ts.
*/
const gatewayRegistry = new Map<string, GatewayPlugin>();
// Sentinel key for the default (unnamed) account. Uses a prefix that cannot
// collide with user-configured account IDs.
const DEFAULT_ACCOUNT_KEY = "\0__default__";
function resolveAccountKey(accountId?: string): string {
return accountId ?? DEFAULT_ACCOUNT_KEY;
}
/** Register a GatewayPlugin instance for an account. */
export function registerGateway(accountId: string | undefined, gateway: GatewayPlugin): void {
gatewayRegistry.set(resolveAccountKey(accountId), gateway);
}
/** Unregister a GatewayPlugin instance for an account. */
export function unregisterGateway(accountId?: string): void {
gatewayRegistry.delete(resolveAccountKey(accountId));
}
/** Get the GatewayPlugin for an account. Returns undefined if not registered. */
export function getGateway(accountId?: string): GatewayPlugin | undefined {
return gatewayRegistry.get(resolveAccountKey(accountId));
}
/** Clear all registered gateways (for testing). */
export function clearGateways(): void {
gatewayRegistry.clear();
}

View File

@@ -0,0 +1,59 @@
import { buildUntrustedChannelMetadata } from "../../../../src/security/channel-metadata.js";
import {
resolveDiscordOwnerAllowFrom,
type DiscordChannelConfigResolved,
type DiscordGuildEntryResolved,
} from "./allow-list.js";
export function buildDiscordGroupSystemPrompt(
channelConfig?: DiscordChannelConfigResolved | null,
): string | undefined {
const systemPromptParts = [channelConfig?.systemPrompt?.trim() || null].filter(
(entry): entry is string => Boolean(entry),
);
return systemPromptParts.length > 0 ? systemPromptParts.join("\n\n") : undefined;
}
export function buildDiscordUntrustedContext(params: {
isGuild: boolean;
channelTopic?: string;
}): string[] | undefined {
if (!params.isGuild) {
return undefined;
}
const untrustedChannelMetadata = buildUntrustedChannelMetadata({
source: "discord",
label: "Discord channel topic",
entries: [params.channelTopic],
});
return untrustedChannelMetadata ? [untrustedChannelMetadata] : undefined;
}
export function buildDiscordInboundAccessContext(params: {
channelConfig?: DiscordChannelConfigResolved | null;
guildInfo?: DiscordGuildEntryResolved | null;
sender: {
id: string;
name?: string;
tag?: string;
};
allowNameMatching?: boolean;
isGuild: boolean;
channelTopic?: string;
}) {
return {
groupSystemPrompt: params.isGuild
? buildDiscordGroupSystemPrompt(params.channelConfig)
: undefined,
untrustedContext: buildDiscordUntrustedContext({
isGuild: params.isGuild,
channelTopic: params.channelTopic,
}),
ownerAllowFrom: resolveDiscordOwnerAllowFrom({
channelConfig: params.channelConfig,
guildInfo: params.guildInfo,
sender: params.sender,
allowNameMatching: params.allowNameMatching,
}),
};
}

View File

@@ -0,0 +1,111 @@
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.types.js";
type DiscordInboundJobRuntimeField =
| "runtime"
| "abortSignal"
| "guildHistories"
| "client"
| "threadBindings"
| "discordRestFetch";
export type DiscordInboundJobRuntime = Pick<
DiscordMessagePreflightContext,
DiscordInboundJobRuntimeField
>;
export type DiscordInboundJobPayload = Omit<
DiscordMessagePreflightContext,
DiscordInboundJobRuntimeField
>;
export type DiscordInboundJob = {
queueKey: string;
payload: DiscordInboundJobPayload;
runtime: DiscordInboundJobRuntime;
};
export function resolveDiscordInboundJobQueueKey(ctx: DiscordMessagePreflightContext): string {
const sessionKey = ctx.route.sessionKey?.trim();
if (sessionKey) {
return sessionKey;
}
const baseSessionKey = ctx.baseSessionKey?.trim();
if (baseSessionKey) {
return baseSessionKey;
}
return ctx.messageChannelId;
}
export function buildDiscordInboundJob(ctx: DiscordMessagePreflightContext): DiscordInboundJob {
const {
runtime,
abortSignal,
guildHistories,
client,
threadBindings,
discordRestFetch,
message,
data,
threadChannel,
...payload
} = ctx;
const sanitizedMessage = sanitizeDiscordInboundMessage(message);
return {
queueKey: resolveDiscordInboundJobQueueKey(ctx),
payload: {
...payload,
message: sanitizedMessage,
data: {
...data,
message: sanitizedMessage,
},
threadChannel: normalizeDiscordThreadChannel(threadChannel),
},
runtime: {
runtime,
abortSignal,
guildHistories,
client,
threadBindings,
discordRestFetch,
},
};
}
export function materializeDiscordInboundJob(
job: DiscordInboundJob,
abortSignal?: AbortSignal,
): DiscordMessagePreflightContext {
return {
...job.payload,
...job.runtime,
abortSignal: abortSignal ?? job.runtime.abortSignal,
};
}
function sanitizeDiscordInboundMessage<T extends object>(message: T): T {
const descriptors = Object.getOwnPropertyDescriptors(message);
delete descriptors.channel;
return Object.create(Object.getPrototypeOf(message), descriptors) as T;
}
function normalizeDiscordThreadChannel(
threadChannel: DiscordMessagePreflightContext["threadChannel"],
): DiscordMessagePreflightContext["threadChannel"] {
if (!threadChannel) {
return null;
}
return {
id: threadChannel.id,
name: threadChannel.name,
parentId: threadChannel.parentId,
parent: threadChannel.parent
? {
id: threadChannel.parent.id,
name: threadChannel.parent.name,
}
: undefined,
ownerId: threadChannel.ownerId,
};
}

View File

@@ -0,0 +1,105 @@
import { createRunStateMachine } from "../../../../src/channels/run-state-machine.js";
import { danger } from "../../../../src/globals.js";
import { formatDurationSeconds } from "../../../../src/infra/format-time/format-duration.ts";
import { KeyedAsyncQueue } from "../../../../src/plugin-sdk/keyed-async-queue.js";
import { materializeDiscordInboundJob, type DiscordInboundJob } from "./inbound-job.js";
import type { RuntimeEnv } from "./message-handler.preflight.types.js";
import { processDiscordMessage } from "./message-handler.process.js";
import type { DiscordMonitorStatusSink } from "./status.js";
import { normalizeDiscordInboundWorkerTimeoutMs, runDiscordTaskWithTimeout } from "./timeouts.js";
type DiscordInboundWorkerParams = {
runtime: RuntimeEnv;
setStatus?: DiscordMonitorStatusSink;
abortSignal?: AbortSignal;
runTimeoutMs?: number;
};
export type DiscordInboundWorker = {
enqueue: (job: DiscordInboundJob) => void;
deactivate: () => void;
};
function formatDiscordRunContextSuffix(job: DiscordInboundJob): string {
const channelId = job.payload.messageChannelId?.trim();
const messageId = job.payload.data?.message?.id?.trim();
const details = [
channelId ? `channelId=${channelId}` : null,
messageId ? `messageId=${messageId}` : null,
].filter((entry): entry is string => Boolean(entry));
if (details.length === 0) {
return "";
}
return ` (${details.join(", ")})`;
}
async function processDiscordInboundJob(params: {
job: DiscordInboundJob;
runtime: RuntimeEnv;
lifecycleSignal?: AbortSignal;
runTimeoutMs?: number;
}) {
const timeoutMs = normalizeDiscordInboundWorkerTimeoutMs(params.runTimeoutMs);
const contextSuffix = formatDiscordRunContextSuffix(params.job);
await runDiscordTaskWithTimeout({
run: async (abortSignal) => {
await processDiscordMessage(materializeDiscordInboundJob(params.job, abortSignal));
},
timeoutMs,
abortSignals: [params.job.runtime.abortSignal, params.lifecycleSignal],
onTimeout: (resolvedTimeoutMs) => {
params.runtime.error?.(
danger(
`discord inbound worker timed out after ${formatDurationSeconds(resolvedTimeoutMs, {
decimals: 1,
unit: "seconds",
})}${contextSuffix}`,
),
);
},
onErrorAfterTimeout: (error) => {
params.runtime.error?.(
danger(`discord inbound worker failed after timeout: ${String(error)}${contextSuffix}`),
);
},
});
}
export function createDiscordInboundWorker(
params: DiscordInboundWorkerParams,
): DiscordInboundWorker {
const runQueue = new KeyedAsyncQueue();
const runState = createRunStateMachine({
setStatus: params.setStatus,
abortSignal: params.abortSignal,
});
return {
enqueue(job) {
void runQueue
.enqueue(job.queueKey, async () => {
if (!runState.isActive()) {
return;
}
runState.onRunStart();
try {
if (!runState.isActive()) {
return;
}
await processDiscordInboundJob({
job,
runtime: params.runtime,
lifecycleSignal: params.abortSignal,
runTimeoutMs: params.runTimeoutMs,
});
} finally {
runState.onRunEnd();
}
})
.catch((error) => {
params.runtime.error?.(danger(`discord inbound worker failed: ${String(error)}`));
});
},
deactivate: runState.deactivate,
};
}

View File

@@ -0,0 +1,774 @@
import {
ChannelType,
type Client,
MessageCreateListener,
MessageReactionAddListener,
MessageReactionRemoveListener,
PresenceUpdateListener,
ThreadUpdateListener,
type User,
} from "@buape/carbon";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import { danger, logVerbose } from "../../../../src/globals.js";
import { formatDurationSeconds } from "../../../../src/infra/format-time/format-duration.ts";
import { enqueueSystemEvent } from "../../../../src/infra/system-events.js";
import { createSubsystemLogger } from "../../../../src/logging/subsystem.js";
import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js";
import {
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
} from "../../../../src/security/dm-policy-shared.js";
import {
isDiscordGroupAllowedByPolicy,
normalizeDiscordAllowList,
normalizeDiscordSlug,
resolveDiscordAllowListMatch,
resolveDiscordChannelConfigWithFallback,
resolveDiscordMemberAccessState,
resolveGroupDmAllow,
resolveDiscordGuildEntry,
shouldEmitDiscordReactionNotification,
} from "./allow-list.js";
import { formatDiscordReactionEmoji, formatDiscordUserTag } from "./format.js";
import { resolveDiscordChannelInfo } from "./message-utils.js";
import { setPresence } from "./presence-cache.js";
import { isThreadArchived } from "./thread-bindings.discord-api.js";
import { closeDiscordThreadSessions } from "./thread-session-close.js";
import { normalizeDiscordListenerTimeoutMs, runDiscordTaskWithTimeout } from "./timeouts.js";
type LoadedConfig = ReturnType<typeof import("../../../../src/config/config.js").loadConfig>;
type RuntimeEnv = import("../../../../src/runtime.js").RuntimeEnv;
type Logger = ReturnType<
typeof import("../../../../src/logging/subsystem.js").createSubsystemLogger
>;
export type DiscordMessageEvent = Parameters<MessageCreateListener["handle"]>[0];
export type DiscordMessageHandler = (
data: DiscordMessageEvent,
client: Client,
options?: { abortSignal?: AbortSignal },
) => Promise<void>;
type DiscordReactionEvent = Parameters<MessageReactionAddListener["handle"]>[0];
type DiscordReactionListenerParams = {
cfg: LoadedConfig;
runtime: RuntimeEnv;
logger: Logger;
onEvent?: () => void;
} & DiscordReactionRoutingParams;
type DiscordReactionRoutingParams = {
accountId: string;
botUserId?: string;
dmEnabled: boolean;
groupDmEnabled: boolean;
groupDmChannels: string[];
dmPolicy: "open" | "pairing" | "allowlist" | "disabled";
allowFrom: string[];
groupPolicy: "open" | "allowlist" | "disabled";
allowNameMatching: boolean;
guildEntries?: Record<string, import("./allow-list.js").DiscordGuildEntryResolved>;
};
const DISCORD_SLOW_LISTENER_THRESHOLD_MS = 30_000;
const discordEventQueueLog = createSubsystemLogger("discord/event-queue");
function formatListenerContextValue(value: unknown): string | null {
if (value === undefined || value === null) {
return null;
}
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
return String(value);
}
return null;
}
function formatListenerContextSuffix(context?: Record<string, unknown>): string {
if (!context) {
return "";
}
const entries = Object.entries(context).flatMap(([key, value]) => {
const formatted = formatListenerContextValue(value);
return formatted ? [`${key}=${formatted}`] : [];
});
if (entries.length === 0) {
return "";
}
return ` (${entries.join(" ")})`;
}
function logSlowDiscordListener(params: {
logger: Logger | undefined;
listener: string;
event: string;
durationMs: number;
context?: Record<string, unknown>;
}) {
if (params.durationMs < DISCORD_SLOW_LISTENER_THRESHOLD_MS) {
return;
}
const duration = formatDurationSeconds(params.durationMs, {
decimals: 1,
unit: "seconds",
});
const message = `Slow listener detected: ${params.listener} took ${duration} for event ${params.event}`;
const logger = params.logger ?? discordEventQueueLog;
logger.warn("Slow listener detected", {
listener: params.listener,
event: params.event,
durationMs: params.durationMs,
duration,
...params.context,
consoleMessage: `${message}${formatListenerContextSuffix(params.context)}`,
});
}
async function runDiscordListenerWithSlowLog(params: {
logger: Logger | undefined;
listener: string;
event: string;
run: (abortSignal: AbortSignal | undefined) => Promise<void>;
timeoutMs?: number;
context?: Record<string, unknown>;
onError?: (err: unknown) => void;
}) {
const startedAt = Date.now();
const timeoutMs = normalizeDiscordListenerTimeoutMs(params.timeoutMs);
const logger = params.logger ?? discordEventQueueLog;
let timedOut = false;
try {
timedOut = await runDiscordTaskWithTimeout({
run: params.run,
timeoutMs,
onTimeout: (resolvedTimeoutMs) => {
logger.error(
danger(
`discord handler timed out after ${formatDurationSeconds(resolvedTimeoutMs, {
decimals: 1,
unit: "seconds",
})}${formatListenerContextSuffix(params.context)}`,
),
);
},
onAbortAfterTimeout: () => {
logger.warn(
`discord handler canceled after timeout${formatListenerContextSuffix(params.context)}`,
);
},
onErrorAfterTimeout: (err) => {
logger.error(
danger(
`discord handler failed after timeout: ${String(err)}${formatListenerContextSuffix(params.context)}`,
),
);
},
});
if (timedOut) {
return;
}
} catch (err) {
if (params.onError) {
params.onError(err);
return;
}
throw err;
} finally {
if (!timedOut) {
logSlowDiscordListener({
logger: params.logger,
listener: params.listener,
event: params.event,
durationMs: Date.now() - startedAt,
context: params.context,
});
}
}
}
export function registerDiscordListener(listeners: Array<object>, listener: object) {
if (listeners.some((existing) => existing.constructor === listener.constructor)) {
return false;
}
listeners.push(listener);
return true;
}
export class DiscordMessageListener extends MessageCreateListener {
constructor(
private handler: DiscordMessageHandler,
private logger?: Logger,
private onEvent?: () => void,
_options?: { timeoutMs?: number },
) {
super();
}
async handle(data: DiscordMessageEvent, client: Client) {
this.onEvent?.();
// Fire-and-forget: hand off to the handler without blocking the
// Carbon listener. Per-session ordering and run timeouts are owned
// by the inbound worker queue, so the listener no longer serializes
// or applies its own timeout.
void Promise.resolve()
.then(() => this.handler(data, client))
.catch((err) => {
const logger = this.logger ?? discordEventQueueLog;
logger.error(danger(`discord handler failed: ${String(err)}`));
});
}
}
export class DiscordReactionListener extends MessageReactionAddListener {
constructor(private params: DiscordReactionListenerParams) {
super();
}
async handle(data: DiscordReactionEvent, client: Client) {
this.params.onEvent?.();
await runDiscordReactionHandler({
data,
client,
action: "added",
handlerParams: this.params,
listener: this.constructor.name,
event: this.type,
});
}
}
export class DiscordReactionRemoveListener extends MessageReactionRemoveListener {
constructor(private params: DiscordReactionListenerParams) {
super();
}
async handle(data: DiscordReactionEvent, client: Client) {
this.params.onEvent?.();
await runDiscordReactionHandler({
data,
client,
action: "removed",
handlerParams: this.params,
listener: this.constructor.name,
event: this.type,
});
}
}
async function runDiscordReactionHandler(params: {
data: DiscordReactionEvent;
client: Client;
action: "added" | "removed";
handlerParams: DiscordReactionListenerParams;
listener: string;
event: string;
}): Promise<void> {
await runDiscordListenerWithSlowLog({
logger: params.handlerParams.logger,
listener: params.listener,
event: params.event,
run: async () =>
handleDiscordReactionEvent({
data: params.data,
client: params.client,
action: params.action,
cfg: params.handlerParams.cfg,
accountId: params.handlerParams.accountId,
botUserId: params.handlerParams.botUserId,
dmEnabled: params.handlerParams.dmEnabled,
groupDmEnabled: params.handlerParams.groupDmEnabled,
groupDmChannels: params.handlerParams.groupDmChannels,
dmPolicy: params.handlerParams.dmPolicy,
allowFrom: params.handlerParams.allowFrom,
groupPolicy: params.handlerParams.groupPolicy,
allowNameMatching: params.handlerParams.allowNameMatching,
guildEntries: params.handlerParams.guildEntries,
logger: params.handlerParams.logger,
}),
});
}
type DiscordReactionIngressAuthorizationParams = {
accountId: string;
user: User;
memberRoleIds: string[];
isDirectMessage: boolean;
isGroupDm: boolean;
isGuildMessage: boolean;
channelId: string;
channelName?: string;
channelSlug: string;
dmEnabled: boolean;
groupDmEnabled: boolean;
groupDmChannels: string[];
dmPolicy: "open" | "pairing" | "allowlist" | "disabled";
allowFrom: string[];
groupPolicy: "open" | "allowlist" | "disabled";
allowNameMatching: boolean;
guildInfo: import("./allow-list.js").DiscordGuildEntryResolved | null;
channelConfig?: import("./allow-list.js").DiscordChannelConfigResolved | null;
};
async function authorizeDiscordReactionIngress(
params: DiscordReactionIngressAuthorizationParams,
): Promise<{ allowed: true } | { allowed: false; reason: string }> {
if (params.isDirectMessage && !params.dmEnabled) {
return { allowed: false, reason: "dm-disabled" };
}
if (params.isGroupDm && !params.groupDmEnabled) {
return { allowed: false, reason: "group-dm-disabled" };
}
if (params.isDirectMessage) {
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: "discord",
accountId: params.accountId,
dmPolicy: params.dmPolicy,
});
const access = resolveDmGroupAccessWithLists({
isGroup: false,
dmPolicy: params.dmPolicy,
groupPolicy: params.groupPolicy,
allowFrom: params.allowFrom,
groupAllowFrom: [],
storeAllowFrom,
isSenderAllowed: (allowEntries) => {
const allowList = normalizeDiscordAllowList(allowEntries, ["discord:", "user:", "pk:"]);
const allowMatch = allowList
? resolveDiscordAllowListMatch({
allowList,
candidate: {
id: params.user.id,
name: params.user.username,
tag: formatDiscordUserTag(params.user),
},
allowNameMatching: params.allowNameMatching,
})
: { allowed: false };
return allowMatch.allowed;
},
});
if (access.decision !== "allow") {
return { allowed: false, reason: access.reason };
}
}
if (
params.isGroupDm &&
!resolveGroupDmAllow({
channels: params.groupDmChannels,
channelId: params.channelId,
channelName: params.channelName,
channelSlug: params.channelSlug,
})
) {
return { allowed: false, reason: "group-dm-not-allowlisted" };
}
if (!params.isGuildMessage) {
return { allowed: true };
}
const channelAllowlistConfigured =
Boolean(params.guildInfo?.channels) && Object.keys(params.guildInfo?.channels ?? {}).length > 0;
const channelAllowed = params.channelConfig?.allowed !== false;
if (
!isDiscordGroupAllowedByPolicy({
groupPolicy: params.groupPolicy,
guildAllowlisted: Boolean(params.guildInfo),
channelAllowlistConfigured,
channelAllowed,
})
) {
return { allowed: false, reason: "guild-policy" };
}
if (params.channelConfig?.allowed === false) {
return { allowed: false, reason: "guild-channel-denied" };
}
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
channelConfig: params.channelConfig,
guildInfo: params.guildInfo,
memberRoleIds: params.memberRoleIds,
sender: {
id: params.user.id,
name: params.user.username,
tag: formatDiscordUserTag(params.user),
},
allowNameMatching: params.allowNameMatching,
});
if (hasAccessRestrictions && !memberAllowed) {
return { allowed: false, reason: "guild-member-denied" };
}
return { allowed: true };
}
async function handleDiscordReactionEvent(
params: {
data: DiscordReactionEvent;
client: Client;
action: "added" | "removed";
cfg: LoadedConfig;
logger: Logger;
} & DiscordReactionRoutingParams,
) {
try {
const { data, client, action, botUserId, guildEntries } = params;
if (!("user" in data)) {
return;
}
const user = data.user;
if (!user || user.bot) {
return;
}
// Early exit: skip bot's own reactions before expensive network calls
if (botUserId && user.id === botUserId) {
return;
}
const isGuildMessage = Boolean(data.guild_id);
const guildInfo = isGuildMessage
? resolveDiscordGuildEntry({
guild: data.guild ?? undefined,
guildId: data.guild_id ?? undefined,
guildEntries,
})
: null;
if (isGuildMessage && 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 channelType = "type" in channel ? channel.type : undefined;
const isDirectMessage = channelType === ChannelType.DM;
const isGroupDm = channelType === ChannelType.GroupDM;
const isThreadChannel =
channelType === ChannelType.PublicThread ||
channelType === ChannelType.PrivateThread ||
channelType === ChannelType.AnnouncementThread;
const memberRoleIds = Array.isArray(data.rawMember?.roles)
? data.rawMember.roles.map((roleId: string) => String(roleId))
: [];
const reactionIngressBase: Omit<DiscordReactionIngressAuthorizationParams, "channelConfig"> = {
accountId: params.accountId,
user,
memberRoleIds,
isDirectMessage,
isGroupDm,
isGuildMessage,
channelId: data.channel_id,
channelName,
channelSlug,
dmEnabled: params.dmEnabled,
groupDmEnabled: params.groupDmEnabled,
groupDmChannels: params.groupDmChannels,
dmPolicy: params.dmPolicy,
allowFrom: params.allowFrom,
groupPolicy: params.groupPolicy,
allowNameMatching: params.allowNameMatching,
guildInfo,
};
// Guild reactions need resolved channel/thread config before member access
// can mirror the normal message preflight path.
if (!isGuildMessage) {
const ingressAccess = await authorizeDiscordReactionIngress(reactionIngressBase);
if (!ingressAccess.allowed) {
logVerbose(`discord reaction blocked sender=${user.id} (reason=${ingressAccess.reason})`);
return;
}
}
let parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined;
let parentName: string | undefined;
let parentSlug = "";
let reactionBase: { baseText: string; contextKey: string } | null = null;
const resolveReactionBase = () => {
if (reactionBase) {
return reactionBase;
}
const emojiLabel = formatDiscordReactionEmoji(data.emoji);
const actorLabel = formatDiscordUserTag(user);
const guildSlug =
guildInfo?.slug ||
(data.guild?.name
? normalizeDiscordSlug(data.guild.name)
: (data.guild_id ?? (isGroupDm ? "group-dm" : "dm")));
const channelLabel = channelSlug
? `#${channelSlug}`
: channelName
? `#${normalizeDiscordSlug(channelName)}`
: `#${data.channel_id}`;
const baseText = `Discord reaction ${action}: ${emojiLabel} by ${actorLabel} on ${guildSlug} ${channelLabel} msg ${data.message_id}`;
const contextKey = `discord:reaction:${action}:${data.message_id}:${user.id}:${emojiLabel}`;
reactionBase = { baseText, contextKey };
return reactionBase;
};
const emitReaction = (text: string, parentPeerId?: string) => {
const { contextKey } = resolveReactionBase();
const route = resolveAgentRoute({
cfg: params.cfg,
channel: "discord",
accountId: params.accountId,
guildId: data.guild_id ?? undefined,
memberRoleIds,
peer: {
kind: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
id: isDirectMessage ? user.id : data.channel_id,
},
parentPeer: parentPeerId ? { kind: "channel", id: parentPeerId } : undefined,
});
enqueueSystemEvent(text, {
sessionKey: route.sessionKey,
contextKey,
});
};
const shouldNotifyReaction = (options: {
mode: "off" | "own" | "all" | "allowlist";
messageAuthorId?: string;
channelConfig?: ReturnType<typeof resolveDiscordChannelConfigWithFallback>;
}) =>
shouldEmitDiscordReactionNotification({
mode: options.mode,
botId: botUserId,
messageAuthorId: options.messageAuthorId,
userId: user.id,
userName: user.username,
userTag: formatDiscordUserTag(user),
channelConfig: options.channelConfig,
guildInfo,
memberRoleIds,
allowNameMatching: params.allowNameMatching,
});
const emitReactionWithAuthor = (message: { author?: User } | null) => {
const { baseText } = resolveReactionBase();
const authorLabel = message?.author ? formatDiscordUserTag(message.author) : undefined;
const text = authorLabel ? `${baseText} from ${authorLabel}` : baseText;
emitReaction(text, parentId);
};
const loadThreadParentInfo = async () => {
if (!parentId) {
return;
}
const parentInfo = await resolveDiscordChannelInfo(client, parentId);
parentName = parentInfo?.name;
parentSlug = parentName ? normalizeDiscordSlug(parentName) : "";
};
const resolveThreadChannelConfig = () =>
resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId: data.channel_id,
channelName,
channelSlug,
parentId,
parentName,
parentSlug,
scope: "thread",
});
const authorizeReactionIngressForChannel = async (
channelConfig: ReturnType<typeof resolveDiscordChannelConfigWithFallback>,
) =>
await authorizeDiscordReactionIngress({
...reactionIngressBase,
channelConfig,
});
const resolveThreadChannelAccess = async (channelInfo: { parentId?: string } | null) => {
parentId = channelInfo?.parentId;
await loadThreadParentInfo();
const channelConfig = resolveThreadChannelConfig();
const access = await authorizeReactionIngressForChannel(channelConfig);
return { access, channelConfig };
};
// Parallelize async operations for thread channels
if (isThreadChannel) {
const reactionMode = guildInfo?.reactionNotifications ?? "own";
// Early exit: skip fetching message if notifications are off
if (reactionMode === "off") {
return;
}
const channelInfoPromise = parentId
? Promise.resolve({ parentId })
: resolveDiscordChannelInfo(client, data.channel_id);
// Fast path: for "all" and "allowlist" modes, we don't need to fetch the message
if (reactionMode === "all" || reactionMode === "allowlist") {
const channelInfo = await channelInfoPromise;
const { access: threadAccess, channelConfig: threadChannelConfig } =
await resolveThreadChannelAccess(channelInfo);
if (!threadAccess.allowed) {
return;
}
if (
!shouldNotifyReaction({
mode: reactionMode,
channelConfig: threadChannelConfig,
})
) {
return;
}
const { baseText } = resolveReactionBase();
emitReaction(baseText, parentId);
return;
}
// For "own" mode, we need to fetch the message to check the author
const messagePromise = data.message.fetch().catch(() => null);
const [channelInfo, message] = await Promise.all([channelInfoPromise, messagePromise]);
const { access: threadAccess, channelConfig: threadChannelConfig } =
await resolveThreadChannelAccess(channelInfo);
if (!threadAccess.allowed) {
return;
}
const messageAuthorId = message?.author?.id ?? undefined;
if (
!shouldNotifyReaction({
mode: reactionMode,
messageAuthorId,
channelConfig: threadChannelConfig,
})
) {
return;
}
emitReactionWithAuthor(message);
return;
}
// Non-thread channel path
const channelConfig = resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId: data.channel_id,
channelName,
channelSlug,
parentId,
parentName,
parentSlug,
scope: "channel",
});
if (isGuildMessage) {
const channelAccess = await authorizeReactionIngressForChannel(channelConfig);
if (!channelAccess.allowed) {
return;
}
}
const reactionMode = guildInfo?.reactionNotifications ?? "own";
// Early exit: skip fetching message if notifications are off
if (reactionMode === "off") {
return;
}
// Fast path: for "all" and "allowlist" modes, we don't need to fetch the message
if (reactionMode === "all" || reactionMode === "allowlist") {
if (!shouldNotifyReaction({ mode: reactionMode, channelConfig })) {
return;
}
const { baseText } = resolveReactionBase();
emitReaction(baseText, parentId);
return;
}
// For "own" mode, we need to fetch the message to check the author
const message = await data.message.fetch().catch(() => null);
const messageAuthorId = message?.author?.id ?? undefined;
if (!shouldNotifyReaction({ mode: reactionMode, messageAuthorId, channelConfig })) {
return;
}
emitReactionWithAuthor(message);
} catch (err) {
params.logger.error(danger(`discord reaction handler failed: ${String(err)}`));
}
}
type PresenceUpdateEvent = Parameters<PresenceUpdateListener["handle"]>[0];
export class DiscordPresenceListener extends PresenceUpdateListener {
private logger?: Logger;
private accountId?: string;
constructor(params: { logger?: Logger; accountId?: string }) {
super();
this.logger = params.logger;
this.accountId = params.accountId;
}
async handle(data: PresenceUpdateEvent) {
try {
const userId =
"user" in data && data.user && typeof data.user === "object" && "id" in data.user
? String(data.user.id)
: undefined;
if (!userId) {
return;
}
setPresence(
this.accountId,
userId,
data as import("discord-api-types/v10").GatewayPresenceUpdate,
);
} catch (err) {
const logger = this.logger ?? discordEventQueueLog;
logger.error(danger(`discord presence handler failed: ${String(err)}`));
}
}
}
type ThreadUpdateEvent = Parameters<ThreadUpdateListener["handle"]>[0];
export class DiscordThreadUpdateListener extends ThreadUpdateListener {
constructor(
private cfg: OpenClawConfig,
private accountId: string,
private logger?: Logger,
) {
super();
}
async handle(data: ThreadUpdateEvent) {
await runDiscordListenerWithSlowLog({
logger: this.logger,
listener: this.constructor.name,
event: this.type,
run: async () => {
// Discord only fires THREAD_UPDATE when a field actually changes, so
// `thread_metadata.archived === true` in this payload means the thread
// just transitioned to the archived state.
if (!isThreadArchived(data)) {
return;
}
const threadId = "id" in data && typeof data.id === "string" ? data.id : undefined;
if (!threadId) {
return;
}
const logger = this.logger ?? discordEventQueueLog;
logger.info("Discord thread archived — resetting session", { threadId });
const count = await closeDiscordThreadSessions({
cfg: this.cfg,
accountId: this.accountId,
threadId,
});
if (count > 0) {
logger.info("Discord thread sessions reset after archival", { threadId, count });
}
},
onError: (err) => {
const logger = this.logger ?? discordEventQueueLog;
logger.error(danger(`discord thread-update handler failed: ${String(err)}`));
},
});
}
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { inboundCtxCapture as capture } from "../../../test/helpers/inbound-contract-dispatch-mock.js";
import { expectInboundContextContract } from "../../../test/helpers/inbound-contract.js";
import { inboundCtxCapture as capture } from "../../../../test/helpers/inbound-contract-dispatch-mock.js";
import { expectInboundContextContract } from "../../../../test/helpers/inbound-contract.js";
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
import { processDiscordMessage } from "./message-handler.process.js";
import {

View File

@@ -1,5 +1,5 @@
import { vi } from "vitest";
import type { MockFn } from "../../test-utils/vitest-mock-fn.js";
import type { MockFn } from "../../../../src/test-utils/vitest-mock-fn.js";
export const preflightDiscordMessageMock: MockFn = vi.fn();
export const processDiscordMessageMock: MockFn = vi.fn();

View File

@@ -3,14 +3,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const ensureConfiguredAcpBindingSessionMock = vi.hoisted(() => vi.fn());
const resolveConfiguredAcpBindingRecordMock = vi.hoisted(() => vi.fn());
vi.mock("../../acp/persistent-bindings.js", () => ({
vi.mock("../../../../src/acp/persistent-bindings.js", () => ({
ensureConfiguredAcpBindingSession: (...args: unknown[]) =>
ensureConfiguredAcpBindingSessionMock(...args),
resolveConfiguredAcpBindingRecord: (...args: unknown[]) =>
resolveConfiguredAcpBindingRecordMock(...args),
}));
import { __testing as sessionBindingTesting } from "../../infra/outbound/session-binding-service.js";
import { __testing as sessionBindingTesting } from "../../../../src/infra/outbound/session-binding-service.js";
import { preflightDiscordMessage } from "./message-handler.preflight.js";
import {
createDiscordMessage,
@@ -70,7 +70,9 @@ function createBasePreflightParams(overrides?: Record<string, unknown>) {
cfg: DEFAULT_PREFLIGHT_CFG,
discordConfig: {
allowBots: true,
} as NonNullable<import("../../config/config.js").OpenClawConfig["channels"]>["discord"],
} as NonNullable<
import("../../../../src/config/config.js").OpenClawConfig["channels"]
>["discord"],
data: createGuildEvent({
channelId: CHANNEL_ID,
guildId: GUILD_ID,
@@ -82,7 +84,9 @@ function createBasePreflightParams(overrides?: Record<string, unknown>) {
}),
discordConfig: {
allowBots: true,
} as NonNullable<import("../../config/config.js").OpenClawConfig["channels"]>["discord"],
} as NonNullable<
import("../../../../src/config/config.js").OpenClawConfig["channels"]
>["discord"],
...overrides,
} satisfies Parameters<typeof preflightDiscordMessage>[0];
}

View File

@@ -1,5 +1,5 @@
import { ChannelType } from "@buape/carbon";
import type { OpenClawConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import type { preflightDiscordMessage } from "./message-handler.preflight.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";
@@ -90,7 +90,7 @@ export function createDiscordPreflightArgs(params: {
discordConfig: params.discordConfig,
accountId: "default",
token: "token",
runtime: {} as import("../../runtime.js").RuntimeEnv,
runtime: {} as import("../../../../src/runtime.js").RuntimeEnv,
botUserId: params.botUserId ?? "openclaw-bot",
guildHistories: new Map(),
historyLimit: 0,

View File

@@ -3,13 +3,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const transcribeFirstAudioMock = vi.hoisted(() => vi.fn());
vi.mock("../../media-understanding/audio-preflight.js", () => ({
vi.mock("../../../../src/media-understanding/audio-preflight.js", () => ({
transcribeFirstAudio: (...args: unknown[]) => transcribeFirstAudioMock(...args),
}));
import {
__testing as sessionBindingTesting,
registerSessionBindingAdapter,
} from "../../infra/outbound/session-binding-service.js";
} from "../../../../src/infra/outbound/session-binding-service.js";
import {
preflightDiscordMessage,
resolvePreflightMentionRequirement,
@@ -32,7 +32,7 @@ import {
function createThreadBinding(
overrides?: Partial<
import("../../infra/outbound/session-binding-service.js").SessionBindingRecord
import("../../../../src/infra/outbound/session-binding-service.js").SessionBindingRecord
>,
) {
return {
@@ -54,11 +54,11 @@ function createThreadBinding(
webhookToken: "tok-1",
},
...overrides,
} satisfies import("../../infra/outbound/session-binding-service.js").SessionBindingRecord;
} satisfies import("../../../../src/infra/outbound/session-binding-service.js").SessionBindingRecord;
}
function createPreflightArgs(params: {
cfg: import("../../config/config.js").OpenClawConfig;
cfg: import("../../../../src/config/config.js").OpenClawConfig;
discordConfig: DiscordConfig;
data: DiscordMessageEvent;
client: DiscordClient;
@@ -94,7 +94,7 @@ async function runThreadBoundPreflight(params: {
threadId: string;
parentId: string;
message: import("@buape/carbon").Message;
threadBinding: import("../../infra/outbound/session-binding-service.js").SessionBindingRecord;
threadBinding: import("../../../../src/infra/outbound/session-binding-service.js").SessionBindingRecord;
discordConfig: DiscordConfig;
registerBindingAdapter?: boolean;
}) {
@@ -136,7 +136,7 @@ async function runGuildPreflight(params: {
guildId: string;
message: import("@buape/carbon").Message;
discordConfig: DiscordConfig;
cfg?: import("../../config/config.js").OpenClawConfig;
cfg?: import("../../../../src/config/config.js").OpenClawConfig;
guildEntries?: Parameters<typeof preflightDiscordMessage>[0]["guildEntries"];
includeGuildObject?: boolean;
}) {
@@ -318,7 +318,7 @@ describe("preflightDiscordMessage", () => {
createPreflightArgs({
cfg: {
...DEFAULT_PREFLIGHT_CFG,
} as import("../../config/config.js").OpenClawConfig,
} as import("../../../../src/config/config.js").OpenClawConfig,
discordConfig: {
allowBots: true,
} as DiscordConfig,
@@ -577,7 +577,7 @@ describe("preflightDiscordMessage", () => {
mentionPatterns: ["openclaw"],
},
},
} as import("../../config/config.js").OpenClawConfig,
} as import("../../../../src/config/config.js").OpenClawConfig,
discordConfig: {} as DiscordConfig,
data: createGuildEvent({
channelId,

View File

@@ -0,0 +1,840 @@
import { ChannelType, MessageType, type User } from "@buape/carbon";
import {
ensureConfiguredAcpRouteReady,
resolveConfiguredAcpRoute,
} from "../../../../src/acp/persistent-bindings.route.js";
import { hasControlCommand } from "../../../../src/auto-reply/command-detection.js";
import { shouldHandleTextCommands } from "../../../../src/auto-reply/commands-registry.js";
import {
recordPendingHistoryEntryIfEnabled,
type HistoryEntry,
} from "../../../../src/auto-reply/reply/history.js";
import {
buildMentionRegexes,
matchesMentionWithExplicit,
} from "../../../../src/auto-reply/reply/mentions.js";
import { formatAllowlistMatchMeta } from "../../../../src/channels/allowlist-match.js";
import { resolveControlCommandGate } from "../../../../src/channels/command-gating.js";
import { logInboundDrop } from "../../../../src/channels/logging.js";
import { resolveMentionGatingWithBypass } from "../../../../src/channels/mention-gating.js";
import { loadConfig } from "../../../../src/config/config.js";
import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js";
import { logVerbose, shouldLogVerbose } from "../../../../src/globals.js";
import { recordChannelActivity } from "../../../../src/infra/channel-activity.js";
import {
getSessionBindingService,
type SessionBindingRecord,
} from "../../../../src/infra/outbound/session-binding-service.js";
import { enqueueSystemEvent } from "../../../../src/infra/system-events.js";
import { logDebug } from "../../../../src/logger.js";
import { getChildLogger } from "../../../../src/logging.js";
import { buildPairingReply } from "../../../../src/pairing/pairing-messages.js";
import { DEFAULT_ACCOUNT_ID } from "../../../../src/routing/session-key.js";
import { fetchPluralKitMessageInfo } from "../pluralkit.js";
import { sendMessageDiscord } from "../send.js";
import {
isDiscordGroupAllowedByPolicy,
normalizeDiscordSlug,
resolveDiscordChannelConfigWithFallback,
resolveDiscordGuildEntry,
resolveDiscordMemberAccessState,
resolveDiscordOwnerAccess,
resolveDiscordShouldRequireMention,
resolveGroupDmAllow,
} from "./allow-list.js";
import { resolveDiscordDmCommandAccess } from "./dm-command-auth.js";
import { handleDiscordDmCommandDecision } from "./dm-command-decision.js";
import {
formatDiscordUserTag,
resolveDiscordSystemLocation,
resolveTimestampMs,
} from "./format.js";
import type {
DiscordMessagePreflightContext,
DiscordMessagePreflightParams,
} from "./message-handler.preflight.types.js";
import {
resolveDiscordChannelInfo,
resolveDiscordMessageChannelId,
resolveDiscordMessageText,
} from "./message-utils.js";
import { resolveDiscordPreflightAudioMentionContext } from "./preflight-audio.js";
import {
buildDiscordRoutePeer,
resolveDiscordConversationRoute,
resolveDiscordEffectiveRoute,
} from "./route-resolution.js";
import { resolveDiscordSenderIdentity, resolveDiscordWebhookId } from "./sender-identity.js";
import { resolveDiscordSystemEvent } from "./system-events.js";
import { isRecentlyUnboundThreadWebhookMessage } from "./thread-bindings.js";
import { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } from "./threading.js";
export type {
DiscordMessagePreflightContext,
DiscordMessagePreflightParams,
} from "./message-handler.preflight.types.js";
const DISCORD_BOUND_THREAD_SYSTEM_PREFIXES = ["⚙️", "🤖", "🧰"];
function isPreflightAborted(abortSignal?: AbortSignal): boolean {
return Boolean(abortSignal?.aborted);
}
function isBoundThreadBotSystemMessage(params: {
isBoundThreadSession: boolean;
isBotAuthor: boolean;
text?: string;
}): boolean {
if (!params.isBoundThreadSession || !params.isBotAuthor) {
return false;
}
const text = params.text?.trim();
if (!text) {
return false;
}
return DISCORD_BOUND_THREAD_SYSTEM_PREFIXES.some((prefix) => text.startsWith(prefix));
}
export function resolvePreflightMentionRequirement(params: {
shouldRequireMention: boolean;
isBoundThreadSession: boolean;
}): boolean {
if (!params.shouldRequireMention) {
return false;
}
return !params.isBoundThreadSession;
}
export function shouldIgnoreBoundThreadWebhookMessage(params: {
accountId?: string;
threadId?: string;
webhookId?: string | null;
threadBinding?: SessionBindingRecord;
}): boolean {
const webhookId = params.webhookId?.trim() || "";
if (!webhookId) {
return false;
}
const boundWebhookId =
typeof params.threadBinding?.metadata?.webhookId === "string"
? params.threadBinding.metadata.webhookId.trim()
: "";
if (!boundWebhookId) {
const threadId = params.threadId?.trim() || "";
if (!threadId) {
return false;
}
return isRecentlyUnboundThreadWebhookMessage({
accountId: params.accountId,
threadId,
webhookId,
});
}
return webhookId === boundWebhookId;
}
export async function preflightDiscordMessage(
params: DiscordMessagePreflightParams,
): Promise<DiscordMessagePreflightContext | null> {
if (isPreflightAborted(params.abortSignal)) {
return null;
}
const logger = getChildLogger({ module: "discord-auto-reply" });
const message = params.data.message;
const author = params.data.author;
if (!author) {
return null;
}
const messageChannelId = resolveDiscordMessageChannelId({
message,
eventChannelId: params.data.channel_id,
});
if (!messageChannelId) {
logVerbose(`discord: drop message ${message.id} (missing channel id)`);
return null;
}
const allowBotsSetting = params.discordConfig?.allowBots;
const allowBotsMode =
allowBotsSetting === "mentions" ? "mentions" : allowBotsSetting === true ? "all" : "off";
if (params.botUserId && author.id === params.botUserId) {
// Always ignore own messages to prevent self-reply loops
return null;
}
const pluralkitConfig = params.discordConfig?.pluralkit;
const webhookId = resolveDiscordWebhookId(message);
const shouldCheckPluralKit = Boolean(pluralkitConfig?.enabled) && !webhookId;
let pluralkitInfo: Awaited<ReturnType<typeof fetchPluralKitMessageInfo>> = null;
if (shouldCheckPluralKit) {
try {
pluralkitInfo = await fetchPluralKitMessageInfo({
messageId: message.id,
config: pluralkitConfig,
});
if (isPreflightAborted(params.abortSignal)) {
return null;
}
} catch (err) {
logVerbose(`discord: pluralkit lookup failed for ${message.id}: ${String(err)}`);
}
}
const sender = resolveDiscordSenderIdentity({
author,
member: params.data.member,
pluralkitInfo,
});
if (author.bot) {
if (allowBotsMode === "off" && !sender.isPluralKit) {
logVerbose("discord: drop bot message (allowBots=false)");
return null;
}
}
const isGuildMessage = Boolean(params.data.guild_id);
const channelInfo = await resolveDiscordChannelInfo(params.client, messageChannelId);
if (isPreflightAborted(params.abortSignal)) {
return null;
}
const isDirectMessage = channelInfo?.type === ChannelType.DM;
const isGroupDm = channelInfo?.type === ChannelType.GroupDM;
logDebug(
`[discord-preflight] channelId=${messageChannelId} guild_id=${params.data.guild_id} channelType=${channelInfo?.type} isGuild=${isGuildMessage} isDM=${isDirectMessage} isGroupDm=${isGroupDm}`,
);
if (isGroupDm && !params.groupDmEnabled) {
logVerbose("discord: drop group dm (group dms disabled)");
return null;
}
if (isDirectMessage && !params.dmEnabled) {
logVerbose("discord: drop dm (dms disabled)");
return null;
}
const dmPolicy = params.discordConfig?.dmPolicy ?? params.discordConfig?.dm?.policy ?? "pairing";
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
const resolvedAccountId = params.accountId ?? DEFAULT_ACCOUNT_ID;
const allowNameMatching = isDangerousNameMatchingEnabled(params.discordConfig);
let commandAuthorized = true;
if (isDirectMessage) {
if (dmPolicy === "disabled") {
logVerbose("discord: drop dm (dmPolicy: disabled)");
return null;
}
const dmAccess = await resolveDiscordDmCommandAccess({
accountId: resolvedAccountId,
dmPolicy,
configuredAllowFrom: params.allowFrom ?? [],
sender: {
id: sender.id,
name: sender.name,
tag: sender.tag,
},
allowNameMatching,
useAccessGroups,
});
if (isPreflightAborted(params.abortSignal)) {
return null;
}
commandAuthorized = dmAccess.commandAuthorized;
if (dmAccess.decision !== "allow") {
const allowMatchMeta = formatAllowlistMatchMeta(
dmAccess.allowMatch.allowed ? dmAccess.allowMatch : undefined,
);
await handleDiscordDmCommandDecision({
dmAccess,
accountId: resolvedAccountId,
sender: {
id: author.id,
tag: formatDiscordUserTag(author),
name: author.username ?? undefined,
},
onPairingCreated: async (code) => {
logVerbose(
`discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)} (${allowMatchMeta})`,
);
try {
await sendMessageDiscord(
`user:${author.id}`,
buildPairingReply({
channel: "discord",
idLine: `Your Discord user id: ${author.id}`,
code,
}),
{
token: params.token,
rest: params.client.rest,
accountId: params.accountId,
},
);
} catch (err) {
logVerbose(`discord pairing reply failed for ${author.id}: ${String(err)}`);
}
},
onUnauthorized: async () => {
logVerbose(
`Blocked unauthorized discord sender ${sender.id} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
);
},
});
return null;
}
}
const botId = params.botUserId;
const baseText = resolveDiscordMessageText(message, {
includeForwarded: false,
});
const messageText = resolveDiscordMessageText(message, {
includeForwarded: true,
});
// Intercept text-only slash commands (e.g. user typing "/reset" instead of using Discord's slash command picker)
// These should not be forwarded to the agent; proper slash command interactions are handled elsewhere
if (!isDirectMessage && baseText && hasControlCommand(baseText, params.cfg)) {
logVerbose(`discord: drop text-based slash command ${message.id} (intercepted at gateway)`);
return null;
}
recordChannelActivity({
channel: "discord",
accountId: params.accountId,
direction: "inbound",
});
// Resolve thread parent early for binding inheritance
const channelName =
channelInfo?.name ??
((isGuildMessage || isGroupDm) && message.channel && "name" in message.channel
? message.channel.name
: undefined);
const earlyThreadChannel = resolveDiscordThreadChannel({
isGuildMessage,
message,
channelInfo,
messageChannelId,
});
let earlyThreadParentId: string | undefined;
let earlyThreadParentName: string | undefined;
let earlyThreadParentType: ChannelType | undefined;
if (earlyThreadChannel) {
const parentInfo = await resolveDiscordThreadParentInfo({
client: params.client,
threadChannel: earlyThreadChannel,
channelInfo,
});
if (isPreflightAborted(params.abortSignal)) {
return null;
}
earlyThreadParentId = parentInfo.id;
earlyThreadParentName = parentInfo.name;
earlyThreadParentType = parentInfo.type;
}
// Fresh config for bindings lookup; other routing inputs are payload-derived.
const memberRoleIds = Array.isArray(params.data.rawMember?.roles)
? params.data.rawMember.roles.map((roleId: string) => String(roleId))
: [];
const freshCfg = loadConfig();
const route = resolveDiscordConversationRoute({
cfg: freshCfg,
accountId: params.accountId,
guildId: params.data.guild_id ?? undefined,
memberRoleIds,
peer: buildDiscordRoutePeer({
isDirectMessage,
isGroupDm,
directUserId: author.id,
conversationId: messageChannelId,
}),
parentConversationId: earlyThreadParentId,
});
let threadBinding: SessionBindingRecord | undefined;
threadBinding =
getSessionBindingService().resolveByConversation({
channel: "discord",
accountId: params.accountId,
conversationId: messageChannelId,
parentConversationId: earlyThreadParentId,
}) ?? undefined;
const configuredRoute =
threadBinding == null
? resolveConfiguredAcpRoute({
cfg: freshCfg,
route,
channel: "discord",
accountId: params.accountId,
conversationId: messageChannelId,
parentConversationId: earlyThreadParentId,
})
: null;
const configuredBinding = configuredRoute?.configuredBinding ?? null;
if (!threadBinding && configuredBinding) {
threadBinding = configuredBinding.record;
}
if (
shouldIgnoreBoundThreadWebhookMessage({
accountId: params.accountId,
threadId: messageChannelId,
webhookId,
threadBinding,
})
) {
logVerbose(`discord: drop bound-thread webhook echo message ${message.id}`);
return null;
}
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
const effectiveRoute = resolveDiscordEffectiveRoute({
route,
boundSessionKey,
configuredRoute,
matchedBy: "binding.channel",
});
const boundAgentId = boundSessionKey ? effectiveRoute.agentId : undefined;
const isBoundThreadSession = Boolean(boundSessionKey && earlyThreadChannel);
if (
isBoundThreadBotSystemMessage({
isBoundThreadSession,
isBotAuthor: Boolean(author.bot),
text: messageText,
})
) {
logVerbose(`discord: drop bound-thread bot system message ${message.id}`);
return null;
}
const mentionRegexes = buildMentionRegexes(params.cfg, effectiveRoute.agentId);
const explicitlyMentioned = Boolean(
botId && message.mentionedUsers?.some((user: User) => user.id === botId),
);
const hasAnyMention = Boolean(
!isDirectMessage &&
((message.mentionedUsers?.length ?? 0) > 0 ||
(message.mentionedRoles?.length ?? 0) > 0 ||
(message.mentionedEveryone && (!author.bot || sender.isPluralKit))),
);
const hasUserOrRoleMention = Boolean(
!isDirectMessage &&
((message.mentionedUsers?.length ?? 0) > 0 || (message.mentionedRoles?.length ?? 0) > 0),
);
if (
isGuildMessage &&
(message.type === MessageType.ChatInputCommand ||
message.type === MessageType.ContextMenuCommand)
) {
logVerbose("discord: drop channel command message");
return null;
}
const guildInfo = isGuildMessage
? resolveDiscordGuildEntry({
guild: params.data.guild ?? undefined,
guildId: params.data.guild_id ?? undefined,
guildEntries: params.guildEntries,
})
: null;
logDebug(
`[discord-preflight] guild_id=${params.data.guild_id} guild_obj=${!!params.data.guild} guild_obj_id=${params.data.guild?.id} guildInfo=${!!guildInfo} guildEntries=${params.guildEntries ? Object.keys(params.guildEntries).join(",") : "none"}`,
);
if (
isGuildMessage &&
params.guildEntries &&
Object.keys(params.guildEntries).length > 0 &&
!guildInfo
) {
logDebug(
`[discord-preflight] guild blocked: guild_id=${params.data.guild_id} guildEntries keys=${Object.keys(params.guildEntries).join(",")}`,
);
logVerbose(
`Blocked discord guild ${params.data.guild_id ?? "unknown"} (not in discord.guilds)`,
);
return null;
}
// Reuse early thread resolution from above (for binding inheritance)
const threadChannel = earlyThreadChannel;
const threadParentId = earlyThreadParentId;
const threadParentName = earlyThreadParentName;
const threadParentType = earlyThreadParentType;
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 ||
(params.data.guild?.name ? normalizeDiscordSlug(params.data.guild.name) : "");
const threadChannelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
const threadParentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : "";
const baseSessionKey = effectiveRoute.sessionKey;
const channelConfig = isGuildMessage
? resolveDiscordChannelConfigWithFallback({
guildInfo,
channelId: messageChannelId,
channelName,
channelSlug: threadChannelSlug,
parentId: threadParentId ?? undefined,
parentName: threadParentName ?? undefined,
parentSlug: threadParentSlug,
scope: threadChannel ? "thread" : "channel",
})
: null;
const channelMatchMeta = formatAllowlistMatchMeta(channelConfig);
if (shouldLogVerbose()) {
const channelConfigSummary = channelConfig
? `allowed=${channelConfig.allowed} enabled=${channelConfig.enabled ?? "unset"} requireMention=${channelConfig.requireMention ?? "unset"} ignoreOtherMentions=${channelConfig.ignoreOtherMentions ?? "unset"} matchKey=${channelConfig.matchKey ?? "none"} matchSource=${channelConfig.matchSource ?? "none"} users=${channelConfig.users?.length ?? 0} roles=${channelConfig.roles?.length ?? 0} skills=${channelConfig.skills?.length ?? 0}`
: "none";
logDebug(
`[discord-preflight] channelConfig=${channelConfigSummary} channelMatchMeta=${channelMatchMeta} channelId=${messageChannelId}`,
);
}
if (isGuildMessage && channelConfig?.enabled === false) {
logDebug(`[discord-preflight] drop: channel disabled`);
logVerbose(
`Blocked discord channel ${messageChannelId} (channel disabled, ${channelMatchMeta})`,
);
return null;
}
const groupDmAllowed =
isGroupDm &&
resolveGroupDmAllow({
channels: params.groupDmChannels,
channelId: messageChannelId,
channelName: displayChannelName,
channelSlug: displayChannelSlug,
});
if (isGroupDm && !groupDmAllowed) {
return null;
}
const channelAllowlistConfigured =
Boolean(guildInfo?.channels) && Object.keys(guildInfo?.channels ?? {}).length > 0;
const channelAllowed = channelConfig?.allowed !== false;
if (
isGuildMessage &&
!isDiscordGroupAllowedByPolicy({
groupPolicy: params.groupPolicy,
guildAllowlisted: Boolean(guildInfo),
channelAllowlistConfigured,
channelAllowed,
})
) {
if (params.groupPolicy === "disabled") {
logDebug(`[discord-preflight] drop: groupPolicy disabled`);
logVerbose(`discord: drop guild message (groupPolicy: disabled, ${channelMatchMeta})`);
} else if (!channelAllowlistConfigured) {
logDebug(`[discord-preflight] drop: groupPolicy allowlist, no channel allowlist configured`);
logVerbose(
`discord: drop guild message (groupPolicy: allowlist, no channel allowlist, ${channelMatchMeta})`,
);
} else {
logDebug(
`[discord] Ignored message from channel ${messageChannelId} (not in guild allowlist). Add to guilds.<guildId>.channels to enable.`,
);
logVerbose(
`Blocked discord channel ${messageChannelId} not in guild channel allowlist (groupPolicy: allowlist, ${channelMatchMeta})`,
);
}
return null;
}
if (isGuildMessage && channelConfig?.allowed === false) {
logDebug(`[discord-preflight] drop: channelConfig.allowed===false`);
logVerbose(
`Blocked discord channel ${messageChannelId} not in guild channel allowlist (${channelMatchMeta})`,
);
return null;
}
if (isGuildMessage) {
logDebug(`[discord-preflight] pass: channel allowed`);
logVerbose(`discord: allow channel ${messageChannelId} (${channelMatchMeta})`);
}
const textForHistory = resolveDiscordMessageText(message, {
includeForwarded: true,
});
const historyEntry =
isGuildMessage && params.historyLimit > 0 && textForHistory
? ({
sender: sender.label,
body: textForHistory,
timestamp: resolveTimestampMs(message.timestamp),
messageId: message.id,
} satisfies HistoryEntry)
: undefined;
const threadOwnerId = threadChannel ? (threadChannel.ownerId ?? channelInfo?.ownerId) : undefined;
const shouldRequireMentionByConfig = resolveDiscordShouldRequireMention({
isGuildMessage,
isThread: Boolean(threadChannel),
botId,
threadOwnerId,
channelConfig,
guildInfo,
});
const shouldRequireMention = resolvePreflightMentionRequirement({
shouldRequireMention: shouldRequireMentionByConfig,
isBoundThreadSession,
});
// Preflight audio transcription for mention detection in guilds.
// This allows voice notes to be checked for mentions before being dropped.
const { hasTypedText, transcript: preflightTranscript } =
await resolveDiscordPreflightAudioMentionContext({
message,
isDirectMessage,
shouldRequireMention,
mentionRegexes,
cfg: params.cfg,
abortSignal: params.abortSignal,
});
if (isPreflightAborted(params.abortSignal)) {
return null;
}
const mentionText = hasTypedText ? baseText : "";
const wasMentioned =
!isDirectMessage &&
matchesMentionWithExplicit({
text: mentionText,
mentionRegexes,
explicit: {
hasAnyMention,
isExplicitlyMentioned: explicitlyMentioned,
canResolveExplicit: Boolean(botId),
},
transcript: preflightTranscript,
});
const implicitMention = Boolean(
!isDirectMessage &&
botId &&
message.referencedMessage?.author?.id &&
message.referencedMessage.author.id === botId,
);
if (shouldLogVerbose()) {
logVerbose(
`discord: inbound id=${message.id} guild=${params.data.guild_id ?? "dm"} channel=${messageChannelId} mention=${wasMentioned ? "yes" : "no"} type=${isDirectMessage ? "dm" : isGroupDm ? "group-dm" : "guild"} content=${messageText ? "yes" : "no"}`,
);
}
const allowTextCommands = shouldHandleTextCommands({
cfg: params.cfg,
surface: "discord",
});
const hasControlCommandInMessage = hasControlCommand(baseText, params.cfg);
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
channelConfig,
guildInfo,
memberRoleIds,
sender,
allowNameMatching,
});
if (!isDirectMessage) {
const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({
allowFrom: params.allowFrom,
sender: {
id: sender.id,
name: sender.name,
tag: sender.tag,
},
allowNameMatching,
});
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: ownerAllowList != null, allowed: ownerOk },
{ configured: hasAccessRestrictions, allowed: memberAllowed },
],
modeWhenAccessGroupsOff: "configured",
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
});
commandAuthorized = commandGate.commandAuthorized;
if (commandGate.shouldBlock) {
logInboundDrop({
log: logVerbose,
channel: "discord",
reason: "control command (unauthorized)",
target: sender.id,
});
return null;
}
}
const canDetectMention = Boolean(botId) || mentionRegexes.length > 0;
const mentionGate = resolveMentionGatingWithBypass({
isGroup: isGuildMessage,
requireMention: Boolean(shouldRequireMention),
canDetectMention,
wasMentioned,
implicitMention,
hasAnyMention,
allowTextCommands,
hasControlCommand: hasControlCommandInMessage,
commandAuthorized,
});
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
logDebug(
`[discord-preflight] shouldRequireMention=${shouldRequireMention} baseRequireMention=${shouldRequireMentionByConfig} boundThreadSession=${isBoundThreadSession} mentionGate.shouldSkip=${mentionGate.shouldSkip} wasMentioned=${wasMentioned}`,
);
if (isGuildMessage && shouldRequireMention) {
if (botId && mentionGate.shouldSkip) {
logDebug(`[discord-preflight] drop: no-mention`);
logVerbose(`discord: drop guild message (mention required, botId=${botId})`);
logger.info(
{
channelId: messageChannelId,
reason: "no-mention",
},
"discord: skipping guild message",
);
recordPendingHistoryEntryIfEnabled({
historyMap: params.guildHistories,
historyKey: messageChannelId,
limit: params.historyLimit,
entry: historyEntry ?? null,
});
return null;
}
}
if (author.bot && !sender.isPluralKit && allowBotsMode === "mentions") {
const botMentioned = isDirectMessage || wasMentioned || implicitMention;
if (!botMentioned) {
logDebug(`[discord-preflight] drop: bot message missing mention (allowBots=mentions)`);
logVerbose("discord: drop bot message (allowBots=mentions, missing mention)");
return null;
}
}
const ignoreOtherMentions =
channelConfig?.ignoreOtherMentions ?? guildInfo?.ignoreOtherMentions ?? false;
if (
isGuildMessage &&
ignoreOtherMentions &&
hasUserOrRoleMention &&
!wasMentioned &&
!implicitMention
) {
logDebug(`[discord-preflight] drop: other-mention`);
logVerbose(
`discord: drop guild message (another user/role mentioned, ignoreOtherMentions=true, botId=${botId})`,
);
recordPendingHistoryEntryIfEnabled({
historyMap: params.guildHistories,
historyKey: messageChannelId,
limit: params.historyLimit,
entry: historyEntry ?? null,
});
return null;
}
if (isGuildMessage && hasAccessRestrictions && !memberAllowed) {
logDebug(`[discord-preflight] drop: member not allowed`);
logVerbose(`Blocked discord guild sender ${sender.id} (not in users/roles allowlist)`);
return null;
}
const systemLocation = resolveDiscordSystemLocation({
isDirectMessage,
isGroupDm,
guild: params.data.guild ?? undefined,
channelName: channelName ?? messageChannelId,
});
const systemText = resolveDiscordSystemEvent(message, systemLocation);
if (systemText) {
logDebug(`[discord-preflight] drop: system event`);
enqueueSystemEvent(systemText, {
sessionKey: effectiveRoute.sessionKey,
contextKey: `discord:system:${messageChannelId}:${message.id}`,
});
return null;
}
if (!messageText) {
logDebug(`[discord-preflight] drop: empty content`);
logVerbose(`discord: drop message ${message.id} (empty content)`);
return null;
}
if (configuredBinding) {
const ensured = await ensureConfiguredAcpRouteReady({
cfg: freshCfg,
configuredBinding,
});
if (!ensured.ok) {
logVerbose(
`discord: configured ACP binding unavailable for channel ${configuredBinding.spec.conversationId}: ${ensured.error}`,
);
return null;
}
}
logDebug(
`[discord-preflight] success: route=${effectiveRoute.agentId} sessionKey=${effectiveRoute.sessionKey}`,
);
return {
cfg: params.cfg,
discordConfig: params.discordConfig,
accountId: params.accountId,
token: params.token,
runtime: params.runtime,
botUserId: params.botUserId,
abortSignal: params.abortSignal,
guildHistories: params.guildHistories,
historyLimit: params.historyLimit,
mediaMaxBytes: params.mediaMaxBytes,
textLimit: params.textLimit,
replyToMode: params.replyToMode,
ackReactionScope: params.ackReactionScope,
groupPolicy: params.groupPolicy,
data: params.data,
client: params.client,
message,
messageChannelId,
author,
sender,
channelInfo,
channelName,
isGuildMessage,
isDirectMessage,
isGroupDm,
commandAuthorized,
baseText,
messageText,
wasMentioned,
route: effectiveRoute,
threadBinding,
boundSessionKey: boundSessionKey || undefined,
boundAgentId,
guildInfo,
guildSlug,
threadChannel,
threadParentId,
threadParentName,
threadParentType,
threadName,
configChannelName,
configChannelSlug,
displayChannelName,
displayChannelSlug,
baseSessionKey,
channelConfig,
channelAllowlistConfigured,
channelAllowed,
shouldRequireMention,
hasAnyMention,
allowTextCommands,
shouldBypassMention: mentionGate.shouldBypassMention,
effectiveWasMentioned,
canDetectMention,
historyEntry,
threadBindings: params.threadBindings,
discordRestFetch: params.discordRestFetch,
};
}

View File

@@ -0,0 +1,106 @@
import type { ChannelType, Client, User } from "@buape/carbon";
import type { HistoryEntry } from "../../../../src/auto-reply/reply/history.js";
import type { ReplyToMode } from "../../../../src/config/config.js";
import type { SessionBindingRecord } from "../../../../src/infra/outbound/session-binding-service.js";
import type { resolveAgentRoute } from "../../../../src/routing/resolve-route.js";
import type { DiscordChannelConfigResolved, DiscordGuildEntryResolved } from "./allow-list.js";
import type { DiscordChannelInfo } from "./message-utils.js";
import type { DiscordThreadBindingLookup } from "./reply-delivery.js";
import type { DiscordSenderIdentity } from "./sender-identity.js";
export type { DiscordSenderIdentity } from "./sender-identity.js";
import type { DiscordThreadChannel } from "./threading.js";
export type LoadedConfig = ReturnType<typeof import("../../../../src/config/config.js").loadConfig>;
export type RuntimeEnv = import("../../../../src/runtime.js").RuntimeEnv;
export type DiscordMessageEvent = import("./listeners.js").DiscordMessageEvent;
type DiscordMessagePreflightSharedFields = {
cfg: LoadedConfig;
discordConfig: NonNullable<
import("../../../../src/config/config.js").OpenClawConfig["channels"]
>["discord"];
accountId: string;
token: string;
runtime: RuntimeEnv;
botUserId?: string;
abortSignal?: AbortSignal;
guildHistories: Map<string, HistoryEntry[]>;
historyLimit: number;
mediaMaxBytes: number;
textLimit: number;
replyToMode: ReplyToMode;
ackReactionScope: "all" | "direct" | "group-all" | "group-mentions" | "off" | "none";
groupPolicy: "open" | "disabled" | "allowlist";
};
export type DiscordMessagePreflightContext = DiscordMessagePreflightSharedFields & {
data: DiscordMessageEvent;
client: Client;
message: DiscordMessageEvent["message"];
messageChannelId: string;
author: User;
sender: DiscordSenderIdentity;
channelInfo: DiscordChannelInfo | null;
channelName?: string;
isGuildMessage: boolean;
isDirectMessage: boolean;
isGroupDm: boolean;
commandAuthorized: boolean;
baseText: string;
messageText: string;
wasMentioned: boolean;
route: ReturnType<typeof resolveAgentRoute>;
threadBinding?: SessionBindingRecord;
boundSessionKey?: string;
boundAgentId?: string;
guildInfo: DiscordGuildEntryResolved | null;
guildSlug: string;
threadChannel: DiscordThreadChannel | null;
threadParentId?: string;
threadParentName?: string;
threadParentType?: ChannelType;
threadName?: string | null;
configChannelName?: string;
configChannelSlug: string;
displayChannelName?: string;
displayChannelSlug: string;
baseSessionKey: string;
channelConfig: DiscordChannelConfigResolved | null;
channelAllowlistConfigured: boolean;
channelAllowed: boolean;
shouldRequireMention: boolean;
hasAnyMention: boolean;
allowTextCommands: boolean;
shouldBypassMention: boolean;
effectiveWasMentioned: boolean;
canDetectMention: boolean;
historyEntry?: HistoryEntry;
threadBindings: DiscordThreadBindingLookup;
discordRestFetch?: typeof fetch;
};
export type DiscordMessagePreflightParams = DiscordMessagePreflightSharedFields & {
dmEnabled: boolean;
groupDmEnabled: boolean;
groupDmChannels?: string[];
allowFrom?: string[];
guildEntries?: Record<string, DiscordGuildEntryResolved>;
ackReactionScope: DiscordMessagePreflightContext["ackReactionScope"];
groupPolicy: DiscordMessagePreflightContext["groupPolicy"];
threadBindings: DiscordThreadBindingLookup;
discordRestFetch?: typeof fetch;
data: DiscordMessageEvent;
client: Client;
};

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_EMOJIS } from "../../channels/status-reactions.js";
import { DEFAULT_EMOJIS } from "../../../../src/channels/status-reactions.js";
import {
createBaseDiscordMessageContext,
createDiscordDirectMessageContextOverrides,
@@ -84,11 +84,11 @@ vi.mock("./reply-delivery.js", () => ({
deliverDiscordReply: deliveryMocks.deliverDiscordReply,
}));
vi.mock("../../auto-reply/dispatch.js", () => ({
vi.mock("../../../../src/auto-reply/dispatch.js", () => ({
dispatchInboundMessage,
}));
vi.mock("../../auto-reply/reply/reply-dispatcher.js", () => ({
vi.mock("../../../../src/auto-reply/reply/reply-dispatcher.js", () => ({
createReplyDispatcherWithTyping: vi.fn(
(opts: { deliver: (payload: unknown, info: { kind: string }) => Promise<void> | void }) => ({
dispatcher: {
@@ -112,11 +112,11 @@ vi.mock("../../auto-reply/reply/reply-dispatcher.js", () => ({
),
}));
vi.mock("../../channels/session.js", () => ({
vi.mock("../../../../src/channels/session.js", () => ({
recordInboundSession,
}));
vi.mock("../../config/sessions.js", () => ({
vi.mock("../../../../src/config/sessions.js", () => ({
readSessionUpdatedAt: configSessionsMocks.readSessionUpdatedAt,
resolveStorePath: configSessionsMocks.resolveStorePath,
}));

View File

@@ -0,0 +1,866 @@
import { ChannelType, type RequestClient } from "@buape/carbon";
import { resolveAckReaction, resolveHumanDelayConfig } from "../../../../src/agents/identity.js";
import { EmbeddedBlockChunker } from "../../../../src/agents/pi-embedded-block-chunker.js";
import { resolveChunkMode } from "../../../../src/auto-reply/chunk.js";
import { dispatchInboundMessage } from "../../../../src/auto-reply/dispatch.js";
import {
formatInboundEnvelope,
resolveEnvelopeFormatOptions,
} from "../../../../src/auto-reply/envelope.js";
import {
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
} from "../../../../src/auto-reply/reply/history.js";
import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js";
import { createReplyDispatcherWithTyping } from "../../../../src/auto-reply/reply/reply-dispatcher.js";
import type { ReplyPayload } from "../../../../src/auto-reply/types.js";
import { shouldAckReaction as shouldAckReactionGate } from "../../../../src/channels/ack-reactions.js";
import { logTypingFailure, logAckFailure } from "../../../../src/channels/logging.js";
import { createReplyPrefixOptions } from "../../../../src/channels/reply-prefix.js";
import { recordInboundSession } from "../../../../src/channels/session.js";
import {
createStatusReactionController,
DEFAULT_TIMING,
type StatusReactionAdapter,
} from "../../../../src/channels/status-reactions.js";
import { createTypingCallbacks } from "../../../../src/channels/typing.js";
import { isDangerousNameMatchingEnabled } from "../../../../src/config/dangerous-name-matching.js";
import { resolveDiscordPreviewStreamMode } from "../../../../src/config/discord-preview-streaming.js";
import { resolveMarkdownTableMode } from "../../../../src/config/markdown-tables.js";
import { readSessionUpdatedAt, resolveStorePath } from "../../../../src/config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../../../../src/globals.js";
import { convertMarkdownTables } from "../../../../src/markdown/tables.js";
import { getAgentScopedMediaLocalRoots } from "../../../../src/media/local-roots.js";
import { buildAgentSessionKey } from "../../../../src/routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../../../../src/routing/session-key.js";
import { stripReasoningTagsFromText } from "../../../../src/shared/text/reasoning-tags.js";
import { truncateUtf16Safe } from "../../../../src/utils.js";
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
import { chunkDiscordTextWithMode } from "../chunk.js";
import { resolveDiscordDraftStreamingChunking } from "../draft-chunking.js";
import { createDiscordDraftStream } from "../draft-stream.js";
import { reactMessageDiscord, removeReactionDiscord } from "../send.js";
import { editMessageDiscord } from "../send.messages.js";
import { normalizeDiscordSlug } from "./allow-list.js";
import { resolveTimestampMs } from "./format.js";
import { buildDiscordInboundAccessContext } from "./inbound-context.js";
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
import {
buildDiscordMediaPayload,
resolveDiscordMessageText,
resolveForwardedMediaList,
resolveMediaList,
} from "./message-utils.js";
import { buildDirectLabel, buildGuildLabel, resolveReplyContext } from "./reply-context.js";
import { deliverDiscordReply } from "./reply-delivery.js";
import { resolveDiscordAutoThreadReplyPlan, resolveDiscordThreadStarter } from "./threading.js";
import { sendTyping } from "./typing.js";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
const DISCORD_TYPING_MAX_DURATION_MS = 20 * 60_000;
function isProcessAborted(abortSignal?: AbortSignal): boolean {
return Boolean(abortSignal?.aborted);
}
export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) {
const {
cfg,
discordConfig,
accountId,
token,
runtime,
guildHistories,
historyLimit,
mediaMaxBytes,
textLimit,
replyToMode,
ackReactionScope,
message,
author,
sender,
data,
client,
channelInfo,
channelName,
messageChannelId,
isGuildMessage,
isDirectMessage,
isGroupDm,
baseText,
messageText,
shouldRequireMention,
canDetectMention,
effectiveWasMentioned,
shouldBypassMention,
threadChannel,
threadParentId,
threadParentName,
threadParentType,
threadName,
displayChannelSlug,
guildInfo,
guildSlug,
channelConfig,
baseSessionKey,
boundSessionKey,
threadBindings,
route,
commandAuthorized,
discordRestFetch,
abortSignal,
} = ctx;
if (isProcessAborted(abortSignal)) {
return;
}
const ssrfPolicy = cfg.browser?.ssrfPolicy;
const mediaList = await resolveMediaList(message, mediaMaxBytes, discordRestFetch, ssrfPolicy);
if (isProcessAborted(abortSignal)) {
return;
}
const forwardedMediaList = await resolveForwardedMediaList(
message,
mediaMaxBytes,
discordRestFetch,
ssrfPolicy,
);
if (isProcessAborted(abortSignal)) {
return;
}
mediaList.push(...forwardedMediaList);
const text = messageText;
if (!text) {
logVerbose("discord: drop message " + message.id + " (empty content)");
return;
}
const boundThreadId = ctx.threadBinding?.conversation?.conversationId?.trim();
if (boundThreadId && typeof threadBindings.touchThread === "function") {
threadBindings.touchThread({ threadId: boundThreadId });
}
const ackReaction = resolveAckReaction(cfg, route.agentId, {
channel: "discord",
accountId,
});
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
const shouldAckReaction = () =>
Boolean(
ackReaction &&
shouldAckReactionGate({
scope: ackReactionScope,
isDirect: isDirectMessage,
isGroup: isGuildMessage || isGroupDm,
isMentionableGroup: isGuildMessage,
requireMention: Boolean(shouldRequireMention),
canDetectMention,
effectiveWasMentioned,
shouldBypassMention,
}),
);
const statusReactionsEnabled = shouldAckReaction();
// Discord outbound helpers expect Carbon's request client shape explicitly.
const discordRest = client.rest as unknown as RequestClient;
const discordAdapter: StatusReactionAdapter = {
setReaction: async (emoji) => {
await reactMessageDiscord(messageChannelId, message.id, emoji, {
rest: discordRest,
});
},
removeReaction: async (emoji) => {
await removeReactionDiscord(messageChannelId, message.id, emoji, {
rest: discordRest,
});
},
};
const statusReactions = createStatusReactionController({
enabled: statusReactionsEnabled,
adapter: discordAdapter,
initialEmoji: ackReaction,
emojis: cfg.messages?.statusReactions?.emojis,
timing: cfg.messages?.statusReactions?.timing,
onError: (err) => {
logAckFailure({
log: logVerbose,
channel: "discord",
target: `${messageChannelId}/${message.id}`,
error: err,
});
},
});
if (statusReactionsEnabled) {
void statusReactions.setQueued();
}
const fromLabel = isDirectMessage
? buildDirectLabel(author)
: buildGuildLabel({
guild: data.guild ?? undefined,
channelName: channelName ?? messageChannelId,
channelId: messageChannelId,
});
const senderLabel = sender.label;
const isForumParent =
threadParentType === ChannelType.GuildForum || threadParentType === ChannelType.GuildMedia;
const forumParentSlug =
isForumParent && threadParentName ? normalizeDiscordSlug(threadParentName) : "";
const threadChannelId = threadChannel?.id;
const isForumStarter =
Boolean(threadChannelId && isForumParent && forumParentSlug) && message.id === threadChannelId;
const forumContextLine = isForumStarter ? `[Forum parent: #${forumParentSlug}]` : null;
const groupChannel = isGuildMessage && displayChannelSlug ? `#${displayChannelSlug}` : undefined;
const groupSubject = isDirectMessage ? undefined : groupChannel;
const senderName = sender.isPluralKit
? (sender.name ?? author.username)
: (data.member?.nickname ?? author.globalName ?? author.username);
const senderUsername = sender.isPluralKit
? (sender.tag ?? sender.name ?? author.username)
: author.username;
const senderTag = sender.tag;
const { groupSystemPrompt, ownerAllowFrom, untrustedContext } = buildDiscordInboundAccessContext({
channelConfig,
guildInfo,
sender: { id: sender.id, name: sender.name, tag: sender.tag },
allowNameMatching: isDangerousNameMatchingEnabled(discordConfig),
isGuild: isGuildMessage,
channelTopic: channelInfo?.topic,
});
const storePath = resolveStorePath(cfg.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const previousTimestamp = readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
let combinedBody = formatInboundEnvelope({
channel: "Discord",
from: fromLabel,
timestamp: resolveTimestampMs(message.timestamp),
body: text,
chatType: isDirectMessage ? "direct" : "channel",
senderLabel,
previousTimestamp,
envelope: envelopeOptions,
});
const shouldIncludeChannelHistory =
!isDirectMessage && !(isGuildMessage && channelConfig?.autoThread && !threadChannel);
if (shouldIncludeChannelHistory) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: guildHistories,
historyKey: messageChannelId,
limit: historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
formatInboundEnvelope({
channel: "Discord",
from: fromLabel,
timestamp: entry.timestamp,
body: `${entry.body} [id:${entry.messageId ?? "unknown"} channel:${messageChannelId}]`,
chatType: "channel",
senderLabel: entry.sender,
envelope: envelopeOptions,
}),
});
}
const replyContext = resolveReplyContext(message, resolveDiscordMessageText);
if (forumContextLine) {
combinedBody = `${combinedBody}\n${forumContextLine}`;
}
let threadStarterBody: string | undefined;
let threadLabel: string | undefined;
let parentSessionKey: string | undefined;
if (threadChannel) {
const includeThreadStarter = channelConfig?.includeThreadStarter !== false;
if (includeThreadStarter) {
const starter = await resolveDiscordThreadStarter({
channel: threadChannel,
client,
parentId: threadParentId,
parentType: threadParentType,
resolveTimestampMs,
});
if (starter?.text) {
// Keep thread starter as raw text; metadata is provided out-of-band in the system prompt.
threadStarterBody = starter.text;
}
}
const parentName = threadParentName ?? "parent";
threadLabel = threadName
? `Discord thread #${normalizeDiscordSlug(parentName)} ${threadName}`
: `Discord thread #${normalizeDiscordSlug(parentName)}`;
if (threadParentId) {
parentSessionKey = buildAgentSessionKey({
agentId: route.agentId,
channel: route.channel,
peer: { kind: "channel", id: threadParentId },
});
}
}
const mediaPayload = buildDiscordMediaPayload(mediaList);
const threadKeys = resolveThreadSessionKeys({
baseSessionKey,
threadId: threadChannel ? messageChannelId : undefined,
parentSessionKey,
useSuffix: false,
});
const replyPlan = await resolveDiscordAutoThreadReplyPlan({
client,
message,
messageChannelId,
isGuildMessage,
channelConfig,
threadChannel,
channelType: channelInfo?.type,
baseText: baseText ?? "",
combinedBody,
replyToMode,
agentId: route.agentId,
channel: route.channel,
});
const deliverTarget = replyPlan.deliverTarget;
const replyTarget = replyPlan.replyTarget;
const replyReference = replyPlan.replyReference;
const autoThreadContext = replyPlan.autoThreadContext;
const effectiveFrom = isDirectMessage
? `discord:${author.id}`
: (autoThreadContext?.From ?? `discord:channel:${messageChannelId}`);
const effectiveTo = autoThreadContext?.To ?? replyTarget;
if (!effectiveTo) {
runtime.error?.(danger("discord: missing reply target"));
return;
}
// Keep DM routes user-addressed so follow-up sends resolve direct session keys.
const lastRouteTo = isDirectMessage ? `user:${author.id}` : effectiveTo;
const inboundHistory =
shouldIncludeChannelHistory && historyLimit > 0
? (guildHistories.get(messageChannelId) ?? []).map((entry) => ({
sender: entry.sender,
body: entry.body,
timestamp: entry.timestamp,
}))
: undefined;
const ctxPayload = finalizeInboundContext({
Body: combinedBody,
BodyForAgent: baseText ?? text,
InboundHistory: inboundHistory,
RawBody: baseText,
CommandBody: baseText,
From: effectiveFrom,
To: effectiveTo,
SessionKey: boundSessionKey ?? autoThreadContext?.SessionKey ?? threadKeys.sessionKey,
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "channel",
ConversationLabel: fromLabel,
SenderName: senderName,
SenderId: sender.id,
SenderUsername: senderUsername,
SenderTag: senderTag,
GroupSubject: groupSubject,
GroupChannel: groupChannel,
UntrustedContext: untrustedContext,
GroupSystemPrompt: isGuildMessage ? groupSystemPrompt : undefined,
GroupSpace: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined,
OwnerAllowFrom: ownerAllowFrom,
Provider: "discord" as const,
Surface: "discord" as const,
WasMentioned: effectiveWasMentioned,
MessageSid: message.id,
ReplyToId: replyContext?.id,
ReplyToBody: replyContext?.body,
ReplyToSender: replyContext?.sender,
ParentSessionKey: autoThreadContext?.ParentSessionKey ?? threadKeys.parentSessionKey,
MessageThreadId: threadChannel?.id ?? autoThreadContext?.createdThreadId ?? undefined,
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: autoThreadContext?.OriginatingTo ?? replyTarget,
});
const persistedSessionKey = ctxPayload.SessionKey ?? route.sessionKey;
await recordInboundSession({
storePath,
sessionKey: persistedSessionKey,
ctx: ctxPayload,
updateLastRoute: {
sessionKey: persistedSessionKey,
channel: "discord",
to: lastRouteTo,
accountId: route.accountId,
},
onRecordError: (err) => {
logVerbose(`discord: failed updating session meta: ${String(err)}`);
},
});
if (shouldLogVerbose()) {
const preview = truncateUtf16Safe(combinedBody, 200).replace(/\n/g, "\\n");
logVerbose(
`discord inbound: channel=${messageChannelId} deliver=${deliverTarget} from=${ctxPayload.From} preview="${preview}"`,
);
}
const typingChannelId = deliverTarget.startsWith("channel:")
? deliverTarget.slice("channel:".length)
: messageChannelId;
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg,
agentId: route.agentId,
channel: "discord",
accountId: route.accountId,
});
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "discord",
accountId,
});
const maxLinesPerMessage = resolveDiscordMaxLinesPerMessage({
cfg,
discordConfig,
accountId,
});
const chunkMode = resolveChunkMode(cfg, "discord", accountId);
const typingCallbacks = createTypingCallbacks({
start: () => sendTyping({ client, channelId: typingChannelId }),
onStartError: (err) => {
logTypingFailure({
log: logVerbose,
channel: "discord",
target: typingChannelId,
error: err,
});
},
// Long tool-heavy runs are expected on Discord; keep heartbeats alive.
maxDurationMs: DISCORD_TYPING_MAX_DURATION_MS,
});
// --- Discord draft stream (edit-based preview streaming) ---
const discordStreamMode = resolveDiscordPreviewStreamMode(discordConfig);
const draftMaxChars = Math.min(textLimit, 2000);
const accountBlockStreamingEnabled =
typeof discordConfig?.blockStreaming === "boolean"
? discordConfig.blockStreaming
: cfg.agents?.defaults?.blockStreamingDefault === "on";
const canStreamDraft = discordStreamMode !== "off" && !accountBlockStreamingEnabled;
const draftReplyToMessageId = () => replyReference.use();
const deliverChannelId = deliverTarget.startsWith("channel:")
? deliverTarget.slice("channel:".length)
: messageChannelId;
const draftStream = canStreamDraft
? createDiscordDraftStream({
rest: client.rest,
channelId: deliverChannelId,
maxChars: draftMaxChars,
replyToMessageId: draftReplyToMessageId,
minInitialChars: 30,
throttleMs: 1200,
log: logVerbose,
warn: logVerbose,
})
: undefined;
const draftChunking =
draftStream && discordStreamMode === "block"
? resolveDiscordDraftStreamingChunking(cfg, accountId)
: undefined;
const shouldSplitPreviewMessages = discordStreamMode === "block";
const draftChunker = draftChunking ? new EmbeddedBlockChunker(draftChunking) : undefined;
let lastPartialText = "";
let draftText = "";
let hasStreamedMessage = false;
let finalizedViaPreviewMessage = false;
const resolvePreviewFinalText = (text?: string) => {
if (typeof text !== "string") {
return undefined;
}
const formatted = convertMarkdownTables(text, tableMode);
const chunks = chunkDiscordTextWithMode(formatted, {
maxChars: draftMaxChars,
maxLines: maxLinesPerMessage,
chunkMode,
});
if (!chunks.length && formatted) {
chunks.push(formatted);
}
if (chunks.length !== 1) {
return undefined;
}
const trimmed = chunks[0].trim();
if (!trimmed) {
return undefined;
}
const currentPreviewText = discordStreamMode === "block" ? draftText : lastPartialText;
if (
currentPreviewText &&
currentPreviewText.startsWith(trimmed) &&
trimmed.length < currentPreviewText.length
) {
return undefined;
}
return trimmed;
};
const updateDraftFromPartial = (text?: string) => {
if (!draftStream || !text) {
return;
}
// Strip reasoning/thinking tags that may leak through the stream.
const cleaned = stripReasoningTagsFromText(text, { mode: "strict", trim: "both" });
// Skip pure-reasoning messages (e.g. "Reasoning:\n…") that contain no answer text.
if (!cleaned || cleaned.startsWith("Reasoning:\n")) {
return;
}
if (cleaned === lastPartialText) {
return;
}
hasStreamedMessage = true;
if (discordStreamMode === "partial") {
// Keep the longer preview to avoid visible punctuation flicker.
if (
lastPartialText &&
lastPartialText.startsWith(cleaned) &&
cleaned.length < lastPartialText.length
) {
return;
}
lastPartialText = cleaned;
draftStream.update(cleaned);
return;
}
let delta = cleaned;
if (cleaned.startsWith(lastPartialText)) {
delta = cleaned.slice(lastPartialText.length);
} else {
// Streaming buffer reset (or non-monotonic stream). Start fresh.
draftChunker?.reset();
draftText = "";
}
lastPartialText = cleaned;
if (!delta) {
return;
}
if (!draftChunker) {
draftText = cleaned;
draftStream.update(draftText);
return;
}
draftChunker.append(delta);
draftChunker.drain({
force: false,
emit: (chunk) => {
draftText += chunk;
draftStream.update(draftText);
},
});
};
const flushDraft = async () => {
if (!draftStream) {
return;
}
if (draftChunker?.hasBuffered()) {
draftChunker.drain({
force: true,
emit: (chunk) => {
draftText += chunk;
},
});
draftChunker.reset();
if (draftText) {
draftStream.update(draftText);
}
}
await draftStream.flush();
};
// When draft streaming is active, suppress block streaming to avoid double-streaming.
const disableBlockStreamingForDraft = draftStream ? true : undefined;
const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
createReplyDispatcherWithTyping({
...prefixOptions,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
typingCallbacks,
deliver: async (payload: ReplyPayload, info) => {
if (isProcessAborted(abortSignal)) {
return;
}
const isFinal = info.kind === "final";
if (payload.isReasoning) {
// Reasoning/thinking payloads should not be delivered to Discord.
return;
}
if (draftStream && isFinal) {
await flushDraft();
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
const finalText = payload.text;
const previewFinalText = resolvePreviewFinalText(finalText);
const previewMessageId = draftStream.messageId();
// Try to finalize via preview edit (text-only, fits in 2000 chars, not an error)
const canFinalizeViaPreviewEdit =
!finalizedViaPreviewMessage &&
!hasMedia &&
typeof previewFinalText === "string" &&
typeof previewMessageId === "string" &&
!payload.isError;
if (canFinalizeViaPreviewEdit) {
await draftStream.stop();
if (isProcessAborted(abortSignal)) {
return;
}
try {
await editMessageDiscord(
deliverChannelId,
previewMessageId,
{ content: previewFinalText },
{ rest: client.rest },
);
finalizedViaPreviewMessage = true;
replyReference.markSent();
return;
} catch (err) {
logVerbose(
`discord: preview final edit failed; falling back to standard send (${String(err)})`,
);
}
}
// Check if stop() flushed a message we can edit
if (!finalizedViaPreviewMessage) {
await draftStream.stop();
if (isProcessAborted(abortSignal)) {
return;
}
const messageIdAfterStop = draftStream.messageId();
if (
typeof messageIdAfterStop === "string" &&
typeof previewFinalText === "string" &&
!hasMedia &&
!payload.isError
) {
try {
await editMessageDiscord(
deliverChannelId,
messageIdAfterStop,
{ content: previewFinalText },
{ rest: client.rest },
);
finalizedViaPreviewMessage = true;
replyReference.markSent();
return;
} catch (err) {
logVerbose(
`discord: post-stop preview edit failed; falling back to standard send (${String(err)})`,
);
}
}
}
// Clear the preview and fall through to standard delivery
if (!finalizedViaPreviewMessage) {
await draftStream.clear();
}
}
if (isProcessAborted(abortSignal)) {
return;
}
const replyToId = replyReference.use();
await deliverDiscordReply({
cfg,
replies: [payload],
target: deliverTarget,
token,
accountId,
rest: client.rest,
runtime,
replyToId,
replyToMode,
textLimit,
maxLinesPerMessage,
tableMode,
chunkMode,
sessionKey: ctxPayload.SessionKey,
threadBindings,
mediaLocalRoots,
});
replyReference.markSent();
},
onError: (err, info) => {
runtime.error?.(danger(`discord ${info.kind} reply failed: ${String(err)}`));
},
onReplyStart: async () => {
if (isProcessAborted(abortSignal)) {
return;
}
await typingCallbacks.onReplyStart();
await statusReactions.setThinking();
},
});
let dispatchResult: Awaited<ReturnType<typeof dispatchInboundMessage>> | null = null;
let dispatchError = false;
let dispatchAborted = false;
try {
if (isProcessAborted(abortSignal)) {
dispatchAborted = true;
return;
}
dispatchResult = await dispatchInboundMessage({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
abortSignal,
skillFilter: channelConfig?.skills,
disableBlockStreaming:
disableBlockStreamingForDraft ??
(typeof discordConfig?.blockStreaming === "boolean"
? !discordConfig.blockStreaming
: undefined),
onPartialReply: draftStream ? (payload) => updateDraftFromPartial(payload.text) : undefined,
onAssistantMessageStart: draftStream
? () => {
if (shouldSplitPreviewMessages && hasStreamedMessage) {
logVerbose("discord: calling forceNewMessage() for draft stream");
draftStream.forceNewMessage();
}
lastPartialText = "";
draftText = "";
draftChunker?.reset();
}
: undefined,
onReasoningEnd: draftStream
? () => {
if (shouldSplitPreviewMessages && hasStreamedMessage) {
logVerbose("discord: calling forceNewMessage() for draft stream");
draftStream.forceNewMessage();
}
lastPartialText = "";
draftText = "";
draftChunker?.reset();
}
: undefined,
onModelSelected,
onReasoningStream: async () => {
await statusReactions.setThinking();
},
onToolStart: async (payload) => {
if (isProcessAborted(abortSignal)) {
return;
}
await statusReactions.setTool(payload.name);
},
onCompactionStart: async () => {
if (isProcessAborted(abortSignal)) {
return;
}
await statusReactions.setCompacting();
},
onCompactionEnd: async () => {
if (isProcessAborted(abortSignal)) {
return;
}
statusReactions.cancelPending();
await statusReactions.setThinking();
},
},
});
if (isProcessAborted(abortSignal)) {
dispatchAborted = true;
return;
}
} catch (err) {
if (isProcessAborted(abortSignal)) {
dispatchAborted = true;
return;
}
dispatchError = true;
throw err;
} finally {
try {
// Must stop() first to flush debounced content before clear() wipes state.
await draftStream?.stop();
if (!finalizedViaPreviewMessage) {
await draftStream?.clear();
}
} catch (err) {
// Draft cleanup should never keep typing alive.
logVerbose(`discord: draft cleanup failed: ${String(err)}`);
} finally {
markRunComplete();
markDispatchIdle();
}
if (statusReactionsEnabled) {
if (dispatchAborted) {
if (removeAckAfterReply) {
void statusReactions.clear();
} else {
void statusReactions.restoreInitial();
}
} else {
if (dispatchError) {
await statusReactions.setError();
} else {
await statusReactions.setDone();
}
if (removeAckAfterReply) {
void (async () => {
await sleep(dispatchError ? DEFAULT_TIMING.errorHoldMs : DEFAULT_TIMING.doneHoldMs);
await statusReactions.clear();
})();
} else {
void statusReactions.restoreInitial();
}
}
}
}
if (dispatchAborted) {
return;
}
if (!dispatchResult?.queuedFinal) {
if (isGuildMessage) {
clearHistoryEntriesIfEnabled({
historyMap: guildHistories,
historyKey: messageChannelId,
limit: historyLimit,
});
}
return;
}
if (shouldLogVerbose()) {
const finalCount = dispatchResult.counts.final;
logVerbose(
`discord: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
);
}
if (isGuildMessage) {
clearHistoryEntriesIfEnabled({
historyMap: guildHistories,
historyKey: messageChannelId,
limit: historyLimit,
});
}
}

View File

@@ -1,5 +1,5 @@
import { vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.js";
import type { OpenClawConfig } from "../../../../src/config/types.js";
import type { createDiscordMessageHandler } from "./message-handler.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";

View File

@@ -0,0 +1,186 @@
import type { Client } from "@buape/carbon";
import {
createChannelInboundDebouncer,
shouldDebounceTextInbound,
} from "../../../../src/channels/inbound-debounce-policy.js";
import { resolveOpenProviderRuntimeGroupPolicy } from "../../../../src/config/runtime-group-policy.js";
import { danger } from "../../../../src/globals.js";
import { buildDiscordInboundJob } from "./inbound-job.js";
import { createDiscordInboundWorker } from "./inbound-worker.js";
import type { DiscordMessageEvent, DiscordMessageHandler } from "./listeners.js";
import { preflightDiscordMessage } from "./message-handler.preflight.js";
import type { DiscordMessagePreflightParams } from "./message-handler.preflight.types.js";
import {
hasDiscordMessageStickers,
resolveDiscordMessageChannelId,
resolveDiscordMessageText,
} from "./message-utils.js";
import type { DiscordMonitorStatusSink } from "./status.js";
type DiscordMessageHandlerParams = Omit<
DiscordMessagePreflightParams,
"ackReactionScope" | "groupPolicy" | "data" | "client"
> & {
setStatus?: DiscordMonitorStatusSink;
abortSignal?: AbortSignal;
workerRunTimeoutMs?: number;
};
export type DiscordMessageHandlerWithLifecycle = DiscordMessageHandler & {
deactivate: () => void;
};
export function createDiscordMessageHandler(
params: DiscordMessageHandlerParams,
): DiscordMessageHandlerWithLifecycle {
const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({
providerConfigPresent: params.cfg.channels?.discord !== undefined,
groupPolicy: params.discordConfig?.groupPolicy,
defaultGroupPolicy: params.cfg.channels?.defaults?.groupPolicy,
});
const ackReactionScope =
params.discordConfig?.ackReactionScope ??
params.cfg.messages?.ackReactionScope ??
"group-mentions";
const inboundWorker = createDiscordInboundWorker({
runtime: params.runtime,
setStatus: params.setStatus,
abortSignal: params.abortSignal,
runTimeoutMs: params.workerRunTimeoutMs,
});
const { debouncer } = createChannelInboundDebouncer<{
data: DiscordMessageEvent;
client: Client;
abortSignal?: AbortSignal;
}>({
cfg: params.cfg,
channel: "discord",
buildKey: (entry) => {
const message = entry.data.message;
const authorId = entry.data.author?.id;
if (!message || !authorId) {
return null;
}
const channelId = resolveDiscordMessageChannelId({
message,
eventChannelId: entry.data.channel_id,
});
if (!channelId) {
return null;
}
return `discord:${params.accountId}:${channelId}:${authorId}`;
},
shouldDebounce: (entry) => {
const message = entry.data.message;
if (!message) {
return false;
}
const baseText = resolveDiscordMessageText(message, { includeForwarded: false });
return shouldDebounceTextInbound({
text: baseText,
cfg: params.cfg,
hasMedia: Boolean(
(message.attachments && message.attachments.length > 0) ||
hasDiscordMessageStickers(message),
),
});
},
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) {
return;
}
const abortSignal = last.abortSignal;
if (abortSignal?.aborted) {
return;
}
if (entries.length === 1) {
const ctx = await preflightDiscordMessage({
...params,
ackReactionScope,
groupPolicy,
abortSignal,
data: last.data,
client: last.client,
});
if (!ctx) {
return;
}
inboundWorker.enqueue(buildDiscordInboundJob(ctx));
return;
}
const combinedBaseText = entries
.map((entry) => resolveDiscordMessageText(entry.data.message, { includeForwarded: false }))
.filter(Boolean)
.join("\n");
const syntheticMessage = {
...last.data.message,
content: combinedBaseText,
attachments: [],
message_snapshots: (last.data.message as { message_snapshots?: unknown }).message_snapshots,
messageSnapshots: (last.data.message as { messageSnapshots?: unknown }).messageSnapshots,
rawData: {
...(last.data.message as { rawData?: Record<string, unknown> }).rawData,
},
};
const syntheticData: DiscordMessageEvent = {
...last.data,
message: syntheticMessage,
};
const ctx = await preflightDiscordMessage({
...params,
ackReactionScope,
groupPolicy,
abortSignal,
data: syntheticData,
client: last.client,
});
if (!ctx) {
return;
}
if (entries.length > 1) {
const ids = entries.map((entry) => entry.data.message?.id).filter(Boolean) as string[];
if (ids.length > 0) {
const ctxBatch = ctx as typeof ctx & {
MessageSids?: string[];
MessageSidFirst?: string;
MessageSidLast?: string;
};
ctxBatch.MessageSids = ids;
ctxBatch.MessageSidFirst = ids[0];
ctxBatch.MessageSidLast = ids[ids.length - 1];
}
}
inboundWorker.enqueue(buildDiscordInboundJob(ctx));
},
onError: (err) => {
params.runtime.error?.(danger(`discord debounce flush failed: ${String(err)}`));
},
});
const handler: DiscordMessageHandlerWithLifecycle = async (data, client, options) => {
try {
if (options?.abortSignal?.aborted) {
return;
}
// Filter bot-own messages before they enter the debounce queue.
// The same check exists in preflightDiscordMessage(), but by that point
// the message has already consumed debounce capacity and blocked
// legitimate user messages. On active servers this causes cumulative
// slowdown (see #15874).
const msgAuthorId = data.message?.author?.id ?? data.author?.id;
if (params.botUserId && msgAuthorId === params.botUserId) {
return;
}
await debouncer.enqueue({ data, client, abortSignal: options?.abortSignal });
} catch (err) {
params.runtime.error?.(danger(`handler failed: ${String(err)}`));
}
};
handler.deactivate = inboundWorker.deactivate;
return handler;
}

View File

@@ -5,15 +5,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const fetchRemoteMedia = vi.fn();
const saveMediaBuffer = vi.fn();
vi.mock("../../media/fetch.js", () => ({
vi.mock("../../../../src/media/fetch.js", () => ({
fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMedia(...args),
}));
vi.mock("../../media/store.js", () => ({
vi.mock("../../../../src/media/store.js", () => ({
saveMediaBuffer: (...args: unknown[]) => saveMediaBuffer(...args),
}));
vi.mock("../../globals.js", () => ({
vi.mock("../../../../src/globals.js", () => ({
logVerbose: () => {},
}));

View File

@@ -0,0 +1,637 @@
import type { ChannelType, Client, Message } from "@buape/carbon";
import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10";
import { buildMediaPayload } from "../../../../src/channels/plugins/media-payload.js";
import { logVerbose } from "../../../../src/globals.js";
import type { SsrFPolicy } from "../../../../src/infra/net/ssrf.js";
import { fetchRemoteMedia, type FetchLike } from "../../../../src/media/fetch.js";
import { saveMediaBuffer } from "../../../../src/media/store.js";
const DISCORD_CDN_HOSTNAMES = [
"cdn.discordapp.com",
"media.discordapp.net",
"*.discordapp.com",
"*.discordapp.net",
];
// Allow Discord CDN downloads when VPN/proxy DNS resolves to RFC2544 benchmark ranges.
const DISCORD_MEDIA_SSRF_POLICY: SsrFPolicy = {
hostnameAllowlist: DISCORD_CDN_HOSTNAMES,
allowRfc2544BenchmarkRange: true,
};
function mergeHostnameList(...lists: Array<string[] | undefined>): string[] | undefined {
const merged = lists
.flatMap((list) => list ?? [])
.map((value) => value.trim())
.filter((value) => value.length > 0);
if (merged.length === 0) {
return undefined;
}
return Array.from(new Set(merged));
}
function resolveDiscordMediaSsrFPolicy(policy?: SsrFPolicy): SsrFPolicy {
if (!policy) {
return DISCORD_MEDIA_SSRF_POLICY;
}
const hostnameAllowlist = mergeHostnameList(
DISCORD_MEDIA_SSRF_POLICY.hostnameAllowlist,
policy.hostnameAllowlist,
);
const allowedHostnames = mergeHostnameList(
DISCORD_MEDIA_SSRF_POLICY.allowedHostnames,
policy.allowedHostnames,
);
return {
...DISCORD_MEDIA_SSRF_POLICY,
...policy,
...(allowedHostnames ? { allowedHostnames } : {}),
...(hostnameAllowlist ? { hostnameAllowlist } : {}),
allowRfc2544BenchmarkRange:
Boolean(DISCORD_MEDIA_SSRF_POLICY.allowRfc2544BenchmarkRange) ||
Boolean(policy.allowRfc2544BenchmarkRange),
};
}
export type DiscordMediaInfo = {
path: string;
contentType?: string;
placeholder: string;
};
export type DiscordChannelInfo = {
type: ChannelType;
name?: string;
topic?: string;
parentId?: string;
ownerId?: string;
};
type DiscordMessageWithChannelId = Message & {
channel_id?: unknown;
rawData?: { channel_id?: unknown };
};
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;
stickers?: APIStickerItem[] | null;
sticker_items?: APIStickerItem[] | null;
author?: DiscordSnapshotAuthor | null;
};
type DiscordMessageSnapshot = {
message?: DiscordSnapshotMessage | null;
};
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_STICKER_ASSET_BASE_URL = "https://media.discordapp.net/stickers";
export function __resetDiscordChannelInfoCacheForTest() {
DISCORD_CHANNEL_INFO_CACHE.clear();
}
function normalizeDiscordChannelId(value: unknown): string {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" || typeof value === "bigint") {
return String(value).trim();
}
return "";
}
export function resolveDiscordMessageChannelId(params: {
message: Message;
eventChannelId?: string | number | null;
}): string {
const message = params.message as DiscordMessageWithChannelId;
return (
normalizeDiscordChannelId(message.channelId) ||
normalizeDiscordChannelId(message.channel_id) ||
normalizeDiscordChannelId(message.rawData?.channel_id) ||
normalizeDiscordChannelId(params.eventChannelId)
);
}
export 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 ownerId = "ownerId" in channel ? (channel.ownerId ?? undefined) : undefined;
const payload: DiscordChannelInfo = {
type: channel.type,
name,
topic,
parentId,
ownerId,
};
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;
}
}
function normalizeStickerItems(value: unknown): APIStickerItem[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter(
(entry): entry is APIStickerItem =>
Boolean(entry) &&
typeof entry === "object" &&
typeof (entry as { id?: unknown }).id === "string" &&
typeof (entry as { name?: unknown }).name === "string",
);
}
export function resolveDiscordMessageStickers(message: Message): APIStickerItem[] {
const stickers = (message as { stickers?: unknown }).stickers;
const normalized = normalizeStickerItems(stickers);
if (normalized.length > 0) {
return normalized;
}
const rawData = (message as { rawData?: { sticker_items?: unknown; stickers?: unknown } })
.rawData;
return normalizeStickerItems(rawData?.sticker_items ?? rawData?.stickers);
}
function resolveDiscordSnapshotStickers(snapshot: DiscordSnapshotMessage): APIStickerItem[] {
return normalizeStickerItems(snapshot.stickers ?? snapshot.sticker_items);
}
export function hasDiscordMessageStickers(message: Message): boolean {
return resolveDiscordMessageStickers(message).length > 0;
}
export async function resolveMediaList(
message: Message,
maxBytes: number,
fetchImpl?: FetchLike,
ssrfPolicy?: SsrFPolicy,
): Promise<DiscordMediaInfo[]> {
const out: DiscordMediaInfo[] = [];
const resolvedSsrFPolicy = resolveDiscordMediaSsrFPolicy(ssrfPolicy);
await appendResolvedMediaFromAttachments({
attachments: message.attachments ?? [],
maxBytes,
out,
errorPrefix: "discord: failed to download attachment",
fetchImpl,
ssrfPolicy: resolvedSsrFPolicy,
});
await appendResolvedMediaFromStickers({
stickers: resolveDiscordMessageStickers(message),
maxBytes,
out,
errorPrefix: "discord: failed to download sticker",
fetchImpl,
ssrfPolicy: resolvedSsrFPolicy,
});
return out;
}
export async function resolveForwardedMediaList(
message: Message,
maxBytes: number,
fetchImpl?: FetchLike,
ssrfPolicy?: SsrFPolicy,
): Promise<DiscordMediaInfo[]> {
const snapshots = resolveDiscordMessageSnapshots(message);
if (snapshots.length === 0) {
return [];
}
const out: DiscordMediaInfo[] = [];
const resolvedSsrFPolicy = resolveDiscordMediaSsrFPolicy(ssrfPolicy);
for (const snapshot of snapshots) {
await appendResolvedMediaFromAttachments({
attachments: snapshot.message?.attachments,
maxBytes,
out,
errorPrefix: "discord: failed to download forwarded attachment",
fetchImpl,
ssrfPolicy: resolvedSsrFPolicy,
});
await appendResolvedMediaFromStickers({
stickers: snapshot.message ? resolveDiscordSnapshotStickers(snapshot.message) : [],
maxBytes,
out,
errorPrefix: "discord: failed to download forwarded sticker",
fetchImpl,
ssrfPolicy: resolvedSsrFPolicy,
});
}
return out;
}
async function appendResolvedMediaFromAttachments(params: {
attachments?: APIAttachment[] | null;
maxBytes: number;
out: DiscordMediaInfo[];
errorPrefix: string;
fetchImpl?: FetchLike;
ssrfPolicy?: SsrFPolicy;
}) {
const attachments = params.attachments;
if (!attachments || attachments.length === 0) {
return;
}
for (const attachment of attachments) {
try {
const fetched = await fetchRemoteMedia({
url: attachment.url,
filePathHint: attachment.filename ?? attachment.url,
maxBytes: params.maxBytes,
fetchImpl: params.fetchImpl,
ssrfPolicy: params.ssrfPolicy,
});
const saved = await saveMediaBuffer(
fetched.buffer,
fetched.contentType ?? attachment.content_type,
"inbound",
params.maxBytes,
);
params.out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: inferPlaceholder(attachment),
});
} catch (err) {
const id = attachment.id ?? attachment.url;
logVerbose(`${params.errorPrefix} ${id}: ${String(err)}`);
// Preserve attachment context even when remote fetch is blocked/fails.
params.out.push({
path: attachment.url,
contentType: attachment.content_type,
placeholder: inferPlaceholder(attachment),
});
}
}
}
type DiscordStickerAssetCandidate = {
url: string;
fileName: string;
};
function resolveStickerAssetCandidates(sticker: APIStickerItem): DiscordStickerAssetCandidate[] {
const baseName = sticker.name?.trim() || `sticker-${sticker.id}`;
switch (sticker.format_type) {
case StickerFormatType.GIF:
return [
{
url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.gif`,
fileName: `${baseName}.gif`,
},
];
case StickerFormatType.Lottie:
return [
{
url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.png?size=160`,
fileName: `${baseName}.png`,
},
{
url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.json`,
fileName: `${baseName}.json`,
},
];
case StickerFormatType.APNG:
case StickerFormatType.PNG:
default:
return [
{
url: `${DISCORD_STICKER_ASSET_BASE_URL}/${sticker.id}.png`,
fileName: `${baseName}.png`,
},
];
}
}
function formatStickerError(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
if (typeof err === "string") {
return err;
}
try {
return JSON.stringify(err) ?? "unknown error";
} catch {
return "unknown error";
}
}
function inferStickerContentType(sticker: APIStickerItem): string | undefined {
switch (sticker.format_type) {
case StickerFormatType.GIF:
return "image/gif";
case StickerFormatType.APNG:
case StickerFormatType.Lottie:
case StickerFormatType.PNG:
return "image/png";
default:
return undefined;
}
}
async function appendResolvedMediaFromStickers(params: {
stickers?: APIStickerItem[] | null;
maxBytes: number;
out: DiscordMediaInfo[];
errorPrefix: string;
fetchImpl?: FetchLike;
ssrfPolicy?: SsrFPolicy;
}) {
const stickers = params.stickers;
if (!stickers || stickers.length === 0) {
return;
}
for (const sticker of stickers) {
const candidates = resolveStickerAssetCandidates(sticker);
let lastError: unknown;
for (const candidate of candidates) {
try {
const fetched = await fetchRemoteMedia({
url: candidate.url,
filePathHint: candidate.fileName,
maxBytes: params.maxBytes,
fetchImpl: params.fetchImpl,
ssrfPolicy: params.ssrfPolicy,
});
const saved = await saveMediaBuffer(
fetched.buffer,
fetched.contentType,
"inbound",
params.maxBytes,
);
params.out.push({
path: saved.path,
contentType: saved.contentType,
placeholder: "<media:sticker>",
});
lastError = null;
break;
} catch (err) {
lastError = err;
}
}
if (lastError) {
logVerbose(`${params.errorPrefix} ${sticker.id}: ${formatStickerError(lastError)}`);
const fallback = candidates[0];
if (fallback) {
params.out.push({
path: fallback.url,
contentType: inferStickerContentType(sticker),
placeholder: "<media:sticker>",
});
}
}
}
}
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 buildDiscordStickerPlaceholder(stickers?: APIStickerItem[]): string {
if (!stickers || stickers.length === 0) {
return "";
}
const count = stickers.length;
const label = count === 1 ? "sticker" : "stickers";
return `<media:sticker> (${count} ${label})`;
}
function buildDiscordMediaPlaceholder(params: {
attachments?: APIAttachment[];
stickers?: APIStickerItem[];
}): string {
const attachmentText = buildDiscordAttachmentPlaceholder(params.attachments);
const stickerText = buildDiscordStickerPlaceholder(params.stickers);
if (attachmentText && stickerText) {
return `${attachmentText}\n${stickerText}`;
}
return attachmentText || stickerText || "";
}
export function resolveDiscordEmbedText(
embed?: { title?: string | null; description?: string | null } | null,
): string {
const title = embed?.title?.trim() || "";
const description = embed?.description?.trim() || "";
if (title && description) {
return `${title}\n${description}`;
}
return title || description || "";
}
export function resolveDiscordMessageText(
message: Message,
options?: { fallbackText?: string; includeForwarded?: boolean },
): string {
const embedText = resolveDiscordEmbedText(
(message.embeds?.[0] as { title?: string | null; description?: string | null } | undefined) ??
null,
);
const rawText =
message.content?.trim() ||
buildDiscordMediaPlaceholder({
attachments: message.attachments ?? undefined,
stickers: resolveDiscordMessageStickers(message),
}) ||
embedText ||
options?.fallbackText?.trim() ||
"";
const baseText = resolveDiscordMentions(rawText, message);
if (!options?.includeForwarded) {
return baseText;
}
const forwardedText = resolveDiscordForwardedMessagesText(message);
if (!forwardedText) {
return baseText;
}
if (!baseText) {
return forwardedText;
}
return `${baseText}\n${forwardedText}`;
}
function resolveDiscordMentions(text: string, message: Message): string {
if (!text.includes("<")) {
return text;
}
const mentions = message.mentionedUsers ?? [];
if (!Array.isArray(mentions) || mentions.length === 0) {
return text;
}
let out = text;
for (const user of mentions) {
const label = user.globalName || user.username;
out = out.replace(new RegExp(`<@!?${user.id}>`, "g"), `@${label}`);
}
return out;
}
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 = buildDiscordMediaPlaceholder({
attachments: snapshot.attachments ?? undefined,
stickers: resolveDiscordSnapshotStickers(snapshot),
});
const embedText = resolveDiscordEmbedText(snapshot.embeds?.[0]);
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[];
} {
return buildMediaPayload(mediaList);
}

View File

@@ -0,0 +1,165 @@
import os from "node:os";
import path from "node:path";
import { normalizeProviderId } from "../../../../src/agents/model-selection.js";
import { resolveStateDir } from "../../../../src/config/paths.js";
import { withFileLock } from "../../../../src/infra/file-lock.js";
import { resolveRequiredHomeDir } from "../../../../src/infra/home-dir.js";
import {
readJsonFileWithFallback,
writeJsonFileAtomically,
} from "../../../../src/plugin-sdk/json-store.js";
import { normalizeAccountId as normalizeSharedAccountId } from "../../../../src/routing/account-id.js";
const MODEL_PICKER_PREFERENCES_LOCK_OPTIONS = {
retries: {
retries: 8,
factor: 2,
minTimeout: 50,
maxTimeout: 5_000,
randomize: true,
},
stale: 15_000,
} as const;
const DEFAULT_RECENT_LIMIT = 5;
type ModelPickerPreferencesEntry = {
recent: string[];
updatedAt: string;
};
type ModelPickerPreferencesStore = {
version: 1;
entries: Record<string, ModelPickerPreferencesEntry>;
};
export type DiscordModelPickerPreferenceScope = {
accountId?: string;
guildId?: string;
userId: string;
};
function resolvePreferencesStorePath(env: NodeJS.ProcessEnv = process.env): string {
const stateDir = resolveStateDir(env, () => resolveRequiredHomeDir(env, os.homedir));
return path.join(stateDir, "discord", "model-picker-preferences.json");
}
function normalizeId(value?: string): string {
return value?.trim() ?? "";
}
export function buildDiscordModelPickerPreferenceKey(
scope: DiscordModelPickerPreferenceScope,
): string | null {
const userId = normalizeId(scope.userId);
if (!userId) {
return null;
}
const accountId = normalizeSharedAccountId(scope.accountId);
const guildId = normalizeId(scope.guildId);
if (guildId) {
return `discord:${accountId}:guild:${guildId}:user:${userId}`;
}
return `discord:${accountId}:dm:user:${userId}`;
}
function normalizeModelRef(raw?: string): string | null {
const value = raw?.trim();
if (!value) {
return null;
}
const slashIndex = value.indexOf("/");
if (slashIndex <= 0 || slashIndex >= value.length - 1) {
return null;
}
const provider = normalizeProviderId(value.slice(0, slashIndex));
const model = value.slice(slashIndex + 1).trim();
if (!provider || !model) {
return null;
}
return `${provider}/${model}`;
}
function sanitizeRecentModels(models: string[] | undefined, limit: number): string[] {
const deduped: string[] = [];
const seen = new Set<string>();
for (const item of models ?? []) {
const normalized = normalizeModelRef(item);
if (!normalized || seen.has(normalized)) {
continue;
}
seen.add(normalized);
deduped.push(normalized);
if (deduped.length >= limit) {
break;
}
}
return deduped;
}
async function readPreferencesStore(filePath: string): Promise<ModelPickerPreferencesStore> {
const { value } = await readJsonFileWithFallback<ModelPickerPreferencesStore>(filePath, {
version: 1,
entries: {},
});
if (!value || typeof value !== "object" || value.version !== 1) {
return { version: 1, entries: {} };
}
return {
version: 1,
entries: value.entries && typeof value.entries === "object" ? value.entries : {},
};
}
export async function readDiscordModelPickerRecentModels(params: {
scope: DiscordModelPickerPreferenceScope;
limit?: number;
allowedModelRefs?: Set<string>;
env?: NodeJS.ProcessEnv;
}): Promise<string[]> {
const key = buildDiscordModelPickerPreferenceKey(params.scope);
if (!key) {
return [];
}
const limit = Math.max(1, Math.min(params.limit ?? DEFAULT_RECENT_LIMIT, 10));
const filePath = resolvePreferencesStorePath(params.env);
const store = await readPreferencesStore(filePath);
const entry = store.entries[key];
const recent = sanitizeRecentModels(entry?.recent, limit);
if (!params.allowedModelRefs || params.allowedModelRefs.size === 0) {
return recent;
}
return recent.filter((modelRef) => params.allowedModelRefs?.has(modelRef));
}
export async function recordDiscordModelPickerRecentModel(params: {
scope: DiscordModelPickerPreferenceScope;
modelRef: string;
limit?: number;
env?: NodeJS.ProcessEnv;
}): Promise<void> {
const key = buildDiscordModelPickerPreferenceKey(params.scope);
const normalizedModelRef = normalizeModelRef(params.modelRef);
if (!key || !normalizedModelRef) {
return;
}
const limit = Math.max(1, Math.min(params.limit ?? DEFAULT_RECENT_LIMIT, 10));
const filePath = resolvePreferencesStorePath(params.env);
await withFileLock(filePath, MODEL_PICKER_PREFERENCES_LOCK_OPTIONS, async () => {
const store = await readPreferencesStore(filePath);
const existing = sanitizeRecentModels(store.entries[key]?.recent, limit);
const next = [
normalizedModelRef,
...existing.filter((entry) => entry !== normalizedModelRef),
].slice(0, limit);
store.entries[key] = {
recent: next,
updatedAt: new Date().toISOString(),
};
await writeJsonFileAtomically(filePath, store);
});
}

View File

@@ -1,4 +1,4 @@
import type { ModelsProviderData } from "../../auto-reply/reply/commands-models.js";
import type { ModelsProviderData } from "../../../../src/auto-reply/reply/commands-models.js";
export function createModelsProviderData(
entries: Record<string, string[]>,

View File

@@ -1,8 +1,8 @@
import { serializePayload } from "@buape/carbon";
import { ComponentType } from "discord-api-types/v10";
import { describe, expect, it, vi } from "vitest";
import * as modelsCommandModule from "../../auto-reply/reply/commands-models.js";
import type { OpenClawConfig } from "../../config/config.js";
import * as modelsCommandModule from "../../../../src/auto-reply/reply/commands-models.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import {
DISCORD_CUSTOM_ID_MAX_CHARS,
DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE,

View File

@@ -0,0 +1,939 @@
import {
Button,
Container,
Row,
Separator,
StringSelectMenu,
TextDisplay,
type ComponentData,
type MessagePayloadObject,
type TopLevelComponents,
} from "@buape/carbon";
import type { APISelectMenuOption } from "discord-api-types/v10";
import { ButtonStyle } from "discord-api-types/v10";
import { normalizeProviderId } from "../../../../src/agents/model-selection.js";
import {
buildModelsProviderData,
type ModelsProviderData,
} from "../../../../src/auto-reply/reply/commands-models.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
export const DISCORD_MODEL_PICKER_CUSTOM_ID_KEY = "mdlpk";
export const DISCORD_CUSTOM_ID_MAX_CHARS = 100;
// Discord component limits.
export const DISCORD_COMPONENT_MAX_ROWS = 5;
export const DISCORD_COMPONENT_MAX_BUTTONS_PER_ROW = 5;
export const DISCORD_COMPONENT_MAX_SELECT_OPTIONS = 25;
// Reserve one row for navigation/utility buttons when rendering providers.
export const DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE =
DISCORD_COMPONENT_MAX_BUTTONS_PER_ROW * (DISCORD_COMPONENT_MAX_ROWS - 1);
// When providers fit in one page, we can use all button rows and hide nav controls.
export const DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX =
DISCORD_COMPONENT_MAX_BUTTONS_PER_ROW * DISCORD_COMPONENT_MAX_ROWS;
export const DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE = DISCORD_COMPONENT_MAX_SELECT_OPTIONS;
const DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS = 18;
const COMMAND_CONTEXTS = ["model", "models"] as const;
const PICKER_ACTIONS = [
"open",
"provider",
"model",
"submit",
"quick",
"back",
"reset",
"cancel",
"recents",
] as const;
const PICKER_VIEWS = ["providers", "models", "recents"] as const;
export type DiscordModelPickerCommandContext = (typeof COMMAND_CONTEXTS)[number];
export type DiscordModelPickerAction = (typeof PICKER_ACTIONS)[number];
export type DiscordModelPickerView = (typeof PICKER_VIEWS)[number];
export type DiscordModelPickerState = {
command: DiscordModelPickerCommandContext;
action: DiscordModelPickerAction;
view: DiscordModelPickerView;
userId: string;
provider?: string;
page: number;
providerPage?: number;
modelIndex?: number;
recentSlot?: number;
};
export type DiscordModelPickerProviderItem = {
id: string;
count: number;
};
export type DiscordModelPickerPage<T> = {
items: T[];
page: number;
pageSize: number;
totalPages: number;
totalItems: number;
hasPrev: boolean;
hasNext: boolean;
};
export type DiscordModelPickerModelPage = DiscordModelPickerPage<string> & {
provider: string;
};
export type DiscordModelPickerLayout = "v2" | "classic";
type DiscordModelPickerButtonOptions = {
label: string;
customId: string;
style?: ButtonStyle;
disabled?: boolean;
};
type DiscordModelPickerCurrentModelRef = {
provider: string;
model: string;
};
type DiscordModelPickerRow = Row<Button> | Row<StringSelectMenu>;
type DiscordModelPickerRenderShellParams = {
layout: DiscordModelPickerLayout;
title: string;
detailLines: string[];
rows: DiscordModelPickerRow[];
footer?: string;
/** Text shown after the divider but before the interactive rows. */
preRowText?: string;
/** Extra rows appended after the main rows, preceded by a divider. */
trailingRows?: DiscordModelPickerRow[];
};
export type DiscordModelPickerRenderedView = {
layout: DiscordModelPickerLayout;
content?: string;
components: TopLevelComponents[];
};
export type DiscordModelPickerProviderViewParams = {
command: DiscordModelPickerCommandContext;
userId: string;
data: ModelsProviderData;
page?: number;
currentModel?: string;
layout?: DiscordModelPickerLayout;
};
export type DiscordModelPickerModelViewParams = {
command: DiscordModelPickerCommandContext;
userId: string;
data: ModelsProviderData;
provider: string;
page?: number;
providerPage?: number;
currentModel?: string;
pendingModel?: string;
pendingModelIndex?: number;
quickModels?: string[];
layout?: DiscordModelPickerLayout;
};
function encodeCustomIdValue(value: string): string {
return encodeURIComponent(value);
}
function decodeCustomIdValue(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function isValidCommandContext(value: string): value is DiscordModelPickerCommandContext {
return (COMMAND_CONTEXTS as readonly string[]).includes(value);
}
function isValidPickerAction(value: string): value is DiscordModelPickerAction {
return (PICKER_ACTIONS as readonly string[]).includes(value);
}
function isValidPickerView(value: string): value is DiscordModelPickerView {
return (PICKER_VIEWS as readonly string[]).includes(value);
}
function normalizePage(value: number | undefined): number {
const numeric = typeof value === "number" ? value : Number.NaN;
if (!Number.isFinite(numeric)) {
return 1;
}
return Math.max(1, Math.floor(numeric));
}
function parseRawPage(value: unknown): number {
if (typeof value === "number") {
return normalizePage(value);
}
if (typeof value === "string" && value.trim()) {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed)) {
return normalizePage(parsed);
}
}
return 1;
}
function parseRawPositiveInt(value: unknown): number | undefined {
if (typeof value !== "string" && typeof value !== "number") {
return undefined;
}
const parsed = Number.parseInt(String(value), 10);
if (!Number.isFinite(parsed) || parsed < 1) {
return undefined;
}
return Math.floor(parsed);
}
function coerceString(value: unknown): string {
return typeof value === "string" || typeof value === "number" ? String(value) : "";
}
function clampPageSize(rawPageSize: number | undefined, max: number, fallback: number): number {
if (!Number.isFinite(rawPageSize)) {
return fallback;
}
return Math.min(max, Math.max(1, Math.floor(rawPageSize ?? fallback)));
}
function paginateItems<T>(params: {
items: T[];
page: number;
pageSize: number;
}): DiscordModelPickerPage<T> {
const totalItems = params.items.length;
const totalPages = Math.max(1, Math.ceil(totalItems / params.pageSize));
const page = Math.max(1, Math.min(params.page, totalPages));
const startIndex = (page - 1) * params.pageSize;
const endIndexExclusive = Math.min(totalItems, startIndex + params.pageSize);
return {
items: params.items.slice(startIndex, endIndexExclusive),
page,
pageSize: params.pageSize,
totalPages,
totalItems,
hasPrev: page > 1,
hasNext: page < totalPages,
};
}
function parseCurrentModelRef(raw?: string): DiscordModelPickerCurrentModelRef | null {
const trimmed = raw?.trim();
const match = trimmed?.match(/^([^/]+)\/(.+)$/u);
if (!match) {
return null;
}
const provider = normalizeProviderId(match[1]);
// Preserve the model suffix exactly as entered after "/" so select defaults
// continue to mirror the stored ref for Discord interactions.
const model = match[2];
if (!provider || !model) {
return null;
}
return { provider, model };
}
function formatCurrentModelLine(currentModel?: string): string {
const parsed = parseCurrentModelRef(currentModel);
if (!parsed) {
return "Current model: default";
}
return `Current model: ${parsed.provider}/${parsed.model}`;
}
function formatProviderButtonLabel(provider: string): string {
if (provider.length <= DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS) {
return provider;
}
return `${provider.slice(0, DISCORD_PROVIDER_BUTTON_LABEL_MAX_CHARS - 1)}`;
}
function chunkProvidersForRows(
items: DiscordModelPickerProviderItem[],
): DiscordModelPickerProviderItem[][] {
if (items.length === 0) {
return [];
}
const rowCount = Math.max(1, Math.ceil(items.length / DISCORD_COMPONENT_MAX_BUTTONS_PER_ROW));
const minPerRow = Math.floor(items.length / rowCount);
const rowsWithExtraItem = items.length % rowCount;
const counts = Array.from({ length: rowCount }, (_, index) =>
index < rowCount - rowsWithExtraItem ? minPerRow : minPerRow + 1,
);
const rows: DiscordModelPickerProviderItem[][] = [];
let cursor = 0;
for (const count of counts) {
rows.push(items.slice(cursor, cursor + count));
cursor += count;
}
return rows;
}
function createModelPickerButton(params: DiscordModelPickerButtonOptions): Button {
class DiscordModelPickerButton extends Button {
label = params.label;
customId = params.customId;
style = params.style ?? ButtonStyle.Secondary;
disabled = params.disabled ?? false;
}
return new DiscordModelPickerButton();
}
function createModelSelect(params: {
customId: string;
options: APISelectMenuOption[];
placeholder?: string;
disabled?: boolean;
}): StringSelectMenu {
class DiscordModelPickerSelect extends StringSelectMenu {
customId = params.customId;
options = params.options;
minValues = 1;
maxValues = 1;
placeholder = params.placeholder;
disabled = params.disabled ?? false;
}
return new DiscordModelPickerSelect();
}
function buildRenderedShell(
params: DiscordModelPickerRenderShellParams,
): DiscordModelPickerRenderedView {
if (params.layout === "classic") {
const lines = [params.title, ...params.detailLines, "", params.footer].filter(Boolean);
return {
layout: "classic",
content: lines.join("\n"),
components: params.rows,
};
}
const containerComponents: Array<TextDisplay | Separator | DiscordModelPickerRow> = [
new TextDisplay(`## ${params.title}`),
];
if (params.detailLines.length > 0) {
containerComponents.push(new TextDisplay(params.detailLines.join("\n")));
}
containerComponents.push(new Separator({ divider: true, spacing: "small" }));
if (params.preRowText) {
containerComponents.push(new TextDisplay(params.preRowText));
}
containerComponents.push(...params.rows);
if (params.trailingRows && params.trailingRows.length > 0) {
containerComponents.push(new Separator({ divider: true, spacing: "small" }));
containerComponents.push(...params.trailingRows);
}
if (params.footer) {
containerComponents.push(new Separator({ divider: false, spacing: "small" }));
containerComponents.push(new TextDisplay(`-# ${params.footer}`));
}
const container = new Container(containerComponents);
return {
layout: "v2",
components: [container],
};
}
function buildProviderRows(params: {
command: DiscordModelPickerCommandContext;
userId: string;
page: DiscordModelPickerPage<DiscordModelPickerProviderItem>;
currentProvider?: string;
}): Row<Button>[] {
const rows = chunkProvidersForRows(params.page.items).map(
(providers) =>
new Row(
providers.map((provider) => {
const style =
provider.id === params.currentProvider ? ButtonStyle.Primary : ButtonStyle.Secondary;
return createModelPickerButton({
label: formatProviderButtonLabel(provider.id),
style,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "provider",
view: "models",
provider: provider.id,
page: params.page.page,
userId: params.userId,
}),
});
}),
),
);
return rows;
}
function buildModelRows(params: {
command: DiscordModelPickerCommandContext;
userId: string;
data: ModelsProviderData;
providerPage: number;
modelPage: DiscordModelPickerModelPage;
currentModel?: string;
pendingModel?: string;
pendingModelIndex?: number;
quickModels?: string[];
}): { rows: DiscordModelPickerRow[]; buttonRow: Row<Button> } {
const parsedCurrentModel = parseCurrentModelRef(params.currentModel);
const parsedPendingModel = parseCurrentModelRef(params.pendingModel);
const rows: DiscordModelPickerRow[] = [];
const hasQuickModels = (params.quickModels ?? []).length > 0;
const providerPage = getDiscordModelPickerProviderPage({
data: params.data,
page: params.providerPage,
});
const providerOptions: APISelectMenuOption[] = providerPage.items.map((provider) => ({
label: provider.id,
value: provider.id,
default: provider.id === params.modelPage.provider,
}));
rows.push(
new Row([
createModelSelect({
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "provider",
view: "models",
provider: params.modelPage.provider,
page: providerPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
options: providerOptions,
placeholder: "Select provider",
}),
]),
);
const selectedModelRef = parsedPendingModel ?? parsedCurrentModel;
const modelOptions: APISelectMenuOption[] = params.modelPage.items.map((model) => ({
label: model,
value: model,
default: selectedModelRef
? selectedModelRef.provider === params.modelPage.provider && selectedModelRef.model === model
: false,
}));
rows.push(
new Row([
createModelSelect({
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "model",
view: "models",
provider: params.modelPage.provider,
page: params.modelPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
options: modelOptions,
placeholder: `Select ${params.modelPage.provider} model`,
}),
]),
);
const resolvedDefault = params.data.resolvedDefault;
const shouldDisableReset =
Boolean(parsedCurrentModel) &&
parsedCurrentModel?.provider === resolvedDefault.provider &&
parsedCurrentModel?.model === resolvedDefault.model;
const hasPendingSelection =
Boolean(parsedPendingModel) &&
parsedPendingModel?.provider === params.modelPage.provider &&
typeof params.pendingModelIndex === "number" &&
params.pendingModelIndex > 0;
const buttonRowItems: Button[] = [
createModelPickerButton({
label: "Cancel",
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "cancel",
view: "models",
provider: params.modelPage.provider,
page: params.modelPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
}),
createModelPickerButton({
label: "Reset to default",
style: ButtonStyle.Secondary,
disabled: shouldDisableReset,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "reset",
view: "models",
provider: params.modelPage.provider,
page: params.modelPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
}),
];
if (hasQuickModels) {
buttonRowItems.push(
createModelPickerButton({
label: "Recents",
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "recents",
view: "recents",
provider: params.modelPage.provider,
page: params.modelPage.page,
providerPage: providerPage.page,
userId: params.userId,
}),
}),
);
}
buttonRowItems.push(
createModelPickerButton({
label: "Submit",
style: ButtonStyle.Primary,
disabled: !hasPendingSelection,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "submit",
view: "models",
provider: params.modelPage.provider,
page: params.modelPage.page,
providerPage: providerPage.page,
modelIndex: params.pendingModelIndex,
userId: params.userId,
}),
}),
);
return { rows, buttonRow: new Row(buttonRowItems) };
}
/**
* Source-of-truth data for Discord picker views. This intentionally reuses the
* same provider/model resolver used by text and Telegram model commands.
*/
export async function loadDiscordModelPickerData(
cfg: OpenClawConfig,
agentId?: string,
): Promise<ModelsProviderData> {
return buildModelsProviderData(cfg, agentId);
}
export function buildDiscordModelPickerCustomId(params: {
command: DiscordModelPickerCommandContext;
action: DiscordModelPickerAction;
view: DiscordModelPickerView;
userId: string;
provider?: string;
page?: number;
providerPage?: number;
modelIndex?: number;
recentSlot?: number;
}): string {
const userId = params.userId.trim();
if (!userId) {
throw new Error("Discord model picker custom_id requires userId");
}
const page = normalizePage(params.page);
const providerPage =
typeof params.providerPage === "number" && Number.isFinite(params.providerPage)
? Math.max(1, Math.floor(params.providerPage))
: undefined;
const normalizedProvider = params.provider ? normalizeProviderId(params.provider) : undefined;
const modelIndex =
typeof params.modelIndex === "number" && Number.isFinite(params.modelIndex)
? Math.max(1, Math.floor(params.modelIndex))
: undefined;
const recentSlot =
typeof params.recentSlot === "number" && Number.isFinite(params.recentSlot)
? Math.max(1, Math.floor(params.recentSlot))
: undefined;
const parts = [
`${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:c=${encodeCustomIdValue(params.command)}`,
`a=${encodeCustomIdValue(params.action)}`,
`v=${encodeCustomIdValue(params.view)}`,
`u=${encodeCustomIdValue(userId)}`,
`g=${String(page)}`,
];
if (normalizedProvider) {
parts.push(`p=${encodeCustomIdValue(normalizedProvider)}`);
}
if (providerPage) {
parts.push(`pp=${String(providerPage)}`);
}
if (modelIndex) {
parts.push(`mi=${String(modelIndex)}`);
}
if (recentSlot) {
parts.push(`rs=${String(recentSlot)}`);
}
const customId = parts.join(";");
if (customId.length > DISCORD_CUSTOM_ID_MAX_CHARS) {
throw new Error(
`Discord model picker custom_id exceeds ${DISCORD_CUSTOM_ID_MAX_CHARS} chars (${customId.length})`,
);
}
return customId;
}
export function parseDiscordModelPickerCustomId(customId: string): DiscordModelPickerState | null {
const trimmed = customId.trim();
if (!trimmed.startsWith(`${DISCORD_MODEL_PICKER_CUSTOM_ID_KEY}:`)) {
return null;
}
const rawParts = trimmed.split(";");
const data: Record<string, string> = {};
for (const part of rawParts) {
const equalsIndex = part.indexOf("=");
if (equalsIndex <= 0) {
continue;
}
const rawKey = part.slice(0, equalsIndex);
const rawValue = part.slice(equalsIndex + 1);
const key = rawKey.includes(":") ? rawKey.split(":").slice(1).join(":") : rawKey;
if (!key) {
continue;
}
data[key] = rawValue;
}
return parseDiscordModelPickerData(data);
}
export function parseDiscordModelPickerData(data: ComponentData): DiscordModelPickerState | null {
if (!data || typeof data !== "object") {
return null;
}
const command = decodeCustomIdValue(coerceString(data.c ?? data.cmd));
const action = decodeCustomIdValue(coerceString(data.a ?? data.act));
const view = decodeCustomIdValue(coerceString(data.v ?? data.view));
const userId = decodeCustomIdValue(coerceString(data.u));
const providerRaw = decodeCustomIdValue(coerceString(data.p));
const page = parseRawPage(data.g ?? data.pg);
const providerPage = parseRawPositiveInt(data.pp);
const modelIndex = parseRawPositiveInt(data.mi);
const recentSlot = parseRawPositiveInt(data.rs);
if (!isValidCommandContext(command) || !isValidPickerAction(action) || !isValidPickerView(view)) {
return null;
}
const trimmedUserId = userId.trim();
if (!trimmedUserId) {
return null;
}
const provider = providerRaw ? normalizeProviderId(providerRaw) : undefined;
return {
command,
action,
view,
userId: trimmedUserId,
provider,
page,
...(typeof providerPage === "number" ? { providerPage } : {}),
...(typeof modelIndex === "number" ? { modelIndex } : {}),
...(typeof recentSlot === "number" ? { recentSlot } : {}),
};
}
export function buildDiscordModelPickerProviderItems(
data: ModelsProviderData,
): DiscordModelPickerProviderItem[] {
return data.providers.map((provider) => ({
id: provider,
count: data.byProvider.get(provider)?.size ?? 0,
}));
}
export function getDiscordModelPickerProviderPage(params: {
data: ModelsProviderData;
page?: number;
pageSize?: number;
}): DiscordModelPickerPage<DiscordModelPickerProviderItem> {
const items = buildDiscordModelPickerProviderItems(params.data);
const canFitSinglePage = items.length <= DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX;
const maxPageSize = canFitSinglePage
? DISCORD_MODEL_PICKER_PROVIDER_SINGLE_PAGE_MAX
: DISCORD_MODEL_PICKER_PROVIDER_PAGE_SIZE;
const pageSize = clampPageSize(params.pageSize, maxPageSize, maxPageSize);
return paginateItems({
items,
page: normalizePage(params.page),
pageSize,
});
}
export function getDiscordModelPickerModelPage(params: {
data: ModelsProviderData;
provider: string;
page?: number;
pageSize?: number;
}): DiscordModelPickerModelPage | null {
const provider = normalizeProviderId(params.provider);
const modelSet = params.data.byProvider.get(provider);
if (!modelSet) {
return null;
}
const pageSize = clampPageSize(
params.pageSize,
DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE,
DISCORD_MODEL_PICKER_MODEL_PAGE_SIZE,
);
const models = [...modelSet].toSorted();
const page = paginateItems({
items: models,
page: normalizePage(params.page),
pageSize,
});
return {
...page,
provider,
};
}
export function renderDiscordModelPickerProvidersView(
params: DiscordModelPickerProviderViewParams,
): DiscordModelPickerRenderedView {
const page = getDiscordModelPickerProviderPage({ data: params.data, page: params.page });
const parsedCurrent = parseCurrentModelRef(params.currentModel);
const rows = buildProviderRows({
command: params.command,
userId: params.userId,
page,
currentProvider: parsedCurrent?.provider,
});
const detailLines = [
formatCurrentModelLine(params.currentModel),
`Select a provider (${page.totalItems} available).`,
];
return buildRenderedShell({
layout: params.layout ?? "v2",
title: "Model Picker",
detailLines,
rows,
footer: `All ${page.totalItems} providers shown`,
});
}
export function renderDiscordModelPickerModelsView(
params: DiscordModelPickerModelViewParams,
): DiscordModelPickerRenderedView {
const providerPage = normalizePage(params.providerPage);
const modelPage = getDiscordModelPickerModelPage({
data: params.data,
provider: params.provider,
page: params.page,
});
if (!modelPage) {
const rows: Row<Button>[] = [
new Row([
createModelPickerButton({
label: "Back",
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "back",
view: "providers",
page: providerPage,
userId: params.userId,
}),
}),
]),
];
return buildRenderedShell({
layout: params.layout ?? "v2",
title: "Model Picker",
detailLines: [
formatCurrentModelLine(params.currentModel),
`Provider not found: ${normalizeProviderId(params.provider)}`,
],
rows,
footer: "Choose a different provider.",
});
}
const { rows, buttonRow } = buildModelRows({
command: params.command,
userId: params.userId,
data: params.data,
providerPage,
modelPage,
currentModel: params.currentModel,
pendingModel: params.pendingModel,
pendingModelIndex: params.pendingModelIndex,
quickModels: params.quickModels,
});
const defaultModel = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
const pendingLine = params.pendingModel
? `Selected: ${params.pendingModel} (press Submit)`
: "Select a model, then press Submit.";
return buildRenderedShell({
layout: params.layout ?? "v2",
title: "Model Picker",
detailLines: [formatCurrentModelLine(params.currentModel), `Default: ${defaultModel}`],
preRowText: pendingLine,
rows,
trailingRows: [buttonRow],
});
}
export type DiscordModelPickerRecentsViewParams = {
command: DiscordModelPickerCommandContext;
userId: string;
data: ModelsProviderData;
quickModels: string[];
currentModel?: string;
provider?: string;
page?: number;
providerPage?: number;
layout?: DiscordModelPickerLayout;
};
function formatRecentsButtonLabel(modelRef: string, suffix?: string): string {
const maxLen = 80;
const label = suffix ? `${modelRef} ${suffix}` : modelRef;
if (label.length <= maxLen) {
return label;
}
const trimmed = suffix
? `${modelRef.slice(0, maxLen - suffix.length - 2)}${suffix}`
: `${modelRef.slice(0, maxLen - 1)}`;
return trimmed;
}
export function renderDiscordModelPickerRecentsView(
params: DiscordModelPickerRecentsViewParams,
): DiscordModelPickerRenderedView {
const defaultModelRef = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
const rows: DiscordModelPickerRow[] = [];
// Dedupe: filter recents that match the default model.
const dedupedQuickModels = params.quickModels.filter((modelRef) => modelRef !== defaultModelRef);
// Default model button — slot 1.
rows.push(
new Row([
createModelPickerButton({
label: formatRecentsButtonLabel(defaultModelRef, "(default)"),
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "submit",
view: "recents",
recentSlot: 1,
provider: params.provider,
page: params.page,
providerPage: params.providerPage,
userId: params.userId,
}),
}),
]),
);
// Recent model buttons — slot 2+.
for (let i = 0; i < dedupedQuickModels.length; i++) {
const modelRef = dedupedQuickModels[i];
rows.push(
new Row([
createModelPickerButton({
label: formatRecentsButtonLabel(modelRef),
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "submit",
view: "recents",
recentSlot: i + 2,
provider: params.provider,
page: params.page,
providerPage: params.providerPage,
userId: params.userId,
}),
}),
]),
);
}
// Back button after a divider (via trailingRows).
const backRow: Row<Button> = new Row([
createModelPickerButton({
label: "Back",
style: ButtonStyle.Secondary,
customId: buildDiscordModelPickerCustomId({
command: params.command,
action: "back",
view: "models",
provider: params.provider,
page: params.page,
providerPage: params.providerPage,
userId: params.userId,
}),
}),
]);
return buildRenderedShell({
layout: params.layout ?? "v2",
title: "Recents",
detailLines: [
"Models you've previously selected appear here.",
formatCurrentModelLine(params.currentModel),
],
preRowText: "Tap a model to switch.",
rows,
trailingRows: [backRow],
});
}
export function toDiscordModelPickerMessagePayload(
view: DiscordModelPickerRenderedView,
): MessagePayloadObject {
if (view.layout === "classic") {
return {
content: view.content,
components: view.components,
};
}
return {
components: view.components,
};
}

View File

@@ -7,9 +7,9 @@ import type {
import type { Client } from "@buape/carbon";
import type { GatewayPresenceUpdate } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { DiscordAccountConfig } from "../../config/types.discord.js";
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import type { DiscordAccountConfig } from "../../../../src/config/types.discord.js";
import { buildAgentSessionKey } from "../../../../src/routing/resolve-route.js";
import {
clearDiscordComponentEntries,
registerDiscordComponentEntries,
@@ -54,20 +54,20 @@ const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn());
const resolveStorePathMock = vi.hoisted(() => vi.fn());
let lastDispatchCtx: Record<string, unknown> | undefined;
vi.mock("../../pairing/pairing-store.js", () => ({
vi.mock("../../../../src/pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
}));
vi.mock("../../infra/system-events.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../infra/system-events.js")>();
vi.mock("../../../../src/infra/system-events.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/infra/system-events.js")>();
return {
...actual,
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
};
});
vi.mock("../../auto-reply/reply/provider-dispatcher.js", () => ({
vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", () => ({
dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args),
}));
@@ -75,12 +75,12 @@ vi.mock("./reply-delivery.js", () => ({
deliverDiscordReply: (...args: unknown[]) => deliverDiscordReplyMock(...args),
}));
vi.mock("../../channels/session.js", () => ({
vi.mock("../../../../src/channels/session.js", () => ({
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
}));
vi.mock("../../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../config/sessions.js")>();
vi.mock("../../../../src/config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/config/sessions.js")>();
return {
...actual,
readSessionUpdatedAt: (...args: unknown[]) => readSessionUpdatedAtMock(...args),

View File

@@ -0,0 +1,93 @@
import type { CommandArgs } from "../../../../src/auto-reply/commands-registry.js";
import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js";
import { type DiscordChannelConfigResolved, type DiscordGuildEntryResolved } from "./allow-list.js";
import { buildDiscordInboundAccessContext } from "./inbound-context.js";
export type BuildDiscordNativeCommandContextParams = {
prompt: string;
commandArgs: CommandArgs;
sessionKey: string;
commandTargetSessionKey: string;
accountId?: string | null;
interactionId: string;
channelId: string;
threadParentId?: string;
guildName?: string;
channelTopic?: string;
channelConfig?: DiscordChannelConfigResolved | null;
guildInfo?: DiscordGuildEntryResolved | null;
allowNameMatching?: boolean;
commandAuthorized: boolean;
isDirectMessage: boolean;
isGroupDm: boolean;
isGuild: boolean;
isThreadChannel: boolean;
user: {
id: string;
username: string;
globalName?: string | null;
};
sender: {
id: string;
name?: string;
tag?: string;
};
timestampMs?: number;
};
export function buildDiscordNativeCommandContext(params: BuildDiscordNativeCommandContextParams) {
const conversationLabel = params.isDirectMessage
? (params.user.globalName ?? params.user.username)
: params.channelId;
const { groupSystemPrompt, ownerAllowFrom, untrustedContext } = buildDiscordInboundAccessContext({
channelConfig: params.channelConfig,
guildInfo: params.guildInfo,
sender: params.sender,
allowNameMatching: params.allowNameMatching,
isGuild: params.isGuild,
channelTopic: params.channelTopic,
});
return finalizeInboundContext({
Body: params.prompt,
BodyForAgent: params.prompt,
RawBody: params.prompt,
CommandBody: params.prompt,
CommandArgs: params.commandArgs,
From: params.isDirectMessage
? `discord:${params.user.id}`
: params.isGroupDm
? `discord:group:${params.channelId}`
: `discord:channel:${params.channelId}`,
To: `slash:${params.user.id}`,
SessionKey: params.sessionKey,
CommandTargetSessionKey: params.commandTargetSessionKey,
AccountId: params.accountId ?? undefined,
ChatType: params.isDirectMessage ? "direct" : params.isGroupDm ? "group" : "channel",
ConversationLabel: conversationLabel,
GroupSubject: params.isGuild ? params.guildName : undefined,
GroupSystemPrompt: groupSystemPrompt,
UntrustedContext: untrustedContext,
OwnerAllowFrom: ownerAllowFrom,
SenderName: params.user.globalName ?? params.user.username,
SenderId: params.user.id,
SenderUsername: params.user.username,
SenderTag: params.sender.tag,
Provider: "discord" as const,
Surface: "discord" as const,
WasMentioned: true,
MessageSid: params.interactionId,
MessageThreadId: params.isThreadChannel ? params.channelId : undefined,
Timestamp: params.timestampMs ?? Date.now(),
CommandAuthorized: params.commandAuthorized,
CommandSource: "native" as const,
// Native slash contexts use To=slash:<user> for interaction routing.
// For follow-up delivery (for example subagent completion announces),
// preserve the real Discord target separately.
OriginatingChannel: "discord" as const,
OriginatingTo: params.isDirectMessage
? `user:${params.user.id}`
: `channel:${params.channelId}`,
ThreadParentId: params.isThreadChannel ? params.threadParentId : undefined,
});
}

View File

@@ -1,10 +1,10 @@
import { ChannelType } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { NativeCommandSpec } from "../../auto-reply/commands-registry.js";
import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { DiscordAccountConfig } from "../../config/types.discord.js";
import * as pluginCommandsModule from "../../plugins/commands.js";
import type { NativeCommandSpec } from "../../../../src/auto-reply/commands-registry.js";
import * as dispatcherModule from "../../../../src/auto-reply/reply/provider-dispatcher.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import type { DiscordAccountConfig } from "../../../../src/config/types.discord.js";
import * as pluginCommandsModule from "../../../../src/plugins/commands.js";
import { createDiscordNativeCommand } from "./native-command.js";
import {
createMockCommandInteraction,

View File

@@ -1,15 +1,15 @@
import { ChannelType } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as commandRegistryModule from "../../auto-reply/commands-registry.js";
import * as commandRegistryModule from "../../../../src/auto-reply/commands-registry.js";
import type {
ChatCommandDefinition,
CommandArgsParsing,
} from "../../auto-reply/commands-registry.types.js";
import type { ModelsProviderData } from "../../auto-reply/reply/commands-models.js";
import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js";
import type { OpenClawConfig } from "../../config/config.js";
import * as globalsModule from "../../globals.js";
import * as timeoutModule from "../../utils/with-timeout.js";
} from "../../../../src/auto-reply/commands-registry.types.js";
import type { ModelsProviderData } from "../../../../src/auto-reply/reply/commands-models.js";
import * as dispatcherModule from "../../../../src/auto-reply/reply/provider-dispatcher.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import * as globalsModule from "../../../../src/globals.js";
import * as timeoutModule from "../../../../src/utils/with-timeout.js";
import * as modelPickerPreferencesModule from "./model-picker-preferences.js";
import * as modelPickerModule from "./model-picker.js";
import { createModelsProviderData as createBaseModelsProviderData } from "./model-picker.test-utils.js";

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { listNativeCommandSpecs } from "../../auto-reply/commands-registry.js";
import type { OpenClawConfig, loadConfig } from "../../config/config.js";
import { listNativeCommandSpecs } from "../../../../src/auto-reply/commands-registry.js";
import type { OpenClawConfig, loadConfig } from "../../../../src/config/config.js";
import { createDiscordNativeCommand } from "./native-command.js";
import { createNoopThreadBindingManager } from "./thread-bindings.js";

View File

@@ -1,9 +1,9 @@
import { ChannelType } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { NativeCommandSpec } from "../../auto-reply/commands-registry.js";
import * as dispatcherModule from "../../auto-reply/reply/provider-dispatcher.js";
import type { OpenClawConfig } from "../../config/config.js";
import * as pluginCommandsModule from "../../plugins/commands.js";
import type { NativeCommandSpec } from "../../../../src/auto-reply/commands-registry.js";
import * as dispatcherModule from "../../../../src/auto-reply/reply/provider-dispatcher.js";
import type { OpenClawConfig } from "../../../../src/config/config.js";
import * as pluginCommandsModule from "../../../../src/plugins/commands.js";
import { createDiscordNativeCommand } from "./native-command.js";
import {
createMockCommandInteraction,
@@ -12,9 +12,9 @@ import {
import { createNoopThreadBindingManager } from "./thread-bindings.js";
type ResolveConfiguredAcpBindingRecordFn =
typeof import("../../acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord;
typeof import("../../../../src/acp/persistent-bindings.js").resolveConfiguredAcpBindingRecord;
type EnsureConfiguredAcpBindingSessionFn =
typeof import("../../acp/persistent-bindings.js").ensureConfiguredAcpBindingSession;
typeof import("../../../../src/acp/persistent-bindings.js").ensureConfiguredAcpBindingSession;
const persistentBindingMocks = vi.hoisted(() => ({
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredAcpBindingRecordFn>(() => null),
@@ -24,8 +24,9 @@ const persistentBindingMocks = vi.hoisted(() => ({
})),
}));
vi.mock("../../acp/persistent-bindings.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../acp/persistent-bindings.js")>();
vi.mock("../../../../src/acp/persistent-bindings.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../../src/acp/persistent-bindings.js")>();
return {
...actual,
resolveConfiguredAcpBindingRecord: persistentBindingMocks.resolveConfiguredAcpBindingRecord,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,89 @@
import type { OpenClawConfig } from "../../../../src/config/config.js";
import { logVerbose } from "../../../../src/globals.js";
type DiscordAudioAttachment = {
content_type?: string;
url?: string;
};
function collectAudioAttachments(
attachments: DiscordAudioAttachment[] | undefined,
): DiscordAudioAttachment[] {
if (!Array.isArray(attachments)) {
return [];
}
return attachments.filter((att) => att.content_type?.startsWith("audio/"));
}
export async function resolveDiscordPreflightAudioMentionContext(params: {
message: {
attachments?: DiscordAudioAttachment[];
content?: string;
};
isDirectMessage: boolean;
shouldRequireMention: boolean;
mentionRegexes: RegExp[];
cfg: OpenClawConfig;
abortSignal?: AbortSignal;
}): Promise<{
hasAudioAttachment: boolean;
hasTypedText: boolean;
transcript?: string;
}> {
const audioAttachments = collectAudioAttachments(params.message.attachments);
const hasAudioAttachment = audioAttachments.length > 0;
const hasTypedText = Boolean(params.message.content?.trim());
const needsPreflightTranscription =
!params.isDirectMessage &&
params.shouldRequireMention &&
hasAudioAttachment &&
// `baseText` includes media placeholders; gate on typed text only.
!hasTypedText &&
params.mentionRegexes.length > 0;
let transcript: string | undefined;
if (needsPreflightTranscription) {
if (params.abortSignal?.aborted) {
return {
hasAudioAttachment,
hasTypedText,
};
}
try {
const { transcribeFirstAudio } =
await import("../../../../src/media-understanding/audio-preflight.js");
if (params.abortSignal?.aborted) {
return {
hasAudioAttachment,
hasTypedText,
};
}
const audioUrls = audioAttachments
.map((att) => att.url)
.filter((url): url is string => typeof url === "string" && url.length > 0);
if (audioUrls.length > 0) {
transcript = await transcribeFirstAudio({
ctx: {
MediaUrls: audioUrls,
MediaTypes: audioAttachments
.map((att) => att.content_type)
.filter((contentType): contentType is string => Boolean(contentType)),
},
cfg: params.cfg,
agentDir: undefined,
});
if (params.abortSignal?.aborted) {
transcript = undefined;
}
}
} catch (err) {
logVerbose(`discord: audio preflight transcription failed: ${String(err)}`);
}
}
return {
hasAudioAttachment,
hasTypedText,
transcript,
};
}

View File

@@ -0,0 +1,61 @@
import type { GatewayPresenceUpdate } from "discord-api-types/v10";
/**
* In-memory cache of Discord user presence data.
* Populated by PRESENCE_UPDATE gateway events when the GuildPresences intent is enabled.
* Per-account maps are capped to prevent unbounded growth (#4948).
*/
const MAX_PRESENCE_PER_ACCOUNT = 5000;
const presenceCache = new Map<string, Map<string, GatewayPresenceUpdate>>();
function resolveAccountKey(accountId?: string): string {
return accountId ?? "default";
}
/** Update cached presence for a user. */
export function setPresence(
accountId: string | undefined,
userId: string,
data: GatewayPresenceUpdate,
): void {
const accountKey = resolveAccountKey(accountId);
let accountCache = presenceCache.get(accountKey);
if (!accountCache) {
accountCache = new Map();
presenceCache.set(accountKey, accountCache);
}
accountCache.set(userId, data);
// Evict oldest entries if cache exceeds limit
if (accountCache.size > MAX_PRESENCE_PER_ACCOUNT) {
const oldest = accountCache.keys().next().value;
if (oldest !== undefined) {
accountCache.delete(oldest);
}
}
}
/** Get cached presence for a user. Returns undefined if not cached. */
export function getPresence(
accountId: string | undefined,
userId: string,
): GatewayPresenceUpdate | undefined {
return presenceCache.get(resolveAccountKey(accountId))?.get(userId);
}
/** Clear cached presence data. */
export function clearPresences(accountId?: string): void {
if (accountId) {
presenceCache.delete(resolveAccountKey(accountId));
return;
}
presenceCache.clear();
}
/** Get the number of cached presence entries. */
export function presenceCacheSize(): number {
let total = 0;
for (const accountCache of presenceCache.values()) {
total += accountCache.size;
}
return total;
}

View File

@@ -0,0 +1,49 @@
import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway";
import type { DiscordAccountConfig } from "../../../../src/config/config.js";
const DEFAULT_CUSTOM_ACTIVITY_TYPE = 4;
const CUSTOM_STATUS_NAME = "Custom Status";
type DiscordPresenceConfig = Pick<
DiscordAccountConfig,
"activity" | "status" | "activityType" | "activityUrl"
>;
export function resolveDiscordPresenceUpdate(
config: DiscordPresenceConfig,
): UpdatePresenceData | null {
const activityText = typeof config.activity === "string" ? config.activity.trim() : "";
const status = typeof config.status === "string" ? config.status.trim() : "";
const activityType = config.activityType;
const activityUrl = typeof config.activityUrl === "string" ? config.activityUrl.trim() : "";
const hasActivity = Boolean(activityText);
const hasStatus = Boolean(status);
if (!hasActivity && !hasStatus) {
return { since: null, activities: [], status: "online", afk: false };
}
const activities: Activity[] = [];
if (hasActivity) {
const resolvedType = activityType ?? DEFAULT_CUSTOM_ACTIVITY_TYPE;
const activity: Activity =
resolvedType === DEFAULT_CUSTOM_ACTIVITY_TYPE
? { name: CUSTOM_STATUS_NAME, type: resolvedType, state: activityText }
: { name: activityText, type: resolvedType };
if (resolvedType === 1 && activityUrl) {
activity.url = activityUrl;
}
activities.push(activity);
}
return {
since: null,
activities,
status: (status || "online") as UpdatePresenceData["status"],
afk: false,
};
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../../runtime.js";
import type { RuntimeEnv } from "../../../../src/runtime.js";
const { resolveDiscordChannelAllowlistMock, resolveDiscordUserAllowlistMock } = vi.hoisted(() => ({
resolveDiscordChannelAllowlistMock: vi.fn(

View File

@@ -0,0 +1,344 @@
import {
addAllowlistUserEntriesFromConfigEntry,
buildAllowlistResolutionSummary,
canonicalizeAllowlistWithResolvedIds,
patchAllowlistUsersInConfigEntries,
summarizeMapping,
} from "../../../../src/channels/allowlists/resolve-utils.js";
import type { DiscordGuildEntry } from "../../../../src/config/types.discord.js";
import { formatErrorMessage } from "../../../../src/infra/errors.js";
import type { RuntimeEnv } from "../../../../src/runtime.js";
import { normalizeStringEntries } from "../../../../src/shared/string-normalization.js";
import { resolveDiscordChannelAllowlist } from "../resolve-channels.js";
import { resolveDiscordUserAllowlist } from "../resolve-users.js";
type GuildEntries = Record<string, DiscordGuildEntry>;
type ChannelResolutionInput = { input: string; guildKey: string; channelKey?: string };
type DiscordChannelLogEntry = {
input: string;
guildId?: string;
guildName?: string;
channelId?: string;
channelName?: string;
note?: string;
};
type DiscordUserLogEntry = {
input: string;
id?: string;
name?: string;
guildName?: string;
note?: string;
};
function formatResolutionLogDetails(base: string, details: Array<string | undefined>): string {
const nonEmpty = details
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value));
return nonEmpty.length > 0 ? `${base} (${nonEmpty.join("; ")})` : base;
}
function formatDiscordChannelResolved(entry: DiscordChannelLogEntry): string {
const target = entry.channelId ? `${entry.guildId}/${entry.channelId}` : entry.guildId;
const base = `${entry.input}${target}`;
return formatResolutionLogDetails(base, [
entry.guildName ? `guild:${entry.guildName}` : undefined,
entry.channelName ? `channel:${entry.channelName}` : undefined,
entry.note,
]);
}
function formatDiscordChannelUnresolved(entry: DiscordChannelLogEntry): string {
return formatResolutionLogDetails(entry.input, [
entry.guildName
? `guild:${entry.guildName}`
: entry.guildId
? `guildId:${entry.guildId}`
: undefined,
entry.channelName
? `channel:${entry.channelName}`
: entry.channelId
? `channelId:${entry.channelId}`
: undefined,
entry.note,
]);
}
function formatDiscordUserResolved(entry: DiscordUserLogEntry): string {
const displayName = entry.name?.trim();
const target = displayName || entry.id;
const base = `${entry.input}${target}`;
return formatResolutionLogDetails(base, [
displayName && entry.id ? `id:${entry.id}` : undefined,
entry.guildName ? `guild:${entry.guildName}` : undefined,
entry.note,
]);
}
function formatDiscordUserUnresolved(entry: DiscordUserLogEntry): string {
return formatResolutionLogDetails(entry.input, [
entry.name ? `name:${entry.name}` : undefined,
entry.guildName ? `guild:${entry.guildName}` : undefined,
entry.note,
]);
}
function toGuildEntries(value: unknown): GuildEntries {
if (!value || typeof value !== "object") {
return {};
}
const out: GuildEntries = {};
for (const [key, entry] of Object.entries(value)) {
if (!entry || typeof entry !== "object") {
continue;
}
out[key] = entry as DiscordGuildEntry;
}
return out;
}
function toAllowlistEntries(value: unknown): string[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
return value.map((entry) => String(entry).trim()).filter((entry) => Boolean(entry));
}
function hasGuildEntries(value: GuildEntries): boolean {
return Object.keys(value).length > 0;
}
function collectChannelResolutionInputs(guildEntries: GuildEntries): ChannelResolutionInput[] {
const entries: ChannelResolutionInput[] = [];
for (const [guildKey, guildCfg] of Object.entries(guildEntries)) {
if (guildKey === "*") {
continue;
}
const channels = guildCfg?.channels ?? {};
const channelKeys = Object.keys(channels).filter((key) => key !== "*");
if (channelKeys.length === 0) {
const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey;
entries.push({ input, guildKey });
continue;
}
for (const channelKey of channelKeys) {
entries.push({
input: `${guildKey}/${channelKey}`,
guildKey,
channelKey,
});
}
}
return entries;
}
async function resolveGuildEntriesByChannelAllowlist(params: {
token: string;
guildEntries: GuildEntries;
fetcher: typeof fetch;
runtime: RuntimeEnv;
}): Promise<GuildEntries> {
const entries = collectChannelResolutionInputs(params.guildEntries);
if (entries.length === 0) {
return params.guildEntries;
}
try {
const resolved = await resolveDiscordChannelAllowlist({
token: params.token,
entries: entries.map((entry) => entry.input),
fetcher: params.fetcher,
});
const sourceByInput = new Map(entries.map((entry) => [entry.input, entry]));
const nextGuilds = { ...params.guildEntries };
const mapping: string[] = [];
const unresolved: string[] = [];
for (const entry of resolved) {
const source = sourceByInput.get(entry.input);
if (!source) {
continue;
}
const sourceGuild = params.guildEntries[source.guildKey] ?? {};
if (!entry.resolved || !entry.guildId) {
unresolved.push(formatDiscordChannelUnresolved(entry));
continue;
}
mapping.push(formatDiscordChannelResolved(entry));
const existing = nextGuilds[entry.guildId] ?? {};
const mergedChannels = {
...sourceGuild.channels,
...existing.channels,
};
const mergedGuild: DiscordGuildEntry = {
...sourceGuild,
...existing,
channels: mergedChannels,
};
nextGuilds[entry.guildId] = mergedGuild;
if (source.channelKey && entry.channelId) {
const sourceChannel = sourceGuild.channels?.[source.channelKey];
if (sourceChannel) {
nextGuilds[entry.guildId] = {
...mergedGuild,
channels: {
...mergedChannels,
[entry.channelId]: {
...sourceChannel,
...mergedChannels[entry.channelId],
},
},
};
}
}
}
summarizeMapping("discord channels", mapping, unresolved, params.runtime);
return nextGuilds;
} catch (err) {
params.runtime.log?.(
`discord channel resolve failed; using config entries. ${formatErrorMessage(err)}`,
);
return params.guildEntries;
}
}
async function resolveAllowFromByUserAllowlist(params: {
token: string;
allowFrom: string[] | undefined;
fetcher: typeof fetch;
runtime: RuntimeEnv;
}): Promise<string[] | undefined> {
const allowEntries = normalizeStringEntries(params.allowFrom).filter((entry) => entry !== "*");
if (allowEntries.length === 0) {
return params.allowFrom;
}
try {
const resolvedUsers = await resolveDiscordUserAllowlist({
token: params.token,
entries: allowEntries,
fetcher: params.fetcher,
});
const { resolvedMap, mapping, unresolved } = buildAllowlistResolutionSummary(resolvedUsers, {
formatResolved: formatDiscordUserResolved,
formatUnresolved: formatDiscordUserUnresolved,
});
const allowFrom = canonicalizeAllowlistWithResolvedIds({
existing: params.allowFrom,
resolvedMap,
});
summarizeMapping("discord users", mapping, unresolved, params.runtime);
return allowFrom;
} catch (err) {
params.runtime.log?.(
`discord user resolve failed; using config entries. ${formatErrorMessage(err)}`,
);
return params.allowFrom;
}
}
function collectGuildUserEntries(guildEntries: GuildEntries): Set<string> {
const userEntries = new Set<string>();
for (const guild of Object.values(guildEntries)) {
if (!guild || typeof guild !== "object") {
continue;
}
addAllowlistUserEntriesFromConfigEntry(userEntries, guild);
const channels = (guild as { channels?: Record<string, unknown> }).channels ?? {};
for (const channel of Object.values(channels)) {
addAllowlistUserEntriesFromConfigEntry(userEntries, channel);
}
}
return userEntries;
}
async function resolveGuildEntriesByUserAllowlist(params: {
token: string;
guildEntries: GuildEntries;
fetcher: typeof fetch;
runtime: RuntimeEnv;
}): Promise<GuildEntries> {
const userEntries = collectGuildUserEntries(params.guildEntries);
if (userEntries.size === 0) {
return params.guildEntries;
}
try {
const resolvedUsers = await resolveDiscordUserAllowlist({
token: params.token,
entries: Array.from(userEntries),
fetcher: params.fetcher,
});
const { resolvedMap, mapping, unresolved } = buildAllowlistResolutionSummary(resolvedUsers, {
formatResolved: formatDiscordUserResolved,
formatUnresolved: formatDiscordUserUnresolved,
});
const nextGuilds = { ...params.guildEntries };
for (const [guildKey, guildConfig] of Object.entries(params.guildEntries)) {
if (!guildConfig || typeof guildConfig !== "object") {
continue;
}
const nextGuild = { ...guildConfig } as Record<string, unknown>;
const users = (guildConfig as { users?: string[] }).users;
if (Array.isArray(users) && users.length > 0) {
nextGuild.users = canonicalizeAllowlistWithResolvedIds({
existing: users,
resolvedMap,
});
}
const channels = (guildConfig as { channels?: Record<string, unknown> }).channels ?? {};
if (channels && typeof channels === "object") {
nextGuild.channels = patchAllowlistUsersInConfigEntries({
entries: channels,
resolvedMap,
strategy: "canonicalize",
});
}
nextGuilds[guildKey] = nextGuild as DiscordGuildEntry;
}
summarizeMapping("discord channel users", mapping, unresolved, params.runtime);
return nextGuilds;
} catch (err) {
params.runtime.log?.(
`discord channel user resolve failed; using config entries. ${formatErrorMessage(err)}`,
);
return params.guildEntries;
}
}
export async function resolveDiscordAllowlistConfig(params: {
token: string;
guildEntries: unknown;
allowFrom: unknown;
fetcher: typeof fetch;
runtime: RuntimeEnv;
}): Promise<{ guildEntries: GuildEntries | undefined; allowFrom: string[] | undefined }> {
let guildEntries = toGuildEntries(params.guildEntries);
let allowFrom = toAllowlistEntries(params.allowFrom);
if (hasGuildEntries(guildEntries)) {
guildEntries = await resolveGuildEntriesByChannelAllowlist({
token: params.token,
guildEntries,
fetcher: params.fetcher,
runtime: params.runtime,
});
}
allowFrom = await resolveAllowFromByUserAllowlist({
token: params.token,
allowFrom,
fetcher: params.fetcher,
runtime: params.runtime,
});
if (hasGuildEntries(guildEntries)) {
guildEntries = await resolveGuildEntriesByUserAllowlist({
token: params.token,
guildEntries,
fetcher: params.fetcher,
runtime: params.runtime,
});
}
return {
guildEntries: hasGuildEntries(guildEntries) ? guildEntries : undefined,
allowFrom,
};
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../test-utils/runtime-group-policy-contract.js";
import { installProviderRuntimeGroupPolicyFallbackSuite } from "../../../../src/test-utils/runtime-group-policy-contract.js";
import { __testing } from "./provider.js";
describe("resolveDiscordRuntimeGroupPolicy", () => {

View File

@@ -1,7 +1,7 @@
import { EventEmitter } from "node:events";
import type { Client } from "@buape/carbon";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../../runtime.js";
import type { RuntimeEnv } from "../../../../src/runtime.js";
import type { WaitForDiscordGatewayStopParams } from "../monitor.gateway.js";
const {

View File

@@ -0,0 +1,343 @@
import type { Client } from "@buape/carbon";
import type { GatewayPlugin } from "@buape/carbon/gateway";
import { createArmableStallWatchdog } from "../../../../src/channels/transport/stall-watchdog.js";
import { createConnectedChannelStatusPatch } from "../../../../src/gateway/channel-status-patches.js";
import { danger } from "../../../../src/globals.js";
import type { RuntimeEnv } from "../../../../src/runtime.js";
import { attachDiscordGatewayLogging } from "../gateway-logging.js";
import { getDiscordGatewayEmitter, waitForDiscordGatewayStop } from "../monitor.gateway.js";
import type { DiscordVoiceManager } from "../voice/manager.js";
import { registerGateway, unregisterGateway } from "./gateway-registry.js";
import type { DiscordMonitorStatusSink } from "./status.js";
type ExecApprovalsHandler = {
start: () => Promise<void>;
stop: () => Promise<void>;
};
export async function runDiscordGatewayLifecycle(params: {
accountId: string;
client: Client;
runtime: RuntimeEnv;
abortSignal?: AbortSignal;
isDisallowedIntentsError: (err: unknown) => boolean;
voiceManager: DiscordVoiceManager | null;
voiceManagerRef: { current: DiscordVoiceManager | null };
execApprovalsHandler: ExecApprovalsHandler | null;
threadBindings: { stop: () => void };
pendingGatewayErrors?: unknown[];
releaseEarlyGatewayErrorGuard?: () => void;
statusSink?: DiscordMonitorStatusSink;
}) {
const HELLO_TIMEOUT_MS = 30000;
const HELLO_CONNECTED_POLL_MS = 250;
const MAX_CONSECUTIVE_HELLO_STALLS = 3;
const RECONNECT_STALL_TIMEOUT_MS = 5 * 60_000;
const gateway = params.client.getPlugin<GatewayPlugin>("gateway");
if (gateway) {
registerGateway(params.accountId, gateway);
}
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
const stopGatewayLogging = attachDiscordGatewayLogging({
emitter: gatewayEmitter,
runtime: params.runtime,
});
let lifecycleStopping = false;
let forceStopHandler: ((err: unknown) => void) | undefined;
let queuedForceStopError: unknown;
const pushStatus = (patch: Parameters<DiscordMonitorStatusSink>[0]) => {
params.statusSink?.(patch);
};
const triggerForceStop = (err: unknown) => {
if (forceStopHandler) {
forceStopHandler(err);
return;
}
queuedForceStopError = err;
};
const reconnectStallWatchdog = createArmableStallWatchdog({
label: `discord:${params.accountId}:reconnect`,
timeoutMs: RECONNECT_STALL_TIMEOUT_MS,
abortSignal: params.abortSignal,
runtime: params.runtime,
onTimeout: () => {
if (params.abortSignal?.aborted || lifecycleStopping) {
return;
}
const at = Date.now();
const error = new Error(
`discord reconnect watchdog timeout after ${RECONNECT_STALL_TIMEOUT_MS}ms`,
);
pushStatus({
connected: false,
lastEventAt: at,
lastDisconnect: {
at,
error: error.message,
},
lastError: error.message,
});
params.runtime.error?.(
danger(
`discord: reconnect watchdog timeout after ${RECONNECT_STALL_TIMEOUT_MS}ms; force-stopping monitor task`,
),
);
triggerForceStop(error);
},
});
const onAbort = () => {
lifecycleStopping = true;
reconnectStallWatchdog.disarm();
const at = Date.now();
pushStatus({ connected: false, lastEventAt: at });
if (!gateway) {
return;
}
gatewayEmitter?.once("error", () => {});
gateway.options.reconnect = { maxAttempts: 0 };
gateway.disconnect();
};
if (params.abortSignal?.aborted) {
onAbort();
} else {
params.abortSignal?.addEventListener("abort", onAbort, { once: true });
}
let helloTimeoutId: ReturnType<typeof setTimeout> | undefined;
let helloConnectedPollId: ReturnType<typeof setInterval> | undefined;
let consecutiveHelloStalls = 0;
const clearHelloWatch = () => {
if (helloTimeoutId) {
clearTimeout(helloTimeoutId);
helloTimeoutId = undefined;
}
if (helloConnectedPollId) {
clearInterval(helloConnectedPollId);
helloConnectedPollId = undefined;
}
};
const resetHelloStallCounter = () => {
consecutiveHelloStalls = 0;
};
const parseGatewayCloseCode = (message: string): number | undefined => {
const match = /code\s+(\d{3,5})/i.exec(message);
if (!match?.[1]) {
return undefined;
}
const code = Number.parseInt(match[1], 10);
return Number.isFinite(code) ? code : undefined;
};
const clearResumeState = () => {
const mutableGateway = gateway as
| (GatewayPlugin & {
state?: {
sessionId?: string | null;
resumeGatewayUrl?: string | null;
sequence?: number | null;
};
sequence?: number | null;
})
| undefined;
if (!mutableGateway?.state) {
return;
}
mutableGateway.state.sessionId = null;
mutableGateway.state.resumeGatewayUrl = null;
mutableGateway.state.sequence = null;
mutableGateway.sequence = null;
};
const onGatewayDebug = (msg: unknown) => {
const message = String(msg);
const at = Date.now();
pushStatus({ lastEventAt: at });
if (message.includes("WebSocket connection closed")) {
// Carbon marks `isConnected` true only after READY/RESUMED and flips it
// false during reconnect handling after this debug line is emitted.
if (gateway?.isConnected) {
resetHelloStallCounter();
}
reconnectStallWatchdog.arm(at);
pushStatus({
connected: false,
lastDisconnect: {
at,
status: parseGatewayCloseCode(message),
},
});
clearHelloWatch();
return;
}
if (!message.includes("WebSocket connection opened")) {
return;
}
reconnectStallWatchdog.disarm();
clearHelloWatch();
let sawConnected = gateway?.isConnected === true;
if (sawConnected) {
pushStatus({
...createConnectedChannelStatusPatch(at),
lastDisconnect: null,
});
}
helloConnectedPollId = setInterval(() => {
if (!gateway?.isConnected) {
return;
}
sawConnected = true;
resetHelloStallCounter();
const connectedAt = Date.now();
reconnectStallWatchdog.disarm();
pushStatus({
...createConnectedChannelStatusPatch(connectedAt),
lastDisconnect: null,
});
if (helloConnectedPollId) {
clearInterval(helloConnectedPollId);
helloConnectedPollId = undefined;
}
}, HELLO_CONNECTED_POLL_MS);
helloTimeoutId = setTimeout(() => {
if (helloConnectedPollId) {
clearInterval(helloConnectedPollId);
helloConnectedPollId = undefined;
}
if (sawConnected || gateway?.isConnected) {
resetHelloStallCounter();
} else {
consecutiveHelloStalls += 1;
const forceFreshIdentify = consecutiveHelloStalls >= MAX_CONSECUTIVE_HELLO_STALLS;
const stalledAt = Date.now();
reconnectStallWatchdog.arm(stalledAt);
pushStatus({
connected: false,
lastEventAt: stalledAt,
lastDisconnect: {
at: stalledAt,
error: "hello-timeout",
},
});
params.runtime.log?.(
danger(
forceFreshIdentify
? `connection stalled: no HELLO within ${HELLO_TIMEOUT_MS}ms (${consecutiveHelloStalls}/${MAX_CONSECUTIVE_HELLO_STALLS}); forcing fresh identify`
: `connection stalled: no HELLO within ${HELLO_TIMEOUT_MS}ms (${consecutiveHelloStalls}/${MAX_CONSECUTIVE_HELLO_STALLS}); retrying resume`,
),
);
if (forceFreshIdentify) {
clearResumeState();
resetHelloStallCounter();
}
gateway?.disconnect();
gateway?.connect(!forceFreshIdentify);
}
helloTimeoutId = undefined;
}, HELLO_TIMEOUT_MS);
};
gatewayEmitter?.on("debug", onGatewayDebug);
// If the gateway is already connected when the lifecycle starts (the
// "WebSocket connection opened" debug event was emitted before we
// registered the listener above), push the initial connected status now.
// Guard against lifecycleStopping: if the abortSignal was already aborted,
// onAbort() ran synchronously above and pushed connected: false — don't
// contradict it with a spurious connected: true.
if (gateway?.isConnected && !lifecycleStopping) {
const at = Date.now();
pushStatus({
...createConnectedChannelStatusPatch(at),
lastDisconnect: null,
});
}
let sawDisallowedIntents = false;
const logGatewayError = (err: unknown) => {
if (params.isDisallowedIntentsError(err)) {
sawDisallowedIntents = true;
params.runtime.error?.(
danger(
"discord: gateway closed with code 4014 (missing privileged gateway intents). Enable the required intents in the Discord Developer Portal or disable them in config.",
),
);
return;
}
params.runtime.error?.(danger(`discord gateway error: ${String(err)}`));
};
const shouldStopOnGatewayError = (err: unknown) => {
const message = String(err);
return (
message.includes("Max reconnect attempts") ||
message.includes("Fatal Gateway error") ||
params.isDisallowedIntentsError(err)
);
};
try {
if (params.execApprovalsHandler) {
await params.execApprovalsHandler.start();
}
// Drain gateway errors emitted before lifecycle listeners were attached.
const pendingGatewayErrors = params.pendingGatewayErrors ?? [];
if (pendingGatewayErrors.length > 0) {
const queuedErrors = [...pendingGatewayErrors];
pendingGatewayErrors.length = 0;
for (const err of queuedErrors) {
logGatewayError(err);
if (!shouldStopOnGatewayError(err)) {
continue;
}
if (params.isDisallowedIntentsError(err)) {
return;
}
throw err;
}
}
await waitForDiscordGatewayStop({
gateway: gateway
? {
emitter: gatewayEmitter,
disconnect: () => gateway.disconnect(),
}
: undefined,
abortSignal: params.abortSignal,
onGatewayError: logGatewayError,
shouldStopOnError: shouldStopOnGatewayError,
registerForceStop: (forceStop) => {
forceStopHandler = forceStop;
if (queuedForceStopError !== undefined) {
const queued = queuedForceStopError;
queuedForceStopError = undefined;
forceStop(queued);
}
},
});
} catch (err) {
if (!sawDisallowedIntents && !params.isDisallowedIntentsError(err)) {
throw err;
}
} finally {
lifecycleStopping = true;
params.releaseEarlyGatewayErrorGuard?.();
unregisterGateway(params.accountId);
stopGatewayLogging();
reconnectStallWatchdog.stop();
clearHelloWatch();
gatewayEmitter?.removeListener("debug", onGatewayDebug);
params.abortSignal?.removeEventListener("abort", onAbort);
if (params.voiceManager) {
await params.voiceManager.destroy();
params.voiceManagerRef.current = null;
}
if (params.execApprovalsHandler) {
await params.execApprovalsHandler.stop();
}
params.threadBindings.stop();
}
}

Some files were not shown because too many files have changed in this diff Show More