309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
import { ChannelType, type Client } from "@buape/carbon";
|
|
import { Routes } from "discord-api-types/v10";
|
|
import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js";
|
|
import type { ReplyToMode } from "../../config/config.js";
|
|
import { logVerbose } from "../../globals.js";
|
|
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
|
|
import { truncateUtf16Safe } from "../../utils.js";
|
|
import type { DiscordChannelConfigResolved } from "./allow-list.js";
|
|
import type { DiscordMessageEvent } from "./listeners.js";
|
|
import { resolveDiscordChannelInfo } from "./message-utils.js";
|
|
|
|
export type DiscordThreadChannel = {
|
|
id: string;
|
|
name?: string | null;
|
|
parentId?: string | null;
|
|
parent?: { id?: string; name?: string };
|
|
};
|
|
|
|
export type DiscordThreadStarter = {
|
|
text: string;
|
|
author: string;
|
|
timestamp?: number;
|
|
};
|
|
|
|
type DiscordThreadParentInfo = {
|
|
id?: string;
|
|
name?: string;
|
|
type?: ChannelType;
|
|
};
|
|
|
|
const DISCORD_THREAD_STARTER_CACHE = new Map<string, DiscordThreadStarter>();
|
|
|
|
function isDiscordThreadType(type: ChannelType | undefined): boolean {
|
|
return (
|
|
type === ChannelType.PublicThread ||
|
|
type === ChannelType.PrivateThread ||
|
|
type === ChannelType.AnnouncementThread
|
|
);
|
|
}
|
|
|
|
export function resolveDiscordThreadChannel(params: {
|
|
isGuildMessage: boolean;
|
|
message: DiscordMessageEvent["message"];
|
|
channelInfo: import("./message-utils.js").DiscordChannelInfo | null;
|
|
}): 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;
|
|
return {
|
|
id: message.channelId,
|
|
name: channelInfo?.name ?? undefined,
|
|
parentId: channelInfo?.parentId ?? undefined,
|
|
parent: undefined,
|
|
};
|
|
}
|
|
|
|
export async function resolveDiscordThreadParentInfo(params: {
|
|
client: Client;
|
|
threadChannel: DiscordThreadChannel;
|
|
channelInfo: import("./message-utils.js").DiscordChannelInfo | null;
|
|
}): Promise<DiscordThreadParentInfo> {
|
|
const { threadChannel, channelInfo, client } = params;
|
|
const parentId =
|
|
threadChannel.parentId ?? threadChannel.parent?.id ?? channelInfo?.parentId ?? undefined;
|
|
if (!parentId) return {};
|
|
let parentName = threadChannel.parent?.name;
|
|
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 cached = DISCORD_THREAD_STARTER_CACHE.get(cacheKey);
|
|
if (cached) return cached;
|
|
try {
|
|
const parentType = params.parentType;
|
|
const isForumParent =
|
|
parentType === ChannelType.GuildForum || parentType === ChannelType.GuildMedia;
|
|
const messageChannelId = isForumParent ? params.channel.id : params.parentId;
|
|
if (!messageChannelId) return null;
|
|
const starter = (await params.client.rest.get(
|
|
Routes.channelMessage(messageChannelId, params.channel.id),
|
|
)) as {
|
|
content?: string | null;
|
|
embeds?: Array<{ description?: string | null }>;
|
|
member?: { nick?: string | null; displayName?: string | null };
|
|
author?: {
|
|
id?: string | null;
|
|
username?: string | null;
|
|
discriminator?: string | null;
|
|
};
|
|
timestamp?: string | null;
|
|
};
|
|
if (!starter) return null;
|
|
const text = starter.content?.trim() ?? starter.embeds?.[0]?.description?.trim() ?? "";
|
|
if (!text) return null;
|
|
const author =
|
|
starter.member?.nick ??
|
|
starter.member?.displayName ??
|
|
(starter.author
|
|
? starter.author.discriminator && starter.author.discriminator !== "0"
|
|
? `${starter.author.username ?? "Unknown"}#${starter.author.discriminator}`
|
|
: (starter.author.username ?? starter.author.id ?? "Unknown")
|
|
: "Unknown");
|
|
const timestamp = params.resolveTimestampMs(starter.timestamp);
|
|
const payload: DiscordThreadStarter = {
|
|
text,
|
|
author,
|
|
timestamp: timestamp ?? undefined,
|
|
};
|
|
DISCORD_THREAD_STARTER_CACHE.set(cacheKey, payload);
|
|
return payload;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function resolveDiscordReplyTarget(opts: {
|
|
replyToMode: ReplyToMode;
|
|
replyToId?: string;
|
|
hasReplied: boolean;
|
|
}): string | undefined {
|
|
if (opts.replyToMode === "off") return undefined;
|
|
const replyToId = opts.replyToId?.trim();
|
|
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;
|
|
ParentSessionKey: string;
|
|
};
|
|
|
|
export function resolveDiscordAutoThreadContext(params: {
|
|
agentId: string;
|
|
channel: string;
|
|
messageChannelId: string;
|
|
createdThreadId?: string | null;
|
|
}): DiscordAutoThreadContext | null {
|
|
const createdThreadId = String(params.createdThreadId ?? "").trim();
|
|
if (!createdThreadId) return null;
|
|
const messageChannelId = params.messageChannelId.trim();
|
|
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,
|
|
ParentSessionKey: parentSessionKey,
|
|
};
|
|
}
|
|
|
|
export type DiscordAutoThreadReplyPlan = DiscordReplyDeliveryPlan & {
|
|
createdThreadId?: string;
|
|
autoThreadContext: DiscordAutoThreadContext | null;
|
|
};
|
|
|
|
export async function resolveDiscordAutoThreadReplyPlan(params: {
|
|
client: Client;
|
|
message: DiscordMessageEvent["message"];
|
|
isGuildMessage: boolean;
|
|
channelConfig?: DiscordChannelConfigResolved | null;
|
|
threadChannel?: DiscordThreadChannel | null;
|
|
baseText: string;
|
|
combinedBody: string;
|
|
replyToMode: ReplyToMode;
|
|
agentId: string;
|
|
channel: string;
|
|
}): Promise<DiscordAutoThreadReplyPlan> {
|
|
const originalReplyTarget = `channel:${params.message.channelId}`;
|
|
const createdThreadId = await maybeCreateDiscordAutoThread({
|
|
client: params.client,
|
|
message: params.message,
|
|
isGuildMessage: params.isGuildMessage,
|
|
channelConfig: params.channelConfig,
|
|
threadChannel: params.threadChannel,
|
|
baseText: params.baseText,
|
|
combinedBody: params.combinedBody,
|
|
});
|
|
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: params.message.channelId,
|
|
createdThreadId,
|
|
})
|
|
: null;
|
|
return { ...deliveryPlan, createdThreadId, autoThreadContext };
|
|
}
|
|
|
|
export async function maybeCreateDiscordAutoThread(params: {
|
|
client: Client;
|
|
message: DiscordMessageEvent["message"];
|
|
isGuildMessage: boolean;
|
|
channelConfig?: DiscordChannelConfigResolved | null;
|
|
threadChannel?: DiscordThreadChannel | null;
|
|
baseText: string;
|
|
combinedBody: string;
|
|
}): Promise<string | undefined> {
|
|
if (!params.isGuildMessage) return undefined;
|
|
if (!params.channelConfig?.autoThread) return undefined;
|
|
if (params.threadChannel) return undefined;
|
|
try {
|
|
const threadName = sanitizeDiscordThreadName(
|
|
params.baseText || params.combinedBody || "Thread",
|
|
params.message.id,
|
|
);
|
|
const created = (await params.client.rest.post(
|
|
`${Routes.channelMessage(params.message.channelId, params.message.id)}/threads`,
|
|
{
|
|
body: {
|
|
name: threadName,
|
|
auto_archive_duration: 60,
|
|
},
|
|
},
|
|
)) as { id?: string };
|
|
const createdId = created?.id ? String(created.id) : "";
|
|
return createdId || undefined;
|
|
} catch (err) {
|
|
logVerbose(
|
|
`discord: autoThread failed for ${params.message.channelId}/${params.message.id}: ${String(err)}`,
|
|
);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
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 };
|
|
}
|