mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-21 16:41:56 +00:00
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:
@@ -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 {
|
||||
132
extensions/discord/src/account-inspect.ts
Normal file
132
extensions/discord/src/account-inspect.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
89
extensions/discord/src/accounts.ts
Normal file
89
extensions/discord/src/accounts.ts
Normal 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);
|
||||
}
|
||||
451
extensions/discord/src/actions/handle-action.guild-admin.ts
Normal file
451
extensions/discord/src/actions/handle-action.guild-admin.ts
Normal 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;
|
||||
}
|
||||
295
extensions/discord/src/actions/handle-action.ts
Normal file
295
extensions/discord/src/actions/handle-action.ts
Normal 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}.`);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
136
extensions/discord/src/api.ts
Normal file
136
extensions/discord/src/api.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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"]);
|
||||
141
extensions/discord/src/audit.ts
Normal file
141
extensions/discord/src/audit.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
140
extensions/discord/src/channel-actions.ts
Normal file
140
extensions/discord/src/channel-actions.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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", () => {
|
||||
277
extensions/discord/src/chunk.ts
Normal file
277
extensions/discord/src/chunk.ts
Normal 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;
|
||||
}
|
||||
@@ -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", () => {
|
||||
88
extensions/discord/src/client.ts
Normal file
88
extensions/discord/src/client.ts
Normal 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;
|
||||
}
|
||||
89
extensions/discord/src/components-registry.ts
Normal file
89
extensions/discord/src/components-registry.ts
Normal 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();
|
||||
}
|
||||
1149
extensions/discord/src/components.ts
Normal file
1149
extensions/discord/src/components.ts
Normal file
File diff suppressed because it is too large
Load Diff
111
extensions/discord/src/directory-cache.ts
Normal file
111
extensions/discord/src/directory-cache.ts
Normal 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();
|
||||
}
|
||||
@@ -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(),
|
||||
132
extensions/discord/src/directory-live.ts
Normal file
132
extensions/discord/src/directory-live.ts
Normal 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;
|
||||
}
|
||||
41
extensions/discord/src/draft-chunking.ts
Normal file
41
extensions/discord/src/draft-chunking.ts
Normal 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 };
|
||||
}
|
||||
145
extensions/discord/src/draft-stream.ts
Normal file
145
extensions/discord/src/draft-stream.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
23
extensions/discord/src/exec-approvals.ts
Normal file
23
extensions/discord/src/exec-approvals.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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 = () => ({
|
||||
67
extensions/discord/src/gateway-logging.ts
Normal file
67
extensions/discord/src/gateway-logging.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
29
extensions/discord/src/guilds.ts
Normal file
29
extensions/discord/src/guilds.ts
Normal 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),
|
||||
}));
|
||||
}
|
||||
83
extensions/discord/src/mentions.ts
Normal file
83
extensions/discord/src/mentions.ts
Normal 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;
|
||||
}
|
||||
78
extensions/discord/src/monitor.gateway.ts
Normal file
78
extensions/discord/src/monitor.gateway.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
>,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -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"),
|
||||
28
extensions/discord/src/monitor.ts
Normal file
28
extensions/discord/src/monitor.ts
Normal 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";
|
||||
1802
extensions/discord/src/monitor/agent-components.ts
Normal file
1802
extensions/discord/src/monitor/agent-components.ts
Normal file
File diff suppressed because it is too large
Load Diff
596
extensions/discord/src/monitor/allow-list.ts
Normal file
596
extensions/discord/src/monitor/allow-list.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
362
extensions/discord/src/monitor/auto-presence.ts
Normal file
362
extensions/discord/src/monitor/auto-presence.ts
Normal 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,
|
||||
};
|
||||
9
extensions/discord/src/monitor/commands.ts
Normal file
9
extensions/discord/src/monitor/commands.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
104
extensions/discord/src/monitor/dm-command-auth.ts
Normal file
104
extensions/discord/src/monitor/dm-command-auth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
48
extensions/discord/src/monitor/dm-command-decision.ts
Normal file
48
extensions/discord/src/monitor/dm-command-decision.ts
Normal 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;
|
||||
}
|
||||
@@ -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(),
|
||||
}));
|
||||
877
extensions/discord/src/monitor/exec-approvals.ts
Normal file
877
extensions/discord/src/monitor/exec-approvals.ts
Normal 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);
|
||||
}
|
||||
45
extensions/discord/src/monitor/format.ts
Normal file
45
extensions/discord/src/monitor/format.ts
Normal 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;
|
||||
}
|
||||
36
extensions/discord/src/monitor/gateway-error-guard.ts
Normal file
36
extensions/discord/src/monitor/gateway-error-guard.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
212
extensions/discord/src/monitor/gateway-plugin.ts
Normal file
212
extensions/discord/src/monitor/gateway-plugin.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
37
extensions/discord/src/monitor/gateway-registry.ts
Normal file
37
extensions/discord/src/monitor/gateway-registry.ts
Normal 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();
|
||||
}
|
||||
59
extensions/discord/src/monitor/inbound-context.ts
Normal file
59
extensions/discord/src/monitor/inbound-context.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
111
extensions/discord/src/monitor/inbound-job.ts
Normal file
111
extensions/discord/src/monitor/inbound-job.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
105
extensions/discord/src/monitor/inbound-worker.ts
Normal file
105
extensions/discord/src/monitor/inbound-worker.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
774
extensions/discord/src/monitor/listeners.ts
Normal file
774
extensions/discord/src/monitor/listeners.ts
Normal 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)}`));
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -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();
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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,
|
||||
840
extensions/discord/src/monitor/message-handler.preflight.ts
Normal file
840
extensions/discord/src/monitor/message-handler.preflight.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
}));
|
||||
866
extensions/discord/src/monitor/message-handler.process.ts
Normal file
866
extensions/discord/src/monitor/message-handler.process.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
186
extensions/discord/src/monitor/message-handler.ts
Normal file
186
extensions/discord/src/monitor/message-handler.ts
Normal 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;
|
||||
}
|
||||
@@ -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: () => {},
|
||||
}));
|
||||
|
||||
637
extensions/discord/src/monitor/message-utils.ts
Normal file
637
extensions/discord/src/monitor/message-utils.ts
Normal 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);
|
||||
}
|
||||
165
extensions/discord/src/monitor/model-picker-preferences.ts
Normal file
165
extensions/discord/src/monitor/model-picker-preferences.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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[]>,
|
||||
@@ -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,
|
||||
939
extensions/discord/src/monitor/model-picker.ts
Normal file
939
extensions/discord/src/monitor/model-picker.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
93
extensions/discord/src/monitor/native-command-context.ts
Normal file
93
extensions/discord/src/monitor/native-command-context.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
1850
extensions/discord/src/monitor/native-command.ts
Normal file
1850
extensions/discord/src/monitor/native-command.ts
Normal file
File diff suppressed because it is too large
Load Diff
89
extensions/discord/src/monitor/preflight-audio.ts
Normal file
89
extensions/discord/src/monitor/preflight-audio.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
61
extensions/discord/src/monitor/presence-cache.ts
Normal file
61
extensions/discord/src/monitor/presence-cache.ts
Normal 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;
|
||||
}
|
||||
49
extensions/discord/src/monitor/presence.ts
Normal file
49
extensions/discord/src/monitor/presence.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
344
extensions/discord/src/monitor/provider.allowlist.ts
Normal file
344
extensions/discord/src/monitor/provider.allowlist.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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", () => {
|
||||
@@ -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 {
|
||||
343
extensions/discord/src/monitor/provider.lifecycle.ts
Normal file
343
extensions/discord/src/monitor/provider.lifecycle.ts
Normal 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
Reference in New Issue
Block a user