import { ChannelType, Routes } from "discord-api-types/v10"; import type { RemoteClawConfig } from "../../../../src/config/config.js"; import { logVerbose } from "../../../../src/globals.js"; import { createDiscordRestClient } from "../client.js"; import { sendMessageDiscord, sendWebhookMessageDiscord } from "../send.js"; import { createThreadDiscord } from "../send.messages.js"; import { BINDINGS_BY_THREAD_ID, REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL, rememberReusableWebhook, toReusableWebhookKey, } from "./thread-bindings.state.js"; import { DISCORD_UNKNOWN_CHANNEL_ERROR_CODE, type ThreadBindingRecord, } from "./thread-bindings.types.js"; function buildThreadTarget(threadId: string): string { return `channel:${threadId}`; } export function isThreadArchived(raw: unknown): boolean { if (!raw || typeof raw !== "object") { return false; } const asRecord = raw as { archived?: unknown; thread_metadata?: { archived?: unknown }; threadMetadata?: { archived?: unknown }; }; if (asRecord.archived === true) { return true; } if (asRecord.thread_metadata?.archived === true) { return true; } if (asRecord.threadMetadata?.archived === true) { return true; } return false; } function isThreadChannelType(type: unknown): boolean { return ( type === ChannelType.PublicThread || type === ChannelType.PrivateThread || type === ChannelType.AnnouncementThread ); } export function summarizeDiscordError(err: unknown): string { if (err instanceof Error) { return err.message; } if (typeof err === "string") { return err; } if ( typeof err === "number" || typeof err === "boolean" || typeof err === "bigint" || typeof err === "symbol" ) { return String(err); } return "error"; } function extractNumericDiscordErrorValue(value: unknown): number | undefined { if (typeof value === "number" && Number.isFinite(value)) { return Math.trunc(value); } if (typeof value === "string" && /^\d+$/.test(value.trim())) { return Number(value); } return undefined; } function extractDiscordErrorStatus(err: unknown): number | undefined { if (!err || typeof err !== "object") { return undefined; } const candidate = err as { status?: unknown; statusCode?: unknown; response?: { status?: unknown }; }; return ( extractNumericDiscordErrorValue(candidate.status) ?? extractNumericDiscordErrorValue(candidate.statusCode) ?? extractNumericDiscordErrorValue(candidate.response?.status) ); } function extractDiscordErrorCode(err: unknown): number | undefined { if (!err || typeof err !== "object") { return undefined; } const candidate = err as { code?: unknown; rawError?: { code?: unknown }; body?: { code?: unknown }; response?: { body?: { code?: unknown }; data?: { code?: unknown } }; }; return ( extractNumericDiscordErrorValue(candidate.code) ?? extractNumericDiscordErrorValue(candidate.rawError?.code) ?? extractNumericDiscordErrorValue(candidate.body?.code) ?? extractNumericDiscordErrorValue(candidate.response?.body?.code) ?? extractNumericDiscordErrorValue(candidate.response?.data?.code) ); } export function isDiscordThreadGoneError(err: unknown): boolean { const code = extractDiscordErrorCode(err); if (code === DISCORD_UNKNOWN_CHANNEL_ERROR_CODE) { return true; } const status = extractDiscordErrorStatus(err); // 404: deleted/unknown channel. 403: bot no longer has access. return status === 404 || status === 403; } export async function maybeSendBindingMessage(params: { cfg?: RemoteClawConfig; record: ThreadBindingRecord; text: string; preferWebhook?: boolean; }) { const text = params.text.trim(); if (!text) { return; } const record = params.record; if (params.preferWebhook !== false && record.webhookId && record.webhookToken) { try { await sendWebhookMessageDiscord(text, { cfg: params.cfg, webhookId: record.webhookId, webhookToken: record.webhookToken, accountId: record.accountId, threadId: record.threadId, username: undefined, }); return; } catch (err) { logVerbose(`discord thread binding webhook send failed: ${summarizeDiscordError(err)}`); } } try { await sendMessageDiscord(buildThreadTarget(record.threadId), text, { cfg: params.cfg, accountId: record.accountId, }); } catch (err) { logVerbose(`discord thread binding fallback send failed: ${summarizeDiscordError(err)}`); } } export async function createWebhookForChannel(params: { cfg?: RemoteClawConfig; accountId: string; token?: string; channelId: string; }): Promise<{ webhookId?: string; webhookToken?: string }> { try { const rest = createDiscordRestClient( { accountId: params.accountId, token: params.token, }, params.cfg, ).rest; const created = (await rest.post(Routes.channelWebhooks(params.channelId), { body: { name: "RemoteClaw Agents", }, })) as { id?: string; token?: string }; const webhookId = typeof created?.id === "string" ? created.id.trim() : ""; const webhookToken = typeof created?.token === "string" ? created.token.trim() : ""; if (!webhookId || !webhookToken) { return {}; } return { webhookId, webhookToken }; } catch (err) { logVerbose( `discord thread binding webhook create failed for ${params.channelId}: ${summarizeDiscordError(err)}`, ); return {}; } } export function findReusableWebhook(params: { accountId: string; channelId: string }): { webhookId?: string; webhookToken?: string; } { const reusableKey = toReusableWebhookKey({ accountId: params.accountId, channelId: params.channelId, }); const cached = REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL.get(reusableKey); if (cached) { return { webhookId: cached.webhookId, webhookToken: cached.webhookToken, }; } for (const record of BINDINGS_BY_THREAD_ID.values()) { if (record.accountId !== params.accountId) { continue; } if (record.channelId !== params.channelId) { continue; } if (!record.webhookId || !record.webhookToken) { continue; } rememberReusableWebhook(record); return { webhookId: record.webhookId, webhookToken: record.webhookToken, }; } return {}; } export async function resolveChannelIdForBinding(params: { cfg?: RemoteClawConfig; accountId: string; token?: string; threadId: string; channelId?: string; }): Promise { const explicit = params.channelId?.trim(); if (explicit) { return explicit; } try { const rest = createDiscordRestClient( { accountId: params.accountId, token: params.token, }, params.cfg, ).rest; const channel = (await rest.get(Routes.channel(params.threadId))) as { id?: string; type?: number; parent_id?: string; parentId?: string; }; const channelId = typeof channel?.id === "string" ? channel.id.trim() : ""; const type = channel?.type; const parentId = typeof channel?.parent_id === "string" ? channel.parent_id.trim() : typeof channel?.parentId === "string" ? channel.parentId.trim() : ""; // Only thread channels should resolve to their parent channel. // Non-thread channels (text/forum/media) must keep their own ID. if (parentId && isThreadChannelType(type)) { return parentId; } return channelId || null; } catch (err) { logVerbose( `discord thread binding channel resolve failed for ${params.threadId}: ${summarizeDiscordError(err)}`, ); return null; } } export async function createThreadForBinding(params: { cfg?: RemoteClawConfig; accountId: string; token?: string; channelId: string; threadName: string; }): Promise { try { const created = await createThreadDiscord( params.channelId, { name: params.threadName, autoArchiveMinutes: 60, }, { cfg: params.cfg, accountId: params.accountId, token: params.token, }, ); const createdId = typeof created?.id === "string" ? created.id.trim() : ""; return createdId || null; } catch (err) { logVerbose( `discord thread binding auto-thread create failed for ${params.channelId}: ${summarizeDiscordError(err)}`, ); return null; } }