mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 01:01:23 +00:00
feat: thread-bound subagents on Discord (#21805)
* docs: thread-bound subagents plan * docs: add exact thread-bound subagent implementation touchpoints * Docs: prioritize auto thread-bound subagent flow * Docs: add ACP harness thread-binding extensions * Discord: add thread-bound session routing and auto-bind spawn flow * Subagents: add focus commands and ACP/session binding lifecycle hooks * Tests: cover thread bindings, focus commands, and ACP unbind hooks * Docs: add plugin-hook appendix for thread-bound subagents * Plugins: add subagent lifecycle hook events * Core: emit subagent lifecycle hooks and decouple Discord bindings * Discord: handle subagent bind lifecycle via plugin hooks * Subagents: unify completion finalizer and split registry modules * Add subagent lifecycle events module * Hooks: fix subagent ended context key * Discord: share thread bindings across ESM and Jiti * Subagents: add persistent sessions_spawn mode for thread-bound sessions * Subagents: clarify thread intro and persistent completion copy * test(subagents): stabilize sessions_spawn lifecycle cleanup assertions * Discord: add thread-bound session TTL with auto-unfocus * Subagents: fail session spawns when thread bind fails * Subagents: cover thread session failure cleanup paths * Session: add thread binding TTL config and /session ttl controls * Tests: align discord reaction expectations * Agent: persist sessionFile for keyed subagent sessions * Discord: normalize imports after conflict resolution * Sessions: centralize sessionFile resolve/persist helper * Discord: harden thread-bound subagent session routing * Rebase: resolve upstream/main conflicts * Subagents: move thread binding into hooks and split bindings modules * Docs: add channel-agnostic subagent routing hook plan * Agents: decouple subagent routing from Discord * Discord: refactor thread-bound subagent flows * Subagents: prevent duplicate end hooks and orphaned failed sessions * Refactor: split subagent command and provider phases * Subagents: honor hook delivery target overrides * Discord: add thread binding kill switches and refresh plan doc * Discord: fix thread bind channel resolution * Routing: centralize account id normalization * Discord: clean up thread bindings on startup failures * Discord: add startup cleanup regression tests * Docs: add long-term thread-bound subagent architecture * Docs: split session binding plan and dedupe thread-bound doc * Subagents: add channel-agnostic session binding routing * Subagents: stabilize announce completion routing tests * Subagents: cover multi-bound completion routing * Subagents: suppress lifecycle hooks on failed thread bind * tests: fix discord provider mock typing regressions * docs/protocol: sync slash command aliases and delete param models * fix: add changelog entry for Discord thread-bound subagents (#21805) (thanks @onutc) --------- Co-authored-by: Shadow <hi@shadowing.dev>
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
upsertPairingRequestMock,
|
||||
} from "./monitor.tool-result.test-harness.js";
|
||||
import { __resetDiscordChannelInfoCacheForTest } from "./monitor/message-utils.js";
|
||||
import { createNoopThreadBindingManager } from "./monitor/thread-bindings.js";
|
||||
const loadConfigMock = vi.fn();
|
||||
|
||||
vi.mock("../config/config.js", async (importOriginal) => {
|
||||
@@ -91,6 +92,7 @@ async function createHandler(cfg: LoadedConfig) {
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: false,
|
||||
guildEntries: cfg.channels?.discord?.guilds,
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -291,6 +293,7 @@ describe("discord tool result dispatch", () => {
|
||||
accountId: "default",
|
||||
sessionPrefix: "discord:slash",
|
||||
ephemeralDefault: true,
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
});
|
||||
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from "./monitor.tool-result.test-harness.js";
|
||||
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>;
|
||||
|
||||
@@ -71,6 +72,7 @@ async function createDmHandler(opts: { cfg: Config; runtimeError?: (err: unknown
|
||||
replyToMode: "off",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: false,
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,6 +109,7 @@ async function createCategoryGuildHandler() {
|
||||
guildEntries: {
|
||||
"*": { requireMention: false, channels: { c1: { allow: true } } },
|
||||
},
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
209
src/discord/monitor/message-handler.preflight.test.ts
Normal file
209
src/discord/monitor/message-handler.preflight.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { ChannelType } from "@buape/carbon";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
preflightDiscordMessage,
|
||||
resolvePreflightMentionRequirement,
|
||||
shouldIgnoreBoundThreadWebhookMessage,
|
||||
} from "./message-handler.preflight.js";
|
||||
import {
|
||||
__testing as threadBindingTesting,
|
||||
createThreadBindingManager,
|
||||
} from "./thread-bindings.js";
|
||||
|
||||
function createThreadBinding(
|
||||
overrides?: Partial<import("./thread-bindings.js").ThreadBindingRecord>,
|
||||
) {
|
||||
return {
|
||||
accountId: "default",
|
||||
channelId: "parent-1",
|
||||
threadId: "thread-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child-1",
|
||||
agentId: "main",
|
||||
boundBy: "test",
|
||||
boundAt: 1,
|
||||
webhookId: "wh-1",
|
||||
webhookToken: "tok-1",
|
||||
...overrides,
|
||||
} satisfies import("./thread-bindings.js").ThreadBindingRecord;
|
||||
}
|
||||
|
||||
describe("resolvePreflightMentionRequirement", () => {
|
||||
it("requires mention when config requires mention and thread is not bound", () => {
|
||||
expect(
|
||||
resolvePreflightMentionRequirement({
|
||||
shouldRequireMention: true,
|
||||
isBoundThreadSession: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("disables mention requirement for bound thread sessions", () => {
|
||||
expect(
|
||||
resolvePreflightMentionRequirement({
|
||||
shouldRequireMention: true,
|
||||
isBoundThreadSession: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps mention requirement disabled when config already disables it", () => {
|
||||
expect(
|
||||
resolvePreflightMentionRequirement({
|
||||
shouldRequireMention: false,
|
||||
isBoundThreadSession: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preflightDiscordMessage", () => {
|
||||
it("bypasses mention gating in bound threads for allowed bot senders", async () => {
|
||||
const threadBinding = createThreadBinding();
|
||||
const threadId = "thread-bot-focus";
|
||||
const parentId = "channel-parent-focus";
|
||||
const client = {
|
||||
fetchChannel: async (channelId: string) => {
|
||||
if (channelId === threadId) {
|
||||
return {
|
||||
id: threadId,
|
||||
type: ChannelType.PublicThread,
|
||||
name: "focus",
|
||||
parentId,
|
||||
ownerId: "owner-1",
|
||||
};
|
||||
}
|
||||
if (channelId === parentId) {
|
||||
return {
|
||||
id: parentId,
|
||||
type: ChannelType.GuildText,
|
||||
name: "general",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Client;
|
||||
const message = {
|
||||
id: "m-bot-1",
|
||||
content: "relay message without mention",
|
||||
timestamp: new Date().toISOString(),
|
||||
channelId: threadId,
|
||||
attachments: [],
|
||||
mentionedUsers: [],
|
||||
mentionedRoles: [],
|
||||
mentionedEveryone: false,
|
||||
author: {
|
||||
id: "relay-bot-1",
|
||||
bot: true,
|
||||
username: "Relay",
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Message;
|
||||
|
||||
const result = await preflightDiscordMessage({
|
||||
cfg: {
|
||||
session: {
|
||||
mainKey: "main",
|
||||
scope: "per-sender",
|
||||
},
|
||||
} as import("../../config/config.js").OpenClawConfig,
|
||||
discordConfig: {
|
||||
allowBots: true,
|
||||
} as NonNullable<import("../../config/config.js").OpenClawConfig["channels"]>["discord"],
|
||||
accountId: "default",
|
||||
token: "token",
|
||||
runtime: {} as import("../../runtime.js").RuntimeEnv,
|
||||
botUserId: "openclaw-bot",
|
||||
guildHistories: new Map(),
|
||||
historyLimit: 0,
|
||||
mediaMaxBytes: 1_000_000,
|
||||
textLimit: 2_000,
|
||||
replyToMode: "all",
|
||||
dmEnabled: true,
|
||||
groupDmEnabled: true,
|
||||
ackReactionScope: "direct",
|
||||
groupPolicy: "open",
|
||||
threadBindings: {
|
||||
getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined),
|
||||
} as import("./thread-bindings.js").ThreadBindingManager,
|
||||
data: {
|
||||
channel_id: threadId,
|
||||
guild_id: "guild-1",
|
||||
guild: {
|
||||
id: "guild-1",
|
||||
name: "Guild One",
|
||||
},
|
||||
author: message.author,
|
||||
message,
|
||||
} as unknown as import("./listeners.js").DiscordMessageEvent,
|
||||
client,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.boundSessionKey).toBe(threadBinding.targetSessionKey);
|
||||
expect(result?.shouldRequireMention).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldIgnoreBoundThreadWebhookMessage", () => {
|
||||
beforeEach(() => {
|
||||
threadBindingTesting.resetThreadBindingsForTests();
|
||||
});
|
||||
|
||||
it("returns true when inbound webhook id matches the bound thread webhook", () => {
|
||||
expect(
|
||||
shouldIgnoreBoundThreadWebhookMessage({
|
||||
webhookId: "wh-1",
|
||||
threadBinding: createThreadBinding(),
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when webhook ids differ", () => {
|
||||
expect(
|
||||
shouldIgnoreBoundThreadWebhookMessage({
|
||||
webhookId: "wh-other",
|
||||
threadBinding: createThreadBinding(),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when there is no bound thread webhook", () => {
|
||||
expect(
|
||||
shouldIgnoreBoundThreadWebhookMessage({
|
||||
webhookId: "wh-1",
|
||||
threadBinding: createThreadBinding({ webhookId: undefined }),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for recently unbound thread webhook echoes", async () => {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
const binding = await manager.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child-1",
|
||||
agentId: "main",
|
||||
webhookId: "wh-1",
|
||||
webhookToken: "tok-1",
|
||||
});
|
||||
expect(binding).not.toBeNull();
|
||||
|
||||
manager.unbindThread({
|
||||
threadId: "thread-1",
|
||||
sendFarewell: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldIgnoreBoundThreadWebhookMessage({
|
||||
accountId: "default",
|
||||
threadId: "thread-1",
|
||||
webhookId: "wh-1",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
upsertChannelPairingRequest,
|
||||
} from "../../pairing/pairing-store.js";
|
||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import { fetchPluralKitMessageInfo } from "../pluralkit.js";
|
||||
import { sendMessageDiscord } from "../send.js";
|
||||
import {
|
||||
@@ -55,6 +56,10 @@ import {
|
||||
} from "./message-utils.js";
|
||||
import { resolveDiscordSenderIdentity, resolveDiscordWebhookId } from "./sender-identity.js";
|
||||
import { resolveDiscordSystemEvent } from "./system-events.js";
|
||||
import {
|
||||
isRecentlyUnboundThreadWebhookMessage,
|
||||
type ThreadBindingRecord,
|
||||
} from "./thread-bindings.js";
|
||||
import { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } from "./threading.js";
|
||||
|
||||
export type {
|
||||
@@ -62,6 +67,41 @@ export type {
|
||||
DiscordMessagePreflightParams,
|
||||
} from "./message-handler.preflight.types.js";
|
||||
|
||||
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?: ThreadBindingRecord;
|
||||
}): boolean {
|
||||
const webhookId = params.webhookId?.trim() || "";
|
||||
if (!webhookId) {
|
||||
return false;
|
||||
}
|
||||
const boundWebhookId = params.threadBinding?.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> {
|
||||
@@ -253,7 +293,30 @@ export async function preflightDiscordMessage(
|
||||
// Pass parent peer for thread binding inheritance
|
||||
parentPeer: earlyThreadParentId ? { kind: "channel", id: earlyThreadParentId } : undefined,
|
||||
});
|
||||
const mentionRegexes = buildMentionRegexes(params.cfg, route.agentId);
|
||||
const threadBinding = earlyThreadChannel
|
||||
? params.threadBindings.getByThreadId(messageChannelId)
|
||||
: undefined;
|
||||
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 boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined;
|
||||
const effectiveRoute = boundSessionKey
|
||||
? {
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: boundAgentId ?? route.agentId,
|
||||
}
|
||||
: route;
|
||||
const mentionRegexes = buildMentionRegexes(params.cfg, effectiveRoute.agentId);
|
||||
const explicitlyMentioned = Boolean(
|
||||
botId && message.mentionedUsers?.some((user: User) => user.id === botId),
|
||||
);
|
||||
@@ -314,7 +377,7 @@ export async function preflightDiscordMessage(
|
||||
const threadChannelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
|
||||
const threadParentSlug = threadParentName ? normalizeDiscordSlug(threadParentName) : "";
|
||||
|
||||
const baseSessionKey = route.sessionKey;
|
||||
const baseSessionKey = effectiveRoute.sessionKey;
|
||||
const channelConfig = isGuildMessage
|
||||
? resolveDiscordChannelConfigWithFallback({
|
||||
guildInfo,
|
||||
@@ -408,7 +471,7 @@ export async function preflightDiscordMessage(
|
||||
: undefined;
|
||||
|
||||
const threadOwnerId = threadChannel ? (threadChannel.ownerId ?? channelInfo?.ownerId) : undefined;
|
||||
const shouldRequireMention = resolveDiscordShouldRequireMention({
|
||||
const shouldRequireMentionByConfig = resolveDiscordShouldRequireMention({
|
||||
isGuildMessage,
|
||||
isThread: Boolean(threadChannel),
|
||||
botId,
|
||||
@@ -416,6 +479,11 @@ export async function preflightDiscordMessage(
|
||||
channelConfig,
|
||||
guildInfo,
|
||||
});
|
||||
const isBoundThreadSession = Boolean(boundSessionKey && threadChannel);
|
||||
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
|
||||
@@ -547,7 +615,7 @@ export async function preflightDiscordMessage(
|
||||
});
|
||||
const effectiveWasMentioned = mentionGate.effectiveWasMentioned;
|
||||
logDebug(
|
||||
`[discord-preflight] shouldRequireMention=${shouldRequireMention} mentionGate.shouldSkip=${mentionGate.shouldSkip} wasMentioned=${wasMentioned}`,
|
||||
`[discord-preflight] shouldRequireMention=${shouldRequireMention} baseRequireMention=${shouldRequireMentionByConfig} boundThreadSession=${isBoundThreadSession} mentionGate.shouldSkip=${mentionGate.shouldSkip} wasMentioned=${wasMentioned}`,
|
||||
);
|
||||
if (isGuildMessage && shouldRequireMention) {
|
||||
if (botId && mentionGate.shouldSkip) {
|
||||
@@ -586,7 +654,7 @@ export async function preflightDiscordMessage(
|
||||
if (systemText) {
|
||||
logDebug(`[discord-preflight] drop: system event`);
|
||||
enqueueSystemEvent(systemText, {
|
||||
sessionKey: route.sessionKey,
|
||||
sessionKey: effectiveRoute.sessionKey,
|
||||
contextKey: `discord:system:${messageChannelId}:${message.id}`,
|
||||
});
|
||||
return null;
|
||||
@@ -598,7 +666,9 @@ export async function preflightDiscordMessage(
|
||||
return null;
|
||||
}
|
||||
|
||||
logDebug(`[discord-preflight] success: route=${route.agentId} sessionKey=${route.sessionKey}`);
|
||||
logDebug(
|
||||
`[discord-preflight] success: route=${effectiveRoute.agentId} sessionKey=${effectiveRoute.sessionKey}`,
|
||||
);
|
||||
return {
|
||||
cfg: params.cfg,
|
||||
discordConfig: params.discordConfig,
|
||||
@@ -628,7 +698,10 @@ export async function preflightDiscordMessage(
|
||||
baseText,
|
||||
messageText,
|
||||
wasMentioned,
|
||||
route,
|
||||
route: effectiveRoute,
|
||||
threadBinding,
|
||||
boundSessionKey: boundSessionKey || undefined,
|
||||
boundAgentId,
|
||||
guildInfo,
|
||||
guildSlug,
|
||||
threadChannel,
|
||||
@@ -651,5 +724,6 @@ export async function preflightDiscordMessage(
|
||||
effectiveWasMentioned,
|
||||
canDetectMention,
|
||||
historyEntry,
|
||||
threadBindings: params.threadBindings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import type { DiscordChannelConfigResolved, DiscordGuildEntryResolved } from "./allow-list.js";
|
||||
import type { DiscordChannelInfo } from "./message-utils.js";
|
||||
import type { DiscordSenderIdentity } from "./sender-identity.js";
|
||||
import type { ThreadBindingManager, ThreadBindingRecord } from "./thread-bindings.js";
|
||||
|
||||
export type { DiscordSenderIdentity } from "./sender-identity.js";
|
||||
import type { DiscordThreadChannel } from "./threading.js";
|
||||
@@ -51,6 +52,9 @@ export type DiscordMessagePreflightContext = {
|
||||
wasMentioned: boolean;
|
||||
|
||||
route: ReturnType<typeof resolveAgentRoute>;
|
||||
threadBinding?: ThreadBindingRecord;
|
||||
boundSessionKey?: string;
|
||||
boundAgentId?: string;
|
||||
|
||||
guildInfo: DiscordGuildEntryResolved | null;
|
||||
guildSlug: string;
|
||||
@@ -79,6 +83,7 @@ export type DiscordMessagePreflightContext = {
|
||||
canDetectMention: boolean;
|
||||
|
||||
historyEntry?: HistoryEntry;
|
||||
threadBindings: ThreadBindingManager;
|
||||
};
|
||||
|
||||
export type DiscordMessagePreflightParams = {
|
||||
@@ -100,6 +105,7 @@ export type DiscordMessagePreflightParams = {
|
||||
guildEntries?: Record<string, DiscordGuildEntryResolved>;
|
||||
ackReactionScope: DiscordMessagePreflightContext["ackReactionScope"];
|
||||
groupPolicy: DiscordMessagePreflightContext["groupPolicy"];
|
||||
threadBindings: ThreadBindingManager;
|
||||
data: DiscordMessageEvent;
|
||||
client: Client;
|
||||
};
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_EMOJIS } from "../../channels/status-reactions.js";
|
||||
import { createBaseDiscordMessageContext } from "./message-handler.test-harness.js";
|
||||
import {
|
||||
__testing as threadBindingTesting,
|
||||
createThreadBindingManager,
|
||||
} from "./thread-bindings.js";
|
||||
|
||||
const reactMessageDiscord = vi.fn(async () => {});
|
||||
const removeReactionDiscord = vi.fn(async () => {});
|
||||
const editMessageDiscord = vi.fn(async () => ({}));
|
||||
const deliverDiscordReply = vi.fn(async () => {});
|
||||
const createDiscordDraftStream = vi.fn(() => ({
|
||||
update: vi.fn<(text: string) => void>(() => {}),
|
||||
flush: vi.fn(async () => {}),
|
||||
messageId: vi.fn(() => "preview-1"),
|
||||
clear: vi.fn(async () => {}),
|
||||
stop: vi.fn(async () => {}),
|
||||
forceNewMessage: vi.fn(() => {}),
|
||||
const sendMocks = vi.hoisted(() => ({
|
||||
reactMessageDiscord: vi.fn(async () => {}),
|
||||
removeReactionDiscord: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const deliveryMocks = vi.hoisted(() => ({
|
||||
editMessageDiscord: vi.fn(async () => ({})),
|
||||
deliverDiscordReply: vi.fn(async () => {}),
|
||||
createDiscordDraftStream: vi.fn(() => ({
|
||||
update: vi.fn<(text: string) => void>(() => {}),
|
||||
flush: vi.fn(async () => {}),
|
||||
messageId: vi.fn(() => "preview-1"),
|
||||
clear: vi.fn(async () => {}),
|
||||
stop: vi.fn(async () => {}),
|
||||
forceNewMessage: vi.fn(() => {}),
|
||||
})),
|
||||
}));
|
||||
const editMessageDiscord = deliveryMocks.editMessageDiscord;
|
||||
const deliverDiscordReply = deliveryMocks.deliverDiscordReply;
|
||||
const createDiscordDraftStream = deliveryMocks.createDiscordDraftStream;
|
||||
type DispatchInboundParams = {
|
||||
dispatcher: {
|
||||
sendFinalReply: (payload: { text?: string }) => boolean | Promise<boolean>;
|
||||
@@ -36,20 +46,20 @@ const readSessionUpdatedAt = vi.fn(() => undefined);
|
||||
const resolveStorePath = vi.fn(() => "/tmp/openclaw-discord-process-test-sessions.json");
|
||||
|
||||
vi.mock("../send.js", () => ({
|
||||
reactMessageDiscord,
|
||||
removeReactionDiscord,
|
||||
reactMessageDiscord: sendMocks.reactMessageDiscord,
|
||||
removeReactionDiscord: sendMocks.removeReactionDiscord,
|
||||
}));
|
||||
|
||||
vi.mock("../send.messages.js", () => ({
|
||||
editMessageDiscord,
|
||||
editMessageDiscord: deliveryMocks.editMessageDiscord,
|
||||
}));
|
||||
|
||||
vi.mock("../draft-stream.js", () => ({
|
||||
createDiscordDraftStream,
|
||||
createDiscordDraftStream: deliveryMocks.createDiscordDraftStream,
|
||||
}));
|
||||
|
||||
vi.mock("./reply-delivery.js", () => ({
|
||||
deliverDiscordReply,
|
||||
deliverDiscordReply: deliveryMocks.deliverDiscordReply,
|
||||
}));
|
||||
|
||||
vi.mock("../../auto-reply/dispatch.js", () => ({
|
||||
@@ -91,8 +101,8 @@ const createBaseContext = createBaseDiscordMessageContext;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
reactMessageDiscord.mockClear();
|
||||
removeReactionDiscord.mockClear();
|
||||
sendMocks.reactMessageDiscord.mockClear();
|
||||
sendMocks.removeReactionDiscord.mockClear();
|
||||
editMessageDiscord.mockClear();
|
||||
deliverDiscordReply.mockClear();
|
||||
createDiscordDraftStream.mockClear();
|
||||
@@ -107,6 +117,7 @@ beforeEach(() => {
|
||||
recordInboundSession.mockResolvedValue(undefined);
|
||||
readSessionUpdatedAt.mockReturnValue(undefined);
|
||||
resolveStorePath.mockReturnValue("/tmp/openclaw-discord-process-test-sessions.json");
|
||||
threadBindingTesting.resetThreadBindingsForTests();
|
||||
});
|
||||
|
||||
function getLastRouteUpdate():
|
||||
@@ -126,6 +137,16 @@ function getLastRouteUpdate():
|
||||
return params?.updateLastRoute;
|
||||
}
|
||||
|
||||
function getLastDispatchCtx():
|
||||
| { SessionKey?: string; MessageThreadId?: string | number }
|
||||
| undefined {
|
||||
const callArgs = dispatchInboundMessage.mock.calls.at(-1) as unknown[] | undefined;
|
||||
const params = callArgs?.[0] as
|
||||
| { ctx?: { SessionKey?: string; MessageThreadId?: string | number } }
|
||||
| undefined;
|
||||
return params?.ctx;
|
||||
}
|
||||
|
||||
describe("processDiscordMessage ack reactions", () => {
|
||||
it("skips ack reactions for group-mentions when mentions are not required", async () => {
|
||||
const ctx = await createBaseContext({
|
||||
@@ -136,7 +157,7 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
await processDiscordMessage(ctx as any);
|
||||
|
||||
expect(reactMessageDiscord).not.toHaveBeenCalled();
|
||||
expect(sendMocks.reactMessageDiscord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends ack reactions for mention-gated guild messages when mentioned", async () => {
|
||||
@@ -148,7 +169,7 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
await processDiscordMessage(ctx as any);
|
||||
|
||||
expect(reactMessageDiscord.mock.calls[0]).toEqual(["c1", "m1", "👀", { rest: {} }]);
|
||||
expect(sendMocks.reactMessageDiscord.mock.calls[0]).toEqual(["c1", "m1", "👀", { rest: {} }]);
|
||||
});
|
||||
|
||||
it("uses preflight-resolved messageChannelId when message.channelId is missing", async () => {
|
||||
@@ -166,7 +187,7 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
await processDiscordMessage(ctx as any);
|
||||
|
||||
expect(reactMessageDiscord.mock.calls[0]).toEqual([
|
||||
expect(sendMocks.reactMessageDiscord.mock.calls[0]).toEqual([
|
||||
"fallback-channel",
|
||||
"m1",
|
||||
"👀",
|
||||
@@ -187,7 +208,7 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
await processDiscordMessage(ctx as any);
|
||||
|
||||
const emojis = (
|
||||
reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]>
|
||||
sendMocks.reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]>
|
||||
).map((call) => call[2]);
|
||||
expect(emojis).toContain("👀");
|
||||
expect(emojis).toContain(DEFAULT_EMOJIS.done);
|
||||
@@ -216,7 +237,7 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
|
||||
await runPromise;
|
||||
const emojis = (
|
||||
reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]>
|
||||
sendMocks.reactMessageDiscord.mock.calls as unknown as Array<[unknown, unknown, string]>
|
||||
).map((call) => call[2]);
|
||||
expect(emojis).toContain(DEFAULT_EMOJIS.stallSoft);
|
||||
expect(emojis).toContain(DEFAULT_EMOJIS.stallHard);
|
||||
@@ -289,6 +310,52 @@ describe("processDiscordMessage session routing", () => {
|
||||
accountId: "default",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers bound session keys and sets MessageThreadId for bound thread messages", async () => {
|
||||
const threadBindings = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
await threadBindings.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "c-parent",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
webhookId: "wh_1",
|
||||
webhookToken: "tok_1",
|
||||
introText: "",
|
||||
});
|
||||
|
||||
const ctx = await createBaseContext({
|
||||
messageChannelId: "thread-1",
|
||||
threadChannel: { id: "thread-1", name: "subagent-thread" },
|
||||
boundSessionKey: "agent:main:subagent:child",
|
||||
threadBindings,
|
||||
route: {
|
||||
agentId: "main",
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:main:discord:channel:c1",
|
||||
mainSessionKey: "agent:main:main",
|
||||
},
|
||||
});
|
||||
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
await processDiscordMessage(ctx as any);
|
||||
|
||||
expect(getLastDispatchCtx()).toMatchObject({
|
||||
SessionKey: "agent:main:subagent:child",
|
||||
MessageThreadId: "thread-1",
|
||||
});
|
||||
expect(getLastRouteUpdate()).toEqual({
|
||||
sessionKey: "agent:main:subagent:child",
|
||||
channel: "discord",
|
||||
to: "channel:thread-1",
|
||||
accountId: "default",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("processDiscordMessage draft streaming", () => {
|
||||
|
||||
@@ -94,6 +94,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
guildSlug,
|
||||
channelConfig,
|
||||
baseSessionKey,
|
||||
boundSessionKey,
|
||||
threadBindings,
|
||||
route,
|
||||
commandAuthorized,
|
||||
} = ctx;
|
||||
@@ -324,7 +326,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
CommandBody: baseText,
|
||||
From: effectiveFrom,
|
||||
To: effectiveTo,
|
||||
SessionKey: autoThreadContext?.SessionKey ?? threadKeys.sessionKey,
|
||||
SessionKey: boundSessionKey ?? autoThreadContext?.SessionKey ?? threadKeys.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isDirectMessage ? "direct" : "channel",
|
||||
ConversationLabel: fromLabel,
|
||||
@@ -346,6 +348,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
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),
|
||||
@@ -633,6 +636,8 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext)
|
||||
maxLinesPerMessage: discordConfig?.maxLinesPerMessage,
|
||||
tableMode,
|
||||
chunkMode,
|
||||
sessionKey: ctxPayload.SessionKey,
|
||||
threadBindings,
|
||||
});
|
||||
replyReference.markSent();
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
export async function createBaseDiscordMessageContext(
|
||||
overrides: Record<string, unknown> = {},
|
||||
@@ -67,6 +68,7 @@ export async function createBaseDiscordMessageContext(
|
||||
sessionKey: "agent:main:discord:guild:g1",
|
||||
mainSessionKey: "agent:main:main",
|
||||
},
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
...overrides,
|
||||
} as unknown as DiscordMessagePreflightContext;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { normalizeProviderId } from "../../agents/model-selection.js";
|
||||
import { resolveStateDir } from "../../config/paths.js";
|
||||
import { withFileLock } from "../../infra/file-lock.js";
|
||||
import { resolveRequiredHomeDir } from "../../infra/home-dir.js";
|
||||
import { normalizeAccountId as normalizeSharedAccountId } from "../../routing/account-id.js";
|
||||
|
||||
const MODEL_PICKER_PREFERENCES_LOCK_OPTIONS = {
|
||||
retries: {
|
||||
@@ -41,11 +42,6 @@ function resolvePreferencesStorePath(env: NodeJS.ProcessEnv = process.env): stri
|
||||
return path.join(stateDir, "discord", "model-picker-preferences.json");
|
||||
}
|
||||
|
||||
function normalizeAccountId(value?: string): string {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized || "default";
|
||||
}
|
||||
|
||||
function normalizeId(value?: string): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
@@ -57,7 +53,7 @@ export function buildDiscordModelPickerPreferenceKey(
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
const accountId = normalizeAccountId(scope.accountId);
|
||||
const accountId = normalizeSharedAccountId(scope.accountId);
|
||||
const guildId = normalizeId(scope.guildId);
|
||||
if (guildId) {
|
||||
return `discord:${accountId}:guild:${guildId}:user:${userId}`;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
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";
|
||||
import * as modelPickerPreferencesModule from "./model-picker-preferences.js";
|
||||
import * as modelPickerModule from "./model-picker.js";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
createDiscordModelPickerFallbackButton,
|
||||
createDiscordModelPickerFallbackSelect,
|
||||
} from "./native-command.js";
|
||||
import { createNoopThreadBindingManager, type ThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
function createModelsProviderData(entries: Record<string, string[]>): ModelsProviderData {
|
||||
const byProvider = new Map<string, Set<string>>();
|
||||
@@ -70,6 +72,7 @@ function createModelPickerContext(): ModelPickerContext {
|
||||
discordConfig: cfg.channels?.discord ?? {},
|
||||
accountId: "default",
|
||||
sessionPrefix: "discord:slash",
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,6 +102,38 @@ function createInteraction(params?: { userId?: string; values?: string[] }): Moc
|
||||
};
|
||||
}
|
||||
|
||||
function createBoundThreadBindingManager(params: {
|
||||
accountId: string;
|
||||
threadId: string;
|
||||
targetSessionKey: string;
|
||||
agentId: string;
|
||||
}): ThreadBindingManager {
|
||||
return {
|
||||
accountId: params.accountId,
|
||||
getSessionTtlMs: () => 24 * 60 * 60 * 1000,
|
||||
getByThreadId: (threadId: string) =>
|
||||
threadId === params.threadId
|
||||
? {
|
||||
accountId: params.accountId,
|
||||
channelId: "parent-1",
|
||||
threadId: params.threadId,
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: params.targetSessionKey,
|
||||
agentId: params.agentId,
|
||||
boundBy: "system",
|
||||
boundAt: Date.now(),
|
||||
}
|
||||
: undefined,
|
||||
getBySessionKey: () => undefined,
|
||||
listBySessionKey: () => [],
|
||||
listBindings: () => [],
|
||||
bindTarget: async () => null,
|
||||
unbindThread: () => null,
|
||||
unbindBySessionKey: () => [],
|
||||
stop: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Discord model picker interactions", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -375,4 +410,78 @@ describe("Discord model picker interactions", () => {
|
||||
expect(dispatchCall.ctx?.CommandBody).toBe("/model openai/gpt-4o");
|
||||
expect(dispatchCall.ctx?.CommandArgs?.values?.model).toBe("openai/gpt-4o");
|
||||
});
|
||||
|
||||
it("verifies model state against the bound thread session", async () => {
|
||||
const context = createModelPickerContext();
|
||||
context.threadBindings = createBoundThreadBindingManager({
|
||||
accountId: "default",
|
||||
threadId: "thread-bound",
|
||||
targetSessionKey: "agent:worker:subagent:bound",
|
||||
agentId: "worker",
|
||||
});
|
||||
const pickerData = createModelsProviderData({
|
||||
openai: ["gpt-4.1", "gpt-4o"],
|
||||
anthropic: ["claude-sonnet-4-5"],
|
||||
});
|
||||
const modelCommand: ChatCommandDefinition = {
|
||||
key: "model",
|
||||
nativeName: "model",
|
||||
description: "Switch model",
|
||||
textAliases: ["/model"],
|
||||
acceptsArgs: true,
|
||||
argsParsing: "none" as CommandArgsParsing,
|
||||
scope: "native",
|
||||
};
|
||||
|
||||
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
|
||||
vi.spyOn(commandRegistryModule, "findCommandByNativeName").mockImplementation((name) =>
|
||||
name === "model" ? modelCommand : undefined,
|
||||
);
|
||||
vi.spyOn(commandRegistryModule, "listChatCommands").mockReturnValue([modelCommand]);
|
||||
vi.spyOn(commandRegistryModule, "resolveCommandArgMenu").mockReturnValue(null);
|
||||
vi.spyOn(dispatcherModule, "dispatchReplyWithDispatcher").mockResolvedValue({} as never);
|
||||
const verboseSpy = vi.spyOn(globalsModule, "logVerbose").mockImplementation(() => {});
|
||||
|
||||
const select = createDiscordModelPickerFallbackSelect(context);
|
||||
const selectInteraction = createInteraction({
|
||||
userId: "owner",
|
||||
values: ["gpt-4o"],
|
||||
});
|
||||
selectInteraction.channel = {
|
||||
type: ChannelType.PublicThread,
|
||||
id: "thread-bound",
|
||||
};
|
||||
const selectData: PickerSelectData = {
|
||||
cmd: "model",
|
||||
act: "model",
|
||||
view: "models",
|
||||
u: "owner",
|
||||
p: "openai",
|
||||
pg: "1",
|
||||
};
|
||||
await select.run(selectInteraction as unknown as PickerSelectInteraction, selectData);
|
||||
|
||||
const button = createDiscordModelPickerFallbackButton(context);
|
||||
const submitInteraction = createInteraction({ userId: "owner" });
|
||||
submitInteraction.channel = {
|
||||
type: ChannelType.PublicThread,
|
||||
id: "thread-bound",
|
||||
};
|
||||
const submitData: PickerButtonData = {
|
||||
cmd: "model",
|
||||
act: "submit",
|
||||
view: "models",
|
||||
u: "owner",
|
||||
p: "openai",
|
||||
pg: "1",
|
||||
mi: "2",
|
||||
};
|
||||
|
||||
await button.run(submitInteraction as unknown as PickerButtonInteraction, submitData);
|
||||
|
||||
const mismatchLog = verboseSpy.mock.calls.find((call) =>
|
||||
String(call[0] ?? "").includes("model picker override mismatch"),
|
||||
)?.[0];
|
||||
expect(mismatchLog).toContain("session key agent:worker:subagent:bound");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
upsertChannelPairingRequest,
|
||||
} from "../../pairing/pairing-store.js";
|
||||
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import { buildUntrustedChannelMetadata } from "../../security/channel-metadata.js";
|
||||
import { chunkItems } from "../../utils/chunk-items.js";
|
||||
import { withTimeout } from "../../utils/with-timeout.js";
|
||||
@@ -80,6 +81,7 @@ import {
|
||||
type DiscordModelPickerCommandContext,
|
||||
} from "./model-picker.js";
|
||||
import { resolveDiscordSenderIdentity } from "./sender-identity.js";
|
||||
import type { ThreadBindingManager } from "./thread-bindings.js";
|
||||
import { resolveDiscordThreadParentInfo } from "./threading.js";
|
||||
|
||||
type DiscordConfig = NonNullable<OpenClawConfig["channels"]>["discord"];
|
||||
@@ -268,6 +270,7 @@ type DiscordCommandArgContext = {
|
||||
discordConfig: DiscordConfig;
|
||||
accountId: string;
|
||||
sessionPrefix: string;
|
||||
threadBindings: ThreadBindingManager;
|
||||
};
|
||||
|
||||
type DiscordModelPickerContext = DiscordCommandArgContext;
|
||||
@@ -353,6 +356,7 @@ async function resolveDiscordModelPickerRoute(params: {
|
||||
interaction: CommandInteraction | ButtonInteraction | StringSelectMenuInteraction;
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
accountId: string;
|
||||
threadBindings: ThreadBindingManager;
|
||||
}) {
|
||||
const { interaction, cfg, accountId } = params;
|
||||
const channel = interaction.channel;
|
||||
@@ -383,7 +387,7 @@ async function resolveDiscordModelPickerRoute(params: {
|
||||
threadParentId = parentInfo.id;
|
||||
}
|
||||
|
||||
return resolveAgentRoute({
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId,
|
||||
@@ -395,6 +399,19 @@ async function resolveDiscordModelPickerRoute(params: {
|
||||
},
|
||||
parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined,
|
||||
});
|
||||
|
||||
const threadBinding = isThreadChannel
|
||||
? params.threadBindings.getByThreadId(rawChannelId)
|
||||
: undefined;
|
||||
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
|
||||
const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined;
|
||||
return boundSessionKey
|
||||
? {
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: boundAgentId ?? route.agentId,
|
||||
}
|
||||
: route;
|
||||
}
|
||||
|
||||
function resolveDiscordModelPickerCurrentModel(params: {
|
||||
@@ -436,6 +453,7 @@ async function replyWithDiscordModelPickerProviders(params: {
|
||||
command: DiscordModelPickerCommandContext;
|
||||
userId: string;
|
||||
accountId: string;
|
||||
threadBindings: ThreadBindingManager;
|
||||
preferFollowUp: boolean;
|
||||
}) {
|
||||
const data = await loadDiscordModelPickerData(params.cfg);
|
||||
@@ -443,6 +461,7 @@ async function replyWithDiscordModelPickerProviders(params: {
|
||||
interaction: params.interaction,
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
threadBindings: params.threadBindings,
|
||||
});
|
||||
const currentModel = resolveDiscordModelPickerCurrentModel({
|
||||
cfg: params.cfg,
|
||||
@@ -603,6 +622,7 @@ async function handleDiscordModelPickerInteraction(
|
||||
interaction,
|
||||
cfg: ctx.cfg,
|
||||
accountId: ctx.accountId,
|
||||
threadBindings: ctx.threadBindings,
|
||||
});
|
||||
const currentModelRef = resolveDiscordModelPickerCurrentModel({
|
||||
cfg: ctx.cfg,
|
||||
@@ -827,6 +847,7 @@ async function handleDiscordModelPickerInteraction(
|
||||
accountId: ctx.accountId,
|
||||
sessionPrefix: ctx.sessionPrefix,
|
||||
preferFollowUp: true,
|
||||
threadBindings: ctx.threadBindings,
|
||||
suppressReplies: true,
|
||||
}),
|
||||
12000,
|
||||
@@ -957,6 +978,7 @@ async function handleDiscordCommandArgInteraction(
|
||||
accountId: ctx.accountId,
|
||||
sessionPrefix: ctx.sessionPrefix,
|
||||
preferFollowUp: true,
|
||||
threadBindings: ctx.threadBindings,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -968,6 +990,7 @@ class DiscordCommandArgButton extends Button {
|
||||
private discordConfig: DiscordConfig;
|
||||
private accountId: string;
|
||||
private sessionPrefix: string;
|
||||
private threadBindings: ThreadBindingManager;
|
||||
|
||||
constructor(params: {
|
||||
label: string;
|
||||
@@ -976,6 +999,7 @@ class DiscordCommandArgButton extends Button {
|
||||
discordConfig: DiscordConfig;
|
||||
accountId: string;
|
||||
sessionPrefix: string;
|
||||
threadBindings: ThreadBindingManager;
|
||||
}) {
|
||||
super();
|
||||
this.label = params.label;
|
||||
@@ -984,6 +1008,7 @@ class DiscordCommandArgButton extends Button {
|
||||
this.discordConfig = params.discordConfig;
|
||||
this.accountId = params.accountId;
|
||||
this.sessionPrefix = params.sessionPrefix;
|
||||
this.threadBindings = params.threadBindings;
|
||||
}
|
||||
|
||||
async run(interaction: ButtonInteraction, data: ComponentData) {
|
||||
@@ -992,6 +1017,7 @@ class DiscordCommandArgButton extends Button {
|
||||
discordConfig: this.discordConfig,
|
||||
accountId: this.accountId,
|
||||
sessionPrefix: this.sessionPrefix,
|
||||
threadBindings: this.threadBindings,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1067,6 +1093,7 @@ function buildDiscordCommandArgMenu(params: {
|
||||
discordConfig: DiscordConfig;
|
||||
accountId: string;
|
||||
sessionPrefix: string;
|
||||
threadBindings: ThreadBindingManager;
|
||||
}): { content: string; components: Row<Button>[] } {
|
||||
const { command, menu, interaction } = params;
|
||||
const commandLabel = command.nativeName ?? command.key;
|
||||
@@ -1086,6 +1113,7 @@ function buildDiscordCommandArgMenu(params: {
|
||||
discordConfig: params.discordConfig,
|
||||
accountId: params.accountId,
|
||||
sessionPrefix: params.sessionPrefix,
|
||||
threadBindings: params.threadBindings,
|
||||
}),
|
||||
);
|
||||
return new Row(buttons);
|
||||
@@ -1102,8 +1130,17 @@ export function createDiscordNativeCommand(params: {
|
||||
accountId: string;
|
||||
sessionPrefix: string;
|
||||
ephemeralDefault: boolean;
|
||||
threadBindings: ThreadBindingManager;
|
||||
}): Command {
|
||||
const { command, cfg, discordConfig, accountId, sessionPrefix, ephemeralDefault } = params;
|
||||
const {
|
||||
command,
|
||||
cfg,
|
||||
discordConfig,
|
||||
accountId,
|
||||
sessionPrefix,
|
||||
ephemeralDefault,
|
||||
threadBindings,
|
||||
} = params;
|
||||
const commandDefinition =
|
||||
findCommandByNativeName(command.name, "discord") ??
|
||||
({
|
||||
@@ -1164,6 +1201,7 @@ export function createDiscordNativeCommand(params: {
|
||||
accountId,
|
||||
sessionPrefix,
|
||||
preferFollowUp: false,
|
||||
threadBindings,
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -1179,6 +1217,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
accountId: string;
|
||||
sessionPrefix: string;
|
||||
preferFollowUp: boolean;
|
||||
threadBindings: ThreadBindingManager;
|
||||
suppressReplies?: boolean;
|
||||
}) {
|
||||
const {
|
||||
@@ -1191,6 +1230,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
accountId,
|
||||
sessionPrefix,
|
||||
preferFollowUp,
|
||||
threadBindings,
|
||||
suppressReplies,
|
||||
} = params;
|
||||
const respond = async (content: string, options?: { ephemeral?: boolean }) => {
|
||||
@@ -1391,6 +1431,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
discordConfig,
|
||||
accountId,
|
||||
sessionPrefix,
|
||||
threadBindings,
|
||||
});
|
||||
if (preferFollowUp) {
|
||||
await safeDiscordInteractionCall("interaction follow-up", () =>
|
||||
@@ -1423,6 +1464,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
command: pickerCommandContext,
|
||||
userId: user.id,
|
||||
accountId,
|
||||
threadBindings,
|
||||
preferFollowUp,
|
||||
});
|
||||
return;
|
||||
@@ -1443,6 +1485,16 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
},
|
||||
parentPeer: threadParentId ? { kind: "channel", id: threadParentId } : undefined,
|
||||
});
|
||||
const threadBinding = isThreadChannel ? threadBindings.getByThreadId(rawChannelId) : undefined;
|
||||
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
|
||||
const boundAgentId = boundSessionKey ? resolveAgentIdFromSessionKey(boundSessionKey) : undefined;
|
||||
const effectiveRoute = boundSessionKey
|
||||
? {
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: boundAgentId ?? route.agentId,
|
||||
}
|
||||
: route;
|
||||
const conversationLabel = isDirectMessage ? (user.globalName ?? user.username) : channelId;
|
||||
const ownerAllowFrom = resolveDiscordOwnerAllowFrom({
|
||||
channelConfig,
|
||||
@@ -1461,9 +1513,9 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
? `discord:group:${channelId}`
|
||||
: `discord:channel:${channelId}`,
|
||||
To: `slash:${user.id}`,
|
||||
SessionKey: `agent:${route.agentId}:${sessionPrefix}:${user.id}`,
|
||||
CommandTargetSessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
SessionKey: boundSessionKey ?? `agent:${effectiveRoute.agentId}:${sessionPrefix}:${user.id}`,
|
||||
CommandTargetSessionKey: boundSessionKey ?? effectiveRoute.sessionKey,
|
||||
AccountId: effectiveRoute.accountId,
|
||||
ChatType: isDirectMessage ? "direct" : isGroupDm ? "group" : "channel",
|
||||
ConversationLabel: conversationLabel,
|
||||
GroupSubject: isGuild ? interaction.guild?.name : undefined,
|
||||
@@ -1496,6 +1548,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
Surface: "discord" as const,
|
||||
WasMentioned: true,
|
||||
MessageSid: interactionId,
|
||||
MessageThreadId: isThreadChannel ? channelId : undefined,
|
||||
Timestamp: Date.now(),
|
||||
CommandAuthorized: commandAuthorized,
|
||||
CommandSource: "native" as const,
|
||||
@@ -1508,11 +1561,11 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
agentId: effectiveRoute.agentId,
|
||||
channel: "discord",
|
||||
accountId: route.accountId,
|
||||
accountId: effectiveRoute.accountId,
|
||||
});
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
|
||||
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, effectiveRoute.agentId);
|
||||
|
||||
let didReply = false;
|
||||
await dispatchReplyWithDispatcher({
|
||||
@@ -1520,7 +1573,7 @@ async function dispatchDiscordCommandInteraction(params: {
|
||||
cfg,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
|
||||
humanDelay: resolveHumanDelayConfig(cfg, effectiveRoute.agentId),
|
||||
deliver: async (payload) => {
|
||||
if (suppressReplies) {
|
||||
return;
|
||||
|
||||
207
src/discord/monitor/provider.allowlist.ts
Normal file
207
src/discord/monitor/provider.allowlist.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import {
|
||||
addAllowlistUserEntriesFromConfigEntry,
|
||||
buildAllowlistResolutionSummary,
|
||||
mergeAllowlist,
|
||||
patchAllowlistUsersInConfigEntries,
|
||||
resolveAllowlistIdAdditions,
|
||||
summarizeMapping,
|
||||
} from "../../channels/allowlists/resolve-utils.js";
|
||||
import type { DiscordGuildEntry } from "../../config/types.discord.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { resolveDiscordChannelAllowlist } from "../resolve-channels.js";
|
||||
import { resolveDiscordUserAllowlist } from "../resolve-users.js";
|
||||
|
||||
type GuildEntries = Record<string, DiscordGuildEntry>;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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 (Object.keys(guildEntries).length > 0) {
|
||||
try {
|
||||
const entries: Array<{ input: string; guildKey: string; channelKey?: string }> = [];
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
const resolved = await resolveDiscordChannelAllowlist({
|
||||
token: params.token,
|
||||
entries: entries.map((entry) => entry.input),
|
||||
fetcher: params.fetcher,
|
||||
});
|
||||
const nextGuilds = { ...guildEntries };
|
||||
const mapping: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
for (const entry of resolved) {
|
||||
const source = entries.find((item) => item.input === entry.input);
|
||||
if (!source) {
|
||||
continue;
|
||||
}
|
||||
const sourceGuild = guildEntries[source.guildKey] ?? {};
|
||||
if (!entry.resolved || !entry.guildId) {
|
||||
unresolved.push(entry.input);
|
||||
continue;
|
||||
}
|
||||
mapping.push(
|
||||
entry.channelId
|
||||
? `${entry.input}→${entry.guildId}/${entry.channelId}`
|
||||
: `${entry.input}→${entry.guildId}`,
|
||||
);
|
||||
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],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
guildEntries = nextGuilds;
|
||||
summarizeMapping("discord channels", mapping, unresolved, params.runtime);
|
||||
}
|
||||
} catch (err) {
|
||||
params.runtime.log?.(
|
||||
`discord channel resolve failed; using config entries. ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allowEntries =
|
||||
allowFrom?.filter((entry) => String(entry).trim() && String(entry).trim() !== "*") ?? [];
|
||||
if (allowEntries.length > 0) {
|
||||
try {
|
||||
const resolvedUsers = await resolveDiscordUserAllowlist({
|
||||
token: params.token,
|
||||
entries: allowEntries.map((entry) => String(entry)),
|
||||
fetcher: params.fetcher,
|
||||
});
|
||||
const { mapping, unresolved, additions } = buildAllowlistResolutionSummary(resolvedUsers);
|
||||
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
|
||||
summarizeMapping("discord users", mapping, unresolved, params.runtime);
|
||||
} catch (err) {
|
||||
params.runtime.log?.(
|
||||
`discord user resolve failed; using config entries. ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(guildEntries).length > 0) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (userEntries.size > 0) {
|
||||
try {
|
||||
const resolvedUsers = await resolveDiscordUserAllowlist({
|
||||
token: params.token,
|
||||
entries: Array.from(userEntries),
|
||||
fetcher: params.fetcher,
|
||||
});
|
||||
const { resolvedMap, mapping, unresolved } = buildAllowlistResolutionSummary(resolvedUsers);
|
||||
|
||||
const nextGuilds = { ...guildEntries };
|
||||
for (const [guildKey, guildConfig] of Object.entries(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) {
|
||||
const additions = resolveAllowlistIdAdditions({ existing: users, resolvedMap });
|
||||
nextGuild.users = mergeAllowlist({ existing: users, additions });
|
||||
}
|
||||
const channels = (guildConfig as { channels?: Record<string, unknown> }).channels ?? {};
|
||||
if (channels && typeof channels === "object") {
|
||||
nextGuild.channels = patchAllowlistUsersInConfigEntries({
|
||||
entries: channels,
|
||||
resolvedMap,
|
||||
});
|
||||
}
|
||||
nextGuilds[guildKey] = nextGuild as DiscordGuildEntry;
|
||||
}
|
||||
guildEntries = nextGuilds;
|
||||
summarizeMapping("discord channel users", mapping, unresolved, params.runtime);
|
||||
} catch (err) {
|
||||
params.runtime.log?.(
|
||||
`discord channel user resolve failed; using config entries. ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
guildEntries: Object.keys(guildEntries).length > 0 ? guildEntries : undefined,
|
||||
allowFrom,
|
||||
};
|
||||
}
|
||||
106
src/discord/monitor/provider.lifecycle.test.ts
Normal file
106
src/discord/monitor/provider.lifecycle.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { Client } from "@buape/carbon";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
|
||||
const {
|
||||
attachDiscordGatewayLoggingMock,
|
||||
getDiscordGatewayEmitterMock,
|
||||
registerGatewayMock,
|
||||
stopGatewayLoggingMock,
|
||||
unregisterGatewayMock,
|
||||
waitForDiscordGatewayStopMock,
|
||||
} = vi.hoisted(() => {
|
||||
const stopGatewayLoggingMock = vi.fn();
|
||||
return {
|
||||
attachDiscordGatewayLoggingMock: vi.fn(() => stopGatewayLoggingMock),
|
||||
getDiscordGatewayEmitterMock: vi.fn(() => undefined),
|
||||
waitForDiscordGatewayStopMock: vi.fn(() => Promise.resolve()),
|
||||
registerGatewayMock: vi.fn(),
|
||||
unregisterGatewayMock: vi.fn(),
|
||||
stopGatewayLoggingMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../gateway-logging.js", () => ({
|
||||
attachDiscordGatewayLogging: attachDiscordGatewayLoggingMock,
|
||||
}));
|
||||
|
||||
vi.mock("../monitor.gateway.js", () => ({
|
||||
getDiscordGatewayEmitter: getDiscordGatewayEmitterMock,
|
||||
waitForDiscordGatewayStop: waitForDiscordGatewayStopMock,
|
||||
}));
|
||||
|
||||
vi.mock("./gateway-registry.js", () => ({
|
||||
registerGateway: registerGatewayMock,
|
||||
unregisterGateway: unregisterGatewayMock,
|
||||
}));
|
||||
|
||||
describe("runDiscordGatewayLifecycle", () => {
|
||||
beforeEach(() => {
|
||||
attachDiscordGatewayLoggingMock.mockClear();
|
||||
getDiscordGatewayEmitterMock.mockClear();
|
||||
waitForDiscordGatewayStopMock.mockClear();
|
||||
registerGatewayMock.mockClear();
|
||||
unregisterGatewayMock.mockClear();
|
||||
stopGatewayLoggingMock.mockClear();
|
||||
});
|
||||
|
||||
it("cleans up thread bindings when exec approvals startup fails", async () => {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
|
||||
const start = vi.fn(async () => {
|
||||
throw new Error("startup failed");
|
||||
});
|
||||
const stop = vi.fn(async () => undefined);
|
||||
const threadStop = vi.fn();
|
||||
|
||||
await expect(
|
||||
runDiscordGatewayLifecycle({
|
||||
accountId: "default",
|
||||
client: { getPlugin: vi.fn(() => undefined) } as unknown as Client,
|
||||
runtime: {} as RuntimeEnv,
|
||||
isDisallowedIntentsError: () => false,
|
||||
voiceManager: null,
|
||||
voiceManagerRef: { current: null },
|
||||
execApprovalsHandler: { start, stop },
|
||||
threadBindings: { stop: threadStop },
|
||||
}),
|
||||
).rejects.toThrow("startup failed");
|
||||
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
expect(waitForDiscordGatewayStopMock).not.toHaveBeenCalled();
|
||||
expect(unregisterGatewayMock).toHaveBeenCalledWith("default");
|
||||
expect(stopGatewayLoggingMock).toHaveBeenCalledTimes(1);
|
||||
expect(threadStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("cleans up when gateway wait fails after startup", async () => {
|
||||
const { runDiscordGatewayLifecycle } = await import("./provider.lifecycle.js");
|
||||
waitForDiscordGatewayStopMock.mockRejectedValueOnce(new Error("gateway wait failed"));
|
||||
|
||||
const start = vi.fn(async () => undefined);
|
||||
const stop = vi.fn(async () => undefined);
|
||||
const threadStop = vi.fn();
|
||||
|
||||
await expect(
|
||||
runDiscordGatewayLifecycle({
|
||||
accountId: "default",
|
||||
client: { getPlugin: vi.fn(() => undefined) } as unknown as Client,
|
||||
runtime: {} as RuntimeEnv,
|
||||
isDisallowedIntentsError: () => false,
|
||||
voiceManager: null,
|
||||
voiceManagerRef: { current: null },
|
||||
execApprovalsHandler: { start, stop },
|
||||
threadBindings: { stop: threadStop },
|
||||
}),
|
||||
).rejects.toThrow("gateway wait failed");
|
||||
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
expect(waitForDiscordGatewayStopMock).toHaveBeenCalledTimes(1);
|
||||
expect(unregisterGatewayMock).toHaveBeenCalledWith("default");
|
||||
expect(stopGatewayLoggingMock).toHaveBeenCalledTimes(1);
|
||||
expect(threadStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
132
src/discord/monitor/provider.lifecycle.ts
Normal file
132
src/discord/monitor/provider.lifecycle.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type { Client } from "@buape/carbon";
|
||||
import type { GatewayPlugin } from "@buape/carbon/gateway";
|
||||
import { danger } from "../../globals.js";
|
||||
import type { RuntimeEnv } from "../../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";
|
||||
|
||||
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 };
|
||||
}) {
|
||||
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,
|
||||
});
|
||||
|
||||
const onAbort = () => {
|
||||
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 });
|
||||
}
|
||||
|
||||
const HELLO_TIMEOUT_MS = 30000;
|
||||
let helloTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const onGatewayDebug = (msg: unknown) => {
|
||||
const message = String(msg);
|
||||
if (!message.includes("WebSocket connection opened")) {
|
||||
return;
|
||||
}
|
||||
if (helloTimeoutId) {
|
||||
clearTimeout(helloTimeoutId);
|
||||
}
|
||||
helloTimeoutId = setTimeout(() => {
|
||||
if (!gateway?.isConnected) {
|
||||
params.runtime.log?.(
|
||||
danger(
|
||||
`connection stalled: no HELLO received within ${HELLO_TIMEOUT_MS}ms, forcing reconnect`,
|
||||
),
|
||||
);
|
||||
gateway?.disconnect();
|
||||
gateway?.connect(false);
|
||||
}
|
||||
helloTimeoutId = undefined;
|
||||
}, HELLO_TIMEOUT_MS);
|
||||
};
|
||||
gatewayEmitter?.on("debug", onGatewayDebug);
|
||||
|
||||
let sawDisallowedIntents = false;
|
||||
try {
|
||||
if (params.execApprovalsHandler) {
|
||||
await params.execApprovalsHandler.start();
|
||||
}
|
||||
|
||||
await waitForDiscordGatewayStop({
|
||||
gateway: gateway
|
||||
? {
|
||||
emitter: gatewayEmitter,
|
||||
disconnect: () => gateway.disconnect(),
|
||||
}
|
||||
: undefined,
|
||||
abortSignal: params.abortSignal,
|
||||
onGatewayError: (err) => {
|
||||
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)}`));
|
||||
},
|
||||
shouldStopOnError: (err) => {
|
||||
const message = String(err);
|
||||
return (
|
||||
message.includes("Max reconnect attempts") ||
|
||||
message.includes("Fatal Gateway error") ||
|
||||
params.isDisallowedIntentsError(err)
|
||||
);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (!sawDisallowedIntents && !params.isDisallowedIntentsError(err)) {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
unregisterGateway(params.accountId);
|
||||
stopGatewayLogging();
|
||||
if (helloTimeoutId) {
|
||||
clearTimeout(helloTimeoutId);
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -24,3 +24,38 @@ describe("dedupeSkillCommandsForDiscord", () => {
|
||||
expect(output[0]?.name).toBe("ClawHub");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveThreadBindingsEnabled", () => {
|
||||
it("defaults to enabled when unset", () => {
|
||||
expect(
|
||||
__testing.resolveThreadBindingsEnabled({
|
||||
channelEnabledRaw: undefined,
|
||||
sessionEnabledRaw: undefined,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("uses global session default when channel value is unset", () => {
|
||||
expect(
|
||||
__testing.resolveThreadBindingsEnabled({
|
||||
channelEnabledRaw: undefined,
|
||||
sessionEnabledRaw: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses channel value to override global session default", () => {
|
||||
expect(
|
||||
__testing.resolveThreadBindingsEnabled({
|
||||
channelEnabledRaw: true,
|
||||
sessionEnabledRaw: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
__testing.resolveThreadBindingsEnabled({
|
||||
channelEnabledRaw: false,
|
||||
sessionEnabledRaw: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
293
src/discord/monitor/provider.test.ts
Normal file
293
src/discord/monitor/provider.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
|
||||
const {
|
||||
createDiscordNativeCommandMock,
|
||||
createNoopThreadBindingManagerMock,
|
||||
createThreadBindingManagerMock,
|
||||
createdBindingManagers,
|
||||
listNativeCommandSpecsForConfigMock,
|
||||
listSkillCommandsForAgentsMock,
|
||||
monitorLifecycleMock,
|
||||
resolveDiscordAccountMock,
|
||||
resolveDiscordAllowlistConfigMock,
|
||||
resolveNativeCommandsEnabledMock,
|
||||
resolveNativeSkillsEnabledMock,
|
||||
} = vi.hoisted(() => {
|
||||
const createdBindingManagers: Array<{ stop: ReturnType<typeof vi.fn> }> = [];
|
||||
return {
|
||||
createDiscordNativeCommandMock: vi.fn(() => ({ name: "mock-command" })),
|
||||
createNoopThreadBindingManagerMock: vi.fn(() => {
|
||||
const manager = { stop: vi.fn() };
|
||||
createdBindingManagers.push(manager);
|
||||
return manager;
|
||||
}),
|
||||
createThreadBindingManagerMock: vi.fn(() => {
|
||||
const manager = { stop: vi.fn() };
|
||||
createdBindingManagers.push(manager);
|
||||
return manager;
|
||||
}),
|
||||
createdBindingManagers,
|
||||
listNativeCommandSpecsForConfigMock: vi.fn(() => [{ name: "cmd" }]),
|
||||
listSkillCommandsForAgentsMock: vi.fn(() => []),
|
||||
monitorLifecycleMock: vi.fn(async (params: { threadBindings: { stop: () => void } }) => {
|
||||
params.threadBindings.stop();
|
||||
}),
|
||||
resolveDiscordAccountMock: vi.fn(() => ({
|
||||
accountId: "default",
|
||||
token: "cfg-token",
|
||||
config: {
|
||||
commands: { native: true, nativeSkills: false },
|
||||
voice: { enabled: false },
|
||||
agentComponents: { enabled: false },
|
||||
execApprovals: { enabled: false },
|
||||
},
|
||||
})),
|
||||
resolveDiscordAllowlistConfigMock: vi.fn(async () => ({
|
||||
guildEntries: undefined,
|
||||
allowFrom: undefined,
|
||||
})),
|
||||
resolveNativeCommandsEnabledMock: vi.fn(() => true),
|
||||
resolveNativeSkillsEnabledMock: vi.fn(() => false),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@buape/carbon", () => {
|
||||
class ReadyListener {}
|
||||
class Client {
|
||||
listeners: unknown[];
|
||||
rest: { put: ReturnType<typeof vi.fn> };
|
||||
constructor(_options: unknown, handlers: { listeners?: unknown[] }) {
|
||||
this.listeners = handlers.listeners ?? [];
|
||||
this.rest = { put: vi.fn(async () => undefined) };
|
||||
}
|
||||
async handleDeployRequest() {
|
||||
return undefined;
|
||||
}
|
||||
async fetchUser(_target: string) {
|
||||
return { id: "bot-1" };
|
||||
}
|
||||
getPlugin(_name: string) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return { Client, ReadyListener };
|
||||
});
|
||||
|
||||
vi.mock("@buape/carbon/gateway", () => ({
|
||||
GatewayCloseCodes: { DisallowedIntents: 4014 },
|
||||
}));
|
||||
|
||||
vi.mock("@buape/carbon/voice", () => ({
|
||||
VoicePlugin: class VoicePlugin {},
|
||||
}));
|
||||
|
||||
vi.mock("../../auto-reply/chunk.js", () => ({
|
||||
resolveTextChunkLimit: () => 2000,
|
||||
}));
|
||||
|
||||
vi.mock("../../auto-reply/commands-registry.js", () => ({
|
||||
listNativeCommandSpecsForConfig: listNativeCommandSpecsForConfigMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../auto-reply/skill-commands.js", () => ({
|
||||
listSkillCommandsForAgents: listSkillCommandsForAgentsMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../config/commands.js", () => ({
|
||||
isNativeCommandsExplicitlyDisabled: () => false,
|
||||
resolveNativeCommandsEnabled: resolveNativeCommandsEnabledMock,
|
||||
resolveNativeSkillsEnabled: resolveNativeSkillsEnabledMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
loadConfig: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("../../globals.js", () => ({
|
||||
danger: (v: string) => v,
|
||||
logVerbose: vi.fn(),
|
||||
shouldLogVerbose: () => false,
|
||||
warn: (v: string) => v,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/errors.js", () => ({
|
||||
formatErrorMessage: (err: unknown) => String(err),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/retry-policy.js", () => ({
|
||||
createDiscordRetryRunner: () => async (run: () => Promise<unknown>) => run(),
|
||||
}));
|
||||
|
||||
vi.mock("../../logging/subsystem.js", () => ({
|
||||
createSubsystemLogger: () => ({ info: vi.fn(), error: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../../runtime.js", () => ({
|
||||
createNonExitingRuntime: () => ({ log: vi.fn(), error: vi.fn(), exit: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../accounts.js", () => ({
|
||||
resolveDiscordAccount: resolveDiscordAccountMock,
|
||||
}));
|
||||
|
||||
vi.mock("../probe.js", () => ({
|
||||
fetchDiscordApplicationId: async () => "app-1",
|
||||
}));
|
||||
|
||||
vi.mock("../token.js", () => ({
|
||||
normalizeDiscordToken: (value?: string) => value,
|
||||
}));
|
||||
|
||||
vi.mock("../voice/command.js", () => ({
|
||||
createDiscordVoiceCommand: () => ({ name: "voice-command" }),
|
||||
}));
|
||||
|
||||
vi.mock("../voice/manager.js", () => ({
|
||||
DiscordVoiceManager: class DiscordVoiceManager {},
|
||||
DiscordVoiceReadyListener: class DiscordVoiceReadyListener {},
|
||||
}));
|
||||
|
||||
vi.mock("./agent-components.js", () => ({
|
||||
createAgentComponentButton: () => ({ id: "btn" }),
|
||||
createAgentSelectMenu: () => ({ id: "menu" }),
|
||||
createDiscordComponentButton: () => ({ id: "btn2" }),
|
||||
createDiscordComponentChannelSelect: () => ({ id: "channel" }),
|
||||
createDiscordComponentMentionableSelect: () => ({ id: "mentionable" }),
|
||||
createDiscordComponentModal: () => ({ id: "modal" }),
|
||||
createDiscordComponentRoleSelect: () => ({ id: "role" }),
|
||||
createDiscordComponentStringSelect: () => ({ id: "string" }),
|
||||
createDiscordComponentUserSelect: () => ({ id: "user" }),
|
||||
}));
|
||||
|
||||
vi.mock("./commands.js", () => ({
|
||||
resolveDiscordSlashCommandConfig: () => ({ ephemeral: false }),
|
||||
}));
|
||||
|
||||
vi.mock("./exec-approvals.js", () => ({
|
||||
createExecApprovalButton: () => ({ id: "exec-approval" }),
|
||||
DiscordExecApprovalHandler: class DiscordExecApprovalHandler {
|
||||
async start() {
|
||||
return undefined;
|
||||
}
|
||||
async stop() {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./gateway-plugin.js", () => ({
|
||||
createDiscordGatewayPlugin: () => ({ id: "gateway-plugin" }),
|
||||
}));
|
||||
|
||||
vi.mock("./listeners.js", () => ({
|
||||
DiscordMessageListener: class DiscordMessageListener {},
|
||||
DiscordPresenceListener: class DiscordPresenceListener {},
|
||||
DiscordReactionListener: class DiscordReactionListener {},
|
||||
DiscordReactionRemoveListener: class DiscordReactionRemoveListener {},
|
||||
registerDiscordListener: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./message-handler.js", () => ({
|
||||
createDiscordMessageHandler: () => ({ handle: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("./native-command.js", () => ({
|
||||
createDiscordCommandArgFallbackButton: () => ({ id: "arg-fallback" }),
|
||||
createDiscordModelPickerFallbackButton: () => ({ id: "model-fallback-btn" }),
|
||||
createDiscordModelPickerFallbackSelect: () => ({ id: "model-fallback-select" }),
|
||||
createDiscordNativeCommand: createDiscordNativeCommandMock,
|
||||
}));
|
||||
|
||||
vi.mock("./presence.js", () => ({
|
||||
resolveDiscordPresenceUpdate: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("./provider.allowlist.js", () => ({
|
||||
resolveDiscordAllowlistConfig: resolveDiscordAllowlistConfigMock,
|
||||
}));
|
||||
|
||||
vi.mock("./provider.lifecycle.js", () => ({
|
||||
runDiscordGatewayLifecycle: monitorLifecycleMock,
|
||||
}));
|
||||
|
||||
vi.mock("./rest-fetch.js", () => ({
|
||||
resolveDiscordRestFetch: () => async () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("./thread-bindings.js", () => ({
|
||||
createNoopThreadBindingManager: createNoopThreadBindingManagerMock,
|
||||
createThreadBindingManager: createThreadBindingManagerMock,
|
||||
}));
|
||||
|
||||
describe("monitorDiscordProvider", () => {
|
||||
const baseRuntime = (): RuntimeEnv => {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
const baseConfig = (): OpenClawConfig =>
|
||||
({
|
||||
channels: {
|
||||
discord: {
|
||||
accounts: {
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as OpenClawConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
createDiscordNativeCommandMock.mockReset().mockReturnValue({ name: "mock-command" });
|
||||
createNoopThreadBindingManagerMock.mockClear();
|
||||
createThreadBindingManagerMock.mockClear();
|
||||
createdBindingManagers.length = 0;
|
||||
listNativeCommandSpecsForConfigMock.mockReset().mockReturnValue([{ name: "cmd" }]);
|
||||
listSkillCommandsForAgentsMock.mockReset().mockReturnValue([]);
|
||||
monitorLifecycleMock.mockReset().mockImplementation(async (params) => {
|
||||
params.threadBindings.stop();
|
||||
});
|
||||
resolveDiscordAccountMock.mockClear();
|
||||
resolveDiscordAllowlistConfigMock.mockReset().mockResolvedValue({
|
||||
guildEntries: undefined,
|
||||
allowFrom: undefined,
|
||||
});
|
||||
resolveNativeCommandsEnabledMock.mockReset().mockReturnValue(true);
|
||||
resolveNativeSkillsEnabledMock.mockReset().mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("stops thread bindings when startup fails before lifecycle begins", async () => {
|
||||
const { monitorDiscordProvider } = await import("./provider.js");
|
||||
createDiscordNativeCommandMock.mockImplementation(() => {
|
||||
throw new Error("native command boom");
|
||||
});
|
||||
|
||||
await expect(
|
||||
monitorDiscordProvider({
|
||||
config: baseConfig(),
|
||||
runtime: baseRuntime(),
|
||||
}),
|
||||
).rejects.toThrow("native command boom");
|
||||
|
||||
expect(monitorLifecycleMock).not.toHaveBeenCalled();
|
||||
expect(createdBindingManagers).toHaveLength(1);
|
||||
expect(createdBindingManagers[0]?.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not double-stop thread bindings when lifecycle performs cleanup", async () => {
|
||||
const { monitorDiscordProvider } = await import("./provider.js");
|
||||
|
||||
await monitorDiscordProvider({
|
||||
config: baseConfig(),
|
||||
runtime: baseRuntime(),
|
||||
});
|
||||
|
||||
expect(monitorLifecycleMock).toHaveBeenCalledTimes(1);
|
||||
expect(createdBindingManagers).toHaveLength(1);
|
||||
expect(createdBindingManagers[0]?.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -14,14 +14,6 @@ import { resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
||||
import { listNativeCommandSpecsForConfig } from "../../auto-reply/commands-registry.js";
|
||||
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
|
||||
import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js";
|
||||
import {
|
||||
addAllowlistUserEntriesFromConfigEntry,
|
||||
buildAllowlistResolutionSummary,
|
||||
mergeAllowlist,
|
||||
resolveAllowlistIdAdditions,
|
||||
patchAllowlistUsersInConfigEntries,
|
||||
summarizeMapping,
|
||||
} from "../../channels/allowlists/resolve-utils.js";
|
||||
import {
|
||||
isNativeCommandsExplicitlyDisabled,
|
||||
resolveNativeCommandsEnabled,
|
||||
@@ -35,11 +27,7 @@ import { createDiscordRetryRunner } from "../../infra/retry-policy.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { resolveDiscordAccount } from "../accounts.js";
|
||||
import { attachDiscordGatewayLogging } from "../gateway-logging.js";
|
||||
import { getDiscordGatewayEmitter, waitForDiscordGatewayStop } from "../monitor.gateway.js";
|
||||
import { fetchDiscordApplicationId } from "../probe.js";
|
||||
import { resolveDiscordChannelAllowlist } from "../resolve-channels.js";
|
||||
import { resolveDiscordUserAllowlist } from "../resolve-users.js";
|
||||
import { normalizeDiscordToken } from "../token.js";
|
||||
import { createDiscordVoiceCommand } from "../voice/command.js";
|
||||
import { DiscordVoiceManager, DiscordVoiceReadyListener } from "../voice/manager.js";
|
||||
@@ -57,7 +45,6 @@ import {
|
||||
import { resolveDiscordSlashCommandConfig } from "./commands.js";
|
||||
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
|
||||
import { createDiscordGatewayPlugin } from "./gateway-plugin.js";
|
||||
import { registerGateway, unregisterGateway } from "./gateway-registry.js";
|
||||
import {
|
||||
DiscordMessageListener,
|
||||
DiscordPresenceListener,
|
||||
@@ -73,7 +60,10 @@ import {
|
||||
createDiscordNativeCommand,
|
||||
} from "./native-command.js";
|
||||
import { resolveDiscordPresenceUpdate } from "./presence.js";
|
||||
import { resolveDiscordAllowlistConfig } from "./provider.allowlist.js";
|
||||
import { runDiscordGatewayLifecycle } from "./provider.lifecycle.js";
|
||||
import { resolveDiscordRestFetch } from "./rest-fetch.js";
|
||||
import { createNoopThreadBindingManager, createThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
export type MonitorDiscordOpts = {
|
||||
token?: string;
|
||||
@@ -105,6 +95,61 @@ function summarizeGuilds(entries?: Record<string, unknown>) {
|
||||
return `${sample.join(", ")}${suffix}`;
|
||||
}
|
||||
|
||||
const DEFAULT_THREAD_BINDING_TTL_HOURS = 24;
|
||||
|
||||
function normalizeThreadBindingTtlHours(raw: unknown): number | undefined {
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
if (raw < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function resolveThreadBindingSessionTtlMs(params: {
|
||||
channelTtlHoursRaw: unknown;
|
||||
sessionTtlHoursRaw: unknown;
|
||||
}): number {
|
||||
const ttlHours =
|
||||
normalizeThreadBindingTtlHours(params.channelTtlHoursRaw) ??
|
||||
normalizeThreadBindingTtlHours(params.sessionTtlHoursRaw) ??
|
||||
DEFAULT_THREAD_BINDING_TTL_HOURS;
|
||||
return Math.floor(ttlHours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
function normalizeThreadBindingsEnabled(raw: unknown): boolean | undefined {
|
||||
if (typeof raw !== "boolean") {
|
||||
return undefined;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function resolveThreadBindingsEnabled(params: {
|
||||
channelEnabledRaw: unknown;
|
||||
sessionEnabledRaw: unknown;
|
||||
}): boolean {
|
||||
return (
|
||||
normalizeThreadBindingsEnabled(params.channelEnabledRaw) ??
|
||||
normalizeThreadBindingsEnabled(params.sessionEnabledRaw) ??
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
function formatThreadBindingSessionTtlLabel(ttlMs: number): string {
|
||||
if (ttlMs <= 0) {
|
||||
return "off";
|
||||
}
|
||||
if (ttlMs < 60_000) {
|
||||
return "<1m";
|
||||
}
|
||||
const totalMinutes = Math.floor(ttlMs / 60_000);
|
||||
if (totalMinutes % 60 === 0) {
|
||||
return `${Math.floor(totalMinutes / 60)}h`;
|
||||
}
|
||||
return `${totalMinutes}m`;
|
||||
}
|
||||
|
||||
function dedupeSkillCommandsForDiscord(
|
||||
skillCommands: ReturnType<typeof listSkillCommandsForAgents>,
|
||||
) {
|
||||
@@ -201,6 +246,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
const runtime: RuntimeEnv = opts.runtime ?? createNonExitingRuntime();
|
||||
|
||||
const discordCfg = account.config;
|
||||
const discordRootThreadBindings = cfg.channels?.discord?.threadBindings;
|
||||
const discordAccountThreadBindings =
|
||||
cfg.channels?.discord?.accounts?.[account.accountId]?.threadBindings;
|
||||
const discordRestFetch = resolveDiscordRestFetch(discordCfg.proxy, runtime);
|
||||
const dmConfig = discordCfg.dm;
|
||||
let guildEntries = discordCfg.guilds;
|
||||
@@ -230,6 +278,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
const replyToMode = opts.replyToMode ?? discordCfg.replyToMode ?? "off";
|
||||
const dmEnabled = dmConfig?.enabled ?? true;
|
||||
const dmPolicy = discordCfg.dmPolicy ?? dmConfig?.policy ?? "pairing";
|
||||
const threadBindingSessionTtlMs = resolveThreadBindingSessionTtlMs({
|
||||
channelTtlHoursRaw:
|
||||
discordAccountThreadBindings?.ttlHours ?? discordRootThreadBindings?.ttlHours,
|
||||
sessionTtlHoursRaw: cfg.session?.threadBindings?.ttlHours,
|
||||
});
|
||||
const threadBindingsEnabled = resolveThreadBindingsEnabled({
|
||||
channelEnabledRaw: discordAccountThreadBindings?.enabled ?? discordRootThreadBindings?.enabled,
|
||||
sessionEnabledRaw: cfg.session?.threadBindings?.enabled,
|
||||
});
|
||||
const groupDmEnabled = dmConfig?.groupEnabled ?? false;
|
||||
const groupDmChannels = dmConfig?.groupChannels;
|
||||
const nativeEnabled = resolveNativeCommandsEnabled({
|
||||
@@ -252,159 +309,19 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
const ephemeralDefault = slashCommand.ephemeral;
|
||||
const voiceEnabled = discordCfg.voice?.enabled !== false;
|
||||
|
||||
if (token) {
|
||||
if (guildEntries && Object.keys(guildEntries).length > 0) {
|
||||
try {
|
||||
const entries: Array<{ input: string; guildKey: string; channelKey?: string }> = [];
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (entries.length > 0) {
|
||||
const resolved = await resolveDiscordChannelAllowlist({
|
||||
token,
|
||||
entries: entries.map((entry) => entry.input),
|
||||
fetcher: discordRestFetch,
|
||||
});
|
||||
const nextGuilds = { ...guildEntries };
|
||||
const mapping: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
for (const entry of resolved) {
|
||||
const source = entries.find((item) => item.input === entry.input);
|
||||
if (!source) {
|
||||
continue;
|
||||
}
|
||||
const sourceGuild = guildEntries?.[source.guildKey] ?? {};
|
||||
if (!entry.resolved || !entry.guildId) {
|
||||
unresolved.push(entry.input);
|
||||
continue;
|
||||
}
|
||||
mapping.push(
|
||||
entry.channelId
|
||||
? `${entry.input}→${entry.guildId}/${entry.channelId}`
|
||||
: `${entry.input}→${entry.guildId}`,
|
||||
);
|
||||
const existing = nextGuilds[entry.guildId] ?? {};
|
||||
const mergedChannels = { ...sourceGuild.channels, ...existing.channels };
|
||||
const mergedGuild = { ...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],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
guildEntries = nextGuilds;
|
||||
summarizeMapping("discord channels", mapping, unresolved, runtime);
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.log?.(
|
||||
`discord channel resolve failed; using config entries. ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allowEntries =
|
||||
allowFrom?.filter((entry) => String(entry).trim() && String(entry).trim() !== "*") ?? [];
|
||||
if (allowEntries.length > 0) {
|
||||
try {
|
||||
const resolvedUsers = await resolveDiscordUserAllowlist({
|
||||
token,
|
||||
entries: allowEntries.map((entry) => String(entry)),
|
||||
fetcher: discordRestFetch,
|
||||
});
|
||||
const { mapping, unresolved, additions } = buildAllowlistResolutionSummary(resolvedUsers);
|
||||
allowFrom = mergeAllowlist({ existing: allowFrom, additions });
|
||||
summarizeMapping("discord users", mapping, unresolved, runtime);
|
||||
} catch (err) {
|
||||
runtime.log?.(
|
||||
`discord user resolve failed; using config entries. ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (guildEntries && Object.keys(guildEntries).length > 0) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (userEntries.size > 0) {
|
||||
try {
|
||||
const resolvedUsers = await resolveDiscordUserAllowlist({
|
||||
token,
|
||||
entries: Array.from(userEntries),
|
||||
fetcher: discordRestFetch,
|
||||
});
|
||||
const { resolvedMap, mapping, unresolved } =
|
||||
buildAllowlistResolutionSummary(resolvedUsers);
|
||||
|
||||
const nextGuilds = { ...guildEntries };
|
||||
for (const [guildKey, guildConfig] of Object.entries(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) {
|
||||
const additions = resolveAllowlistIdAdditions({ existing: users, resolvedMap });
|
||||
nextGuild.users = mergeAllowlist({ existing: users, additions });
|
||||
}
|
||||
const channels = (guildConfig as { channels?: Record<string, unknown> }).channels ?? {};
|
||||
if (channels && typeof channels === "object") {
|
||||
nextGuild.channels = patchAllowlistUsersInConfigEntries({
|
||||
entries: channels,
|
||||
resolvedMap,
|
||||
});
|
||||
}
|
||||
nextGuilds[guildKey] = nextGuild;
|
||||
}
|
||||
guildEntries = nextGuilds;
|
||||
summarizeMapping("discord channel users", mapping, unresolved, runtime);
|
||||
} catch (err) {
|
||||
runtime.log?.(
|
||||
`discord channel user resolve failed; using config entries. ${formatErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const allowlistResolved = await resolveDiscordAllowlistConfig({
|
||||
token,
|
||||
guildEntries,
|
||||
allowFrom,
|
||||
fetcher: discordRestFetch,
|
||||
runtime,
|
||||
});
|
||||
guildEntries = allowlistResolved.guildEntries;
|
||||
allowFrom = allowlistResolved.allowFrom;
|
||||
|
||||
if (shouldLogVerbose()) {
|
||||
logVerbose(
|
||||
`discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} nativeSkills=${nativeSkillsEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"}`,
|
||||
`discord: config dm=${dmEnabled ? "on" : "off"} dmPolicy=${dmPolicy} allowFrom=${summarizeAllowList(allowFrom)} groupDm=${groupDmEnabled ? "on" : "off"} groupDmChannels=${summarizeAllowList(groupDmChannels)} groupPolicy=${groupPolicy} guilds=${summarizeGuilds(guildEntries)} historyLimit=${historyLimit} mediaMaxMb=${Math.round(mediaMaxBytes / (1024 * 1024))} native=${nativeEnabled ? "on" : "off"} nativeSkills=${nativeSkillsEnabled ? "on" : "off"} accessGroups=${useAccessGroups ? "on" : "off"} threadBindings=${threadBindingsEnabled ? "on" : "off"} threadSessionTtl=${formatThreadBindingSessionTtlLabel(threadBindingSessionTtlMs)}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -439,326 +356,250 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
);
|
||||
}
|
||||
const voiceManagerRef: { current: DiscordVoiceManager | null } = { current: null };
|
||||
const commands: BaseCommand[] = commandSpecs.map((spec) =>
|
||||
createDiscordNativeCommand({
|
||||
command: spec,
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
ephemeralDefault,
|
||||
}),
|
||||
);
|
||||
if (nativeEnabled && voiceEnabled) {
|
||||
commands.push(
|
||||
createDiscordVoiceCommand({
|
||||
const threadBindings = threadBindingsEnabled
|
||||
? createThreadBindingManager({
|
||||
accountId: account.accountId,
|
||||
token,
|
||||
sessionTtlMs: threadBindingSessionTtlMs,
|
||||
})
|
||||
: createNoopThreadBindingManager(account.accountId);
|
||||
let lifecycleStarted = false;
|
||||
try {
|
||||
const commands: BaseCommand[] = commandSpecs.map((spec) =>
|
||||
createDiscordNativeCommand({
|
||||
command: spec,
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
groupPolicy,
|
||||
useAccessGroups,
|
||||
getManager: () => voiceManagerRef.current,
|
||||
sessionPrefix,
|
||||
ephemeralDefault,
|
||||
threadBindings,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (nativeEnabled && voiceEnabled) {
|
||||
commands.push(
|
||||
createDiscordVoiceCommand({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
groupPolicy,
|
||||
useAccessGroups,
|
||||
getManager: () => voiceManagerRef.current,
|
||||
ephemeralDefault,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize exec approvals handler if enabled
|
||||
const execApprovalsConfig = discordCfg.execApprovals ?? {};
|
||||
const execApprovalsHandler = execApprovalsConfig.enabled
|
||||
? new DiscordExecApprovalHandler({
|
||||
token,
|
||||
accountId: account.accountId,
|
||||
config: execApprovalsConfig,
|
||||
// Initialize exec approvals handler if enabled
|
||||
const execApprovalsConfig = discordCfg.execApprovals ?? {};
|
||||
const execApprovalsHandler = execApprovalsConfig.enabled
|
||||
? new DiscordExecApprovalHandler({
|
||||
token,
|
||||
accountId: account.accountId,
|
||||
config: execApprovalsConfig,
|
||||
cfg,
|
||||
runtime,
|
||||
})
|
||||
: null;
|
||||
|
||||
const agentComponentsConfig = discordCfg.agentComponents ?? {};
|
||||
const agentComponentsEnabled = agentComponentsConfig.enabled ?? true;
|
||||
|
||||
const components: BaseMessageInteractiveComponent[] = [
|
||||
createDiscordCommandArgFallbackButton({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
threadBindings,
|
||||
}),
|
||||
createDiscordModelPickerFallbackButton({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
threadBindings,
|
||||
}),
|
||||
createDiscordModelPickerFallbackSelect({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
threadBindings,
|
||||
}),
|
||||
];
|
||||
const modals: Modal[] = [];
|
||||
|
||||
if (execApprovalsHandler) {
|
||||
components.push(createExecApprovalButton({ handler: execApprovalsHandler }));
|
||||
}
|
||||
|
||||
if (agentComponentsEnabled) {
|
||||
const componentContext = {
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
guildEntries,
|
||||
allowFrom,
|
||||
dmPolicy,
|
||||
runtime,
|
||||
})
|
||||
: null;
|
||||
|
||||
const agentComponentsConfig = discordCfg.agentComponents ?? {};
|
||||
const agentComponentsEnabled = agentComponentsConfig.enabled ?? true;
|
||||
|
||||
const components: BaseMessageInteractiveComponent[] = [
|
||||
createDiscordCommandArgFallbackButton({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
}),
|
||||
createDiscordModelPickerFallbackButton({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
}),
|
||||
createDiscordModelPickerFallbackSelect({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
sessionPrefix,
|
||||
}),
|
||||
];
|
||||
const modals: Modal[] = [];
|
||||
|
||||
if (execApprovalsHandler) {
|
||||
components.push(createExecApprovalButton({ handler: execApprovalsHandler }));
|
||||
}
|
||||
|
||||
if (agentComponentsEnabled) {
|
||||
const componentContext = {
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
guildEntries,
|
||||
allowFrom,
|
||||
dmPolicy,
|
||||
runtime,
|
||||
token,
|
||||
};
|
||||
components.push(createAgentComponentButton(componentContext));
|
||||
components.push(createAgentSelectMenu(componentContext));
|
||||
components.push(createDiscordComponentButton(componentContext));
|
||||
components.push(createDiscordComponentStringSelect(componentContext));
|
||||
components.push(createDiscordComponentUserSelect(componentContext));
|
||||
components.push(createDiscordComponentRoleSelect(componentContext));
|
||||
components.push(createDiscordComponentMentionableSelect(componentContext));
|
||||
components.push(createDiscordComponentChannelSelect(componentContext));
|
||||
modals.push(createDiscordComponentModal(componentContext));
|
||||
}
|
||||
|
||||
class DiscordStatusReadyListener extends ReadyListener {
|
||||
async handle(_data: unknown, client: Client) {
|
||||
const gateway = client.getPlugin<GatewayPlugin>("gateway");
|
||||
if (!gateway) {
|
||||
return;
|
||||
}
|
||||
|
||||
const presence = resolveDiscordPresenceUpdate(discordCfg);
|
||||
if (!presence) {
|
||||
return;
|
||||
}
|
||||
|
||||
gateway.updatePresence(presence);
|
||||
token,
|
||||
};
|
||||
components.push(createAgentComponentButton(componentContext));
|
||||
components.push(createAgentSelectMenu(componentContext));
|
||||
components.push(createDiscordComponentButton(componentContext));
|
||||
components.push(createDiscordComponentStringSelect(componentContext));
|
||||
components.push(createDiscordComponentUserSelect(componentContext));
|
||||
components.push(createDiscordComponentRoleSelect(componentContext));
|
||||
components.push(createDiscordComponentMentionableSelect(componentContext));
|
||||
components.push(createDiscordComponentChannelSelect(componentContext));
|
||||
modals.push(createDiscordComponentModal(componentContext));
|
||||
}
|
||||
}
|
||||
|
||||
const clientPlugins: Plugin[] = [
|
||||
createDiscordGatewayPlugin({ discordConfig: discordCfg, runtime }),
|
||||
];
|
||||
if (voiceEnabled) {
|
||||
clientPlugins.push(new VoicePlugin());
|
||||
}
|
||||
const client = new Client(
|
||||
{
|
||||
baseUrl: "http://localhost",
|
||||
deploySecret: "a",
|
||||
clientId: applicationId,
|
||||
publicKey: "a",
|
||||
token,
|
||||
autoDeploy: false,
|
||||
},
|
||||
{
|
||||
commands,
|
||||
listeners: [new DiscordStatusReadyListener()],
|
||||
components,
|
||||
modals,
|
||||
},
|
||||
clientPlugins,
|
||||
);
|
||||
|
||||
await deployDiscordCommands({ client, runtime, enabled: nativeEnabled });
|
||||
|
||||
const logger = createSubsystemLogger("discord/monitor");
|
||||
const guildHistories = new Map<string, HistoryEntry[]>();
|
||||
let botUserId: string | undefined;
|
||||
let voiceManager: DiscordVoiceManager | null = null;
|
||||
|
||||
if (nativeDisabledExplicit) {
|
||||
await clearDiscordNativeCommands({
|
||||
client,
|
||||
applicationId,
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const botUser = await client.fetchUser("@me");
|
||||
botUserId = botUser?.id;
|
||||
} catch (err) {
|
||||
runtime.error?.(danger(`discord: failed to fetch bot identity: ${String(err)}`));
|
||||
}
|
||||
|
||||
if (voiceEnabled) {
|
||||
voiceManager = new DiscordVoiceManager({
|
||||
client,
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
botUserId,
|
||||
});
|
||||
voiceManagerRef.current = voiceManager;
|
||||
registerDiscordListener(client.listeners, new DiscordVoiceReadyListener(voiceManager));
|
||||
}
|
||||
|
||||
const messageHandler = createDiscordMessageHandler({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
token,
|
||||
runtime,
|
||||
botUserId,
|
||||
guildHistories,
|
||||
historyLimit,
|
||||
mediaMaxBytes,
|
||||
textLimit,
|
||||
replyToMode,
|
||||
dmEnabled,
|
||||
groupDmEnabled,
|
||||
groupDmChannels,
|
||||
allowFrom,
|
||||
guildEntries,
|
||||
});
|
||||
|
||||
registerDiscordListener(client.listeners, new DiscordMessageListener(messageHandler, logger));
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordReactionListener({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
botUserId,
|
||||
guildEntries,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordReactionRemoveListener({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
botUserId,
|
||||
guildEntries,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
|
||||
if (discordCfg.intents?.presence) {
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordPresenceListener({ logger, accountId: account.accountId }),
|
||||
);
|
||||
runtime.log?.("discord: GuildPresences intent enabled — presence listener registered");
|
||||
}
|
||||
|
||||
runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`);
|
||||
|
||||
// Start exec approvals handler after client is ready
|
||||
if (execApprovalsHandler) {
|
||||
await execApprovalsHandler.start();
|
||||
}
|
||||
|
||||
const gateway = client.getPlugin<GatewayPlugin>("gateway");
|
||||
if (gateway) {
|
||||
registerGateway(account.accountId, gateway);
|
||||
}
|
||||
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
|
||||
const stopGatewayLogging = attachDiscordGatewayLogging({
|
||||
emitter: gatewayEmitter,
|
||||
runtime,
|
||||
});
|
||||
const abortSignal = opts.abortSignal;
|
||||
const onAbort = () => {
|
||||
if (!gateway) {
|
||||
return;
|
||||
}
|
||||
// Carbon emits an error when maxAttempts is 0; keep a one-shot listener to avoid
|
||||
// an unhandled error after we tear down listeners during abort.
|
||||
gatewayEmitter?.once("error", () => {});
|
||||
gateway.options.reconnect = { maxAttempts: 0 };
|
||||
gateway.disconnect();
|
||||
};
|
||||
if (abortSignal?.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
// Timeout to detect zombie connections where HELLO is never received.
|
||||
const HELLO_TIMEOUT_MS = 30000;
|
||||
let helloTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const onGatewayDebug = (msg: unknown) => {
|
||||
const message = String(msg);
|
||||
if (!message.includes("WebSocket connection opened")) {
|
||||
return;
|
||||
}
|
||||
if (helloTimeoutId) {
|
||||
clearTimeout(helloTimeoutId);
|
||||
}
|
||||
helloTimeoutId = setTimeout(() => {
|
||||
if (!gateway?.isConnected) {
|
||||
runtime.log?.(
|
||||
danger(
|
||||
`connection stalled: no HELLO received within ${HELLO_TIMEOUT_MS}ms, forcing reconnect`,
|
||||
),
|
||||
);
|
||||
gateway?.disconnect();
|
||||
gateway?.connect(false);
|
||||
}
|
||||
helloTimeoutId = undefined;
|
||||
}, HELLO_TIMEOUT_MS);
|
||||
};
|
||||
gatewayEmitter?.on("debug", onGatewayDebug);
|
||||
// Disallowed intents (4014) should stop the provider without crashing the gateway.
|
||||
let sawDisallowedIntents = false;
|
||||
try {
|
||||
await waitForDiscordGatewayStop({
|
||||
gateway: gateway
|
||||
? {
|
||||
emitter: gatewayEmitter,
|
||||
disconnect: () => gateway.disconnect(),
|
||||
}
|
||||
: undefined,
|
||||
abortSignal,
|
||||
onGatewayError: (err) => {
|
||||
if (isDiscordDisallowedIntentsError(err)) {
|
||||
sawDisallowedIntents = true;
|
||||
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.",
|
||||
),
|
||||
);
|
||||
class DiscordStatusReadyListener extends ReadyListener {
|
||||
async handle(_data: unknown, client: Client) {
|
||||
const gateway = client.getPlugin<GatewayPlugin>("gateway");
|
||||
if (!gateway) {
|
||||
return;
|
||||
}
|
||||
runtime.error?.(danger(`discord gateway error: ${String(err)}`));
|
||||
|
||||
const presence = resolveDiscordPresenceUpdate(discordCfg);
|
||||
if (!presence) {
|
||||
return;
|
||||
}
|
||||
|
||||
gateway.updatePresence(presence);
|
||||
}
|
||||
}
|
||||
|
||||
const clientPlugins: Plugin[] = [
|
||||
createDiscordGatewayPlugin({ discordConfig: discordCfg, runtime }),
|
||||
];
|
||||
if (voiceEnabled) {
|
||||
clientPlugins.push(new VoicePlugin());
|
||||
}
|
||||
const client = new Client(
|
||||
{
|
||||
baseUrl: "http://localhost",
|
||||
deploySecret: "a",
|
||||
clientId: applicationId,
|
||||
publicKey: "a",
|
||||
token,
|
||||
autoDeploy: false,
|
||||
},
|
||||
shouldStopOnError: (err) => {
|
||||
const message = String(err);
|
||||
return (
|
||||
message.includes("Max reconnect attempts") ||
|
||||
message.includes("Fatal Gateway error") ||
|
||||
isDiscordDisallowedIntentsError(err)
|
||||
);
|
||||
{
|
||||
commands,
|
||||
listeners: [new DiscordStatusReadyListener()],
|
||||
components,
|
||||
modals,
|
||||
},
|
||||
clientPlugins,
|
||||
);
|
||||
|
||||
await deployDiscordCommands({ client, runtime, enabled: nativeEnabled });
|
||||
|
||||
const logger = createSubsystemLogger("discord/monitor");
|
||||
const guildHistories = new Map<string, HistoryEntry[]>();
|
||||
let botUserId: string | undefined;
|
||||
let voiceManager: DiscordVoiceManager | null = null;
|
||||
|
||||
if (nativeDisabledExplicit) {
|
||||
await clearDiscordNativeCommands({
|
||||
client,
|
||||
applicationId,
|
||||
runtime,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const botUser = await client.fetchUser("@me");
|
||||
botUserId = botUser?.id;
|
||||
} catch (err) {
|
||||
runtime.error?.(danger(`discord: failed to fetch bot identity: ${String(err)}`));
|
||||
}
|
||||
|
||||
if (voiceEnabled) {
|
||||
voiceManager = new DiscordVoiceManager({
|
||||
client,
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
botUserId,
|
||||
});
|
||||
voiceManagerRef.current = voiceManager;
|
||||
registerDiscordListener(client.listeners, new DiscordVoiceReadyListener(voiceManager));
|
||||
}
|
||||
|
||||
const messageHandler = createDiscordMessageHandler({
|
||||
cfg,
|
||||
discordConfig: discordCfg,
|
||||
accountId: account.accountId,
|
||||
token,
|
||||
runtime,
|
||||
botUserId,
|
||||
guildHistories,
|
||||
historyLimit,
|
||||
mediaMaxBytes,
|
||||
textLimit,
|
||||
replyToMode,
|
||||
dmEnabled,
|
||||
groupDmEnabled,
|
||||
groupDmChannels,
|
||||
allowFrom,
|
||||
guildEntries,
|
||||
threadBindings,
|
||||
});
|
||||
} catch (err) {
|
||||
if (!sawDisallowedIntents && !isDiscordDisallowedIntentsError(err)) {
|
||||
throw err;
|
||||
|
||||
registerDiscordListener(client.listeners, new DiscordMessageListener(messageHandler, logger));
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordReactionListener({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
botUserId,
|
||||
guildEntries,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordReactionRemoveListener({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
runtime,
|
||||
botUserId,
|
||||
guildEntries,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
|
||||
if (discordCfg.intents?.presence) {
|
||||
registerDiscordListener(
|
||||
client.listeners,
|
||||
new DiscordPresenceListener({ logger, accountId: account.accountId }),
|
||||
);
|
||||
runtime.log?.("discord: GuildPresences intent enabled — presence listener registered");
|
||||
}
|
||||
|
||||
runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`);
|
||||
|
||||
lifecycleStarted = true;
|
||||
await runDiscordGatewayLifecycle({
|
||||
accountId: account.accountId,
|
||||
client,
|
||||
runtime,
|
||||
abortSignal: opts.abortSignal,
|
||||
isDisallowedIntentsError: isDiscordDisallowedIntentsError,
|
||||
voiceManager,
|
||||
voiceManagerRef,
|
||||
execApprovalsHandler,
|
||||
threadBindings,
|
||||
});
|
||||
} finally {
|
||||
unregisterGateway(account.accountId);
|
||||
stopGatewayLogging();
|
||||
if (helloTimeoutId) {
|
||||
clearTimeout(helloTimeoutId);
|
||||
}
|
||||
gatewayEmitter?.removeListener("debug", onGatewayDebug);
|
||||
abortSignal?.removeEventListener("abort", onAbort);
|
||||
if (voiceManager) {
|
||||
await voiceManager.destroy();
|
||||
voiceManagerRef.current = null;
|
||||
}
|
||||
if (execApprovalsHandler) {
|
||||
await execApprovalsHandler.stop();
|
||||
if (!lifecycleStarted) {
|
||||
threadBindings.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -782,4 +623,5 @@ export const __testing = {
|
||||
createDiscordGatewayPlugin,
|
||||
dedupeSkillCommandsForDiscord,
|
||||
resolveDiscordRestFetch,
|
||||
resolveThreadBindingsEnabled,
|
||||
};
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { deliverDiscordReply } from "./reply-delivery.js";
|
||||
import {
|
||||
__testing as threadBindingTesting,
|
||||
createThreadBindingManager,
|
||||
} from "./thread-bindings.js";
|
||||
|
||||
const sendMessageDiscordMock = vi.hoisted(() => vi.fn());
|
||||
const sendVoiceMessageDiscordMock = vi.hoisted(() => vi.fn());
|
||||
const sendWebhookMessageDiscordMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../send.js", () => ({
|
||||
sendMessageDiscord: (...args: unknown[]) => sendMessageDiscordMock(...args),
|
||||
sendVoiceMessageDiscord: (...args: unknown[]) => sendVoiceMessageDiscordMock(...args),
|
||||
sendWebhookMessageDiscord: (...args: unknown[]) => sendWebhookMessageDiscordMock(...args),
|
||||
}));
|
||||
|
||||
describe("deliverDiscordReply", () => {
|
||||
@@ -22,6 +28,11 @@ describe("deliverDiscordReply", () => {
|
||||
messageId: "voice-1",
|
||||
channelId: "channel-1",
|
||||
});
|
||||
sendWebhookMessageDiscordMock.mockReset().mockResolvedValue({
|
||||
messageId: "webhook-1",
|
||||
channelId: "thread-1",
|
||||
});
|
||||
threadBindingTesting.resetThreadBindingsForTests();
|
||||
});
|
||||
|
||||
it("routes audioAsVoice payloads through the voice API and sends text separately", async () => {
|
||||
@@ -104,4 +115,141 @@ describe("deliverDiscordReply", () => {
|
||||
expect(sendMessageDiscordMock.mock.calls[0]?.[2]?.replyTo).toBe("reply-1");
|
||||
expect(sendMessageDiscordMock.mock.calls[1]?.[2]?.replyTo).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not consume replyToId for replyToMode=first on whitespace-only payloads", async () => {
|
||||
await deliverDiscordReply({
|
||||
replies: [{ text: " " }, { text: "actual reply" }],
|
||||
target: "channel:789",
|
||||
token: "token",
|
||||
runtime,
|
||||
textLimit: 2000,
|
||||
replyToId: "reply-1",
|
||||
replyToMode: "first",
|
||||
});
|
||||
|
||||
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageDiscordMock).toHaveBeenCalledWith(
|
||||
"channel:789",
|
||||
"actual reply",
|
||||
expect.objectContaining({ token: "token", replyTo: "reply-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends bound-session text replies through webhook delivery", async () => {
|
||||
const threadBindings = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
await threadBindings.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
label: "codex-refactor",
|
||||
webhookId: "wh_1",
|
||||
webhookToken: "tok_1",
|
||||
introText: "",
|
||||
});
|
||||
|
||||
await deliverDiscordReply({
|
||||
replies: [{ text: "Hello from subagent" }],
|
||||
target: "channel:thread-1",
|
||||
token: "token",
|
||||
runtime,
|
||||
textLimit: 2000,
|
||||
replyToId: "reply-1",
|
||||
sessionKey: "agent:main:subagent:child",
|
||||
threadBindings,
|
||||
});
|
||||
|
||||
expect(sendWebhookMessageDiscordMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendWebhookMessageDiscordMock).toHaveBeenCalledWith(
|
||||
"Hello from subagent",
|
||||
expect.objectContaining({
|
||||
webhookId: "wh_1",
|
||||
webhookToken: "tok_1",
|
||||
accountId: "default",
|
||||
threadId: "thread-1",
|
||||
replyTo: "reply-1",
|
||||
}),
|
||||
);
|
||||
expect(sendMessageDiscordMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to bot send when webhook delivery fails", async () => {
|
||||
const threadBindings = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
await threadBindings.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
webhookId: "wh_1",
|
||||
webhookToken: "tok_1",
|
||||
introText: "",
|
||||
});
|
||||
sendWebhookMessageDiscordMock.mockRejectedValueOnce(new Error("rate limited"));
|
||||
|
||||
await deliverDiscordReply({
|
||||
replies: [{ text: "Fallback path" }],
|
||||
target: "channel:thread-1",
|
||||
token: "token",
|
||||
accountId: "default",
|
||||
runtime,
|
||||
textLimit: 2000,
|
||||
sessionKey: "agent:main:subagent:child",
|
||||
threadBindings,
|
||||
});
|
||||
|
||||
expect(sendWebhookMessageDiscordMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageDiscordMock).toHaveBeenCalledWith(
|
||||
"channel:thread-1",
|
||||
"Fallback path",
|
||||
expect.objectContaining({ token: "token", accountId: "default" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not use thread webhook when outbound target is not a bound thread", async () => {
|
||||
const threadBindings = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
await threadBindings.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
webhookId: "wh_1",
|
||||
webhookToken: "tok_1",
|
||||
introText: "",
|
||||
});
|
||||
|
||||
await deliverDiscordReply({
|
||||
replies: [{ text: "Parent channel delivery" }],
|
||||
target: "channel:parent-1",
|
||||
token: "token",
|
||||
accountId: "default",
|
||||
runtime,
|
||||
textLimit: 2000,
|
||||
sessionKey: "agent:main:subagent:child",
|
||||
threadBindings,
|
||||
});
|
||||
|
||||
expect(sendWebhookMessageDiscordMock).not.toHaveBeenCalled();
|
||||
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageDiscordMock).toHaveBeenCalledWith(
|
||||
"channel:parent-1",
|
||||
"Parent channel delivery",
|
||||
expect.objectContaining({ token: "token", accountId: "default" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,104 @@
|
||||
import type { RequestClient } from "@buape/carbon";
|
||||
import { resolveAgentAvatar } from "../../agents/identity-avatar.js";
|
||||
import type { ChunkMode } from "../../auto-reply/chunk.js";
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import type { MarkdownTableMode, ReplyToMode } from "../../config/types.base.js";
|
||||
import { convertMarkdownTables } from "../../markdown/tables.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { chunkDiscordTextWithMode } from "../chunk.js";
|
||||
import { sendMessageDiscord, sendVoiceMessageDiscord } from "../send.js";
|
||||
import { sendMessageDiscord, sendVoiceMessageDiscord, sendWebhookMessageDiscord } from "../send.js";
|
||||
import type { ThreadBindingManager, ThreadBindingRecord } from "./thread-bindings.js";
|
||||
|
||||
function resolveTargetChannelId(target: string): string | undefined {
|
||||
if (!target.startsWith("channel:")) {
|
||||
return undefined;
|
||||
}
|
||||
const channelId = target.slice("channel:".length).trim();
|
||||
return channelId || undefined;
|
||||
}
|
||||
|
||||
function resolveBoundThreadBinding(params: {
|
||||
threadBindings?: ThreadBindingManager;
|
||||
sessionKey?: string;
|
||||
target: string;
|
||||
}): ThreadBindingRecord | undefined {
|
||||
const sessionKey = params.sessionKey?.trim();
|
||||
if (!params.threadBindings || !sessionKey) {
|
||||
return undefined;
|
||||
}
|
||||
const bindings = params.threadBindings.listBySessionKey(sessionKey);
|
||||
if (bindings.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const targetChannelId = resolveTargetChannelId(params.target);
|
||||
if (!targetChannelId) {
|
||||
return undefined;
|
||||
}
|
||||
return bindings.find((entry) => entry.threadId === targetChannelId);
|
||||
}
|
||||
|
||||
function resolveBindingPersona(binding: ThreadBindingRecord | undefined): {
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
} {
|
||||
if (!binding) {
|
||||
return {};
|
||||
}
|
||||
const baseLabel = binding.label?.trim() || binding.agentId;
|
||||
const username = (`🤖 ${baseLabel}`.trim() || "🤖 agent").slice(0, 80);
|
||||
|
||||
let avatarUrl: string | undefined;
|
||||
try {
|
||||
const avatar = resolveAgentAvatar(loadConfig(), binding.agentId);
|
||||
if (avatar.kind === "remote") {
|
||||
avatarUrl = avatar.url;
|
||||
}
|
||||
} catch {
|
||||
avatarUrl = undefined;
|
||||
}
|
||||
return { username, avatarUrl };
|
||||
}
|
||||
|
||||
async function sendDiscordChunkWithFallback(params: {
|
||||
target: string;
|
||||
text: string;
|
||||
token: string;
|
||||
accountId?: string;
|
||||
rest?: RequestClient;
|
||||
replyTo?: string;
|
||||
binding?: ThreadBindingRecord;
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
}) {
|
||||
const text = params.text.trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const binding = params.binding;
|
||||
if (binding?.webhookId && binding?.webhookToken) {
|
||||
try {
|
||||
await sendWebhookMessageDiscord(text, {
|
||||
webhookId: binding.webhookId,
|
||||
webhookToken: binding.webhookToken,
|
||||
accountId: binding.accountId,
|
||||
threadId: binding.threadId,
|
||||
replyTo: params.replyTo,
|
||||
username: params.username,
|
||||
avatarUrl: params.avatarUrl,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to the standard bot sender path.
|
||||
}
|
||||
}
|
||||
await sendMessageDiscord(params.target, text, {
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
accountId: params.accountId,
|
||||
replyTo: params.replyTo,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deliverDiscordReply(params: {
|
||||
replies: ReplyPayload[];
|
||||
@@ -20,6 +113,8 @@ export async function deliverDiscordReply(params: {
|
||||
replyToMode?: ReplyToMode;
|
||||
tableMode?: MarkdownTableMode;
|
||||
chunkMode?: ChunkMode;
|
||||
sessionKey?: string;
|
||||
threadBindings?: ThreadBindingManager;
|
||||
}) {
|
||||
const chunkLimit = Math.min(params.textLimit, 2000);
|
||||
const replyTo = params.replyToId?.trim() || undefined;
|
||||
@@ -40,6 +135,12 @@ export async function deliverDiscordReply(params: {
|
||||
replyUsed = true;
|
||||
return replyTo;
|
||||
};
|
||||
const binding = resolveBoundThreadBinding({
|
||||
threadBindings: params.threadBindings,
|
||||
sessionKey: params.sessionKey,
|
||||
target: params.target,
|
||||
});
|
||||
const persona = resolveBindingPersona(binding);
|
||||
for (const payload of params.replies) {
|
||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const rawText = payload.text ?? "";
|
||||
@@ -59,16 +160,20 @@ export async function deliverDiscordReply(params: {
|
||||
chunks.push(text);
|
||||
}
|
||||
for (const chunk of chunks) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed) {
|
||||
if (!chunk.trim()) {
|
||||
continue;
|
||||
}
|
||||
const replyTo = resolveReplyTo();
|
||||
await sendMessageDiscord(params.target, trimmed, {
|
||||
await sendDiscordChunkWithFallback({
|
||||
target: params.target,
|
||||
text: chunk,
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
accountId: params.accountId,
|
||||
replyTo,
|
||||
binding,
|
||||
username: persona.username,
|
||||
avatarUrl: persona.avatarUrl,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
@@ -79,7 +184,7 @@ export async function deliverDiscordReply(params: {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Voice message path: audioAsVoice flag routes through sendVoiceMessageDiscord
|
||||
// Voice message path: audioAsVoice flag routes through sendVoiceMessageDiscord.
|
||||
if (payload.audioAsVoice) {
|
||||
const replyTo = resolveReplyTo();
|
||||
await sendVoiceMessageDiscord(params.target, firstMedia, {
|
||||
@@ -88,17 +193,19 @@ export async function deliverDiscordReply(params: {
|
||||
accountId: params.accountId,
|
||||
replyTo,
|
||||
});
|
||||
// Voice messages cannot include text; send remaining text separately if present
|
||||
if (text.trim()) {
|
||||
const replyTo = resolveReplyTo();
|
||||
await sendMessageDiscord(params.target, text, {
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
accountId: params.accountId,
|
||||
replyTo,
|
||||
});
|
||||
}
|
||||
// Additional media items are sent as regular attachments (voice is single-file only)
|
||||
// Voice messages cannot include text; send remaining text separately if present.
|
||||
await sendDiscordChunkWithFallback({
|
||||
target: params.target,
|
||||
text,
|
||||
token: params.token,
|
||||
rest: params.rest,
|
||||
accountId: params.accountId,
|
||||
replyTo: resolveReplyTo(),
|
||||
binding,
|
||||
username: persona.username,
|
||||
avatarUrl: persona.avatarUrl,
|
||||
});
|
||||
// Additional media items are sent as regular attachments (voice is single-file only).
|
||||
for (const extra of mediaList.slice(1)) {
|
||||
const replyTo = resolveReplyTo();
|
||||
await sendMessageDiscord(params.target, "", {
|
||||
|
||||
85
src/discord/monitor/thread-bindings.discord-api.test.ts
Normal file
85
src/discord/monitor/thread-bindings.discord-api.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { ChannelType } from "discord-api-types/v10";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const restGet = vi.fn();
|
||||
const createDiscordRestClient = vi.fn(() => ({
|
||||
rest: {
|
||||
get: restGet,
|
||||
},
|
||||
}));
|
||||
return {
|
||||
restGet,
|
||||
createDiscordRestClient,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../client.js", () => ({
|
||||
createDiscordRestClient: hoisted.createDiscordRestClient,
|
||||
}));
|
||||
|
||||
const { resolveChannelIdForBinding } = await import("./thread-bindings.discord-api.js");
|
||||
|
||||
describe("resolveChannelIdForBinding", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.restGet.mockReset();
|
||||
hoisted.createDiscordRestClient.mockClear();
|
||||
});
|
||||
|
||||
it("returns explicit channelId without resolving route", async () => {
|
||||
const resolved = await resolveChannelIdForBinding({
|
||||
accountId: "default",
|
||||
threadId: "thread-1",
|
||||
channelId: "channel-explicit",
|
||||
});
|
||||
|
||||
expect(resolved).toBe("channel-explicit");
|
||||
expect(hoisted.createDiscordRestClient).not.toHaveBeenCalled();
|
||||
expect(hoisted.restGet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns parent channel for thread channels", async () => {
|
||||
hoisted.restGet.mockResolvedValueOnce({
|
||||
id: "thread-1",
|
||||
type: ChannelType.PublicThread,
|
||||
parent_id: "channel-parent",
|
||||
});
|
||||
|
||||
const resolved = await resolveChannelIdForBinding({
|
||||
accountId: "default",
|
||||
threadId: "thread-1",
|
||||
});
|
||||
|
||||
expect(resolved).toBe("channel-parent");
|
||||
});
|
||||
|
||||
it("keeps non-thread channel id even when parent_id exists", async () => {
|
||||
hoisted.restGet.mockResolvedValueOnce({
|
||||
id: "channel-text",
|
||||
type: ChannelType.GuildText,
|
||||
parent_id: "category-1",
|
||||
});
|
||||
|
||||
const resolved = await resolveChannelIdForBinding({
|
||||
accountId: "default",
|
||||
threadId: "channel-text",
|
||||
});
|
||||
|
||||
expect(resolved).toBe("channel-text");
|
||||
});
|
||||
|
||||
it("keeps forum channel id instead of parent category", async () => {
|
||||
hoisted.restGet.mockResolvedValueOnce({
|
||||
id: "forum-1",
|
||||
type: ChannelType.GuildForum,
|
||||
parent_id: "category-1",
|
||||
});
|
||||
|
||||
const resolved = await resolveChannelIdForBinding({
|
||||
accountId: "default",
|
||||
threadId: "forum-1",
|
||||
});
|
||||
|
||||
expect(resolved).toBe("forum-1");
|
||||
});
|
||||
});
|
||||
289
src/discord/monitor/thread-bindings.discord-api.ts
Normal file
289
src/discord/monitor/thread-bindings.discord-api.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import { ChannelType, Routes } from "discord-api-types/v10";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { createDiscordRestClient } from "../client.js";
|
||||
import { sendMessageDiscord, sendWebhookMessageDiscord } from "../send.js";
|
||||
import { createThreadDiscord } from "../send.messages.js";
|
||||
import { summarizeBindingPersona } from "./thread-bindings.messages.js";
|
||||
import {
|
||||
BINDINGS_BY_THREAD_ID,
|
||||
REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL,
|
||||
rememberReusableWebhook,
|
||||
toReusableWebhookKey,
|
||||
} from "./thread-bindings.state.js";
|
||||
import {
|
||||
DISCORD_UNKNOWN_CHANNEL_ERROR_CODE,
|
||||
type ThreadBindingRecord,
|
||||
} from "./thread-bindings.types.js";
|
||||
|
||||
function buildThreadTarget(threadId: string): string {
|
||||
return `channel:${threadId}`;
|
||||
}
|
||||
|
||||
export function isThreadArchived(raw: unknown): boolean {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return false;
|
||||
}
|
||||
const asRecord = raw as {
|
||||
archived?: unknown;
|
||||
thread_metadata?: { archived?: unknown };
|
||||
threadMetadata?: { archived?: unknown };
|
||||
};
|
||||
if (asRecord.archived === true) {
|
||||
return true;
|
||||
}
|
||||
if (asRecord.thread_metadata?.archived === true) {
|
||||
return true;
|
||||
}
|
||||
if (asRecord.threadMetadata?.archived === true) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isThreadChannelType(type: unknown): boolean {
|
||||
return (
|
||||
type === ChannelType.PublicThread ||
|
||||
type === ChannelType.PrivateThread ||
|
||||
type === ChannelType.AnnouncementThread
|
||||
);
|
||||
}
|
||||
|
||||
export function summarizeDiscordError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
if (typeof err === "string") {
|
||||
return err;
|
||||
}
|
||||
if (
|
||||
typeof err === "number" ||
|
||||
typeof err === "boolean" ||
|
||||
typeof err === "bigint" ||
|
||||
typeof err === "symbol"
|
||||
) {
|
||||
return String(err);
|
||||
}
|
||||
return "error";
|
||||
}
|
||||
|
||||
function extractNumericDiscordErrorValue(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return Math.trunc(value);
|
||||
}
|
||||
if (typeof value === "string" && /^\d+$/.test(value.trim())) {
|
||||
return Number(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractDiscordErrorStatus(err: unknown): number | undefined {
|
||||
if (!err || typeof err !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = err as {
|
||||
status?: unknown;
|
||||
statusCode?: unknown;
|
||||
response?: { status?: unknown };
|
||||
};
|
||||
return (
|
||||
extractNumericDiscordErrorValue(candidate.status) ??
|
||||
extractNumericDiscordErrorValue(candidate.statusCode) ??
|
||||
extractNumericDiscordErrorValue(candidate.response?.status)
|
||||
);
|
||||
}
|
||||
|
||||
function extractDiscordErrorCode(err: unknown): number | undefined {
|
||||
if (!err || typeof err !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = err as {
|
||||
code?: unknown;
|
||||
rawError?: { code?: unknown };
|
||||
body?: { code?: unknown };
|
||||
response?: { body?: { code?: unknown }; data?: { code?: unknown } };
|
||||
};
|
||||
return (
|
||||
extractNumericDiscordErrorValue(candidate.code) ??
|
||||
extractNumericDiscordErrorValue(candidate.rawError?.code) ??
|
||||
extractNumericDiscordErrorValue(candidate.body?.code) ??
|
||||
extractNumericDiscordErrorValue(candidate.response?.body?.code) ??
|
||||
extractNumericDiscordErrorValue(candidate.response?.data?.code)
|
||||
);
|
||||
}
|
||||
|
||||
export function isDiscordThreadGoneError(err: unknown): boolean {
|
||||
const code = extractDiscordErrorCode(err);
|
||||
if (code === DISCORD_UNKNOWN_CHANNEL_ERROR_CODE) {
|
||||
return true;
|
||||
}
|
||||
const status = extractDiscordErrorStatus(err);
|
||||
// 404: deleted/unknown channel. 403: bot no longer has access.
|
||||
return status === 404 || status === 403;
|
||||
}
|
||||
|
||||
export async function maybeSendBindingMessage(params: {
|
||||
record: ThreadBindingRecord;
|
||||
text: string;
|
||||
preferWebhook?: boolean;
|
||||
}) {
|
||||
const text = params.text.trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const record = params.record;
|
||||
if (params.preferWebhook !== false && record.webhookId && record.webhookToken) {
|
||||
try {
|
||||
await sendWebhookMessageDiscord(text, {
|
||||
webhookId: record.webhookId,
|
||||
webhookToken: record.webhookToken,
|
||||
accountId: record.accountId,
|
||||
threadId: record.threadId,
|
||||
username: summarizeBindingPersona(record),
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
logVerbose(`discord thread binding webhook send failed: ${summarizeDiscordError(err)}`);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await sendMessageDiscord(buildThreadTarget(record.threadId), text, {
|
||||
accountId: record.accountId,
|
||||
});
|
||||
} catch (err) {
|
||||
logVerbose(`discord thread binding fallback send failed: ${summarizeDiscordError(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWebhookForChannel(params: {
|
||||
accountId: string;
|
||||
token?: string;
|
||||
channelId: string;
|
||||
}): Promise<{ webhookId?: string; webhookToken?: string }> {
|
||||
try {
|
||||
const rest = createDiscordRestClient({
|
||||
accountId: params.accountId,
|
||||
token: params.token,
|
||||
}).rest;
|
||||
const created = (await rest.post(Routes.channelWebhooks(params.channelId), {
|
||||
body: {
|
||||
name: "OpenClaw Agents",
|
||||
},
|
||||
})) as { id?: string; token?: string };
|
||||
const webhookId = typeof created?.id === "string" ? created.id.trim() : "";
|
||||
const webhookToken = typeof created?.token === "string" ? created.token.trim() : "";
|
||||
if (!webhookId || !webhookToken) {
|
||||
return {};
|
||||
}
|
||||
return { webhookId, webhookToken };
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`discord thread binding webhook create failed for ${params.channelId}: ${summarizeDiscordError(err)}`,
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function findReusableWebhook(params: { accountId: string; channelId: string }): {
|
||||
webhookId?: string;
|
||||
webhookToken?: string;
|
||||
} {
|
||||
const reusableKey = toReusableWebhookKey({
|
||||
accountId: params.accountId,
|
||||
channelId: params.channelId,
|
||||
});
|
||||
const cached = REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL.get(reusableKey);
|
||||
if (cached) {
|
||||
return {
|
||||
webhookId: cached.webhookId,
|
||||
webhookToken: cached.webhookToken,
|
||||
};
|
||||
}
|
||||
for (const record of BINDINGS_BY_THREAD_ID.values()) {
|
||||
if (record.accountId !== params.accountId) {
|
||||
continue;
|
||||
}
|
||||
if (record.channelId !== params.channelId) {
|
||||
continue;
|
||||
}
|
||||
if (!record.webhookId || !record.webhookToken) {
|
||||
continue;
|
||||
}
|
||||
rememberReusableWebhook(record);
|
||||
return {
|
||||
webhookId: record.webhookId,
|
||||
webhookToken: record.webhookToken,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export async function resolveChannelIdForBinding(params: {
|
||||
accountId: string;
|
||||
token?: string;
|
||||
threadId: string;
|
||||
channelId?: string;
|
||||
}): Promise<string | null> {
|
||||
const explicit = params.channelId?.trim();
|
||||
if (explicit) {
|
||||
return explicit;
|
||||
}
|
||||
try {
|
||||
const rest = createDiscordRestClient({
|
||||
accountId: params.accountId,
|
||||
token: params.token,
|
||||
}).rest;
|
||||
const channel = (await rest.get(Routes.channel(params.threadId))) as {
|
||||
id?: string;
|
||||
type?: number;
|
||||
parent_id?: string;
|
||||
parentId?: string;
|
||||
};
|
||||
const channelId = typeof channel?.id === "string" ? channel.id.trim() : "";
|
||||
const type = channel?.type;
|
||||
const parentId =
|
||||
typeof channel?.parent_id === "string"
|
||||
? channel.parent_id.trim()
|
||||
: typeof channel?.parentId === "string"
|
||||
? channel.parentId.trim()
|
||||
: "";
|
||||
// Only thread channels should resolve to their parent channel.
|
||||
// Non-thread channels (text/forum/media) must keep their own ID.
|
||||
if (parentId && isThreadChannelType(type)) {
|
||||
return parentId;
|
||||
}
|
||||
return channelId || null;
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`discord thread binding channel resolve failed for ${params.threadId}: ${summarizeDiscordError(err)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createThreadForBinding(params: {
|
||||
accountId: string;
|
||||
token?: string;
|
||||
channelId: string;
|
||||
threadName: string;
|
||||
}): Promise<string | null> {
|
||||
try {
|
||||
const created = await createThreadDiscord(
|
||||
params.channelId,
|
||||
{
|
||||
name: params.threadName,
|
||||
autoArchiveMinutes: 60,
|
||||
},
|
||||
{
|
||||
accountId: params.accountId,
|
||||
token: params.token,
|
||||
},
|
||||
);
|
||||
const createdId = typeof created?.id === "string" ? created.id.trim() : "";
|
||||
return createdId || null;
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
`discord thread binding auto-thread create failed for ${params.channelId}: ${summarizeDiscordError(err)}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
225
src/discord/monitor/thread-bindings.lifecycle.ts
Normal file
225
src/discord/monitor/thread-bindings.lifecycle.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { parseDiscordTarget } from "../targets.js";
|
||||
import { resolveChannelIdForBinding } from "./thread-bindings.discord-api.js";
|
||||
import { getThreadBindingManager } from "./thread-bindings.manager.js";
|
||||
import {
|
||||
resolveThreadBindingIntroText,
|
||||
resolveThreadBindingThreadName,
|
||||
} from "./thread-bindings.messages.js";
|
||||
import {
|
||||
BINDINGS_BY_THREAD_ID,
|
||||
MANAGERS_BY_ACCOUNT_ID,
|
||||
ensureBindingsLoaded,
|
||||
getThreadBindingToken,
|
||||
normalizeThreadBindingTtlMs,
|
||||
normalizeThreadId,
|
||||
rememberRecentUnboundWebhookEcho,
|
||||
removeBindingRecord,
|
||||
resolveBindingIdsForSession,
|
||||
saveBindingsToDisk,
|
||||
setBindingRecord,
|
||||
shouldPersistBindingMutations,
|
||||
} from "./thread-bindings.state.js";
|
||||
import type { ThreadBindingRecord, ThreadBindingTargetKind } from "./thread-bindings.types.js";
|
||||
|
||||
export function listThreadBindingsForAccount(accountId?: string): ThreadBindingRecord[] {
|
||||
const manager = getThreadBindingManager(accountId);
|
||||
if (!manager) {
|
||||
return [];
|
||||
}
|
||||
return manager.listBindings();
|
||||
}
|
||||
|
||||
export function listThreadBindingsBySessionKey(params: {
|
||||
targetSessionKey: string;
|
||||
accountId?: string;
|
||||
targetKind?: ThreadBindingTargetKind;
|
||||
}): ThreadBindingRecord[] {
|
||||
ensureBindingsLoaded();
|
||||
const targetSessionKey = params.targetSessionKey.trim();
|
||||
if (!targetSessionKey) {
|
||||
return [];
|
||||
}
|
||||
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
|
||||
const ids = resolveBindingIdsForSession({
|
||||
targetSessionKey,
|
||||
accountId,
|
||||
targetKind: params.targetKind,
|
||||
});
|
||||
return ids
|
||||
.map((bindingKey) => BINDINGS_BY_THREAD_ID.get(bindingKey))
|
||||
.filter((entry): entry is ThreadBindingRecord => Boolean(entry));
|
||||
}
|
||||
|
||||
export async function autoBindSpawnedDiscordSubagent(params: {
|
||||
accountId?: string;
|
||||
channel?: string;
|
||||
to?: string;
|
||||
threadId?: string | number;
|
||||
childSessionKey: string;
|
||||
agentId: string;
|
||||
label?: string;
|
||||
boundBy?: string;
|
||||
}): Promise<ThreadBindingRecord | null> {
|
||||
const channel = params.channel?.trim().toLowerCase();
|
||||
if (channel !== "discord") {
|
||||
return null;
|
||||
}
|
||||
const manager = getThreadBindingManager(params.accountId);
|
||||
if (!manager) {
|
||||
return null;
|
||||
}
|
||||
const managerToken = getThreadBindingToken(manager.accountId);
|
||||
|
||||
const requesterThreadId = normalizeThreadId(params.threadId);
|
||||
let channelId = "";
|
||||
if (requesterThreadId) {
|
||||
const existing = manager.getByThreadId(requesterThreadId);
|
||||
if (existing?.channelId?.trim()) {
|
||||
channelId = existing.channelId.trim();
|
||||
} else {
|
||||
channelId =
|
||||
(await resolveChannelIdForBinding({
|
||||
accountId: manager.accountId,
|
||||
token: managerToken,
|
||||
threadId: requesterThreadId,
|
||||
})) ?? "";
|
||||
}
|
||||
}
|
||||
if (!channelId) {
|
||||
const to = params.to?.trim() || "";
|
||||
if (!to) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const target = parseDiscordTarget(to, { defaultKind: "channel" });
|
||||
if (!target || target.kind !== "channel") {
|
||||
return null;
|
||||
}
|
||||
channelId =
|
||||
(await resolveChannelIdForBinding({
|
||||
accountId: manager.accountId,
|
||||
token: managerToken,
|
||||
threadId: target.id,
|
||||
})) ?? "";
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return await manager.bindTarget({
|
||||
threadId: undefined,
|
||||
channelId,
|
||||
createThread: true,
|
||||
threadName: resolveThreadBindingThreadName({
|
||||
agentId: params.agentId,
|
||||
label: params.label,
|
||||
}),
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: params.childSessionKey,
|
||||
agentId: params.agentId,
|
||||
label: params.label,
|
||||
boundBy: params.boundBy ?? "system",
|
||||
introText: resolveThreadBindingIntroText({
|
||||
agentId: params.agentId,
|
||||
label: params.label,
|
||||
sessionTtlMs: manager.getSessionTtlMs(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function unbindThreadBindingsBySessionKey(params: {
|
||||
targetSessionKey: string;
|
||||
accountId?: string;
|
||||
targetKind?: ThreadBindingTargetKind;
|
||||
reason?: string;
|
||||
sendFarewell?: boolean;
|
||||
farewellText?: string;
|
||||
}): ThreadBindingRecord[] {
|
||||
ensureBindingsLoaded();
|
||||
const targetSessionKey = params.targetSessionKey.trim();
|
||||
if (!targetSessionKey) {
|
||||
return [];
|
||||
}
|
||||
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
|
||||
const ids = resolveBindingIdsForSession({
|
||||
targetSessionKey,
|
||||
accountId,
|
||||
targetKind: params.targetKind,
|
||||
});
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const removed: ThreadBindingRecord[] = [];
|
||||
for (const bindingKey of ids) {
|
||||
const record = BINDINGS_BY_THREAD_ID.get(bindingKey);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
const manager = MANAGERS_BY_ACCOUNT_ID.get(record.accountId);
|
||||
if (manager) {
|
||||
const unbound = manager.unbindThread({
|
||||
threadId: record.threadId,
|
||||
reason: params.reason,
|
||||
sendFarewell: params.sendFarewell,
|
||||
farewellText: params.farewellText,
|
||||
});
|
||||
if (unbound) {
|
||||
removed.push(unbound);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const unbound = removeBindingRecord(bindingKey);
|
||||
if (unbound) {
|
||||
rememberRecentUnboundWebhookEcho(unbound);
|
||||
removed.push(unbound);
|
||||
}
|
||||
}
|
||||
|
||||
if (removed.length > 0 && shouldPersistBindingMutations()) {
|
||||
saveBindingsToDisk({ force: true });
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export function setThreadBindingTtlBySessionKey(params: {
|
||||
targetSessionKey: string;
|
||||
accountId?: string;
|
||||
ttlMs: number;
|
||||
}): ThreadBindingRecord[] {
|
||||
ensureBindingsLoaded();
|
||||
const targetSessionKey = params.targetSessionKey.trim();
|
||||
if (!targetSessionKey) {
|
||||
return [];
|
||||
}
|
||||
const accountId = params.accountId ? normalizeAccountId(params.accountId) : undefined;
|
||||
const ids = resolveBindingIdsForSession({
|
||||
targetSessionKey,
|
||||
accountId,
|
||||
});
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const ttlMs = normalizeThreadBindingTtlMs(params.ttlMs);
|
||||
const now = Date.now();
|
||||
const expiresAt = ttlMs > 0 ? now + ttlMs : 0;
|
||||
const updated: ThreadBindingRecord[] = [];
|
||||
for (const bindingKey of ids) {
|
||||
const existing = BINDINGS_BY_THREAD_ID.get(bindingKey);
|
||||
if (!existing) {
|
||||
continue;
|
||||
}
|
||||
const nextRecord: ThreadBindingRecord = {
|
||||
...existing,
|
||||
boundAt: now,
|
||||
expiresAt,
|
||||
};
|
||||
setBindingRecord(nextRecord);
|
||||
updated.push(nextRecord);
|
||||
}
|
||||
if (updated.length > 0 && shouldPersistBindingMutations()) {
|
||||
saveBindingsToDisk({ force: true });
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
515
src/discord/monitor/thread-bindings.manager.ts
Normal file
515
src/discord/monitor/thread-bindings.manager.ts
Normal file
@@ -0,0 +1,515 @@
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import {
|
||||
registerSessionBindingAdapter,
|
||||
unregisterSessionBindingAdapter,
|
||||
type BindingTargetKind,
|
||||
type SessionBindingRecord,
|
||||
} from "../../infra/outbound/session-binding-service.js";
|
||||
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import { createDiscordRestClient } from "../client.js";
|
||||
import {
|
||||
createThreadForBinding,
|
||||
createWebhookForChannel,
|
||||
findReusableWebhook,
|
||||
isDiscordThreadGoneError,
|
||||
isThreadArchived,
|
||||
maybeSendBindingMessage,
|
||||
resolveChannelIdForBinding,
|
||||
summarizeDiscordError,
|
||||
} from "./thread-bindings.discord-api.js";
|
||||
import {
|
||||
resolveThreadBindingFarewellText,
|
||||
resolveThreadBindingThreadName,
|
||||
} from "./thread-bindings.messages.js";
|
||||
import {
|
||||
BINDINGS_BY_THREAD_ID,
|
||||
forgetThreadBindingToken,
|
||||
getThreadBindingToken,
|
||||
MANAGERS_BY_ACCOUNT_ID,
|
||||
PERSIST_BY_ACCOUNT_ID,
|
||||
ensureBindingsLoaded,
|
||||
rememberThreadBindingToken,
|
||||
normalizeTargetKind,
|
||||
normalizeThreadBindingTtlMs,
|
||||
normalizeThreadId,
|
||||
rememberRecentUnboundWebhookEcho,
|
||||
removeBindingRecord,
|
||||
resolveBindingIdsForSession,
|
||||
resolveBindingRecordKey,
|
||||
resolveThreadBindingExpiresAt,
|
||||
resolveThreadBindingsPath,
|
||||
saveBindingsToDisk,
|
||||
setBindingRecord,
|
||||
shouldDefaultPersist,
|
||||
resetThreadBindingsForTests,
|
||||
} from "./thread-bindings.state.js";
|
||||
import {
|
||||
DEFAULT_THREAD_BINDING_TTL_MS,
|
||||
THREAD_BINDINGS_SWEEP_INTERVAL_MS,
|
||||
type ThreadBindingManager,
|
||||
type ThreadBindingRecord,
|
||||
} from "./thread-bindings.types.js";
|
||||
|
||||
function registerManager(manager: ThreadBindingManager) {
|
||||
MANAGERS_BY_ACCOUNT_ID.set(manager.accountId, manager);
|
||||
}
|
||||
|
||||
function unregisterManager(accountId: string, manager: ThreadBindingManager) {
|
||||
const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId);
|
||||
if (existing === manager) {
|
||||
MANAGERS_BY_ACCOUNT_ID.delete(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
function createNoopManager(accountIdRaw?: string): ThreadBindingManager {
|
||||
const accountId = normalizeAccountId(accountIdRaw);
|
||||
return {
|
||||
accountId,
|
||||
getSessionTtlMs: () => DEFAULT_THREAD_BINDING_TTL_MS,
|
||||
getByThreadId: () => undefined,
|
||||
getBySessionKey: () => undefined,
|
||||
listBySessionKey: () => [],
|
||||
listBindings: () => [],
|
||||
bindTarget: async () => null,
|
||||
unbindThread: () => null,
|
||||
unbindBySessionKey: () => [],
|
||||
stop: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function toSessionBindingTargetKind(raw: string): BindingTargetKind {
|
||||
return raw === "subagent" ? "subagent" : "session";
|
||||
}
|
||||
|
||||
function toThreadBindingTargetKind(raw: BindingTargetKind): "subagent" | "acp" {
|
||||
return raw === "subagent" ? "subagent" : "acp";
|
||||
}
|
||||
|
||||
function toSessionBindingRecord(record: ThreadBindingRecord): SessionBindingRecord {
|
||||
const bindingId =
|
||||
resolveBindingRecordKey({
|
||||
accountId: record.accountId,
|
||||
threadId: record.threadId,
|
||||
}) ?? `${record.accountId}:${record.threadId}`;
|
||||
return {
|
||||
bindingId,
|
||||
targetSessionKey: record.targetSessionKey,
|
||||
targetKind: toSessionBindingTargetKind(record.targetKind),
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: record.accountId,
|
||||
conversationId: record.threadId,
|
||||
parentConversationId: record.channelId,
|
||||
},
|
||||
status: "active",
|
||||
boundAt: record.boundAt,
|
||||
expiresAt: record.expiresAt,
|
||||
metadata: {
|
||||
agentId: record.agentId,
|
||||
label: record.label,
|
||||
webhookId: record.webhookId,
|
||||
webhookToken: record.webhookToken,
|
||||
boundBy: record.boundBy,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function resolveThreadIdFromBindingId(params: {
|
||||
accountId: string;
|
||||
bindingId?: string;
|
||||
}): string | undefined {
|
||||
const bindingId = params.bindingId?.trim();
|
||||
if (!bindingId) {
|
||||
return undefined;
|
||||
}
|
||||
const prefix = `${params.accountId}:`;
|
||||
if (!bindingId.startsWith(prefix)) {
|
||||
return undefined;
|
||||
}
|
||||
const threadId = bindingId.slice(prefix.length).trim();
|
||||
return threadId || undefined;
|
||||
}
|
||||
|
||||
export function createThreadBindingManager(
|
||||
params: {
|
||||
accountId?: string;
|
||||
token?: string;
|
||||
persist?: boolean;
|
||||
enableSweeper?: boolean;
|
||||
sessionTtlMs?: number;
|
||||
} = {},
|
||||
): ThreadBindingManager {
|
||||
ensureBindingsLoaded();
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId);
|
||||
if (existing) {
|
||||
rememberThreadBindingToken({ accountId, token: params.token });
|
||||
return existing;
|
||||
}
|
||||
|
||||
rememberThreadBindingToken({ accountId, token: params.token });
|
||||
|
||||
const persist = params.persist ?? shouldDefaultPersist();
|
||||
PERSIST_BY_ACCOUNT_ID.set(accountId, persist);
|
||||
const sessionTtlMs = normalizeThreadBindingTtlMs(params.sessionTtlMs);
|
||||
const resolveCurrentToken = () => getThreadBindingToken(accountId) ?? params.token;
|
||||
|
||||
let sweepTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
const manager: ThreadBindingManager = {
|
||||
accountId,
|
||||
getSessionTtlMs: () => sessionTtlMs,
|
||||
getByThreadId: (threadId) => {
|
||||
const key = resolveBindingRecordKey({
|
||||
accountId,
|
||||
threadId,
|
||||
});
|
||||
if (!key) {
|
||||
return undefined;
|
||||
}
|
||||
const entry = BINDINGS_BY_THREAD_ID.get(key);
|
||||
if (!entry || entry.accountId !== accountId) {
|
||||
return undefined;
|
||||
}
|
||||
return entry;
|
||||
},
|
||||
getBySessionKey: (targetSessionKey) => {
|
||||
const all = manager.listBySessionKey(targetSessionKey);
|
||||
return all[0];
|
||||
},
|
||||
listBySessionKey: (targetSessionKey) => {
|
||||
const ids = resolveBindingIdsForSession({
|
||||
targetSessionKey,
|
||||
accountId,
|
||||
});
|
||||
return ids
|
||||
.map((bindingKey) => BINDINGS_BY_THREAD_ID.get(bindingKey))
|
||||
.filter((entry): entry is ThreadBindingRecord => Boolean(entry));
|
||||
},
|
||||
listBindings: () =>
|
||||
[...BINDINGS_BY_THREAD_ID.values()].filter((entry) => entry.accountId === accountId),
|
||||
bindTarget: async (bindParams) => {
|
||||
let threadId = normalizeThreadId(bindParams.threadId);
|
||||
let channelId = bindParams.channelId?.trim() || "";
|
||||
|
||||
if (!threadId && bindParams.createThread) {
|
||||
if (!channelId) {
|
||||
return null;
|
||||
}
|
||||
const threadName = resolveThreadBindingThreadName({
|
||||
agentId: bindParams.agentId,
|
||||
label: bindParams.label,
|
||||
});
|
||||
threadId =
|
||||
(await createThreadForBinding({
|
||||
accountId,
|
||||
token: resolveCurrentToken(),
|
||||
channelId,
|
||||
threadName: bindParams.threadName?.trim() || threadName,
|
||||
})) ?? undefined;
|
||||
}
|
||||
|
||||
if (!threadId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!channelId) {
|
||||
channelId =
|
||||
(await resolveChannelIdForBinding({
|
||||
accountId,
|
||||
token: resolveCurrentToken(),
|
||||
threadId,
|
||||
channelId: bindParams.channelId,
|
||||
})) ?? "";
|
||||
}
|
||||
if (!channelId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetSessionKey = bindParams.targetSessionKey.trim();
|
||||
if (!targetSessionKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetKind = normalizeTargetKind(bindParams.targetKind, targetSessionKey);
|
||||
let webhookId = bindParams.webhookId?.trim() || "";
|
||||
let webhookToken = bindParams.webhookToken?.trim() || "";
|
||||
if (!webhookId || !webhookToken) {
|
||||
const cachedWebhook = findReusableWebhook({ accountId, channelId });
|
||||
webhookId = cachedWebhook.webhookId ?? "";
|
||||
webhookToken = cachedWebhook.webhookToken ?? "";
|
||||
}
|
||||
if (!webhookId || !webhookToken) {
|
||||
const createdWebhook = await createWebhookForChannel({
|
||||
accountId,
|
||||
token: resolveCurrentToken(),
|
||||
channelId,
|
||||
});
|
||||
webhookId = createdWebhook.webhookId ?? "";
|
||||
webhookToken = createdWebhook.webhookToken ?? "";
|
||||
}
|
||||
|
||||
const boundAt = Date.now();
|
||||
const record: ThreadBindingRecord = {
|
||||
accountId,
|
||||
channelId,
|
||||
threadId,
|
||||
targetKind,
|
||||
targetSessionKey,
|
||||
agentId: bindParams.agentId?.trim() || resolveAgentIdFromSessionKey(targetSessionKey),
|
||||
label: bindParams.label?.trim() || undefined,
|
||||
webhookId: webhookId || undefined,
|
||||
webhookToken: webhookToken || undefined,
|
||||
boundBy: bindParams.boundBy?.trim() || "system",
|
||||
boundAt,
|
||||
expiresAt: sessionTtlMs > 0 ? boundAt + sessionTtlMs : undefined,
|
||||
};
|
||||
|
||||
setBindingRecord(record);
|
||||
if (persist) {
|
||||
saveBindingsToDisk();
|
||||
}
|
||||
|
||||
const introText = bindParams.introText?.trim();
|
||||
if (introText) {
|
||||
void maybeSendBindingMessage({ record, text: introText });
|
||||
}
|
||||
return record;
|
||||
},
|
||||
unbindThread: (unbindParams) => {
|
||||
const bindingKey = resolveBindingRecordKey({
|
||||
accountId,
|
||||
threadId: unbindParams.threadId,
|
||||
});
|
||||
if (!bindingKey) {
|
||||
return null;
|
||||
}
|
||||
const existing = BINDINGS_BY_THREAD_ID.get(bindingKey);
|
||||
if (!existing || existing.accountId !== accountId) {
|
||||
return null;
|
||||
}
|
||||
const removed = removeBindingRecord(bindingKey);
|
||||
if (!removed) {
|
||||
return null;
|
||||
}
|
||||
rememberRecentUnboundWebhookEcho(removed);
|
||||
if (persist) {
|
||||
saveBindingsToDisk();
|
||||
}
|
||||
if (unbindParams.sendFarewell !== false) {
|
||||
const farewell = resolveThreadBindingFarewellText({
|
||||
reason: unbindParams.reason,
|
||||
farewellText: unbindParams.farewellText,
|
||||
sessionTtlMs,
|
||||
});
|
||||
// Use bot send path for farewell messages so unbound threads don't process
|
||||
// webhook echoes as fresh inbound turns when allowBots is enabled.
|
||||
void maybeSendBindingMessage({ record: removed, text: farewell, preferWebhook: false });
|
||||
}
|
||||
return removed;
|
||||
},
|
||||
unbindBySessionKey: (unbindParams) => {
|
||||
const ids = resolveBindingIdsForSession({
|
||||
targetSessionKey: unbindParams.targetSessionKey,
|
||||
accountId,
|
||||
targetKind: unbindParams.targetKind,
|
||||
});
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const removed: ThreadBindingRecord[] = [];
|
||||
for (const bindingKey of ids) {
|
||||
const binding = BINDINGS_BY_THREAD_ID.get(bindingKey);
|
||||
if (!binding) {
|
||||
continue;
|
||||
}
|
||||
const entry = manager.unbindThread({
|
||||
threadId: binding.threadId,
|
||||
reason: unbindParams.reason,
|
||||
sendFarewell: unbindParams.sendFarewell,
|
||||
farewellText: unbindParams.farewellText,
|
||||
});
|
||||
if (entry) {
|
||||
removed.push(entry);
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
},
|
||||
stop: () => {
|
||||
if (sweepTimer) {
|
||||
clearInterval(sweepTimer);
|
||||
sweepTimer = null;
|
||||
}
|
||||
unregisterManager(accountId, manager);
|
||||
unregisterSessionBindingAdapter({
|
||||
channel: "discord",
|
||||
accountId,
|
||||
});
|
||||
forgetThreadBindingToken(accountId);
|
||||
},
|
||||
};
|
||||
|
||||
if (params.enableSweeper !== false) {
|
||||
sweepTimer = setInterval(() => {
|
||||
void (async () => {
|
||||
const bindings = manager.listBindings();
|
||||
if (bindings.length === 0) {
|
||||
return;
|
||||
}
|
||||
let rest;
|
||||
try {
|
||||
rest = createDiscordRestClient({
|
||||
accountId,
|
||||
token: resolveCurrentToken(),
|
||||
}).rest;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const binding of bindings) {
|
||||
const expiresAt = resolveThreadBindingExpiresAt({
|
||||
record: binding,
|
||||
sessionTtlMs,
|
||||
});
|
||||
if (expiresAt != null && Date.now() >= expiresAt) {
|
||||
const ttlFromBinding = Math.max(0, expiresAt - binding.boundAt);
|
||||
manager.unbindThread({
|
||||
threadId: binding.threadId,
|
||||
reason: "ttl-expired",
|
||||
sendFarewell: true,
|
||||
farewellText: resolveThreadBindingFarewellText({
|
||||
reason: "ttl-expired",
|
||||
sessionTtlMs: ttlFromBinding,
|
||||
}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const channel = await rest.get(Routes.channel(binding.threadId));
|
||||
if (!channel || typeof channel !== "object") {
|
||||
logVerbose(
|
||||
`discord thread binding sweep probe returned invalid payload for ${binding.threadId}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (isThreadArchived(channel)) {
|
||||
manager.unbindThread({
|
||||
threadId: binding.threadId,
|
||||
reason: "thread-archived",
|
||||
sendFarewell: true,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDiscordThreadGoneError(err)) {
|
||||
logVerbose(
|
||||
`discord thread binding sweep removing stale binding ${binding.threadId}: ${summarizeDiscordError(err)}`,
|
||||
);
|
||||
manager.unbindThread({
|
||||
threadId: binding.threadId,
|
||||
reason: "thread-delete",
|
||||
sendFarewell: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
logVerbose(
|
||||
`discord thread binding sweep probe failed for ${binding.threadId}: ${summarizeDiscordError(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, THREAD_BINDINGS_SWEEP_INTERVAL_MS);
|
||||
sweepTimer.unref?.();
|
||||
}
|
||||
|
||||
registerSessionBindingAdapter({
|
||||
channel: "discord",
|
||||
accountId,
|
||||
bind: async (input) => {
|
||||
if (input.conversation.channel !== "discord") {
|
||||
return null;
|
||||
}
|
||||
const targetSessionKey = input.targetSessionKey.trim();
|
||||
if (!targetSessionKey) {
|
||||
return null;
|
||||
}
|
||||
const conversationId = input.conversation.conversationId.trim();
|
||||
const metadata = input.metadata ?? {};
|
||||
const label =
|
||||
typeof metadata.label === "string" ? metadata.label.trim() || undefined : undefined;
|
||||
const threadName =
|
||||
typeof metadata.threadName === "string"
|
||||
? metadata.threadName.trim() || undefined
|
||||
: undefined;
|
||||
const introText =
|
||||
typeof metadata.introText === "string" ? metadata.introText.trim() || undefined : undefined;
|
||||
const boundBy =
|
||||
typeof metadata.boundBy === "string" ? metadata.boundBy.trim() || undefined : undefined;
|
||||
const agentId =
|
||||
typeof metadata.agentId === "string" ? metadata.agentId.trim() || undefined : undefined;
|
||||
const bound = await manager.bindTarget({
|
||||
threadId: conversationId || undefined,
|
||||
channelId: input.conversation.parentConversationId?.trim() || undefined,
|
||||
createThread: !conversationId,
|
||||
threadName,
|
||||
targetKind: toThreadBindingTargetKind(input.targetKind),
|
||||
targetSessionKey,
|
||||
agentId,
|
||||
label,
|
||||
boundBy,
|
||||
introText,
|
||||
});
|
||||
return bound ? toSessionBindingRecord(bound) : null;
|
||||
},
|
||||
listBySession: (targetSessionKey) =>
|
||||
manager.listBySessionKey(targetSessionKey).map(toSessionBindingRecord),
|
||||
resolveByConversation: (ref) => {
|
||||
if (ref.channel !== "discord") {
|
||||
return null;
|
||||
}
|
||||
const binding = manager.getByThreadId(ref.conversationId);
|
||||
return binding ? toSessionBindingRecord(binding) : null;
|
||||
},
|
||||
touch: () => {
|
||||
// Thread bindings are activity-touched by inbound/outbound message flows.
|
||||
},
|
||||
unbind: async (input) => {
|
||||
if (input.targetSessionKey?.trim()) {
|
||||
const removed = manager.unbindBySessionKey({
|
||||
targetSessionKey: input.targetSessionKey,
|
||||
reason: input.reason,
|
||||
});
|
||||
return removed.map(toSessionBindingRecord);
|
||||
}
|
||||
const threadId = resolveThreadIdFromBindingId({
|
||||
accountId,
|
||||
bindingId: input.bindingId,
|
||||
});
|
||||
if (!threadId) {
|
||||
return [];
|
||||
}
|
||||
const removed = manager.unbindThread({
|
||||
threadId,
|
||||
reason: input.reason,
|
||||
});
|
||||
return removed ? [toSessionBindingRecord(removed)] : [];
|
||||
},
|
||||
});
|
||||
|
||||
registerManager(manager);
|
||||
return manager;
|
||||
}
|
||||
|
||||
export function createNoopThreadBindingManager(accountId?: string): ThreadBindingManager {
|
||||
return createNoopManager(accountId);
|
||||
}
|
||||
|
||||
export function getThreadBindingManager(accountId?: string): ThreadBindingManager | null {
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
return MANAGERS_BY_ACCOUNT_ID.get(normalized) ?? null;
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resolveThreadBindingsPath,
|
||||
resolveThreadBindingThreadName,
|
||||
resetThreadBindingsForTests,
|
||||
};
|
||||
72
src/discord/monitor/thread-bindings.messages.ts
Normal file
72
src/discord/monitor/thread-bindings.messages.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { DEFAULT_FAREWELL_TEXT, type ThreadBindingRecord } from "./thread-bindings.types.js";
|
||||
|
||||
function normalizeThreadBindingMessageTtlMs(raw: unknown): number {
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return 0;
|
||||
}
|
||||
const ttlMs = Math.floor(raw);
|
||||
if (ttlMs < 0) {
|
||||
return 0;
|
||||
}
|
||||
return ttlMs;
|
||||
}
|
||||
|
||||
export function formatThreadBindingTtlLabel(ttlMs: number): string {
|
||||
if (ttlMs <= 0) {
|
||||
return "disabled";
|
||||
}
|
||||
if (ttlMs < 60_000) {
|
||||
return "<1m";
|
||||
}
|
||||
const totalMinutes = Math.floor(ttlMs / 60_000);
|
||||
if (totalMinutes % 60 === 0) {
|
||||
return `${Math.floor(totalMinutes / 60)}h`;
|
||||
}
|
||||
return `${totalMinutes}m`;
|
||||
}
|
||||
|
||||
export function resolveThreadBindingThreadName(params: {
|
||||
agentId?: string;
|
||||
label?: string;
|
||||
}): string {
|
||||
const label = params.label?.trim();
|
||||
const base = label || params.agentId?.trim() || "agent";
|
||||
const raw = `🤖 ${base}`.replace(/\s+/g, " ").trim();
|
||||
return raw.slice(0, 100);
|
||||
}
|
||||
|
||||
export function resolveThreadBindingIntroText(params: {
|
||||
agentId?: string;
|
||||
label?: string;
|
||||
sessionTtlMs?: number;
|
||||
}): string {
|
||||
const label = params.label?.trim();
|
||||
const base = label || params.agentId?.trim() || "agent";
|
||||
const normalized = base.replace(/\s+/g, " ").trim().slice(0, 100) || "agent";
|
||||
const ttlMs = normalizeThreadBindingMessageTtlMs(params.sessionTtlMs);
|
||||
if (ttlMs > 0) {
|
||||
return `🤖 ${normalized} session active (auto-unfocus in ${formatThreadBindingTtlLabel(ttlMs)}). Messages here go directly to this session.`;
|
||||
}
|
||||
return `🤖 ${normalized} session active. Messages here go directly to this session.`;
|
||||
}
|
||||
|
||||
export function resolveThreadBindingFarewellText(params: {
|
||||
reason?: string;
|
||||
farewellText?: string;
|
||||
sessionTtlMs: number;
|
||||
}): string {
|
||||
const custom = params.farewellText?.trim();
|
||||
if (custom) {
|
||||
return custom;
|
||||
}
|
||||
if (params.reason === "ttl-expired") {
|
||||
return `Session ended automatically after ${formatThreadBindingTtlLabel(params.sessionTtlMs)}. Messages here will no longer be routed.`;
|
||||
}
|
||||
return DEFAULT_FAREWELL_TEXT;
|
||||
}
|
||||
|
||||
export function summarizeBindingPersona(record: ThreadBindingRecord): string {
|
||||
const label = record.label?.trim();
|
||||
const base = label || record.agentId;
|
||||
return (`🤖 ${base}`.trim() || "🤖 agent").slice(0, 80);
|
||||
}
|
||||
31
src/discord/monitor/thread-bindings.shared-state.test.ts
Normal file
31
src/discord/monitor/thread-bindings.shared-state.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createJiti } from "jiti";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
__testing as threadBindingsTesting,
|
||||
createThreadBindingManager,
|
||||
getThreadBindingManager,
|
||||
} from "./thread-bindings.js";
|
||||
|
||||
describe("thread binding manager state", () => {
|
||||
beforeEach(() => {
|
||||
threadBindingsTesting.resetThreadBindingsForTests();
|
||||
});
|
||||
|
||||
it("shares managers between ESM and Jiti-loaded module instances", () => {
|
||||
const jiti = createJiti(import.meta.url, {
|
||||
interopDefault: true,
|
||||
});
|
||||
const viaJiti = jiti("./thread-bindings.ts") as {
|
||||
getThreadBindingManager: typeof getThreadBindingManager;
|
||||
};
|
||||
|
||||
createThreadBindingManager({
|
||||
accountId: "work",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
});
|
||||
|
||||
expect(getThreadBindingManager("work")).not.toBeNull();
|
||||
expect(viaJiti.getThreadBindingManager("work")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
444
src/discord/monitor/thread-bindings.state.ts
Normal file
444
src/discord/monitor/thread-bindings.state.ts
Normal file
@@ -0,0 +1,444 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveStateDir } from "../../config/paths.js";
|
||||
import { loadJsonFile, saveJsonFile } from "../../infra/json-file.js";
|
||||
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import {
|
||||
DEFAULT_THREAD_BINDING_TTL_MS,
|
||||
RECENT_UNBOUND_WEBHOOK_ECHO_TTL_MS,
|
||||
THREAD_BINDINGS_VERSION,
|
||||
type PersistedThreadBindingRecord,
|
||||
type PersistedThreadBindingsPayload,
|
||||
type ThreadBindingManager,
|
||||
type ThreadBindingRecord,
|
||||
type ThreadBindingTargetKind,
|
||||
} from "./thread-bindings.types.js";
|
||||
|
||||
type ThreadBindingsGlobalState = {
|
||||
managersByAccountId: Map<string, ThreadBindingManager>;
|
||||
bindingsByThreadId: Map<string, ThreadBindingRecord>;
|
||||
bindingsBySessionKey: Map<string, Set<string>>;
|
||||
tokensByAccountId: Map<string, string>;
|
||||
recentUnboundWebhookEchoesByBindingKey: Map<string, { webhookId: string; expiresAt: number }>;
|
||||
reusableWebhooksByAccountChannel: Map<string, { webhookId: string; webhookToken: string }>;
|
||||
persistByAccountId: Map<string, boolean>;
|
||||
loadedBindings: boolean;
|
||||
};
|
||||
|
||||
// Plugin hooks can load this module via Jiti while core imports it via ESM.
|
||||
// Store mutable state on globalThis so both loader paths share one registry.
|
||||
const THREAD_BINDINGS_STATE_KEY = "__openclawDiscordThreadBindingsState";
|
||||
|
||||
function createThreadBindingsGlobalState(): ThreadBindingsGlobalState {
|
||||
return {
|
||||
managersByAccountId: new Map<string, ThreadBindingManager>(),
|
||||
bindingsByThreadId: new Map<string, ThreadBindingRecord>(),
|
||||
bindingsBySessionKey: new Map<string, Set<string>>(),
|
||||
tokensByAccountId: new Map<string, string>(),
|
||||
recentUnboundWebhookEchoesByBindingKey: new Map<
|
||||
string,
|
||||
{ webhookId: string; expiresAt: number }
|
||||
>(),
|
||||
reusableWebhooksByAccountChannel: new Map<
|
||||
string,
|
||||
{ webhookId: string; webhookToken: string }
|
||||
>(),
|
||||
persistByAccountId: new Map<string, boolean>(),
|
||||
loadedBindings: false,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveThreadBindingsGlobalState(): ThreadBindingsGlobalState {
|
||||
const runtimeGlobal = globalThis as typeof globalThis & {
|
||||
[THREAD_BINDINGS_STATE_KEY]?: ThreadBindingsGlobalState;
|
||||
};
|
||||
if (!runtimeGlobal[THREAD_BINDINGS_STATE_KEY]) {
|
||||
runtimeGlobal[THREAD_BINDINGS_STATE_KEY] = createThreadBindingsGlobalState();
|
||||
}
|
||||
return runtimeGlobal[THREAD_BINDINGS_STATE_KEY];
|
||||
}
|
||||
|
||||
const THREAD_BINDINGS_STATE = resolveThreadBindingsGlobalState();
|
||||
|
||||
export const MANAGERS_BY_ACCOUNT_ID = THREAD_BINDINGS_STATE.managersByAccountId;
|
||||
export const BINDINGS_BY_THREAD_ID = THREAD_BINDINGS_STATE.bindingsByThreadId;
|
||||
export const BINDINGS_BY_SESSION_KEY = THREAD_BINDINGS_STATE.bindingsBySessionKey;
|
||||
export const TOKENS_BY_ACCOUNT_ID = THREAD_BINDINGS_STATE.tokensByAccountId;
|
||||
export const RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY =
|
||||
THREAD_BINDINGS_STATE.recentUnboundWebhookEchoesByBindingKey;
|
||||
export const REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL =
|
||||
THREAD_BINDINGS_STATE.reusableWebhooksByAccountChannel;
|
||||
export const PERSIST_BY_ACCOUNT_ID = THREAD_BINDINGS_STATE.persistByAccountId;
|
||||
|
||||
export function rememberThreadBindingToken(params: { accountId?: string; token?: string }) {
|
||||
const normalizedAccountId = normalizeAccountId(params.accountId);
|
||||
const token = params.token?.trim();
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
TOKENS_BY_ACCOUNT_ID.set(normalizedAccountId, token);
|
||||
}
|
||||
|
||||
export function forgetThreadBindingToken(accountId?: string) {
|
||||
TOKENS_BY_ACCOUNT_ID.delete(normalizeAccountId(accountId));
|
||||
}
|
||||
|
||||
export function getThreadBindingToken(accountId?: string): string | undefined {
|
||||
return TOKENS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId));
|
||||
}
|
||||
|
||||
export function shouldDefaultPersist(): boolean {
|
||||
return !(process.env.VITEST || process.env.NODE_ENV === "test");
|
||||
}
|
||||
|
||||
export function resolveThreadBindingsPath(): string {
|
||||
return path.join(resolveStateDir(process.env), "discord", "thread-bindings.json");
|
||||
}
|
||||
|
||||
export function normalizeTargetKind(
|
||||
raw: unknown,
|
||||
targetSessionKey: string,
|
||||
): ThreadBindingTargetKind {
|
||||
if (raw === "subagent" || raw === "acp") {
|
||||
return raw;
|
||||
}
|
||||
return targetSessionKey.includes(":subagent:") ? "subagent" : "acp";
|
||||
}
|
||||
|
||||
export function normalizeThreadId(raw: unknown): string | undefined {
|
||||
if (typeof raw === "number" && Number.isFinite(raw)) {
|
||||
return String(Math.floor(raw));
|
||||
}
|
||||
if (typeof raw !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function toBindingRecordKey(params: { accountId: string; threadId: string }): string {
|
||||
return `${normalizeAccountId(params.accountId)}:${params.threadId.trim()}`;
|
||||
}
|
||||
|
||||
export function resolveBindingRecordKey(params: {
|
||||
accountId?: string;
|
||||
threadId: string;
|
||||
}): string | undefined {
|
||||
const threadId = normalizeThreadId(params.threadId);
|
||||
if (!threadId) {
|
||||
return undefined;
|
||||
}
|
||||
return toBindingRecordKey({
|
||||
accountId: normalizeAccountId(params.accountId),
|
||||
threadId,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBindingRecord | null {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return null;
|
||||
}
|
||||
const value = raw as Partial<PersistedThreadBindingRecord>;
|
||||
const threadId = normalizeThreadId(value.threadId ?? threadIdKey);
|
||||
const channelId = typeof value.channelId === "string" ? value.channelId.trim() : "";
|
||||
const targetSessionKey =
|
||||
typeof value.targetSessionKey === "string"
|
||||
? value.targetSessionKey.trim()
|
||||
: typeof value.sessionKey === "string"
|
||||
? value.sessionKey.trim()
|
||||
: "";
|
||||
if (!threadId || !channelId || !targetSessionKey) {
|
||||
return null;
|
||||
}
|
||||
const accountId = normalizeAccountId(value.accountId);
|
||||
const targetKind = normalizeTargetKind(value.targetKind, targetSessionKey);
|
||||
const agentIdRaw = typeof value.agentId === "string" ? value.agentId.trim() : "";
|
||||
const agentId = agentIdRaw || resolveAgentIdFromSessionKey(targetSessionKey);
|
||||
const label = typeof value.label === "string" ? value.label.trim() || undefined : undefined;
|
||||
const webhookId =
|
||||
typeof value.webhookId === "string" ? value.webhookId.trim() || undefined : undefined;
|
||||
const webhookToken =
|
||||
typeof value.webhookToken === "string" ? value.webhookToken.trim() || undefined : undefined;
|
||||
const boundBy = typeof value.boundBy === "string" ? value.boundBy.trim() || "system" : "system";
|
||||
const boundAt =
|
||||
typeof value.boundAt === "number" && Number.isFinite(value.boundAt)
|
||||
? Math.floor(value.boundAt)
|
||||
: Date.now();
|
||||
const expiresAt =
|
||||
typeof value.expiresAt === "number" && Number.isFinite(value.expiresAt)
|
||||
? Math.max(0, Math.floor(value.expiresAt))
|
||||
: undefined;
|
||||
return {
|
||||
accountId,
|
||||
channelId,
|
||||
threadId,
|
||||
targetKind,
|
||||
targetSessionKey,
|
||||
agentId,
|
||||
label,
|
||||
webhookId,
|
||||
webhookToken,
|
||||
boundBy,
|
||||
boundAt,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeThreadBindingTtlMs(raw: unknown): number {
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return DEFAULT_THREAD_BINDING_TTL_MS;
|
||||
}
|
||||
const ttlMs = Math.floor(raw);
|
||||
if (ttlMs < 0) {
|
||||
return DEFAULT_THREAD_BINDING_TTL_MS;
|
||||
}
|
||||
return ttlMs;
|
||||
}
|
||||
|
||||
export function resolveThreadBindingExpiresAt(params: {
|
||||
record: Pick<ThreadBindingRecord, "boundAt" | "expiresAt">;
|
||||
sessionTtlMs: number;
|
||||
}): number | undefined {
|
||||
if (typeof params.record.expiresAt === "number" && Number.isFinite(params.record.expiresAt)) {
|
||||
const explicitExpiresAt = Math.floor(params.record.expiresAt);
|
||||
if (explicitExpiresAt <= 0) {
|
||||
// 0 is an explicit per-binding TTL disable sentinel.
|
||||
return undefined;
|
||||
}
|
||||
return explicitExpiresAt;
|
||||
}
|
||||
if (params.sessionTtlMs <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
const boundAt = Math.floor(params.record.boundAt);
|
||||
if (!Number.isFinite(boundAt) || boundAt <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
return boundAt + params.sessionTtlMs;
|
||||
}
|
||||
|
||||
function linkSessionBinding(targetSessionKey: string, bindingKey: string) {
|
||||
const key = targetSessionKey.trim();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const threads = BINDINGS_BY_SESSION_KEY.get(key) ?? new Set<string>();
|
||||
threads.add(bindingKey);
|
||||
BINDINGS_BY_SESSION_KEY.set(key, threads);
|
||||
}
|
||||
|
||||
function unlinkSessionBinding(targetSessionKey: string, bindingKey: string) {
|
||||
const key = targetSessionKey.trim();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const threads = BINDINGS_BY_SESSION_KEY.get(key);
|
||||
if (!threads) {
|
||||
return;
|
||||
}
|
||||
threads.delete(bindingKey);
|
||||
if (threads.size === 0) {
|
||||
BINDINGS_BY_SESSION_KEY.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function toReusableWebhookKey(params: { accountId: string; channelId: string }): string {
|
||||
return `${params.accountId.trim().toLowerCase()}:${params.channelId.trim()}`;
|
||||
}
|
||||
|
||||
export function rememberReusableWebhook(record: ThreadBindingRecord) {
|
||||
const webhookId = record.webhookId?.trim();
|
||||
const webhookToken = record.webhookToken?.trim();
|
||||
if (!webhookId || !webhookToken) {
|
||||
return;
|
||||
}
|
||||
const key = toReusableWebhookKey({
|
||||
accountId: record.accountId,
|
||||
channelId: record.channelId,
|
||||
});
|
||||
REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL.set(key, { webhookId, webhookToken });
|
||||
}
|
||||
|
||||
export function rememberRecentUnboundWebhookEcho(record: ThreadBindingRecord) {
|
||||
const webhookId = record.webhookId?.trim();
|
||||
if (!webhookId) {
|
||||
return;
|
||||
}
|
||||
const bindingKey = resolveBindingRecordKey({
|
||||
accountId: record.accountId,
|
||||
threadId: record.threadId,
|
||||
});
|
||||
if (!bindingKey) {
|
||||
return;
|
||||
}
|
||||
RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.set(bindingKey, {
|
||||
webhookId,
|
||||
expiresAt: Date.now() + RECENT_UNBOUND_WEBHOOK_ECHO_TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function clearRecentUnboundWebhookEcho(bindingKeyRaw: string) {
|
||||
const key = bindingKeyRaw.trim();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.delete(key);
|
||||
}
|
||||
|
||||
export function setBindingRecord(record: ThreadBindingRecord) {
|
||||
const bindingKey = toBindingRecordKey({
|
||||
accountId: record.accountId,
|
||||
threadId: record.threadId,
|
||||
});
|
||||
const existing = BINDINGS_BY_THREAD_ID.get(bindingKey);
|
||||
if (existing) {
|
||||
unlinkSessionBinding(existing.targetSessionKey, bindingKey);
|
||||
}
|
||||
BINDINGS_BY_THREAD_ID.set(bindingKey, record);
|
||||
linkSessionBinding(record.targetSessionKey, bindingKey);
|
||||
clearRecentUnboundWebhookEcho(bindingKey);
|
||||
rememberReusableWebhook(record);
|
||||
}
|
||||
|
||||
export function removeBindingRecord(bindingKeyRaw: string): ThreadBindingRecord | null {
|
||||
const key = bindingKeyRaw.trim();
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
const existing = BINDINGS_BY_THREAD_ID.get(key);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
BINDINGS_BY_THREAD_ID.delete(key);
|
||||
unlinkSessionBinding(existing.targetSessionKey, key);
|
||||
return existing;
|
||||
}
|
||||
|
||||
export function isRecentlyUnboundThreadWebhookMessage(params: {
|
||||
accountId?: string;
|
||||
threadId: string;
|
||||
webhookId?: string | null;
|
||||
}): boolean {
|
||||
const webhookId = params.webhookId?.trim() || "";
|
||||
if (!webhookId) {
|
||||
return false;
|
||||
}
|
||||
const bindingKey = resolveBindingRecordKey({
|
||||
accountId: params.accountId,
|
||||
threadId: params.threadId,
|
||||
});
|
||||
if (!bindingKey) {
|
||||
return false;
|
||||
}
|
||||
const suppressed = RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.get(bindingKey);
|
||||
if (!suppressed) {
|
||||
return false;
|
||||
}
|
||||
if (suppressed.expiresAt <= Date.now()) {
|
||||
RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.delete(bindingKey);
|
||||
return false;
|
||||
}
|
||||
return suppressed.webhookId === webhookId;
|
||||
}
|
||||
|
||||
function shouldPersistAnyBindingState(): boolean {
|
||||
for (const value of PERSIST_BY_ACCOUNT_ID.values()) {
|
||||
if (value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldPersistBindingMutations(): boolean {
|
||||
if (shouldPersistAnyBindingState()) {
|
||||
return true;
|
||||
}
|
||||
return fs.existsSync(resolveThreadBindingsPath());
|
||||
}
|
||||
|
||||
export function saveBindingsToDisk(params: { force?: boolean } = {}) {
|
||||
if (!params.force && !shouldPersistAnyBindingState()) {
|
||||
return;
|
||||
}
|
||||
const bindings: Record<string, PersistedThreadBindingRecord> = {};
|
||||
for (const [bindingKey, record] of BINDINGS_BY_THREAD_ID.entries()) {
|
||||
bindings[bindingKey] = { ...record };
|
||||
}
|
||||
const payload: PersistedThreadBindingsPayload = {
|
||||
version: THREAD_BINDINGS_VERSION,
|
||||
bindings,
|
||||
};
|
||||
saveJsonFile(resolveThreadBindingsPath(), payload);
|
||||
}
|
||||
|
||||
export function ensureBindingsLoaded() {
|
||||
if (THREAD_BINDINGS_STATE.loadedBindings) {
|
||||
return;
|
||||
}
|
||||
THREAD_BINDINGS_STATE.loadedBindings = true;
|
||||
BINDINGS_BY_THREAD_ID.clear();
|
||||
BINDINGS_BY_SESSION_KEY.clear();
|
||||
REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL.clear();
|
||||
|
||||
const raw = loadJsonFile(resolveThreadBindingsPath());
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return;
|
||||
}
|
||||
const payload = raw as Partial<PersistedThreadBindingsPayload>;
|
||||
if (payload.version !== 1 || !payload.bindings || typeof payload.bindings !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [threadId, entry] of Object.entries(payload.bindings)) {
|
||||
const normalized = normalizePersistedBinding(threadId, entry);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
setBindingRecord(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveBindingIdsForSession(params: {
|
||||
targetSessionKey: string;
|
||||
accountId?: string;
|
||||
targetKind?: ThreadBindingTargetKind;
|
||||
}): string[] {
|
||||
const key = params.targetSessionKey.trim();
|
||||
if (!key) {
|
||||
return [];
|
||||
}
|
||||
const ids = BINDINGS_BY_SESSION_KEY.get(key);
|
||||
if (!ids) {
|
||||
return [];
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (const bindingKey of ids.values()) {
|
||||
const record = BINDINGS_BY_THREAD_ID.get(bindingKey);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
if (params.accountId && record.accountId !== params.accountId) {
|
||||
continue;
|
||||
}
|
||||
if (params.targetKind && record.targetKind !== params.targetKind) {
|
||||
continue;
|
||||
}
|
||||
out.push(bindingKey);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function resetThreadBindingsForTests() {
|
||||
for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) {
|
||||
manager.stop();
|
||||
}
|
||||
MANAGERS_BY_ACCOUNT_ID.clear();
|
||||
BINDINGS_BY_THREAD_ID.clear();
|
||||
BINDINGS_BY_SESSION_KEY.clear();
|
||||
RECENT_UNBOUND_WEBHOOK_ECHOES_BY_BINDING_KEY.clear();
|
||||
REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL.clear();
|
||||
TOKENS_BY_ACCOUNT_ID.clear();
|
||||
PERSIST_BY_ACCOUNT_ID.clear();
|
||||
THREAD_BINDINGS_STATE.loadedBindings = false;
|
||||
}
|
||||
28
src/discord/monitor/thread-bindings.ts
Normal file
28
src/discord/monitor/thread-bindings.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export type {
|
||||
ThreadBindingManager,
|
||||
ThreadBindingRecord,
|
||||
ThreadBindingTargetKind,
|
||||
} from "./thread-bindings.types.js";
|
||||
|
||||
export {
|
||||
formatThreadBindingTtlLabel,
|
||||
resolveThreadBindingIntroText,
|
||||
resolveThreadBindingThreadName,
|
||||
} from "./thread-bindings.messages.js";
|
||||
|
||||
export { isRecentlyUnboundThreadWebhookMessage } from "./thread-bindings.state.js";
|
||||
|
||||
export {
|
||||
autoBindSpawnedDiscordSubagent,
|
||||
listThreadBindingsBySessionKey,
|
||||
listThreadBindingsForAccount,
|
||||
setThreadBindingTtlBySessionKey,
|
||||
unbindThreadBindingsBySessionKey,
|
||||
} from "./thread-bindings.lifecycle.js";
|
||||
|
||||
export {
|
||||
__testing,
|
||||
createNoopThreadBindingManager,
|
||||
createThreadBindingManager,
|
||||
getThreadBindingManager,
|
||||
} from "./thread-bindings.manager.js";
|
||||
541
src/discord/monitor/thread-bindings.ttl.test.ts
Normal file
541
src/discord/monitor/thread-bindings.ttl.test.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({}));
|
||||
const sendWebhookMessageDiscord = vi.fn(async (_text: string, _opts?: unknown) => ({}));
|
||||
const restGet = vi.fn(async () => ({
|
||||
id: "thread-1",
|
||||
type: 11,
|
||||
parent_id: "parent-1",
|
||||
}));
|
||||
const restPost = vi.fn(async () => ({
|
||||
id: "wh-created",
|
||||
token: "tok-created",
|
||||
}));
|
||||
const createDiscordRestClient = vi.fn((..._args: unknown[]) => ({
|
||||
rest: {
|
||||
get: restGet,
|
||||
post: restPost,
|
||||
},
|
||||
}));
|
||||
const createThreadDiscord = vi.fn(async (..._args: unknown[]) => ({ id: "thread-created" }));
|
||||
return {
|
||||
sendMessageDiscord,
|
||||
sendWebhookMessageDiscord,
|
||||
restGet,
|
||||
restPost,
|
||||
createDiscordRestClient,
|
||||
createThreadDiscord,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../send.js", () => ({
|
||||
sendMessageDiscord: hoisted.sendMessageDiscord,
|
||||
sendWebhookMessageDiscord: hoisted.sendWebhookMessageDiscord,
|
||||
}));
|
||||
|
||||
vi.mock("../client.js", () => ({
|
||||
createDiscordRestClient: hoisted.createDiscordRestClient,
|
||||
}));
|
||||
|
||||
vi.mock("../send.messages.js", () => ({
|
||||
createThreadDiscord: hoisted.createThreadDiscord,
|
||||
}));
|
||||
|
||||
const {
|
||||
__testing,
|
||||
autoBindSpawnedDiscordSubagent,
|
||||
createThreadBindingManager,
|
||||
resolveThreadBindingIntroText,
|
||||
setThreadBindingTtlBySessionKey,
|
||||
unbindThreadBindingsBySessionKey,
|
||||
} = await import("./thread-bindings.js");
|
||||
|
||||
describe("thread binding ttl", () => {
|
||||
beforeEach(() => {
|
||||
__testing.resetThreadBindingsForTests();
|
||||
hoisted.sendMessageDiscord.mockClear();
|
||||
hoisted.sendWebhookMessageDiscord.mockClear();
|
||||
hoisted.restGet.mockClear();
|
||||
hoisted.restPost.mockClear();
|
||||
hoisted.createDiscordRestClient.mockClear();
|
||||
hoisted.createThreadDiscord.mockClear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("includes ttl in intro text", () => {
|
||||
const intro = resolveThreadBindingIntroText({
|
||||
agentId: "main",
|
||||
label: "worker",
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
expect(intro).toContain("auto-unfocus in 24h");
|
||||
});
|
||||
|
||||
it("auto-unfocuses expired bindings and sends a ttl-expired message", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: true,
|
||||
sessionTtlMs: 60_000,
|
||||
});
|
||||
|
||||
const binding = await manager.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
webhookId: "wh-1",
|
||||
webhookToken: "tok-1",
|
||||
introText: "intro",
|
||||
});
|
||||
expect(binding).not.toBeNull();
|
||||
hoisted.sendMessageDiscord.mockClear();
|
||||
hoisted.sendWebhookMessageDiscord.mockClear();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
|
||||
expect(manager.getByThreadId("thread-1")).toBeUndefined();
|
||||
expect(hoisted.restGet).not.toHaveBeenCalled();
|
||||
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
|
||||
expect(hoisted.sendMessageDiscord).toHaveBeenCalledTimes(1);
|
||||
const farewell = hoisted.sendMessageDiscord.mock.calls[0]?.[1] as string | undefined;
|
||||
expect(farewell).toContain("Session ended automatically after 1m");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps binding when thread sweep probe fails transiently", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: true,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
await manager.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
webhookId: "wh-1",
|
||||
webhookToken: "tok-1",
|
||||
});
|
||||
|
||||
hoisted.restGet.mockRejectedValueOnce(new Error("ECONNRESET"));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
|
||||
expect(manager.getByThreadId("thread-1")).toBeDefined();
|
||||
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("unbinds when thread sweep probe reports unknown channel", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: true,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
await manager.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
webhookId: "wh-1",
|
||||
webhookToken: "tok-1",
|
||||
});
|
||||
|
||||
hoisted.restGet.mockRejectedValueOnce({
|
||||
status: 404,
|
||||
rawError: { code: 10003, message: "Unknown Channel" },
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(120_000);
|
||||
|
||||
expect(manager.getByThreadId("thread-1")).toBeUndefined();
|
||||
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("updates ttl by target session key", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
vi.setSystemTime(new Date("2026-02-20T23:00:00.000Z"));
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
await manager.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
webhookId: "wh-1",
|
||||
webhookToken: "tok-1",
|
||||
});
|
||||
vi.setSystemTime(new Date("2026-02-20T23:15:00.000Z"));
|
||||
|
||||
const updated = setThreadBindingTtlBySessionKey({
|
||||
accountId: "default",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
ttlMs: 2 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
expect(updated).toHaveLength(1);
|
||||
expect(updated[0]?.boundAt).toBe(new Date("2026-02-20T23:15:00.000Z").getTime());
|
||||
expect(updated[0]?.expiresAt).toBe(new Date("2026-02-21T01:15:00.000Z").getTime());
|
||||
expect(manager.getByThreadId("thread-1")?.expiresAt).toBe(
|
||||
new Date("2026-02-21T01:15:00.000Z").getTime(),
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps binding when ttl is disabled per session key", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: true,
|
||||
sessionTtlMs: 60_000,
|
||||
});
|
||||
|
||||
await manager.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
webhookId: "wh-1",
|
||||
webhookToken: "tok-1",
|
||||
});
|
||||
|
||||
const updated = setThreadBindingTtlBySessionKey({
|
||||
accountId: "default",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
ttlMs: 0,
|
||||
});
|
||||
expect(updated).toHaveLength(1);
|
||||
expect(updated[0]?.expiresAt).toBe(0);
|
||||
hoisted.sendWebhookMessageDiscord.mockClear();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(240_000);
|
||||
|
||||
expect(manager.getByThreadId("thread-1")).toBeDefined();
|
||||
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses webhook credentials after unbind when rebinding in the same channel", async () => {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
const first = await manager.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child-1",
|
||||
agentId: "main",
|
||||
});
|
||||
expect(first).not.toBeNull();
|
||||
expect(hoisted.restPost).toHaveBeenCalledTimes(1);
|
||||
|
||||
manager.unbindThread({
|
||||
threadId: "thread-1",
|
||||
sendFarewell: false,
|
||||
});
|
||||
|
||||
const second = await manager.bindTarget({
|
||||
threadId: "thread-2",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child-2",
|
||||
agentId: "main",
|
||||
});
|
||||
expect(second).not.toBeNull();
|
||||
expect(second?.webhookId).toBe("wh-created");
|
||||
expect(second?.webhookToken).toBe("tok-created");
|
||||
expect(hoisted.restPost).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("creates a new thread when spawning from an already bound thread", async () => {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
await manager.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:parent",
|
||||
agentId: "main",
|
||||
});
|
||||
hoisted.createThreadDiscord.mockClear();
|
||||
hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-2" });
|
||||
|
||||
const childBinding = await autoBindSpawnedDiscordSubagent({
|
||||
accountId: "default",
|
||||
channel: "discord",
|
||||
to: "channel:thread-1",
|
||||
threadId: "thread-1",
|
||||
childSessionKey: "agent:main:subagent:child-2",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
expect(childBinding).not.toBeNull();
|
||||
expect(hoisted.createThreadDiscord).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.createThreadDiscord).toHaveBeenCalledWith(
|
||||
"parent-1",
|
||||
expect.objectContaining({ autoArchiveMinutes: 60 }),
|
||||
expect.objectContaining({ accountId: "default" }),
|
||||
);
|
||||
expect(manager.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:parent");
|
||||
expect(manager.getByThreadId("thread-created-2")?.targetSessionKey).toBe(
|
||||
"agent:main:subagent:child-2",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves parent channel when thread target is passed via to without threadId", async () => {
|
||||
createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
hoisted.restGet.mockClear();
|
||||
hoisted.restGet.mockResolvedValueOnce({
|
||||
id: "thread-lookup",
|
||||
type: 11,
|
||||
parent_id: "parent-1",
|
||||
});
|
||||
hoisted.createThreadDiscord.mockClear();
|
||||
hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-lookup" });
|
||||
|
||||
const childBinding = await autoBindSpawnedDiscordSubagent({
|
||||
accountId: "default",
|
||||
channel: "discord",
|
||||
to: "channel:thread-lookup",
|
||||
childSessionKey: "agent:main:subagent:child-lookup",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
expect(childBinding).not.toBeNull();
|
||||
expect(childBinding?.channelId).toBe("parent-1");
|
||||
expect(hoisted.restGet).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.createThreadDiscord).toHaveBeenCalledWith(
|
||||
"parent-1",
|
||||
expect.objectContaining({ autoArchiveMinutes: 60 }),
|
||||
expect.objectContaining({ accountId: "default" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes manager token when resolving parent channels for auto-bind", async () => {
|
||||
createThreadBindingManager({
|
||||
accountId: "runtime",
|
||||
token: "runtime-token",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
hoisted.createDiscordRestClient.mockClear();
|
||||
hoisted.restGet.mockClear();
|
||||
hoisted.restGet.mockResolvedValueOnce({
|
||||
id: "thread-runtime",
|
||||
type: 11,
|
||||
parent_id: "parent-runtime",
|
||||
});
|
||||
hoisted.createThreadDiscord.mockClear();
|
||||
hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-runtime" });
|
||||
|
||||
const childBinding = await autoBindSpawnedDiscordSubagent({
|
||||
accountId: "runtime",
|
||||
channel: "discord",
|
||||
to: "channel:thread-runtime",
|
||||
childSessionKey: "agent:main:subagent:child-runtime",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
expect(childBinding).not.toBeNull();
|
||||
const firstClientArgs = hoisted.createDiscordRestClient.mock.calls[0]?.[0] as
|
||||
| { accountId?: string; token?: string }
|
||||
| undefined;
|
||||
expect(firstClientArgs).toMatchObject({
|
||||
accountId: "runtime",
|
||||
token: "runtime-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes manager token when an existing manager is reused", async () => {
|
||||
createThreadBindingManager({
|
||||
accountId: "runtime",
|
||||
token: "token-old",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "runtime",
|
||||
token: "token-new",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
hoisted.createThreadDiscord.mockClear();
|
||||
hoisted.createThreadDiscord.mockResolvedValueOnce({ id: "thread-created-token-refresh" });
|
||||
hoisted.createDiscordRestClient.mockClear();
|
||||
|
||||
const bound = await manager.bindTarget({
|
||||
createThread: true,
|
||||
channelId: "parent-runtime",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:token-refresh",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
expect(bound).not.toBeNull();
|
||||
expect(hoisted.createThreadDiscord).toHaveBeenCalledWith(
|
||||
"parent-runtime",
|
||||
expect.objectContaining({ autoArchiveMinutes: 60 }),
|
||||
expect.objectContaining({ accountId: "runtime", token: "token-new" }),
|
||||
);
|
||||
const usedTokenNew = hoisted.createDiscordRestClient.mock.calls.some(
|
||||
(call) => (call?.[0] as { token?: string } | undefined)?.token === "token-new",
|
||||
);
|
||||
expect(usedTokenNew).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps overlapping thread ids isolated per account", async () => {
|
||||
const a = createThreadBindingManager({
|
||||
accountId: "a",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
const b = createThreadBindingManager({
|
||||
accountId: "b",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
const aBinding = await a.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:a",
|
||||
agentId: "main",
|
||||
});
|
||||
const bBinding = await b.bindTarget({
|
||||
threadId: "thread-1",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:b",
|
||||
agentId: "main",
|
||||
});
|
||||
|
||||
expect(aBinding?.accountId).toBe("a");
|
||||
expect(bBinding?.accountId).toBe("b");
|
||||
expect(a.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:a");
|
||||
expect(b.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:b");
|
||||
|
||||
const removedA = a.unbindBySessionKey({
|
||||
targetSessionKey: "agent:main:subagent:a",
|
||||
sendFarewell: false,
|
||||
});
|
||||
expect(removedA).toHaveLength(1);
|
||||
expect(a.getByThreadId("thread-1")).toBeUndefined();
|
||||
expect(b.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:b");
|
||||
});
|
||||
|
||||
it("persists unbinds even when no manager is active", () => {
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-thread-bindings-"));
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
try {
|
||||
__testing.resetThreadBindingsForTests();
|
||||
const bindingsPath = __testing.resolveThreadBindingsPath();
|
||||
fs.mkdirSync(path.dirname(bindingsPath), { recursive: true });
|
||||
const now = Date.now();
|
||||
fs.writeFileSync(
|
||||
bindingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
bindings: {
|
||||
"thread-1": {
|
||||
accountId: "default",
|
||||
channelId: "parent-1",
|
||||
threadId: "thread-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
boundBy: "system",
|
||||
boundAt: now,
|
||||
expiresAt: now + 60_000,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const removed = unbindThreadBindingsBySessionKey({
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
});
|
||||
expect(removed).toHaveLength(1);
|
||||
|
||||
const payload = JSON.parse(fs.readFileSync(bindingsPath, "utf-8")) as {
|
||||
bindings?: Record<string, unknown>;
|
||||
};
|
||||
expect(Object.keys(payload.bindings ?? {})).toEqual([]);
|
||||
} finally {
|
||||
__testing.resetThreadBindingsForTests();
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
69
src/discord/monitor/thread-bindings.types.ts
Normal file
69
src/discord/monitor/thread-bindings.types.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export type ThreadBindingTargetKind = "subagent" | "acp";
|
||||
|
||||
export type ThreadBindingRecord = {
|
||||
accountId: string;
|
||||
channelId: string;
|
||||
threadId: string;
|
||||
targetKind: ThreadBindingTargetKind;
|
||||
targetSessionKey: string;
|
||||
agentId: string;
|
||||
label?: string;
|
||||
webhookId?: string;
|
||||
webhookToken?: string;
|
||||
boundBy: string;
|
||||
boundAt: number;
|
||||
expiresAt?: number;
|
||||
};
|
||||
|
||||
export type PersistedThreadBindingRecord = ThreadBindingRecord & {
|
||||
sessionKey?: string;
|
||||
};
|
||||
|
||||
export type PersistedThreadBindingsPayload = {
|
||||
version: 1;
|
||||
bindings: Record<string, PersistedThreadBindingRecord>;
|
||||
};
|
||||
|
||||
export type ThreadBindingManager = {
|
||||
accountId: string;
|
||||
getSessionTtlMs: () => number;
|
||||
getByThreadId: (threadId: string) => ThreadBindingRecord | undefined;
|
||||
getBySessionKey: (targetSessionKey: string) => ThreadBindingRecord | undefined;
|
||||
listBySessionKey: (targetSessionKey: string) => ThreadBindingRecord[];
|
||||
listBindings: () => ThreadBindingRecord[];
|
||||
bindTarget: (params: {
|
||||
threadId?: string | number;
|
||||
channelId?: string;
|
||||
createThread?: boolean;
|
||||
threadName?: string;
|
||||
targetKind: ThreadBindingTargetKind;
|
||||
targetSessionKey: string;
|
||||
agentId?: string;
|
||||
label?: string;
|
||||
boundBy?: string;
|
||||
introText?: string;
|
||||
webhookId?: string;
|
||||
webhookToken?: string;
|
||||
}) => Promise<ThreadBindingRecord | null>;
|
||||
unbindThread: (params: {
|
||||
threadId: string;
|
||||
reason?: string;
|
||||
sendFarewell?: boolean;
|
||||
farewellText?: string;
|
||||
}) => ThreadBindingRecord | null;
|
||||
unbindBySessionKey: (params: {
|
||||
targetSessionKey: string;
|
||||
targetKind?: ThreadBindingTargetKind;
|
||||
reason?: string;
|
||||
sendFarewell?: boolean;
|
||||
farewellText?: string;
|
||||
}) => ThreadBindingRecord[];
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
export const THREAD_BINDINGS_VERSION = 1 as const;
|
||||
export const THREAD_BINDINGS_SWEEP_INTERVAL_MS = 120_000;
|
||||
export const DEFAULT_THREAD_BINDING_TTL_MS = 24 * 60 * 60 * 1000; // 24h
|
||||
export const DEFAULT_FAREWELL_TEXT = "Session ended. Messages here will no longer be routed.";
|
||||
export const DISCORD_UNKNOWN_CHANNEL_ERROR_CODE = 10_003;
|
||||
export const RECENT_UNBOUND_WEBHOOK_ECHO_TTL_MS = 30_000;
|
||||
@@ -10,7 +10,7 @@ vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: (...args: Parameters<typeof actual.loadConfig>) => loadConfigMock(...args),
|
||||
loadConfig: (..._args: unknown[]) => loadConfigMock(),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -295,6 +295,100 @@ export async function sendMessageDiscord(
|
||||
return toDiscordSendResult(result, channelId);
|
||||
}
|
||||
|
||||
type DiscordWebhookSendOpts = {
|
||||
webhookId: string;
|
||||
webhookToken: string;
|
||||
accountId?: string;
|
||||
threadId?: string | number;
|
||||
replyTo?: string;
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
wait?: boolean;
|
||||
};
|
||||
|
||||
function resolveWebhookExecutionUrl(params: {
|
||||
webhookId: string;
|
||||
webhookToken: string;
|
||||
threadId?: string | number;
|
||||
wait?: boolean;
|
||||
}) {
|
||||
const baseUrl = new URL(
|
||||
`https://discord.com/api/v10/webhooks/${encodeURIComponent(params.webhookId)}/${encodeURIComponent(params.webhookToken)}`,
|
||||
);
|
||||
baseUrl.searchParams.set("wait", params.wait === false ? "false" : "true");
|
||||
if (params.threadId !== undefined && params.threadId !== null && params.threadId !== "") {
|
||||
baseUrl.searchParams.set("thread_id", String(params.threadId));
|
||||
}
|
||||
return baseUrl.toString();
|
||||
}
|
||||
|
||||
export async function sendWebhookMessageDiscord(
|
||||
text: string,
|
||||
opts: DiscordWebhookSendOpts,
|
||||
): Promise<DiscordSendResult> {
|
||||
const webhookId = opts.webhookId.trim();
|
||||
const webhookToken = opts.webhookToken.trim();
|
||||
if (!webhookId || !webhookToken) {
|
||||
throw new Error("Discord webhook id/token are required");
|
||||
}
|
||||
|
||||
const replyTo = typeof opts.replyTo === "string" ? opts.replyTo.trim() : "";
|
||||
const messageReference = replyTo ? { message_id: replyTo, fail_if_not_exists: false } : undefined;
|
||||
|
||||
const response = await fetch(
|
||||
resolveWebhookExecutionUrl({
|
||||
webhookId,
|
||||
webhookToken,
|
||||
threadId: opts.threadId,
|
||||
wait: opts.wait,
|
||||
}),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: text,
|
||||
username: opts.username?.trim() || undefined,
|
||||
avatar_url: opts.avatarUrl?.trim() || undefined,
|
||||
...(messageReference ? { message_reference: messageReference } : {}),
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const raw = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Discord webhook send failed (${response.status}${raw ? `: ${raw.slice(0, 200)}` : ""})`,
|
||||
);
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => ({}))) as {
|
||||
id?: string;
|
||||
channel_id?: string;
|
||||
};
|
||||
try {
|
||||
const account = resolveDiscordAccount({
|
||||
cfg: loadConfig(),
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
recordChannelActivity({
|
||||
channel: "discord",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort telemetry only.
|
||||
}
|
||||
return {
|
||||
messageId: payload.id ? String(payload.id) : "unknown",
|
||||
channelId: payload.channel_id
|
||||
? String(payload.channel_id)
|
||||
: opts.threadId
|
||||
? String(opts.threadId)
|
||||
: "",
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendStickerDiscord(
|
||||
to: string,
|
||||
stickerIds: string[],
|
||||
|
||||
@@ -41,6 +41,7 @@ export {
|
||||
sendMessageDiscord,
|
||||
sendPollDiscord,
|
||||
sendStickerDiscord,
|
||||
sendWebhookMessageDiscord,
|
||||
sendVoiceMessageDiscord,
|
||||
} from "./send.outbound.js";
|
||||
export { sendDiscordComponentMessage } from "./send.components.js";
|
||||
|
||||
50
src/discord/send.webhook-activity.test.ts
Normal file
50
src/discord/send.webhook-activity.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sendWebhookMessageDiscord } from "./send.js";
|
||||
|
||||
const recordChannelActivityMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../infra/channel-activity.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../infra/channel-activity.js")>();
|
||||
return {
|
||||
...actual,
|
||||
recordChannelActivity: (...args: unknown[]) => recordChannelActivityMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe("sendWebhookMessageDiscord activity", () => {
|
||||
beforeEach(() => {
|
||||
recordChannelActivityMock.mockReset();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(JSON.stringify({ id: "msg-1", channel_id: "thread-1" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("records outbound channel activity for webhook sends", async () => {
|
||||
const result = await sendWebhookMessageDiscord("hello world", {
|
||||
webhookId: "wh-1",
|
||||
webhookToken: "tok-1",
|
||||
accountId: "runtime",
|
||||
threadId: "thread-1",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
messageId: "msg-1",
|
||||
channelId: "thread-1",
|
||||
});
|
||||
expect(recordChannelActivityMock).toHaveBeenCalledWith({
|
||||
channel: "discord",
|
||||
accountId: "runtime",
|
||||
direction: "outbound",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user