refactor(discord): split threading and voice segment helpers

This commit is contained in:
Peter Steinberger
2026-04-29 19:09:13 +01:00
parent 6470a23504
commit 7e1acf2f1e
8 changed files with 906 additions and 830 deletions

View File

@@ -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,

View File

@@ -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<DiscordAutoThreadReplyPlan> {
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<string | undefined> {
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<void> {
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)}`);
}
}

View File

@@ -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<string, DiscordThreadStarterCacheEntry>();
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);
}
}

View File

@@ -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<DiscordThreadParentInfo> {
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<DiscordThreadStarter | null> {
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<DiscordThreadStarterRestMessage | null> {
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<DiscordThreadStarter, "text" | "timestamp"> {
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 };
}

View File

@@ -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<MessageCreateListener["handle"]>[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<string, DiscordThreadStarterCacheEntry>();
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<DiscordThreadParentInfo> {
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<DiscordThreadStarter | null> {
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<DiscordThreadStarterRestMessage | null> {
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<DiscordThreadStarter, "text" | "timestamp"> {
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<typeof createReplyReferencePlanner>;
};
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<DiscordAutoThreadReplyPlan> {
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<string | undefined> {
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<void> {
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";

View File

@@ -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<MessageCreateListener["handle"]>[0];
export type DiscordReplyDeliveryPlan = {
deliverTarget: string;
replyTarget: string;
replyReference: ReturnType<typeof createReplyReferencePlanner>;
};
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;
};

View File

@@ -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}`);
});
}

View File

@@ -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<string | undefined>;
speakerContext: DiscordVoiceSpeakerContextResolver;
enqueuePlayback: (entry: VoiceSessionEntry, task: () => Promise<void>) => 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}`);
});
}