From 7e1acf2f1ee5a653d35f32504f225a7b8399f2a6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 19:09:13 +0100 Subject: [PATCH] refactor(discord): split threading and voice segment helpers --- .../src/monitor/message-handler.process.ts | 5 +- .../src/monitor/threading.auto-thread.ts | 287 +++++++ .../discord/src/monitor/threading.cache.ts | 45 ++ .../discord/src/monitor/threading.starter.ts | 287 +++++++ extensions/discord/src/monitor/threading.ts | 726 +----------------- .../discord/src/monitor/threading.types.ts | 102 +++ extensions/discord/src/voice/manager.ts | 133 +--- extensions/discord/src/voice/segment.ts | 151 ++++ 8 files changed, 906 insertions(+), 830 deletions(-) create mode 100644 extensions/discord/src/monitor/threading.auto-thread.ts create mode 100644 extensions/discord/src/monitor/threading.cache.ts create mode 100644 extensions/discord/src/monitor/threading.starter.ts create mode 100644 extensions/discord/src/monitor/threading.types.ts create mode 100644 extensions/discord/src/voice/segment.ts diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts index 60d25072bc1..e9292906b8a 100644 --- a/extensions/discord/src/monitor/message-handler.process.ts +++ b/extensions/discord/src/monitor/message-handler.process.ts @@ -21,7 +21,6 @@ import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-pay import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env"; import { resolveDiscordMaxLinesPerMessage } from "../accounts.js"; import { createDiscordRestClient } from "../client.js"; -import type { RequestClient } from "../internal/discord.js"; import { removeReactionDiscord } from "../send.js"; import { editMessageDiscord } from "../send.messages.js"; import { @@ -179,12 +178,12 @@ export async function processDiscordMessage( cfg, token, accountId, - }).rest as unknown as RequestClient; + }).rest; const deliveryRest = createDiscordRestClient({ cfg, token, accountId, - }).rest as unknown as RequestClient; + }).rest; // Discord outbound helpers expect the internal REST client shape explicitly. const ackReactionContext = createDiscordAckReactionContext({ rest: feedbackRest, diff --git a/extensions/discord/src/monitor/threading.auto-thread.ts b/extensions/discord/src/monitor/threading.auto-thread.ts new file mode 100644 index 00000000000..a709d7d6406 --- /dev/null +++ b/extensions/discord/src/monitor/threading.auto-thread.ts @@ -0,0 +1,287 @@ +import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-types"; +import { resolveChannelModelOverride } from "openclaw/plugin-sdk/model-session-runtime"; +import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing"; +import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; +import { + normalizeOptionalString, + normalizeOptionalStringifiedId, +} from "openclaw/plugin-sdk/text-runtime"; +import { + ChannelType, + createThread, + editChannel, + getChannelMessage, + type Client, +} from "../internal/discord.js"; +import { resolveDiscordMessageChannelId } from "./message-utils.js"; +import { generateThreadTitle } from "./thread-title.js"; +import { resolveDiscordReplyDeliveryPlan, sanitizeDiscordThreadName } from "./threading.starter.js"; +import type { + DiscordAutoThreadContext, + DiscordAutoThreadReplyPlan, + DiscordMessageEvent, + MaybeCreateDiscordAutoThreadParams, +} from "./threading.types.js"; + +function resolveTrimmedDiscordMessageChannelId(params: { + message: DiscordMessageEvent["message"]; + messageChannelId?: string; +}) { + return ( + params.messageChannelId || + resolveDiscordMessageChannelId({ + message: params.message, + }) + ).trim(); +} + +export function resolveDiscordAutoThreadContext(params: { + agentId: string; + channel: string; + messageChannelId: string; + createdThreadId?: string | null; + parentInheritanceEnabled?: boolean; +}): DiscordAutoThreadContext | null { + const createdThreadId = normalizeOptionalStringifiedId(params.createdThreadId) ?? ""; + if (!createdThreadId) { + return null; + } + const messageChannelId = normalizeOptionalString(params.messageChannelId) ?? ""; + if (!messageChannelId) { + return null; + } + + const threadSessionKey = buildAgentSessionKey({ + agentId: params.agentId, + channel: params.channel, + peer: { kind: "channel", id: createdThreadId }, + }); + const parentSessionKey = buildAgentSessionKey({ + agentId: params.agentId, + channel: params.channel, + peer: { kind: "channel", id: messageChannelId }, + }); + + return { + createdThreadId, + From: `${params.channel}:channel:${createdThreadId}`, + To: `channel:${createdThreadId}`, + OriginatingTo: `channel:${createdThreadId}`, + SessionKey: threadSessionKey, + ModelParentSessionKey: parentSessionKey, + ...(params.parentInheritanceEnabled === true ? { ParentSessionKey: parentSessionKey } : {}), + }; +} + +export async function resolveDiscordAutoThreadReplyPlan( + params: MaybeCreateDiscordAutoThreadParams & { + replyToMode: ReplyToMode; + agentId: string; + channel: string; + cfg: OpenClawConfig; + threadParentInheritanceEnabled?: boolean; + }, +): Promise { + const messageChannelId = resolveTrimmedDiscordMessageChannelId(params); + const targetChannelId = params.threadChannel?.id ?? (messageChannelId || "unknown"); + const originalReplyTarget = `channel:${targetChannelId}`; + const createdThreadId = await maybeCreateDiscordAutoThread({ + client: params.client, + message: params.message, + messageChannelId: messageChannelId || undefined, + channel: params.channel, + isGuildMessage: params.isGuildMessage, + channelConfig: params.channelConfig, + threadChannel: params.threadChannel, + channelType: params.channelType, + channelName: params.channelName, + channelDescription: params.channelDescription, + baseText: params.baseText, + combinedBody: params.combinedBody, + cfg: params.cfg, + agentId: params.agentId, + }); + const deliveryPlan = resolveDiscordReplyDeliveryPlan({ + replyTarget: originalReplyTarget, + replyToMode: params.replyToMode, + messageId: params.message.id, + threadChannel: params.threadChannel, + createdThreadId, + }); + const autoThreadContext = params.isGuildMessage + ? resolveDiscordAutoThreadContext({ + agentId: params.agentId, + channel: params.channel, + messageChannelId, + createdThreadId, + parentInheritanceEnabled: params.threadParentInheritanceEnabled, + }) + : null; + return { ...deliveryPlan, createdThreadId, autoThreadContext }; +} + +export async function maybeCreateDiscordAutoThread( + params: MaybeCreateDiscordAutoThreadParams, +): Promise { + if (!params.isGuildMessage) { + return undefined; + } + if (!params.channelConfig?.autoThread) { + return undefined; + } + if (params.threadChannel) { + return undefined; + } + if ( + params.channelType === ChannelType.GuildForum || + params.channelType === ChannelType.GuildMedia || + params.channelType === ChannelType.GuildVoice || + params.channelType === ChannelType.GuildStageVoice + ) { + return undefined; + } + + const messageChannelId = resolveTrimmedDiscordMessageChannelId(params); + if (!messageChannelId) { + return undefined; + } + try { + const rawThreadSource = params.baseText || params.combinedBody || "Thread"; + const threadName = sanitizeDiscordThreadName(rawThreadSource, params.message.id); + const archiveDuration = params.channelConfig?.autoArchiveDuration + ? Number(params.channelConfig.autoArchiveDuration) + : 60; + + const created = await createThread<{ id?: string }>( + params.client.rest, + messageChannelId, + { + body: { + name: threadName, + auto_archive_duration: archiveDuration, + }, + }, + params.message.id, + ); + const createdId = created?.id || ""; + if ( + createdId && + params.channelConfig?.autoThreadName === "generated" && + params.cfg && + params.agentId + ) { + const modelRef = resolveDiscordThreadTitleModelRef({ + cfg: params.cfg, + channel: params.channel, + agentId: params.agentId, + threadId: createdId, + messageChannelId, + channelName: params.channelName, + }); + void maybeRenameDiscordAutoThread({ + client: params.client, + threadId: createdId, + currentName: threadName, + fallbackId: params.message.id, + sourceText: rawThreadSource, + modelRef, + channelName: params.channelName, + channelDescription: params.channelDescription, + cfg: params.cfg, + agentId: params.agentId, + }); + } + return createdId || undefined; + } catch (err) { + logVerbose( + `discord: autoThread creation failed for ${messageChannelId}/${params.message.id}: ${String(err)}`, + ); + try { + const msg = (await getChannelMessage( + params.client.rest, + messageChannelId, + params.message.id, + )) as { + thread?: { id?: string }; + }; + const existingThreadId = msg?.thread?.id || ""; + if (existingThreadId) { + logVerbose( + `discord: autoThread reusing existing thread ${existingThreadId} on ${messageChannelId}/${params.message.id}`, + ); + return existingThreadId; + } + } catch { + // If the refetch also fails, fall through to return undefined. + } + return undefined; + } +} + +function resolveDiscordThreadTitleModelRef(params: { + cfg: OpenClawConfig; + channel?: string; + agentId: string; + threadId: string; + messageChannelId: string; + channelName?: string; +}): string | undefined { + const channel = params.channel?.trim(); + if (!channel) { + return undefined; + } + const parentSessionKey = buildAgentSessionKey({ + agentId: params.agentId, + channel, + peer: { kind: "channel", id: params.messageChannelId }, + }); + const channelLabel = params.channelName?.trim(); + const groupChannel = channelLabel ? `#${channelLabel}` : undefined; + const channelOverride = resolveChannelModelOverride({ + cfg: params.cfg, + channel, + groupId: params.threadId, + groupChatType: "channel", + groupChannel, + groupSubject: groupChannel, + parentSessionKey, + }); + return channelOverride?.model; +} + +async function maybeRenameDiscordAutoThread(params: { + client: Client; + threadId: string; + currentName: string; + fallbackId: string; + sourceText: string; + modelRef?: string; + channelName?: string; + channelDescription?: string; + cfg: OpenClawConfig; + agentId: string; +}): Promise { + try { + const fallbackName = sanitizeDiscordThreadName("", params.fallbackId); + const generated = await generateThreadTitle({ + cfg: params.cfg, + agentId: params.agentId, + messageText: params.sourceText, + modelRef: params.modelRef, + channelName: params.channelName, + channelDescription: params.channelDescription, + }); + if (!generated) { + return; + } + const nextName = sanitizeDiscordThreadName(generated, params.fallbackId); + if (!nextName || nextName === params.currentName || nextName === fallbackName) { + return; + } + await editChannel(params.client.rest, params.threadId, { + body: { name: nextName }, + }); + } catch (err) { + logVerbose(`discord: autoThread rename failed for ${params.threadId}: ${String(err)}`); + } +} diff --git a/extensions/discord/src/monitor/threading.cache.ts b/extensions/discord/src/monitor/threading.cache.ts new file mode 100644 index 00000000000..ab8942f1a45 --- /dev/null +++ b/extensions/discord/src/monitor/threading.cache.ts @@ -0,0 +1,45 @@ +import type { DiscordThreadStarter } from "./threading.types.js"; + +type DiscordThreadStarterCacheEntry = { + value: DiscordThreadStarter; + updatedAt: number; +}; + +const DISCORD_THREAD_STARTER_CACHE_TTL_MS = 5 * 60 * 1000; +const DISCORD_THREAD_STARTER_CACHE_MAX = 500; + +const DISCORD_THREAD_STARTER_CACHE = new Map(); + +export function __resetDiscordThreadStarterCacheForTest() { + DISCORD_THREAD_STARTER_CACHE.clear(); +} + +export function getCachedThreadStarter(key: string, now: number): DiscordThreadStarter | undefined { + const entry = DISCORD_THREAD_STARTER_CACHE.get(key); + if (!entry) { + return undefined; + } + if (now - entry.updatedAt > DISCORD_THREAD_STARTER_CACHE_TTL_MS) { + DISCORD_THREAD_STARTER_CACHE.delete(key); + return undefined; + } + DISCORD_THREAD_STARTER_CACHE.delete(key); + DISCORD_THREAD_STARTER_CACHE.set(key, { ...entry, updatedAt: now }); + return entry.value; +} + +export function setCachedThreadStarter( + key: string, + value: DiscordThreadStarter, + now: number, +): void { + DISCORD_THREAD_STARTER_CACHE.delete(key); + DISCORD_THREAD_STARTER_CACHE.set(key, { value, updatedAt: now }); + while (DISCORD_THREAD_STARTER_CACHE.size > DISCORD_THREAD_STARTER_CACHE_MAX) { + const iter = DISCORD_THREAD_STARTER_CACHE.keys().next(); + if (iter.done) { + break; + } + DISCORD_THREAD_STARTER_CACHE.delete(iter.value); + } +} diff --git a/extensions/discord/src/monitor/threading.starter.ts b/extensions/discord/src/monitor/threading.starter.ts new file mode 100644 index 00000000000..2fc113efda2 --- /dev/null +++ b/extensions/discord/src/monitor/threading.starter.ts @@ -0,0 +1,287 @@ +import type { ReplyToMode } from "openclaw/plugin-sdk/config-types"; +import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-reference"; +import { normalizeOptionalString, truncateUtf16Safe } from "openclaw/plugin-sdk/text-runtime"; +import { ChannelType, getChannelMessage, type Client } from "../internal/discord.js"; +import { + resolveDiscordChannelIdSafe, + resolveDiscordChannelNameSafe, + resolveDiscordChannelParentIdSafe, + resolveDiscordChannelParentSafe, +} from "./channel-access.js"; +import { + resolveDiscordChannelInfo, + resolveDiscordEmbedText, + resolveDiscordForwardedMessagesTextFromSnapshots, + resolveDiscordMessageChannelId, + type DiscordChannelInfo, + type DiscordChannelInfoClient, +} from "./message-utils.js"; +import { getCachedThreadStarter, setCachedThreadStarter } from "./threading.cache.js"; +import type { + DiscordMessageEvent, + DiscordReplyDeliveryPlan, + DiscordThreadChannel, + DiscordThreadParentInfo, + DiscordThreadStarter, + DiscordThreadStarterRestAuthor, + DiscordThreadStarterRestMember, + DiscordThreadStarterRestMessage, +} from "./threading.types.js"; + +function isDiscordThreadType(type: ChannelType | undefined): boolean { + return ( + type === ChannelType.PublicThread || + type === ChannelType.PrivateThread || + type === ChannelType.AnnouncementThread + ); +} + +function isDiscordForumParentType(parentType: ChannelType | undefined): boolean { + return parentType === ChannelType.GuildForum || parentType === ChannelType.GuildMedia; +} + +export function resolveDiscordThreadChannel(params: { + isGuildMessage: boolean; + message: DiscordMessageEvent["message"]; + channelInfo: DiscordChannelInfo | null; + messageChannelId?: string; +}): DiscordThreadChannel | null { + if (!params.isGuildMessage) { + return null; + } + const { message, channelInfo } = params; + const channel = "channel" in message ? (message as { channel?: unknown }).channel : undefined; + const isThreadChannel = + channel && + typeof channel === "object" && + "isThread" in channel && + typeof (channel as { isThread?: unknown }).isThread === "function" && + (channel as { isThread: () => boolean }).isThread(); + if (isThreadChannel) { + return channel as unknown as DiscordThreadChannel; + } + if (!isDiscordThreadType(channelInfo?.type)) { + return null; + } + const messageChannelId = + params.messageChannelId || + resolveDiscordMessageChannelId({ + message, + }); + if (!messageChannelId) { + return null; + } + return { + id: messageChannelId, + name: channelInfo?.name ?? undefined, + parentId: channelInfo?.parentId ?? undefined, + parent: undefined, + ownerId: channelInfo?.ownerId ?? undefined, + }; +} + +export async function resolveDiscordThreadParentInfo(params: { + client: DiscordChannelInfoClient; + threadChannel: DiscordThreadChannel; + channelInfo: DiscordChannelInfo | null; +}): Promise { + const { threadChannel, channelInfo, client } = params; + const parent = resolveDiscordChannelParentSafe(threadChannel); + let parentId = + resolveDiscordChannelParentIdSafe(threadChannel) ?? + resolveDiscordChannelIdSafe(parent) ?? + channelInfo?.parentId ?? + undefined; + if (!parentId && threadChannel.id) { + const threadInfo = await resolveDiscordChannelInfo(client, threadChannel.id); + parentId = threadInfo?.parentId ?? undefined; + } + if (!parentId) { + return {}; + } + let parentName = resolveDiscordChannelNameSafe(parent); + const parentInfo = await resolveDiscordChannelInfo(client, parentId); + parentName = parentName ?? parentInfo?.name; + const parentType = parentInfo?.type; + return { id: parentId, name: parentName, type: parentType }; +} + +export async function resolveDiscordThreadStarter(params: { + channel: DiscordThreadChannel; + client: Client; + parentId?: string; + parentType?: ChannelType; + resolveTimestampMs: (value?: string | null) => number | undefined; +}): Promise { + const cacheKey = params.channel.id; + const now = Date.now(); + const cached = getCachedThreadStarter(cacheKey, now); + if (cached) { + return cached; + } + try { + const messageChannelId = resolveDiscordThreadStarterMessageChannelId(params); + if (!messageChannelId) { + return null; + } + const starter = await fetchDiscordThreadStarterMessage({ + client: params.client, + messageChannelId, + threadId: params.channel.id, + }); + if (!starter) { + return null; + } + const payload = buildDiscordThreadStarterPayload({ + starter, + resolveTimestampMs: params.resolveTimestampMs, + }); + if (!payload) { + return null; + } + setCachedThreadStarter(cacheKey, payload, Date.now()); + return payload; + } catch { + return null; + } +} + +function resolveDiscordThreadStarterMessageChannelId(params: { + channel: DiscordThreadChannel; + parentId?: string; + parentType?: ChannelType; +}): string | undefined { + return isDiscordForumParentType(params.parentType) ? params.channel.id : params.parentId; +} + +async function fetchDiscordThreadStarterMessage(params: { + client: Client; + messageChannelId: string; + threadId: string; +}): Promise { + const starter = await getChannelMessage( + params.client.rest, + params.messageChannelId, + params.threadId, + ); + return starter ? (starter as DiscordThreadStarterRestMessage) : null; +} + +function buildDiscordThreadStarterPayload(params: { + starter: DiscordThreadStarterRestMessage; + resolveTimestampMs: (value?: string | null) => number | undefined; +}): DiscordThreadStarter | null { + const text = resolveDiscordThreadStarterText(params.starter); + if (!text) { + return null; + } + return { + text, + ...resolveDiscordThreadStarterIdentity(params.starter), + timestamp: params.resolveTimestampMs(params.starter.timestamp) ?? undefined, + }; +} + +function resolveDiscordThreadStarterText(starter: DiscordThreadStarterRestMessage): string { + const content = normalizeOptionalString(starter.content) ?? ""; + const embedText = resolveDiscordEmbedText(starter.embeds?.[0]); + const forwardedText = resolveDiscordForwardedMessagesTextFromSnapshots(starter.message_snapshots); + return content || embedText || forwardedText; +} + +function resolveDiscordThreadStarterIdentity( + starter: DiscordThreadStarterRestMessage, +): Omit { + const author = resolveDiscordThreadStarterAuthor(starter); + return { + author, + authorId: starter.author?.id ?? undefined, + authorName: starter.author?.username ?? undefined, + authorTag: resolveDiscordThreadStarterAuthorTag(starter.author), + memberRoleIds: resolveDiscordThreadStarterRoleIds(starter.member), + }; +} + +function resolveDiscordThreadStarterAuthor(starter: DiscordThreadStarterRestMessage): string { + return ( + starter.member?.nick ?? + starter.member?.displayName ?? + resolveDiscordThreadStarterAuthorTag(starter.author) ?? + starter.author?.username ?? + starter.author?.id ?? + "Unknown" + ); +} + +function resolveDiscordThreadStarterAuthorTag( + author: DiscordThreadStarterRestAuthor | null | undefined, +): string | undefined { + if (!author?.username || !author.discriminator) { + return undefined; + } + if (author.discriminator !== "0") { + return `${author.username}#${author.discriminator}`; + } + return author.username; +} + +function resolveDiscordThreadStarterRoleIds( + member: DiscordThreadStarterRestMember | null | undefined, +): string[] | undefined { + return Array.isArray(member?.roles) ? member.roles : undefined; +} + +export function resolveDiscordReplyTarget(opts: { + replyToMode: ReplyToMode; + replyToId?: string; + hasReplied: boolean; +}): string | undefined { + if (opts.replyToMode === "off") { + return undefined; + } + const replyToId = normalizeOptionalString(opts.replyToId); + if (!replyToId) { + return undefined; + } + if (opts.replyToMode === "all") { + return replyToId; + } + return opts.hasReplied ? undefined : replyToId; +} + +export function sanitizeDiscordThreadName(rawName: string, fallbackId: string): string { + const cleanedName = rawName + .replace(/<@!?\d+>/g, "") + .replace(/<@&\d+>/g, "") + .replace(/<#\d+>/g, "") + .replace(/\s+/g, " ") + .trim(); + const baseSource = cleanedName || `Thread ${fallbackId}`; + const base = truncateUtf16Safe(baseSource, 80); + return truncateUtf16Safe(base, 100) || `Thread ${fallbackId}`; +} + +export function resolveDiscordReplyDeliveryPlan(params: { + replyTarget: string; + replyToMode: ReplyToMode; + messageId: string; + threadChannel?: DiscordThreadChannel | null; + createdThreadId?: string | null; +}): DiscordReplyDeliveryPlan { + const originalReplyTarget = params.replyTarget; + let deliverTarget = originalReplyTarget; + let replyTarget = originalReplyTarget; + + if (params.createdThreadId) { + deliverTarget = `channel:${params.createdThreadId}`; + replyTarget = deliverTarget; + } + const allowReference = deliverTarget === originalReplyTarget; + const replyReference = createReplyReferencePlanner({ + replyToMode: allowReference ? params.replyToMode : "off", + existingId: params.threadChannel ? params.messageId : undefined, + startId: params.messageId, + allowReference, + }); + return { deliverTarget, replyTarget, replyReference }; +} diff --git a/extensions/discord/src/monitor/threading.ts b/extensions/discord/src/monitor/threading.ts index 8bcbfd09830..2249dc893aa 100644 --- a/extensions/discord/src/monitor/threading.ts +++ b/extensions/discord/src/monitor/threading.ts @@ -1,706 +1,20 @@ -import type { APIAttachment, APIStickerItem } from "discord-api-types/v10"; -import type { OpenClawConfig, ReplyToMode } from "openclaw/plugin-sdk/config-types"; -import { resolveChannelModelOverride } from "openclaw/plugin-sdk/model-session-runtime"; -import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-reference"; -import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing"; -import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; -import { - normalizeOptionalString, - normalizeOptionalStringifiedId, - truncateUtf16Safe, -} from "openclaw/plugin-sdk/text-runtime"; -import { - ChannelType, - createThread, - editChannel, - getChannelMessage, - type Client, - type MessageCreateListener, -} from "../internal/discord.js"; -import type { DiscordChannelConfigResolved } from "./allow-list.js"; -import { - resolveDiscordChannelIdSafe, - resolveDiscordChannelNameSafe, - resolveDiscordChannelParentIdSafe, - resolveDiscordChannelParentSafe, -} from "./channel-access.js"; -import { - resolveDiscordChannelInfo, - type DiscordChannelInfoClient, - resolveDiscordEmbedText, - resolveDiscordForwardedMessagesTextFromSnapshots, - resolveDiscordMessageChannelId, -} from "./message-utils.js"; -import { generateThreadTitle } from "./thread-title.js"; - -export type DiscordThreadChannel = { - id: string; - name?: string | null; - parentId?: string | null; - parent?: { id?: string; name?: string }; - ownerId?: string | null; -}; - -export type DiscordThreadStarter = { - text: string; - author: string; - authorId?: string; - authorName?: string; - authorTag?: string; - memberRoleIds?: string[]; - timestamp?: number; -}; - -type DiscordThreadParentInfo = { - id?: string; - name?: string; - type?: ChannelType; -}; - -type DiscordThreadStarterRestEmbed = { - title?: string | null; - description?: string | null; -}; - -type DiscordThreadStarterRestSnapshotMessage = { - content?: string | null; - attachments?: APIAttachment[] | null; - embeds?: DiscordThreadStarterRestEmbed[] | null; - sticker_items?: APIStickerItem[] | null; -}; - -type DiscordThreadStarterRestAuthor = { - id?: string | null; - username?: string | null; - discriminator?: string | null; -}; - -type DiscordThreadStarterRestMember = { - nick?: string | null; - displayName?: string | null; - roles?: string[]; -}; - -type DiscordThreadStarterRestMessage = { - content?: string | null; - embeds?: DiscordThreadStarterRestEmbed[] | null; - message_snapshots?: Array<{ message?: DiscordThreadStarterRestSnapshotMessage | null }> | null; - member?: DiscordThreadStarterRestMember | null; - author?: DiscordThreadStarterRestAuthor | null; - timestamp?: string | null; -}; -type DiscordMessageEvent = Parameters[0]; - -// Cache entry with timestamp for TTL-based eviction -type DiscordThreadStarterCacheEntry = { - value: DiscordThreadStarter; - updatedAt: number; -}; - -// Cache configuration: 5 minute TTL (thread starters rarely change), max 500 entries -const DISCORD_THREAD_STARTER_CACHE_TTL_MS = 5 * 60 * 1000; -const DISCORD_THREAD_STARTER_CACHE_MAX = 500; - -const DISCORD_THREAD_STARTER_CACHE = new Map(); - -export function __resetDiscordThreadStarterCacheForTest() { - DISCORD_THREAD_STARTER_CACHE.clear(); -} - -// Get cached entry with TTL check, refresh LRU position on hit -function getCachedThreadStarter(key: string, now: number): DiscordThreadStarter | undefined { - const entry = DISCORD_THREAD_STARTER_CACHE.get(key); - if (!entry) { - return undefined; - } - // Check TTL expiry - if (now - entry.updatedAt > DISCORD_THREAD_STARTER_CACHE_TTL_MS) { - DISCORD_THREAD_STARTER_CACHE.delete(key); - return undefined; - } - // Refresh LRU position by re-inserting (Map maintains insertion order) - DISCORD_THREAD_STARTER_CACHE.delete(key); - DISCORD_THREAD_STARTER_CACHE.set(key, { ...entry, updatedAt: now }); - return entry.value; -} - -// Set cached entry with LRU eviction when max size exceeded -function setCachedThreadStarter(key: string, value: DiscordThreadStarter, now: number): void { - // Remove existing entry first (to update LRU position) - DISCORD_THREAD_STARTER_CACHE.delete(key); - DISCORD_THREAD_STARTER_CACHE.set(key, { value, updatedAt: now }); - // Evict oldest entries (first in Map) when over max size - while (DISCORD_THREAD_STARTER_CACHE.size > DISCORD_THREAD_STARTER_CACHE_MAX) { - const iter = DISCORD_THREAD_STARTER_CACHE.keys().next(); - if (iter.done) { - break; - } - DISCORD_THREAD_STARTER_CACHE.delete(iter.value); - } -} - -function isDiscordThreadType(type: ChannelType | undefined): boolean { - return ( - type === ChannelType.PublicThread || - type === ChannelType.PrivateThread || - type === ChannelType.AnnouncementThread - ); -} - -function isDiscordForumParentType(parentType: ChannelType | undefined): boolean { - return parentType === ChannelType.GuildForum || parentType === ChannelType.GuildMedia; -} - -function resolveTrimmedDiscordMessageChannelId(params: { - message: DiscordMessageEvent["message"]; - messageChannelId?: string; -}) { - return ( - params.messageChannelId || - resolveDiscordMessageChannelId({ - message: params.message, - }) - ).trim(); -} - -export function resolveDiscordThreadChannel(params: { - isGuildMessage: boolean; - message: DiscordMessageEvent["message"]; - channelInfo: import("./message-utils.js").DiscordChannelInfo | null; - messageChannelId?: string; -}): DiscordThreadChannel | null { - if (!params.isGuildMessage) { - return null; - } - const { message, channelInfo } = params; - const channel = "channel" in message ? (message as { channel?: unknown }).channel : undefined; - const isThreadChannel = - channel && - typeof channel === "object" && - "isThread" in channel && - typeof (channel as { isThread?: unknown }).isThread === "function" && - (channel as { isThread: () => boolean }).isThread(); - if (isThreadChannel) { - return channel as unknown as DiscordThreadChannel; - } - if (!isDiscordThreadType(channelInfo?.type)) { - return null; - } - const messageChannelId = - params.messageChannelId || - resolveDiscordMessageChannelId({ - message, - }); - if (!messageChannelId) { - return null; - } - return { - id: messageChannelId, - name: channelInfo?.name ?? undefined, - parentId: channelInfo?.parentId ?? undefined, - parent: undefined, - ownerId: channelInfo?.ownerId ?? undefined, - }; -} - -export async function resolveDiscordThreadParentInfo(params: { - client: DiscordChannelInfoClient; - threadChannel: DiscordThreadChannel; - channelInfo: import("./message-utils.js").DiscordChannelInfo | null; -}): Promise { - const { threadChannel, channelInfo, client } = params; - const parent = resolveDiscordChannelParentSafe(threadChannel); - let parentId = - resolveDiscordChannelParentIdSafe(threadChannel) ?? - resolveDiscordChannelIdSafe(parent) ?? - channelInfo?.parentId ?? - undefined; - if (!parentId && threadChannel.id) { - const threadInfo = await resolveDiscordChannelInfo(client, threadChannel.id); - parentId = threadInfo?.parentId ?? undefined; - } - if (!parentId) { - return {}; - } - let parentName = resolveDiscordChannelNameSafe(parent); - const parentInfo = await resolveDiscordChannelInfo(client, parentId); - parentName = parentName ?? parentInfo?.name; - const parentType = parentInfo?.type; - return { id: parentId, name: parentName, type: parentType }; -} - -export async function resolveDiscordThreadStarter(params: { - channel: DiscordThreadChannel; - client: Client; - parentId?: string; - parentType?: ChannelType; - resolveTimestampMs: (value?: string | null) => number | undefined; -}): Promise { - const cacheKey = params.channel.id; - const now = Date.now(); - const cached = getCachedThreadStarter(cacheKey, now); - if (cached) { - return cached; - } - try { - const messageChannelId = resolveDiscordThreadStarterMessageChannelId(params); - if (!messageChannelId) { - return null; - } - const starter = await fetchDiscordThreadStarterMessage({ - client: params.client, - messageChannelId, - threadId: params.channel.id, - }); - if (!starter) { - return null; - } - const payload = buildDiscordThreadStarterPayload({ - starter, - resolveTimestampMs: params.resolveTimestampMs, - }); - if (!payload) { - return null; - } - setCachedThreadStarter(cacheKey, payload, Date.now()); - return payload; - } catch { - return null; - } -} - -function resolveDiscordThreadStarterMessageChannelId(params: { - channel: DiscordThreadChannel; - parentId?: string; - parentType?: ChannelType; -}): string | undefined { - return isDiscordForumParentType(params.parentType) ? params.channel.id : params.parentId; -} - -async function fetchDiscordThreadStarterMessage(params: { - client: Client; - messageChannelId: string; - threadId: string; -}): Promise { - const starter = await getChannelMessage( - params.client.rest, - params.messageChannelId, - params.threadId, - ); - return starter ? (starter as DiscordThreadStarterRestMessage) : null; -} - -function buildDiscordThreadStarterPayload(params: { - starter: DiscordThreadStarterRestMessage; - resolveTimestampMs: (value?: string | null) => number | undefined; -}): DiscordThreadStarter | null { - const text = resolveDiscordThreadStarterText(params.starter); - if (!text) { - return null; - } - return { - text, - ...resolveDiscordThreadStarterIdentity(params.starter), - timestamp: params.resolveTimestampMs(params.starter.timestamp) ?? undefined, - }; -} - -function resolveDiscordThreadStarterText(starter: DiscordThreadStarterRestMessage): string { - const content = normalizeOptionalString(starter.content) ?? ""; - const embedText = resolveDiscordEmbedText(starter.embeds?.[0]); - const forwardedText = resolveDiscordForwardedMessagesTextFromSnapshots(starter.message_snapshots); - return content || embedText || forwardedText; -} - -function resolveDiscordThreadStarterIdentity( - starter: DiscordThreadStarterRestMessage, -): Omit { - const author = resolveDiscordThreadStarterAuthor(starter); - return { - author, - authorId: starter.author?.id ?? undefined, - authorName: starter.author?.username ?? undefined, - authorTag: resolveDiscordThreadStarterAuthorTag(starter.author), - memberRoleIds: resolveDiscordThreadStarterRoleIds(starter.member), - }; -} - -function resolveDiscordThreadStarterAuthor(starter: DiscordThreadStarterRestMessage): string { - return ( - starter.member?.nick ?? - starter.member?.displayName ?? - resolveDiscordThreadStarterAuthorTag(starter.author) ?? - starter.author?.username ?? - starter.author?.id ?? - "Unknown" - ); -} - -function resolveDiscordThreadStarterAuthorTag( - author: DiscordThreadStarterRestAuthor | null | undefined, -): string | undefined { - if (!author?.username || !author.discriminator) { - return undefined; - } - if (author.discriminator !== "0") { - return `${author.username}#${author.discriminator}`; - } - return author.username; -} - -function resolveDiscordThreadStarterRoleIds( - member: DiscordThreadStarterRestMember | null | undefined, -): string[] | undefined { - return Array.isArray(member?.roles) ? member.roles : undefined; -} - -export function resolveDiscordReplyTarget(opts: { - replyToMode: ReplyToMode; - replyToId?: string; - hasReplied: boolean; -}): string | undefined { - if (opts.replyToMode === "off") { - return undefined; - } - const replyToId = normalizeOptionalString(opts.replyToId); - if (!replyToId) { - return undefined; - } - if (opts.replyToMode === "all") { - return replyToId; - } - return opts.hasReplied ? undefined : replyToId; -} - -export function sanitizeDiscordThreadName(rawName: string, fallbackId: string): string { - const cleanedName = rawName - .replace(/<@!?\d+>/g, "") // user mentions - .replace(/<@&\d+>/g, "") // role mentions - .replace(/<#\d+>/g, "") // channel mentions - .replace(/\s+/g, " ") - .trim(); - const baseSource = cleanedName || `Thread ${fallbackId}`; - const base = truncateUtf16Safe(baseSource, 80); - return truncateUtf16Safe(base, 100) || `Thread ${fallbackId}`; -} - -type DiscordReplyDeliveryPlan = { - deliverTarget: string; - replyTarget: string; - replyReference: ReturnType; -}; - -export type DiscordAutoThreadContext = { - createdThreadId: string; - From: string; - To: string; - OriginatingTo: string; - SessionKey: string; - ModelParentSessionKey?: string; - ParentSessionKey?: string; -}; - -export function resolveDiscordAutoThreadContext(params: { - agentId: string; - channel: string; - messageChannelId: string; - createdThreadId?: string | null; - parentInheritanceEnabled?: boolean; -}): DiscordAutoThreadContext | null { - const createdThreadId = normalizeOptionalStringifiedId(params.createdThreadId) ?? ""; - if (!createdThreadId) { - return null; - } - const messageChannelId = normalizeOptionalString(params.messageChannelId) ?? ""; - if (!messageChannelId) { - return null; - } - - const threadSessionKey = buildAgentSessionKey({ - agentId: params.agentId, - channel: params.channel, - peer: { kind: "channel", id: createdThreadId }, - }); - const parentSessionKey = buildAgentSessionKey({ - agentId: params.agentId, - channel: params.channel, - peer: { kind: "channel", id: messageChannelId }, - }); - - return { - createdThreadId, - From: `${params.channel}:channel:${createdThreadId}`, - To: `channel:${createdThreadId}`, - OriginatingTo: `channel:${createdThreadId}`, - SessionKey: threadSessionKey, - ModelParentSessionKey: parentSessionKey, - ...(params.parentInheritanceEnabled === true ? { ParentSessionKey: parentSessionKey } : {}), - }; -} - -export type DiscordAutoThreadReplyPlan = DiscordReplyDeliveryPlan & { - createdThreadId?: string; - autoThreadContext: DiscordAutoThreadContext | null; -}; - -type MaybeCreateDiscordAutoThreadParams = { - client: Client; - message: DiscordMessageEvent["message"]; - messageChannelId?: string; - channel?: string; - isGuildMessage: boolean; - channelConfig?: DiscordChannelConfigResolved | null; - threadChannel?: DiscordThreadChannel | null; - channelType?: ChannelType; - channelName?: string; - channelDescription?: string; - baseText: string; - combinedBody: string; - cfg: OpenClawConfig; - agentId?: string; -}; - -export async function resolveDiscordAutoThreadReplyPlan( - params: MaybeCreateDiscordAutoThreadParams & { - replyToMode: ReplyToMode; - agentId: string; - channel: string; - cfg: OpenClawConfig; - threadParentInheritanceEnabled?: boolean; - }, -): Promise { - const messageChannelId = resolveTrimmedDiscordMessageChannelId(params); - // Prefer the resolved thread channel ID when available so replies stay in-thread. - const targetChannelId = params.threadChannel?.id ?? (messageChannelId || "unknown"); - const originalReplyTarget = `channel:${targetChannelId}`; - const createdThreadId = await maybeCreateDiscordAutoThread({ - client: params.client, - message: params.message, - messageChannelId: messageChannelId || undefined, - channel: params.channel, - isGuildMessage: params.isGuildMessage, - channelConfig: params.channelConfig, - threadChannel: params.threadChannel, - channelType: params.channelType, - channelName: params.channelName, - channelDescription: params.channelDescription, - baseText: params.baseText, - combinedBody: params.combinedBody, - cfg: params.cfg, - agentId: params.agentId, - }); - const deliveryPlan = resolveDiscordReplyDeliveryPlan({ - replyTarget: originalReplyTarget, - replyToMode: params.replyToMode, - messageId: params.message.id, - threadChannel: params.threadChannel, - createdThreadId, - }); - const autoThreadContext = params.isGuildMessage - ? resolveDiscordAutoThreadContext({ - agentId: params.agentId, - channel: params.channel, - messageChannelId, - createdThreadId, - parentInheritanceEnabled: params.threadParentInheritanceEnabled, - }) - : null; - return { ...deliveryPlan, createdThreadId, autoThreadContext }; -} - -export async function maybeCreateDiscordAutoThread( - params: MaybeCreateDiscordAutoThreadParams, -): Promise { - if (!params.isGuildMessage) { - return undefined; - } - if (!params.channelConfig?.autoThread) { - return undefined; - } - if (params.threadChannel) { - return undefined; - } - // Avoid creating threads in channels that don't support it or are already forums - if ( - params.channelType === ChannelType.GuildForum || - params.channelType === ChannelType.GuildMedia || - params.channelType === ChannelType.GuildVoice || - params.channelType === ChannelType.GuildStageVoice - ) { - return undefined; - } - - const messageChannelId = resolveTrimmedDiscordMessageChannelId(params); - if (!messageChannelId) { - return undefined; - } - try { - const rawThreadSource = params.baseText || params.combinedBody || "Thread"; - const threadName = sanitizeDiscordThreadName(rawThreadSource, params.message.id); - - // Parse archive duration from config, default to 60 minutes - const archiveDuration = params.channelConfig?.autoArchiveDuration - ? Number(params.channelConfig.autoArchiveDuration) - : 60; - - const created = await createThread<{ id?: string }>( - params.client.rest, - messageChannelId, - { - body: { - name: threadName, - auto_archive_duration: archiveDuration, - }, - }, - params.message.id, - ); - const createdId = created?.id || ""; - if ( - createdId && - params.channelConfig?.autoThreadName === "generated" && - params.cfg && - params.agentId - ) { - const modelRef = resolveDiscordThreadTitleModelRef({ - cfg: params.cfg, - channel: params.channel, - agentId: params.agentId, - threadId: createdId, - messageChannelId, - channelName: params.channelName, - }); - void maybeRenameDiscordAutoThread({ - client: params.client, - threadId: createdId, - currentName: threadName, - fallbackId: params.message.id, - sourceText: rawThreadSource, - modelRef, - channelName: params.channelName, - channelDescription: params.channelDescription, - cfg: params.cfg, - agentId: params.agentId, - }); - } - return createdId || undefined; - } catch (err) { - logVerbose( - `discord: autoThread creation failed for ${messageChannelId}/${params.message.id}: ${String(err)}`, - ); - // Race condition: another agent may have already created a thread on this - // message. Re-fetch the message to check for an existing thread. - try { - const msg = (await getChannelMessage( - params.client.rest, - messageChannelId, - params.message.id, - )) as { - thread?: { id?: string }; - }; - const existingThreadId = msg?.thread?.id || ""; - if (existingThreadId) { - logVerbose( - `discord: autoThread reusing existing thread ${existingThreadId} on ${messageChannelId}/${params.message.id}`, - ); - return existingThreadId; - } - } catch { - // If the refetch also fails, fall through to return undefined. - } - return undefined; - } -} - -function resolveDiscordThreadTitleModelRef(params: { - cfg: OpenClawConfig; - channel?: string; - agentId: string; - threadId: string; - messageChannelId: string; - channelName?: string; -}): string | undefined { - const channel = params.channel?.trim(); - if (!channel) { - return undefined; - } - const parentSessionKey = buildAgentSessionKey({ - agentId: params.agentId, - channel, - peer: { kind: "channel", id: params.messageChannelId }, - }); - const channelLabel = params.channelName?.trim(); - const groupChannel = channelLabel ? `#${channelLabel}` : undefined; - const channelOverride = resolveChannelModelOverride({ - cfg: params.cfg, - channel, - groupId: params.threadId, - groupChatType: "channel", - groupChannel, - groupSubject: groupChannel, - parentSessionKey, - }); - return channelOverride?.model; -} - -async function maybeRenameDiscordAutoThread(params: { - client: Client; - threadId: string; - currentName: string; - fallbackId: string; - sourceText: string; - modelRef?: string; - channelName?: string; - channelDescription?: string; - cfg: OpenClawConfig; - agentId: string; -}): Promise { - try { - const fallbackName = sanitizeDiscordThreadName("", params.fallbackId); - const generated = await generateThreadTitle({ - cfg: params.cfg, - agentId: params.agentId, - messageText: params.sourceText, - modelRef: params.modelRef, - channelName: params.channelName, - channelDescription: params.channelDescription, - }); - if (!generated) { - return; - } - const nextName = sanitizeDiscordThreadName(generated, params.fallbackId); - if (!nextName || nextName === params.currentName || nextName === fallbackName) { - return; - } - await editChannel(params.client.rest, params.threadId, { - body: { name: nextName }, - }); - } catch (err) { - logVerbose(`discord: autoThread rename failed for ${params.threadId}: ${String(err)}`); - } -} - -export function resolveDiscordReplyDeliveryPlan(params: { - replyTarget: string; - replyToMode: ReplyToMode; - messageId: string; - threadChannel?: DiscordThreadChannel | null; - createdThreadId?: string | null; -}): DiscordReplyDeliveryPlan { - const originalReplyTarget = params.replyTarget; - let deliverTarget = originalReplyTarget; - let replyTarget = originalReplyTarget; - - // When a new thread was created, route to the new thread. - if (params.createdThreadId) { - deliverTarget = `channel:${params.createdThreadId}`; - replyTarget = deliverTarget; - } - const allowReference = deliverTarget === originalReplyTarget; - const replyReference = createReplyReferencePlanner({ - replyToMode: allowReference ? params.replyToMode : "off", - existingId: params.threadChannel ? params.messageId : undefined, - startId: params.messageId, - allowReference, - }); - return { deliverTarget, replyTarget, replyReference }; -} +export { + maybeCreateDiscordAutoThread, + resolveDiscordAutoThreadContext, + resolveDiscordAutoThreadReplyPlan, +} from "./threading.auto-thread.js"; +export { __resetDiscordThreadStarterCacheForTest } from "./threading.cache.js"; +export { + resolveDiscordReplyDeliveryPlan, + resolveDiscordReplyTarget, + resolveDiscordThreadChannel, + resolveDiscordThreadParentInfo, + resolveDiscordThreadStarter, + sanitizeDiscordThreadName, +} from "./threading.starter.js"; +export type { + DiscordAutoThreadContext, + DiscordAutoThreadReplyPlan, + DiscordThreadChannel, + DiscordThreadStarter, +} from "./threading.types.js"; diff --git a/extensions/discord/src/monitor/threading.types.ts b/extensions/discord/src/monitor/threading.types.ts new file mode 100644 index 00000000000..76e42e0df48 --- /dev/null +++ b/extensions/discord/src/monitor/threading.types.ts @@ -0,0 +1,102 @@ +import type { APIAttachment, APIStickerItem } from "discord-api-types/v10"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import type { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-reference"; +import type { ChannelType, Client, MessageCreateListener } from "../internal/discord.js"; +import type { DiscordChannelConfigResolved } from "./allow-list.js"; + +export type DiscordThreadChannel = { + id: string; + name?: string | null; + parentId?: string | null; + parent?: { id?: string; name?: string }; + ownerId?: string | null; +}; + +export type DiscordThreadStarter = { + text: string; + author: string; + authorId?: string; + authorName?: string; + authorTag?: string; + memberRoleIds?: string[]; + timestamp?: number; +}; + +export type DiscordThreadParentInfo = { + id?: string; + name?: string; + type?: ChannelType; +}; + +export type DiscordThreadStarterRestEmbed = { + title?: string | null; + description?: string | null; +}; + +export type DiscordThreadStarterRestSnapshotMessage = { + content?: string | null; + attachments?: APIAttachment[] | null; + embeds?: DiscordThreadStarterRestEmbed[] | null; + sticker_items?: APIStickerItem[] | null; +}; + +export type DiscordThreadStarterRestAuthor = { + id?: string | null; + username?: string | null; + discriminator?: string | null; +}; + +export type DiscordThreadStarterRestMember = { + nick?: string | null; + displayName?: string | null; + roles?: string[]; +}; + +export type DiscordThreadStarterRestMessage = { + content?: string | null; + embeds?: DiscordThreadStarterRestEmbed[] | null; + message_snapshots?: Array<{ message?: DiscordThreadStarterRestSnapshotMessage | null }> | null; + member?: DiscordThreadStarterRestMember | null; + author?: DiscordThreadStarterRestAuthor | null; + timestamp?: string | null; +}; + +export type DiscordMessageEvent = Parameters[0]; + +export type DiscordReplyDeliveryPlan = { + deliverTarget: string; + replyTarget: string; + replyReference: ReturnType; +}; + +export type DiscordAutoThreadContext = { + createdThreadId: string; + From: string; + To: string; + OriginatingTo: string; + SessionKey: string; + ModelParentSessionKey?: string; + ParentSessionKey?: string; +}; + +export type DiscordAutoThreadReplyPlan = DiscordReplyDeliveryPlan & { + createdThreadId?: string; + autoThreadContext: DiscordAutoThreadContext | null; +}; + +export type MaybeCreateDiscordAutoThreadParams = { + client: Client; + message: DiscordMessageEvent["message"]; + messageChannelId?: string; + channel?: string; + isGuildMessage: boolean; + channelConfig?: DiscordChannelConfigResolved | null; + threadChannel?: DiscordThreadChannel | null; + channelType?: ChannelType; + channelName?: string; + channelDescription?: string; + baseText: string; + combinedBody: string; + cfg: OpenClawConfig; + agentId?: string; +}; diff --git a/extensions/discord/src/voice/manager.ts b/extensions/discord/src/voice/manager.ts index e612fbd976f..6bf56a7e5d3 100644 --- a/extensions/discord/src/voice/manager.ts +++ b/extensions/discord/src/voice/manager.ts @@ -1,18 +1,13 @@ -import path from "node:path"; -import { agentCommandFromIngress } from "openclaw/plugin-sdk/agent-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-types"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { resolveDiscordAccountAllowFrom } from "../accounts.js"; import { type Client, ReadyListener } from "../internal/discord.js"; import type { VoicePlugin } from "../internal/voice.js"; import { formatMention } from "../mentions.js"; -import { normalizeDiscordSlug } from "../monitor/allow-list.js"; -import { authorizeDiscordVoiceIngress } from "./access.js"; import { decodeOpusStream, writeVoiceWavFile } from "./audio.js"; import { beginVoiceCapture, @@ -24,7 +19,6 @@ import { scheduleVoiceCaptureFinalize, stopVoiceCaptureState, } from "./capture-state.js"; -import { formatVoiceIngressPrompt } from "./prompt.js"; import { analyzeVoiceReceiveError, createVoiceReceiveRecoveryState, @@ -36,19 +30,17 @@ import { resetVoiceReceiveRecoveryState, } from "./receive-recovery.js"; import { loadDiscordVoiceSdk } from "./sdk-runtime.js"; +import { processDiscordVoiceSegment } from "./segment.js"; import { CAPTURE_FINALIZE_GRACE_MS, isVoiceChannel, logVoiceVerbose, MIN_SEGMENT_SECONDS, - PLAYBACK_READY_TIMEOUT_MS, - SPEAKING_READY_TIMEOUT_MS, VOICE_CONNECT_READY_TIMEOUT_MS, type VoiceOperationResult, type VoiceSessionEntry, } from "./session.js"; import { DiscordVoiceSpeakerContextResolver } from "./speaker-context.js"; -import { synthesizeVoiceReplyAudio, transcribeVoiceAudio } from "./tts.js"; const logger = createSubsystemLogger("discord/voice"); @@ -470,123 +462,22 @@ export class DiscordVoiceManager { userId: string; durationSeconds: number; }) { - const { entry, wavPath, userId, durationSeconds } = params; - logVoiceVerbose( - `segment processing (${durationSeconds.toFixed(2)}s): guild ${entry.guildId} channel ${entry.channelId}`, - ); - if (!entry.guildName) { - const guild = await this.params.client.fetchGuild(entry.guildId).catch(() => null); - if (guild && typeof guild.name === "string" && guild.name.trim()) { - entry.guildName = guild.name; - } - } - const speaker = await this.speakerContext.resolveContext(entry.guildId, userId); - const speakerIdentity = await this.speakerContext.resolveIdentity(entry.guildId, userId); - const access = await authorizeDiscordVoiceIngress({ + await processDiscordVoiceSegment({ + ...params, cfg: this.params.cfg, discordConfig: this.params.discordConfig, - guildName: entry.guildName, - guildId: entry.guildId, - channelId: entry.channelId, - channelName: entry.channelName, - channelSlug: entry.channelName ? normalizeDiscordSlug(entry.channelName) : "", - channelLabel: formatMention({ channelId: entry.channelId }), - memberRoleIds: speakerIdentity.memberRoleIds, ownerAllowFrom: this.ownerAllowFrom, - sender: { - id: speakerIdentity.id, - name: speakerIdentity.name, - tag: speakerIdentity.tag, + runtime: this.params.runtime, + speakerContext: this.speakerContext, + fetchGuildName: async (guildId) => { + const guild = await this.params.client.fetchGuild(guildId).catch(() => null); + return guild && typeof guild.name === "string" && guild.name.trim() + ? guild.name + : undefined; }, - }); - if (!access.ok) { - logVoiceVerbose( - `segment unauthorized: guild ${entry.guildId} channel ${entry.channelId} user ${userId} reason=${access.message}`, - ); - return; - } - const transcript = await transcribeVoiceAudio({ - cfg: this.params.cfg, - agentId: entry.route.agentId, - filePath: wavPath, - }); - if (!transcript) { - logVoiceVerbose( - `transcription empty: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, - ); - return; - } - logVoiceVerbose( - `transcription ok (${transcript.length} chars): guild ${entry.guildId} channel ${entry.channelId}`, - ); - - const prompt = formatVoiceIngressPrompt(transcript, speaker.label); - const modelOverride = normalizeOptionalString(this.params.discordConfig.voice?.model); - - const result = await agentCommandFromIngress( - { - message: prompt, - sessionKey: entry.route.sessionKey, - agentId: entry.route.agentId, - messageChannel: "discord", - senderIsOwner: speaker.senderIsOwner, - allowModelOverride: Boolean(modelOverride), - model: modelOverride, - deliver: false, + enqueuePlayback: (entry, task) => { + this.enqueuePlayback(entry, task); }, - this.params.runtime, - ); - - const replyText = (result.payloads ?? []) - .map((payload) => payload.text) - .filter((text) => typeof text === "string" && text.trim()) - .join("\n") - .trim(); - - if (!replyText) { - logVoiceVerbose( - `reply empty: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, - ); - return; - } - logVoiceVerbose( - `reply ok (${replyText.length} chars): guild ${entry.guildId} channel ${entry.channelId}`, - ); - - const voiceReplyAudio = await synthesizeVoiceReplyAudio({ - cfg: this.params.cfg, - override: this.params.discordConfig.voice?.tts, - replyText, - speakerLabel: speaker.label, - }); - if (voiceReplyAudio.status === "empty") { - logVoiceVerbose( - `tts skipped (empty): guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, - ); - return; - } - if (voiceReplyAudio.status === "failed") { - logger.warn(`discord voice: TTS failed: ${voiceReplyAudio.error ?? "unknown error"}`); - return; - } - logVoiceVerbose( - `tts ok (${voiceReplyAudio.speakText.length} chars): guild ${entry.guildId} channel ${entry.channelId}`, - ); - - this.enqueuePlayback(entry, async () => { - logVoiceVerbose( - `playback start: guild ${entry.guildId} channel ${entry.channelId} file ${path.basename(voiceReplyAudio.audioPath)}`, - ); - const voiceSdk = loadDiscordVoiceSdk(); - const resource = voiceSdk.createAudioResource(voiceReplyAudio.audioPath); - entry.player.play(resource); - await voiceSdk - .entersState(entry.player, voiceSdk.AudioPlayerStatus.Playing, PLAYBACK_READY_TIMEOUT_MS) - .catch(() => undefined); - await voiceSdk - .entersState(entry.player, voiceSdk.AudioPlayerStatus.Idle, SPEAKING_READY_TIMEOUT_MS) - .catch(() => undefined); - logVoiceVerbose(`playback done: guild ${entry.guildId} channel ${entry.channelId}`); }); } diff --git a/extensions/discord/src/voice/segment.ts b/extensions/discord/src/voice/segment.ts new file mode 100644 index 00000000000..5f32d22e55a --- /dev/null +++ b/extensions/discord/src/voice/segment.ts @@ -0,0 +1,151 @@ +import path from "node:path"; +import { agentCommandFromIngress } from "openclaw/plugin-sdk/agent-runtime"; +import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-types"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; +import { formatMention } from "../mentions.js"; +import { normalizeDiscordSlug } from "../monitor/allow-list.js"; +import { authorizeDiscordVoiceIngress } from "./access.js"; +import { formatVoiceIngressPrompt } from "./prompt.js"; +import { loadDiscordVoiceSdk } from "./sdk-runtime.js"; +import { + logVoiceVerbose, + PLAYBACK_READY_TIMEOUT_MS, + SPEAKING_READY_TIMEOUT_MS, + type VoiceSessionEntry, +} from "./session.js"; +import type { DiscordVoiceSpeakerContextResolver } from "./speaker-context.js"; +import { synthesizeVoiceReplyAudio, transcribeVoiceAudio } from "./tts.js"; + +const logger = createSubsystemLogger("discord/voice"); + +export async function processDiscordVoiceSegment(params: { + entry: VoiceSessionEntry; + wavPath: string; + userId: string; + durationSeconds: number; + cfg: OpenClawConfig; + discordConfig: DiscordAccountConfig; + runtime: RuntimeEnv; + ownerAllowFrom?: string[]; + fetchGuildName: (guildId: string) => Promise; + speakerContext: DiscordVoiceSpeakerContextResolver; + enqueuePlayback: (entry: VoiceSessionEntry, task: () => Promise) => void; +}) { + const { entry, wavPath, userId, durationSeconds } = params; + logVoiceVerbose( + `segment processing (${durationSeconds.toFixed(2)}s): guild ${entry.guildId} channel ${entry.channelId}`, + ); + if (!entry.guildName) { + entry.guildName = await params.fetchGuildName(entry.guildId); + } + const speaker = await params.speakerContext.resolveContext(entry.guildId, userId); + const speakerIdentity = await params.speakerContext.resolveIdentity(entry.guildId, userId); + const access = await authorizeDiscordVoiceIngress({ + cfg: params.cfg, + discordConfig: params.discordConfig, + guildName: entry.guildName, + guildId: entry.guildId, + channelId: entry.channelId, + channelName: entry.channelName, + channelSlug: entry.channelName ? normalizeDiscordSlug(entry.channelName) : "", + channelLabel: formatMention({ channelId: entry.channelId }), + memberRoleIds: speakerIdentity.memberRoleIds, + ownerAllowFrom: params.ownerAllowFrom, + sender: { + id: speakerIdentity.id, + name: speakerIdentity.name, + tag: speakerIdentity.tag, + }, + }); + if (!access.ok) { + logVoiceVerbose( + `segment unauthorized: guild ${entry.guildId} channel ${entry.channelId} user ${userId} reason=${access.message}`, + ); + return; + } + const transcript = await transcribeVoiceAudio({ + cfg: params.cfg, + agentId: entry.route.agentId, + filePath: wavPath, + }); + if (!transcript) { + logVoiceVerbose( + `transcription empty: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, + ); + return; + } + logVoiceVerbose( + `transcription ok (${transcript.length} chars): guild ${entry.guildId} channel ${entry.channelId}`, + ); + + const prompt = formatVoiceIngressPrompt(transcript, speaker.label); + const modelOverride = normalizeOptionalString(params.discordConfig.voice?.model); + + const result = await agentCommandFromIngress( + { + message: prompt, + sessionKey: entry.route.sessionKey, + agentId: entry.route.agentId, + messageChannel: "discord", + senderIsOwner: speaker.senderIsOwner, + allowModelOverride: Boolean(modelOverride), + model: modelOverride, + deliver: false, + }, + params.runtime, + ); + + const replyText = (result.payloads ?? []) + .map((payload) => payload.text) + .filter((text) => typeof text === "string" && text.trim()) + .join("\n") + .trim(); + + if (!replyText) { + logVoiceVerbose( + `reply empty: guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, + ); + return; + } + logVoiceVerbose( + `reply ok (${replyText.length} chars): guild ${entry.guildId} channel ${entry.channelId}`, + ); + + const voiceReplyAudio = await synthesizeVoiceReplyAudio({ + cfg: params.cfg, + override: params.discordConfig.voice?.tts, + replyText, + speakerLabel: speaker.label, + }); + if (voiceReplyAudio.status === "empty") { + logVoiceVerbose( + `tts skipped (empty): guild ${entry.guildId} channel ${entry.channelId} user ${userId}`, + ); + return; + } + if (voiceReplyAudio.status === "failed") { + logger.warn(`discord voice: TTS failed: ${voiceReplyAudio.error ?? "unknown error"}`); + return; + } + logVoiceVerbose( + `tts ok (${voiceReplyAudio.speakText.length} chars): guild ${entry.guildId} channel ${entry.channelId}`, + ); + + params.enqueuePlayback(entry, async () => { + logVoiceVerbose( + `playback start: guild ${entry.guildId} channel ${entry.channelId} file ${path.basename(voiceReplyAudio.audioPath)}`, + ); + const voiceSdk = loadDiscordVoiceSdk(); + const resource = voiceSdk.createAudioResource(voiceReplyAudio.audioPath); + entry.player.play(resource); + await voiceSdk + .entersState(entry.player, voiceSdk.AudioPlayerStatus.Playing, PLAYBACK_READY_TIMEOUT_MS) + .catch(() => undefined); + await voiceSdk + .entersState(entry.player, voiceSdk.AudioPlayerStatus.Idle, SPEAKING_READY_TIMEOUT_MS) + .catch(() => undefined); + logVoiceVerbose(`playback done: guild ${entry.guildId} channel ${entry.channelId}`); + }); +}