dsh-im-ops/src/channels/discord/discord-runtime.mjs

965 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
import { fetchFileStream } from '../shared/file-download.mjs';
import { fetchImageBuffer } from '../shared/image-prompt.mjs';
import { t } from '../shared/i18n.mjs';
import { captureContextEnhancement } from '../shared/context-enhancement.mjs';
import { evaluateInboundAccess } from '../shared/inbound-access.mjs';
import { DiscordApi } from './discord-api.mjs';
import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
const DISCORD_GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 12) | (1 << 15);
const THREAD_RECOVERY_TIMEOUT_MS = 5_000;
const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
const GUILD_TEXT = 0;
const GUILD_ANNOUNCEMENT = 5;
const ANNOUNCEMENT_THREAD = 10;
const PUBLIC_THREAD = 11;
const PRIVATE_THREAD = 12;
const THREAD_TYPES = new Set([ANNOUNCEMENT_THREAD, PUBLIC_THREAD, PRIVATE_THREAD]);
const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
const IMAGE_FILE_TYPES = new Map([
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],
['.png', 'image/png'],
['.webp', 'image/webp'],
['.gif', 'image/gif'],
]);
const DISCORD_IMAGE_HOSTS = Object.freeze(['cdn.discordapp.com']);
function socketUrl(value) {
const url = new URL(value);
if (url.protocol !== 'wss:') throw new Error('Discord returned an insecure Gateway URL');
url.searchParams.set('v', '10');
url.searchParams.set('encoding', 'json');
return url.href;
}
function addSocketListener(socket, event, listener) {
if (typeof socket.addEventListener === 'function') socket.addEventListener(event, listener);
else if (typeof socket.on === 'function') socket.on(event, listener);
else throw new TypeError('Discord WebSocket does not support events');
}
function eventData(event) {
if (typeof event === 'string') return event;
if (typeof event?.data === 'string') return event.data;
if (Buffer.isBuffer(event)) return event.toString('utf8');
if (Buffer.isBuffer(event?.data)) return event.data.toString('utf8');
return null;
}
function gatewayCloseError(code) {
if (code === 4004) {
const error = new Error(t('Discord Bot Token 无效,请重新填写。'));
error.code = 'discord-401';
return error;
}
if (code === 4013 || code === 4014) {
const error = new Error(t('Discord Gateway Intents 配置不正确,请检查 Developer Portal 的 Bot 设置。'));
error.code = 'discord-intents';
return error;
}
const error = new Error(`Discord Gateway closed (${code || 'unknown'})`);
error.code = 'discord-gateway-closed';
return error;
}
function stripBotMention(text, botId) {
if (typeof text !== 'string') return '';
return text.replace(new RegExp(`<@!?${botId}>`, 'g'), '').trim();
}
function cleanThreadName(message, botId) {
const name = stripBotMention(message?.content ?? '', botId).replace(/\s+/g, ' ').trim()
|| 'DeepSeek Harness';
return [...name].slice(0, 100).join('');
}
function isThreadChannel(channel) {
return THREAD_TYPES.has(Number(channel?.type));
}
function isThreadFromMessage(channel, message) {
return isThreadChannel(channel)
&& String(channel?.id ?? '') === String(message?.id ?? '')
&& String(channel?.parent_id ?? '') === String(message?.channel_id ?? '');
}
function rememberChannel(channel, callback) {
if (channel?.id) callback?.(channel);
return channel;
}
function withConversationRoute(normalized, channel, botId, {
fromSourceMessage = false,
fallback = null,
notice = null,
} = {}) {
const channelId = String(channel?.id ?? normalized.conversationId);
const thread = isThreadChannel(channel);
const managed = thread && String(channel?.owner_id ?? '') === String(botId);
const parentId = thread && channel?.parent_id ? String(channel.parent_id) : channelId;
return {
...normalized,
conversationId: channelId,
addressed: normalized.addressed || managed,
requiresMention: thread ? !managed : normalized.kind === 'group',
replyTarget: {
channelId,
...(!fromSourceMessage && normalized.replyTarget?.replyToMessageId
? { replyToMessageId: normalized.replyTarget.replyToMessageId }
: {}),
...(notice ? { notice } : {}),
},
connectionTestTarget: { channelId },
conversationRoute: {
peerId: parentId,
...(thread ? { threadId: channelId, managed } : {}),
...(fallback ? { fallback } : {}),
},
};
}
function uncertainThreadCreate(error) {
const status = Number(error?.status);
return !Number.isInteger(status) || status >= 500;
}
function threadCreateUncertain(cause) {
const error = new Error('Discord Thread creation result is uncertain', { cause });
error.code = 'discord-thread-create-uncertain';
return error;
}
async function findCreatedThread(api, message, { signal, onChannel } = {}) {
try {
const channel = rememberChannel(await api.getChannel({
channelId: String(message.id),
signal,
}), onChannel);
return isThreadFromMessage(channel, message) ? channel : null;
} catch (error) {
if (error?.status === 404) return null;
throw error;
}
}
async function sendThreadUncertainNotice(api, normalized, signal) {
try {
await api.createMessage({
channelId: normalized.replyTarget.channelId,
replyToMessageId: normalized.replyTarget.replyToMessageId,
content: 'Thread 创建结果暂时无法确认。若已创建,请在对应 Thread 中重试;若未创建,请稍后重新 @机器人。',
signal,
});
} catch (error) {
if (signal?.aborted) throw signal.reason ?? error;
}
}
function attachmentMediaType(attachment) {
const value = typeof attachment?.content_type === 'string'
? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : '';
if (IMAGE_MEDIA_TYPES.has(value)) return value;
const filename = typeof attachment?.filename === 'string' ? attachment.filename.toLowerCase() : '';
for (const [extension, mediaType] of IMAGE_FILE_TYPES) {
if (filename.endsWith(extension)) return mediaType;
}
return null;
}
function attachmentSize(value) {
return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
}
function discordImageSource(attachment, fetchImpl) {
const mediaType = attachmentMediaType(attachment);
if (!mediaType || typeof attachment?.url !== 'string') return null;
return {
name: typeof attachment.filename === 'string' ? attachment.filename : undefined,
mediaType,
size: attachmentSize(attachment.size),
load: (options) => fetchImageBuffer(attachment.url, {
...options,
fetchImpl,
allowedHosts: DISCORD_IMAGE_HOSTS,
}),
};
}
function discordFileSource(attachment, fetchImpl) {
if (attachmentMediaType(attachment) || typeof attachment?.url !== 'string' || !attachment.url) {
return null;
}
const mediaType = typeof attachment.content_type === 'string' && attachment.content_type
? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : undefined;
return {
name: typeof attachment.filename === 'string' && attachment.filename
? attachment.filename : String(attachment.id ?? 'discord-file'),
...(mediaType ? { mediaType } : {}),
size: attachmentSize(attachment.size),
load: ({ signal } = {}) => fetchFileStream(attachment.url, {
fetchImpl,
signal,
allowedHosts: DISCORD_IMAGE_HOSTS,
}),
};
}
function discordReplyAttachment(attachment) {
if (!attachment || typeof attachment !== 'object') return null;
const mediaType = typeof attachment.content_type === 'string'
? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : '';
const kind = mediaType.startsWith('image/') ? 'image'
: mediaType.startsWith('audio/') ? 'audio'
: mediaType.startsWith('video/') ? 'video' : 'file';
const name = typeof attachment.filename === 'string' && attachment.filename
? attachment.filename : undefined;
return { kind, ...(name ? { name } : {}) };
}
function discordReplySnapshot(message, fallbackMessageId) {
if (!message || typeof message !== 'object') return null;
const messageId = typeof message.id === 'string' && message.id
? message.id : fallbackMessageId;
const authorId = typeof message.author?.id === 'string' && message.author.id
? message.author.id : undefined;
const authorName = [message.member?.nick, message.author?.global_name, message.author?.username]
.find((value) => typeof value === 'string' && value.trim());
const attachments = Array.isArray(message.attachments)
? message.attachments.map(discordReplyAttachment).filter(Boolean)
: [];
if (Array.isArray(message.sticker_items)) {
attachments.push(...message.sticker_items.map((sticker) => ({
kind: 'image',
...(typeof sticker?.name === 'string' && sticker.name ? { name: sticker.name } : {}),
})));
}
return {
...(messageId ? { messageId: String(messageId) } : {}),
...(authorId ? { authorId } : {}),
...(authorName ? { authorName } : {}),
content: typeof message.content === 'string' ? message.content : '',
attachments,
};
}
function discordReplyReference(message, loadReply) {
const channelId = String(message?.channel_id ?? '');
const referenceId = typeof message?.message_reference?.message_id === 'string'
&& message.message_reference.message_id
? message.message_reference.message_id : undefined;
const referenceChannelId = message?.message_reference?.channel_id;
if (referenceChannelId !== undefined && String(referenceChannelId) !== channelId) {
return {
...(referenceId ? { messageId: referenceId } : {}),
unavailableReason: 'not-found',
};
}
if (Object.hasOwn(message ?? {}, 'referenced_message')) {
if (message.referenced_message === null) {
return {
...(referenceId ? { messageId: referenceId } : {}),
unavailableReason: 'deleted',
};
}
if (message.referenced_message && typeof message.referenced_message === 'object') {
const snapshotId = typeof message.referenced_message.id === 'string'
&& message.referenced_message.id ? message.referenced_message.id : undefined;
if (String(message.referenced_message.channel_id ?? '') !== channelId
|| !snapshotId || (referenceId && snapshotId !== referenceId)) {
return {
...(referenceId ? { messageId: referenceId } : {}),
unavailableReason: 'not-found',
};
}
return discordReplySnapshot(message.referenced_message, referenceId) ?? undefined;
}
}
if (!referenceId) return undefined;
if (typeof loadReply !== 'function') {
return { messageId: referenceId, unavailableReason: 'not-delivered' };
}
return {
messageId: referenceId,
load: async ({ signal } = {}) => {
const referenced = await loadReply({ channelId, messageId: referenceId, signal });
if (!referenced || String(referenced.id ?? '') !== referenceId
|| String(referenced.channel_id ?? '') !== channelId) return null;
return discordReplySnapshot(referenced, referenceId);
},
};
}
export function normalizeDiscordMessage(message, botId, {
fetchImpl = fetch,
loadReply,
} = {}) {
if (!message?.id || !message?.channel_id || !message?.author?.id
|| Number(message.type) === 21) return null;
const direct = !message.guild_id;
const addressed = direct
|| message.mentions?.some((mention) => String(mention?.id) === String(botId));
const replyTo = discordReplyReference(message, loadReply);
return {
messageId: String(message.id),
senderId: String(message.author.id),
contextSource: () => ({
senderName: [message.member?.nick, message.author.global_name, message.author.username]
.find((value) => typeof value === 'string' && value.trim()),
}),
senderIsBot: message.author.bot === true,
kind: direct ? 'direct' : 'group',
conversationId: String(message.channel_id),
content: stripBotMention(message.content ?? '', botId),
plainText: (!Array.isArray(message.attachments) || message.attachments.length === 0)
&& (!Array.isArray(message.sticker_items) || message.sticker_items.length === 0)
&& !message.poll,
images: Array.isArray(message.attachments)
? message.attachments.map((attachment) => discordImageSource(attachment, fetchImpl)).filter(Boolean)
: [],
files: Array.isArray(message.attachments)
? message.attachments.map((attachment) => discordFileSource(attachment, fetchImpl)).filter(Boolean)
: [],
...(replyTo ? { replyTo } : {}),
addressed,
replyTarget: {
channelId: String(message.channel_id),
replyToMessageId: String(message.id),
},
reactionTarget: {
channelId: String(message.channel_id),
messageId: String(message.id),
},
connectionTestTarget: { channelId: String(message.channel_id) },
};
}
export async function resolveDiscordMessageRoute(message, botId, {
api,
channel,
fetchImpl = fetch,
signal,
onChannel,
} = {}) {
const normalized = normalizeDiscordMessage(message, botId, {
fetchImpl,
loadReply: typeof api?.getMessage === 'function'
? (options) => api.getMessage(options)
: undefined,
});
if (!normalized || normalized.senderIsBot) return normalized;
signal?.throwIfAborted();
if (normalized.kind === 'direct') {
return withConversationRoute(normalized, { id: normalized.conversationId, type: 1 }, botId);
}
if (!api || typeof api.getChannel !== 'function') {
throw new TypeError('Discord route resolution requires the Discord API');
}
const sourceChannel = rememberChannel(channel ?? await api.getChannel({
channelId: normalized.conversationId,
signal,
}), onChannel);
if (isThreadChannel(sourceChannel)) {
return withConversationRoute(normalized, sourceChannel, botId);
}
if (!normalized.addressed) return withConversationRoute(normalized, sourceChannel, botId);
const sourceType = Number(sourceChannel?.type);
if (sourceType !== GUILD_TEXT && sourceType !== GUILD_ANNOUNCEMENT) {
return withConversationRoute(normalized, sourceChannel, botId, {
fallback: 'unsupported-channel',
notice: '当前频道不支持自动创建 Thread,已直接在当前频道回复。',
});
}
signal?.throwIfAborted();
let created;
try {
created = rememberChannel(await api.startThreadFromMessage({
channelId: normalized.conversationId,
messageId: normalized.messageId,
name: cleanThreadName(message, botId),
signal,
}), onChannel);
if (!isThreadFromMessage(created, message)) {
throw new Error('Discord returned an invalid thread for the source message');
}
} catch (error) {
let recovered;
try {
recovered = await findCreatedThread(api, message, {
signal: AbortSignal.timeout(THREAD_RECOVERY_TIMEOUT_MS),
onChannel,
});
} catch (recoveryError) {
if (signal?.aborted) throw signal.reason ?? error;
await sendThreadUncertainNotice(api, normalized, signal);
throw threadCreateUncertain(recoveryError);
}
if (recovered) {
return withConversationRoute(normalized, recovered, botId, { fromSourceMessage: true });
}
if (signal?.aborted) throw signal.reason ?? error;
if (uncertainThreadCreate(error)) {
await sendThreadUncertainNotice(api, normalized, signal);
throw threadCreateUncertain(error);
}
return withConversationRoute(normalized, sourceChannel, botId, {
fallback: 'thread-create-failed',
notice: '无法创建 Thread,已直接在当前频道回复。',
});
}
return withConversationRoute(normalized, created, botId, { fromSourceMessage: true });
}
export class DiscordBotClient {
#api;
#signal;
#deliveredNotices = new WeakSet();
constructor({ api, signal }) {
this.#api = api;
this.#signal = signal;
}
async sendText(target, text) {
const notice = !this.#deliveredNotices.has(target) && target?.notice
? String(target.notice) : null;
const chunks = splitMessageText(notice ? `${notice}\n\n${text}` : text, 1_900);
const providerMessageIds = [];
for (const [index, chunk] of chunks.entries()) {
const result = await this.#api.createMessage({
channelId: target.channelId,
content: chunk,
replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
signal: this.#signal,
});
if (notice && index === 0) this.#deliveredNotices.add(target);
if (typeof result?.id === 'string' && result.id) providerMessageIds.push(result.id);
}
return { providerMessageIds };
}
sendTyping(target) {
return this.#api.sendTyping({ channelId: target.channelId, signal: this.#signal });
}
sendFile(target, file) {
return this.#api.createFileMessage({
channelId: target.channelId,
file,
replyToMessageId: target.replyToMessageId,
signal: this.#signal,
});
}
addReaction(target, emoji, { signal } = {}) {
return this.#api.addOwnReaction({
channelId: target.channelId,
messageId: target.messageId,
emoji,
signal: this.#operationSignal(signal),
});
}
removeReaction(target, reactionKey, { signal } = {}) {
return this.#api.removeOwnReaction({
channelId: target.channelId,
messageId: target.messageId,
emoji: reactionKey,
signal: this.#operationSignal(signal),
});
}
async openStream(target) {
const notice = !this.#deliveredNotices.has(target) && target?.notice
? String(target.notice) : null;
const decorate = (content) => notice ? `${notice}\n\n${content}` : content;
const stream = createEditableMessageStream({
limit: notice ? 1_800 : 1_900,
create: async (content) => {
const message = await this.#api.createMessage({
channelId: target.channelId,
content: decorate(content),
replyToMessageId: target.replyToMessageId,
signal: this.#signal,
});
if (notice) this.#deliveredNotices.add(target);
return message.id;
},
edit: (messageId, content) => this.#api.editMessage({
channelId: target.channelId,
messageId,
content: decorate(content),
signal: this.#signal,
}),
sendRemainder: (content) => this.#api.createMessage({
channelId: target.channelId,
content,
signal: this.#signal,
}),
messageIdForResult: (message) => message?.id,
});
return stream.start();
}
#operationSignal(signal) {
return signal ?? this.#signal;
}
}
export function createDiscordRuntimeStatus() {
return {
startedAt: null,
ready: false,
connectionState: 'idle',
harnessReachable: false,
lastCheckedAt: null,
lastConnectedAt: null,
lastError: null,
...createDiscordBridgeStatus(),
};
}
export class DiscordRuntime {
#config;
#token;
#harness;
#state;
#contextEnhancement;
#accessPolicy;
#logger;
#replyTimeoutMs;
#connectTimeoutMs;
#createApi;
#createWebSocket;
#random;
#status = createDiscordRuntimeStatus();
#api = null;
#bridge = null;
#abortController = null;
#socket = null;
#gatewayUrl = null;
#resumeUrl = null;
#sessionId = null;
#sequence = null;
#heartbeatTimer = null;
#heartbeatAcked = true;
#reconnectTimer = null;
#reconnectAttempt = 0;
#generation = 0;
#stopped = true;
#starting = null;
#channels = new Map();
#routing = new Map();
constructor({
config,
token,
harness,
state,
contextEnhancement,
accessPolicy,
logger = console,
replyTimeoutMs = 600_000,
connectTimeoutMs = 20_000,
createApi = (options) => new DiscordApi(options),
createWebSocket = (url) => new WebSocket(url),
random = Math.random,
}) {
if (!config || !token || !harness || !state) {
throw new TypeError('DiscordRuntime requires config, token, Harness, and state');
}
if (typeof createWebSocket !== 'function') throw new TypeError('DiscordRuntime requires WebSocket');
this.#config = config;
this.#token = token;
this.#harness = harness;
this.#state = state;
this.#contextEnhancement = contextEnhancement;
this.#accessPolicy = accessPolicy;
this.#logger = logger;
this.#replyTimeoutMs = replyTimeoutMs;
this.#connectTimeoutMs = connectTimeoutMs;
this.#createApi = createApi;
this.#createWebSocket = createWebSocket;
this.#random = random;
}
get status() {
return structuredClone(this.#status);
}
async sendConnectionTest(text) {
if (!this.#status.ready || !this.#bridge) {
const error = new Error('Discord bot is not connected');
error.code = 'test-target-unavailable';
throw error;
}
return this.#bridge.sendConnectionTest(text);
}
async sendProactiveText(target, text, options = {}) {
if (!this.#status.ready || !this.#bridge) {
const error = new Error('Discord bot is not connected');
error.code = 'bot-not-connected';
throw error;
}
const channelId = typeof target?.route?.channelId === 'string'
? target.route.channelId.trim() : '';
if (target?.kind !== 'channel' || !channelId) {
const error = new TypeError('Invalid Discord proactive delivery target');
error.code = 'invalid-target';
throw error;
}
return this.#bridge.sendProactiveText({ channelId }, text, options);
}
async start() {
if (this.#status.ready && this.#socket) return this.status;
if (this.#starting) return this.#starting;
this.#starting = this.#start().finally(() => {
this.#starting = null;
});
return this.#starting;
}
async #start() {
await this.stop();
this.#stopped = false;
this.#sessionId = null;
this.#resumeUrl = null;
this.#sequence = null;
this.#reconnectAttempt = 0;
this.#channels.clear();
this.#routing.clear();
this.#status.startedAt = new Date().toISOString();
this.#status.connectionState = 'connecting';
this.#status.lastError = null;
await this.#harness.ensureRunning();
this.#status.harnessReachable = true;
const controller = new AbortController();
this.#abortController = controller;
const api = this.#createApi({ token: this.#token });
this.#api = api;
try {
const [bot, gateway] = await Promise.all([
api.getCurrentUser({ signal: controller.signal }),
api.getGatewayBot({ signal: controller.signal }),
]);
if (String(bot?.id ?? '') !== this.#config.platformId || bot?.bot !== true) {
throw new Error('Discord token identity does not match the saved bot');
}
this.#gatewayUrl = gateway?.url;
const client = new DiscordBotClient({ api, signal: controller.signal });
this.#bridge = new DiscordHarnessBridge({
bot: client,
harness: this.#harness,
state: this.#state,
contextEnhancement: this.#contextEnhancement,
accessPolicy: this.#accessPolicy,
status: this.#status,
logger: this.#logger,
replyTimeoutMs: this.#replyTimeoutMs,
signal: controller.signal,
});
let timer;
try {
await Promise.race([
this.#openSocket(false),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error('Discord Gateway did not become ready in time')), this.#connectTimeoutMs);
}),
]);
} finally {
clearTimeout(timer);
}
return this.status;
} catch (error) {
this.#status.ready = false;
this.#status.connectionState = 'failed';
this.#status.lastError = error?.message ?? String(error);
await this.stop();
throw error;
}
}
#openSocket(resume) {
if (this.#stopped) return Promise.reject(new Error('Discord runtime is stopped'));
const generation = ++this.#generation;
const url = socketUrl(resume && this.#resumeUrl ? this.#resumeUrl : this.#gatewayUrl);
const socket = this.#createWebSocket(url);
this.#socket = socket;
let settled = false;
return new Promise((resolve, reject) => {
const markReady = () => {
if (settled || generation !== this.#generation) return;
settled = true;
this.#reconnectAttempt = 0;
const now = Date.now();
this.#status.ready = true;
this.#status.connectionState = 'connected';
this.#status.lastCheckedAt = now;
this.#status.lastConnectedAt = now;
this.#status.lastError = null;
resolve();
};
addSocketListener(socket, 'message', (event) => {
if (generation !== this.#generation || this.#stopped) return;
const raw = eventData(event);
if (!raw) return;
let packet;
try {
packet = JSON.parse(raw);
} catch {
this.#logger.warn?.('[dsh-im:discord] ignored malformed Gateway JSON');
return;
}
if (Number.isSafeInteger(packet.s)) this.#sequence = packet.s;
if (packet.op === 10) {
this.#startHeartbeat(packet.d?.heartbeat_interval, socket, generation);
if (resume && this.#sessionId) {
this.#sendGateway(socket, {
op: 6,
d: { token: this.#token, session_id: this.#sessionId, seq: this.#sequence },
});
} else {
this.#sendGateway(socket, {
op: 2,
d: {
token: this.#token,
intents: DISCORD_GATEWAY_INTENTS,
properties: {
os: process.platform,
browser: 'dsh-im',
device: 'dsh-im',
},
},
});
}
return;
}
if (packet.op === 11) {
this.#heartbeatAcked = true;
this.#status.lastCheckedAt = Date.now();
return;
}
if (packet.op === 1) {
this.#heartbeat(socket);
return;
}
if (packet.op === 7) {
socket.close(4000, 'Reconnect requested');
return;
}
if (packet.op === 9) {
if (packet.d !== true) {
this.#sessionId = null;
this.#resumeUrl = null;
this.#sequence = null;
}
socket.close(4000, 'Invalid session');
return;
}
if (packet.op !== 0) return;
if (packet.t === 'READY') {
this.#sessionId = packet.d?.session_id ?? null;
this.#resumeUrl = packet.d?.resume_gateway_url ?? null;
markReady();
} else if (packet.t === 'RESUMED') {
markReady();
} else if (packet.t === 'GUILD_CREATE') {
for (const channel of [...(packet.d?.channels ?? []), ...(packet.d?.threads ?? [])]) {
this.#rememberChannel(channel);
}
} else if (packet.t === 'CHANNEL_CREATE' || packet.t === 'CHANNEL_UPDATE'
|| packet.t === 'THREAD_CREATE' || packet.t === 'THREAD_UPDATE') {
this.#rememberChannel(packet.d);
} else if (packet.t === 'CHANNEL_DELETE' || packet.t === 'THREAD_DELETE') {
if (packet.d?.id) this.#channels.delete(String(packet.d.id));
} else if (packet.t === 'THREAD_LIST_SYNC') {
for (const channel of packet.d?.threads ?? []) this.#rememberChannel(channel);
} else if (packet.t === 'MESSAGE_CREATE') {
const bridge = this.#bridge;
if (packet.d && bridge) {
void this.#acceptMessage(packet.d, bridge).catch((error) => {
if (generation !== this.#generation || this.#stopped) return;
this.#logger.error?.(
`[dsh-im:discord] bot ${this.#config.botId} message handling failed:`,
error,
);
});
}
}
});
addSocketListener(socket, 'close', (event = {}) => {
if (generation !== this.#generation) return;
this.#clearHeartbeat();
if (this.#socket === socket) this.#socket = null;
if (this.#stopped) {
if (!settled) reject(new DOMException('Stopped', 'AbortError'));
return;
}
const error = gatewayCloseError(Number(event.code) || 0);
this.#status.ready = false;
this.#status.connectionState = 'connecting';
this.#status.lastError = error.message;
if (!settled) {
settled = true;
reject(error);
}
if (error.code === 'discord-401' || error.code === 'discord-intents') {
this.#status.connectionState = 'failed';
return;
}
this.#scheduleReconnect();
});
addSocketListener(socket, 'error', () => {
if (generation !== this.#generation || this.#stopped) return;
this.#status.lastError = 'Discord Gateway WebSocket error';
});
});
}
#rememberChannel(channel) {
if (!channel?.id) return;
this.#channels.set(String(channel.id), channel);
}
async #acceptMessage(message, bridge) {
const messageId = String(message?.id ?? '');
if (!messageId || this.#state.hasSeen(messageId)) return;
const preflight = normalizeDiscordMessage(message, this.#config.platformId);
let accessDecision;
if (preflight?.kind === 'group' && preflight.addressed === true
&& preflight.senderIsBot !== true) {
accessDecision = evaluateInboundAccess(this.#accessPolicy, {
conversationType: 'group',
senderIds: [preflight.senderId],
text: preflight.content,
hasImages: preflight.images.length > 0,
hasFiles: preflight.files.length > 0,
});
if (!accessDecision.allowed) {
// Let the shared bridge apply its normal silent/command-denial behavior,
// but do so against the source channel before creating a Thread.
await bridge.accept(preflight, { accessDecision });
return;
}
}
let route = this.#routing.get(messageId);
if (!route) {
const contextSnapshot = captureContextEnhancement(
this.#contextEnhancement,
message.guild_id ? 'group' : 'direct',
);
const pendingRoute = resolveDiscordMessageRoute(message, this.#config.platformId, {
api: this.#api,
channel: this.#channels.get(String(message.channel_id)),
signal: this.#abortController?.signal,
onChannel: (resolved) => this.#rememberChannel(resolved),
});
route = { pendingRoute, contextSnapshot };
this.#routing.set(messageId, route);
void pendingRoute.finally(() => {
if (this.#routing.get(messageId) === route) this.#routing.delete(messageId);
}).catch(() => undefined);
}
try {
const normalized = await route.pendingRoute;
if (normalized) {
await bridge.accept(normalized, {
contextSnapshot: route.contextSnapshot,
...(accessDecision ? { accessDecision } : {}),
});
}
} catch (error) {
if (error?.code === 'discord-thread-create-uncertain') {
await this.#state.markSeen(messageId);
}
throw error;
}
}
#sendGateway(socket, payload) {
if (socket.readyState !== 1) return;
socket.send(JSON.stringify(payload));
}
#startHeartbeat(interval, socket, generation) {
this.#clearHeartbeat();
if (!Number.isFinite(interval) || interval < 1_000) {
socket.close(4000, 'Invalid heartbeat interval');
return;
}
this.#heartbeatAcked = true;
const schedule = (delay) => {
this.#heartbeatTimer = setTimeout(() => {
if (this.#stopped || generation !== this.#generation || this.#socket !== socket) return;
if (!this.#heartbeatAcked) {
socket.close(4000, 'Heartbeat was not acknowledged');
return;
}
this.#heartbeat(socket);
schedule(interval);
}, delay);
this.#heartbeatTimer?.unref?.();
};
schedule(Math.floor(interval * this.#random()));
}
#heartbeat(socket) {
this.#heartbeatAcked = false;
this.#sendGateway(socket, { op: 1, d: this.#sequence });
}
#clearHeartbeat() {
if (this.#heartbeatTimer !== null) clearTimeout(this.#heartbeatTimer);
this.#heartbeatTimer = null;
this.#heartbeatAcked = true;
}
#scheduleReconnect() {
if (this.#stopped || this.#reconnectTimer !== null) return;
const delay = RECONNECT_DELAYS_MS[Math.min(this.#reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
this.#reconnectAttempt += 1;
this.#reconnectTimer = setTimeout(() => {
this.#reconnectTimer = null;
void this.#openSocket(Boolean(this.#sessionId)).catch((error) => {
if (this.#stopped) return;
this.#logger.warn?.('[dsh-im:discord] Gateway reconnect failed:', error);
this.#scheduleReconnect();
});
}, delay);
this.#reconnectTimer?.unref?.();
}
async stop() {
this.#stopped = true;
this.#generation += 1;
this.#abortController?.abort();
this.#abortController = null;
if (this.#reconnectTimer !== null) clearTimeout(this.#reconnectTimer);
this.#reconnectTimer = null;
this.#clearHeartbeat();
const socket = this.#socket;
const bridge = this.#bridge;
this.#socket = null;
this.#bridge = null;
this.#api = null;
this.#routing.clear();
try {
if (socket && socket.readyState < 2) socket.close(1000, 'Plugin stopped');
} catch (error) {
this.#logger.warn?.(`[dsh-im:discord] bot ${this.#config.botId} failed to close Gateway:`, error);
}
await Promise.race([
bridge?.waitForIdle() ?? Promise.resolve(),
new Promise((resolve) => setTimeout(resolve, 2_000)),
]);
this.#status.ready = false;
this.#status.connectionState = 'idle';
return this.status;
}
}