mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 23:13:17 +08:00
965 lines
33 KiB
JavaScript
965 lines
33 KiB
JavaScript
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;
|
||
}
|
||
}
|