import { APP_VERSION } from '@snowluma/common/version'; import { createLogger, type Logger } from '@snowluma/common/logger'; import type { BridgeInterface } from '@snowluma/core/bridge-interface'; import { formatGroup, formatMessageSegments, formatReply, formatUser, } from '@snowluma/protocol/format'; import path from 'path'; import { ApiHandler, type ActionObserver } from './api-handler'; import type { ConverterContext } from './event-converter'; import { registerEventPipeline, type EventPipelineHandle } from './event-pipeline'; import { buildApiContext, type OneBotInstanceContext } from './instance-context'; import { TempSessionStore } from './temp-session-store'; import { RKeyCache } from './instance-rkey'; import type { GlobalSettings } from './global-config'; import { MediaIndexer } from './media-indexer'; import { MediaStore } from './media-store'; import { MediaUrlResolver } from './media-url-resolver'; import { GROUP_MESSAGE_EVENT, PRIVATE_MESSAGE_EVENT, privateMessageEventName } from './message-id'; import { MessageStore } from './message-store'; import { createMessageIdResolver } from './message-id-resolver'; import { sendGroupMessage, sendPrivateMessage } from './modules/message-actions'; import { buildStatusText, matchesStatusCommand, statusCooldownElapsed, } from './modules/status-command'; import { loginHistorySyncCoordinator } from './history-sync'; import { HttpPostAdapter, HttpServerAdapter, OneBotNetworkManager, WsClientAdapter, WsServerAdapter, type AdapterStatus, type DesiredNetworkAdapter, type NetworkAdapterContext, type NetworkReconcileResult, type NetworkShutdownResult, } from './network'; import { ReactionStore } from './reaction-store'; import { sameSelfSentMessage, selfSentEventKey, SELF_SENT_ECHO_WINDOW_MS } from './self-sent-event'; import type { ApiResponse, JsonObject, JsonValue, MessageMeta, OneBotConfig } from './types'; const moduleLog = createLogger('Event'); export class OneBotInstance { readonly uin: string; private readonly bridge: BridgeInterface; private readonly apiHandler: ApiHandler; private readonly converterCtx: ConverterContext; private readonly messageStore: MessageStore; private readonly mediaStore: MediaStore; private readonly reactionStore: ReactionStore; private readonly networkManager: OneBotNetworkManager; private readonly networkReady: Promise; private lifecycleTail: Promise; private readonly rkeyCache: RKeyCache; private readonly ctx: OneBotInstanceContext; /** Account-session uptime shared by the status action and `#sl`. */ private readonly startedAt = Date.now(); /** Per-conversation last-reply timestamp for the `#sl` cooldown. */ private readonly statusCommandCooldown = new Map(); /** Reported self sends retained while matching subsequent QQ echoes. */ private readonly recentSelfSentEvents = new Map< string, { event: JsonObject; expiresAt: number } >(); private eventPipeline: EventPipelineHandle | null = null; private eventPipelineDrain: Promise = Promise.resolve(); private disposePromise: Promise | null = null; private disposeRequested = false; private acceptingActions = true; private readonly inFlightActions = new Set>(); private readonly historySyncAbortController = new AbortController(); private readonly historySyncEnabledAtStartup: boolean; private historySyncStartRequested = false; private historySyncTask: Promise | null = null; private readonly storeCloseState = { message: false, media: false, reaction: false, }; private readonly pids = new Set(); private online = true; private heartbeatTimer: NodeJS.Timeout | null = null; private static readonly HEARTBEAT_INTERVAL = 30000; private readonly log: Logger; get nickname(): string { return this.bridge.identity.nickname; } /** Live status of this account's OneBot network adapters. */ getConnectionStatuses(): AdapterStatus[] { return this.networkManager.describeStatuses(); } // ── Debug taps (WebUI debug stream / tester) ── /** Tap every emitted OneBot event for this account. Returns an unsubscribe. */ subscribeDebugEvents(cb: (event: JsonObject) => void): () => void { return this.networkManager.subscribeDebug(cb); } /** Observe every handled action for this account. Returns an unsubscribe. */ observeActions(cb: ActionObserver): () => void { return this.apiHandler.setObserver(cb); } /** Invoke an OneBot action against this account (debug tester). */ invokeAction(action: string, params: JsonObject): Promise { return this.trackAction(`debug action ${action}`, () => this.apiHandler.handle(action, params), ); } /** Drive an action through the streaming seam (debug tester, stream actions). * `rawRequest` is a `{action, params, echo?}` JSON string; `emit` receives * each frame as a JSON string. Non-stream actions emit a single frame. */ invokeStream( rawRequest: string, emit: (json: string) => void | Promise, isAlive?: () => boolean, ): Promise { return this.trackAction('debug stream action', () => this.apiHandler.processStreamRequest(rawRequest, emit, isAlive), ); } constructor( uin: string, bridge: BridgeInterface, config: OneBotConfig, globalSettings: GlobalSettings, ) { this.uin = uin; this.bridge = bridge; this.historySyncEnabledAtStartup = config.historySync.enabled; const uinNum = Number.parseInt(uin, 10); this.log = Number.isFinite(uinNum) && uinNum > 0 ? moduleLog.child({ uin: uinNum }) : moduleLog; this.rkeyCache = new RKeyCache(globalSettings.rkey); this.mediaStore = new MediaStore(path.join('data', this.uin, 'messages.db')); this.messageStore = new MessageStore(path.join('data', this.uin, 'messages.json')); this.reactionStore = new ReactionStore(path.join('data', this.uin, 'reactions.db')); const mediaUrlResolver = new MediaUrlResolver(this.bridge, this.rkeyCache); const mediaIndexer = new MediaIndexer(this.mediaStore); this.converterCtx = { selfId: parseInt(this.uin, 10) || 0, imageUrlResolver: (element, isGroup) => this.rkeyCache.resolveImageUrl(this.bridge, element, isGroup), mediaUrlResolver: (element, isGroup, sessionId) => mediaUrlResolver.resolve(element, isGroup, sessionId), messageIdResolver: createMessageIdResolver(this.messageStore), mediaSegmentSink: async (mediaType, element, data, isGroup, sessionId) => await mediaIndexer.remember(mediaType, element, data, isGroup, sessionId), }; const ctx: OneBotInstanceContext = { uin: this.uin, selfId: parseInt(this.uin, 10) || 0, bridge: this.bridge, messageStore: this.messageStore, mediaStore: this.mediaStore, reactionStore: this.reactionStore, tempSessions: new TempSessionStore(), converterCtx: this.converterCtx, config, musicSignUrl: globalSettings.musicSignUrl, getUptimeMs: () => Math.max(0, Date.now() - this.startedAt), cacheMessageMeta: async (messageId, meta) => await this.cacheMessageMeta(messageId, meta), dispatchEvent: (event, source = 'bridge', startedAt) => this.dispatchEvent(event, source, startedAt), }; this.ctx = ctx; this.apiHandler = new ApiHandler(buildApiContext(ctx), uinNum > 0 ? uinNum : undefined); const networkCtx = this.buildNetworkContext(); this.networkManager = new OneBotNetworkManager((desired) => this.createNetworkAdapter(desired, networkCtx), ); this.networkReady = Promise.all([ bridge.identity.initialization, this.messageStore.initialization, this.mediaStore.initialization, this.reactionStore.initialization, ]).then(() => this.networkManager.reconcile(config)); this.lifecycleTail = this.networkReady.then( () => undefined, () => undefined, ); this.startHeartbeat(); this.rkeyCache.warmUp(this.bridge, this.uin); this.eventPipeline = registerEventPipeline(ctx); } waitUntilNetworkReady(): Promise { return this.networkReady; } /** Bridge session edge → OneBot notice. Awaited so offline can leave * adapters before this generation is disposed. */ emitBotStatus(subType: 'online' | 'offline'): Promise { return this.dispatchEvent(makeBotStatusEvent(parseInt(this.uin, 10) || 0, subType)); } /** * Start the optional login-time backfill once roster warmup has completed. * The startup snapshot is intentional: hot config edits take effect on the * next account lifecycle and never cause an unexpected QQ request. */ startLoginHistorySync(): void { if (this.historySyncStartRequested || this.disposeRequested) { return; } this.historySyncStartRequested = true; if (!this.historySyncEnabledAtStartup) { this.log.debug('login history sync disabled for this account lifecycle'); return; } this.historySyncTask = loginHistorySyncCoordinator .schedule( { selfId: Number.parseInt(this.uin, 10) || 0, bridge: this.bridge, messageStore: this.messageStore, converterCtx: this.converterCtx, }, this.historySyncAbortController.signal, ) .then( () => undefined, (error) => { if (this.historySyncAbortController.signal.aborted) { this.log.debug( 'login history sync cancelled: %s', error instanceof Error ? error.message : String(error), ); return; } this.log.error( 'login history sync failed: %s', error instanceof Error ? (error.stack ?? error.message) : String(error), ); }, ); } reloadConfig(config: OneBotConfig): Promise { if (this.disposeRequested) { return Promise.reject(new Error(`OneBot instance UIN=${this.uin} is disposing`)); } const snapshot = structuredClone(config); // Validate before entering the FIFO so deterministic errors fail fast. this.networkManager.compileDesiredPlan(snapshot); return this.enqueueLifecycle(async () => { // Keep the shared context's config in sync for exactly the same ordered // snapshot the network manager is applying. Concurrent POSTs therefore // linearize as A then B instead of publishing B while A is reconciling. this.ctx.config = snapshot; return this.networkManager.reconcile(snapshot); }); } /** Apply edited global (all-accounts) settings without a restart. Called by * the manager when config/snowluma.json is saved from the WebUI. */ applyGlobalSettings(globalSettings: GlobalSettings): void { this.rkeyCache.setFallbackServers(globalSettings.rkey.fallbackServers); this.ctx.musicSignUrl = globalSettings.musicSignUrl; } /** Drop listeners and sockets without waiting for shutdown to finish. * Used when dispose() does not settle and a replacement session must bind. */ forceRelease(): void { try { this.quiesce(); } catch (error) { this.log.warn( 'quiesce during forced release failed: %s', error instanceof Error ? error.message : String(error), ); } this.networkManager.forceRelease(); } /** Synchronously stop every ingress seam for this instance generation. * * This operation is sticky and idempotent. It is intentionally separate * from the asynchronous resource release so the manager can quiesce all * generations before waiting for older lifecycle work to settle. */ quiesce(): void { if (this.disposeRequested) { return; } this.disposeRequested = true; this.acceptingActions = false; this.apiHandler.quiesce(); this.online = false; this.stopHeartbeat(); this.historySyncAbortController?.abort( new Error(`OneBot instance UIN=${this.uin} is disposing`), ); const pipeline = this.eventPipeline; if (pipeline) { pipeline.stop(); this.eventPipeline = null; this.eventPipelineDrain = pipeline.drain(); } } dispose(): Promise { if (this.disposePromise) { return this.disposePromise; } this.quiesce(); const attempt = this.enqueueLifecycle(async () => { await Promise.all([ Promise.all(this.inFlightActions), this.eventPipelineDrain, this.historySyncTask ?? Promise.resolve(), ]); const shutdown = await this.networkManager.shutdown(); if (!shutdown.closed) { throw new AggregateError( shutdown.errors.map( (error) => new Error( `adapter ${error.name} ${error.phase} failed: ${error.message}`, ), ), `network shutdown incomplete for UIN=${this.uin}; stores remain open`, ); } // Stores are intentionally closed only after every queued network // reconcile and every transport have actually closed. const closeErrors: unknown[] = []; const stores = [ ['message', async () => await this.messageStore.close()], ['media', async () => await this.mediaStore.close()], ['reaction', async () => await this.reactionStore.close()], ] as const; for (const [name, close] of stores) { if (this.storeCloseState[name]) { continue; } try { await close(); this.storeCloseState[name] = true; } catch (error) { closeErrors.push(error); } } if (closeErrors.length > 0) { throw new AggregateError( closeErrors, `failed to close OneBot stores for UIN=${this.uin}`, ); } return shutdown; }); this.disposePromise = attempt; void attempt.then( () => undefined, () => { // Network-close and per-store completion state are retained. Let a // later dispose call retry only the meaningful unfinished remainder. if (this.disposePromise === attempt) { this.disposePromise = null; } }, ); return attempt; } addPid(pid: number): void { this.pids.add(pid); } removePid(pid: number): void { this.pids.delete(pid); } hasPid(pid: number): boolean { return this.pids.has(pid); } getPids(): number[] { return [...this.pids]; } get empty(): boolean { return this.pids.size === 0; } private async dispatchEvent( event: JsonObject, source: 'bridge' | 'send' = 'bridge', startedAt = Date.now(), ): Promise { if (!(await this.cacheMessageEvent(event))) { this.traceEventHandoffTerminal(event, startedAt, 'suppressed', 'private_recall'); return; } if (source === 'bridge') { if (this.isDuplicateSelfSentEcho(event)) { this.log.trace( 'duplicate self-message echo suppressed message_id=%d message_seq=%d', toInt(event.message_id), toInt(event.message_seq), ); this.traceEventHandoffTerminal( event, startedAt, 'suppressed', 'duplicate_self_echo', ); return; } if (event.post_type === 'message_sent') { this.rememberSelfSentEvent(event); } } else { this.rememberSelfSentEvent(event); } // The send path already logged the real peer and payload. Logging its // synthetic event here would produce a second, less accurate "received" // line whose private user_id points at the bot itself. if (source === 'bridge') { await this.logReceivedMessage(event); } // Built-in `#sl` only consumes events observed from QQ. Action-originated // messages must not recursively trigger the command handler. if (source === 'bridge' && this.handleStatusCommand(event)) { this.traceEventHandoffTerminal(event, startedAt, 'suppressed', 'status_command'); return; } this.traceEventHandoffTerminal(event, startedAt, 'reporting_started'); try { await this.networkManager.emitEvent(event); } catch (err) { this.log.warn( 'emitEvent failed: %s', err instanceof Error ? (err.stack ?? err.message) : String(err), ); } } private traceEventHandoffTerminal( event: JsonObject, startedAt: number, outcome: 'suppressed' | 'reporting_started', reason?: 'duplicate_self_echo' | 'status_command' | 'private_recall', ): void { this.log.trace(() => [ 'event_terminal post_type=%s outcome=%s%s ms=%d', String(event.post_type ?? '?'), outcome, reason === undefined ? '' : ` reason=${reason}`, Date.now() - startedAt, ]); } private rememberSelfSentEvent(event: JsonObject): void { const key = selfSentEventKey(event); if (key === null) { return; } const now = Date.now(); this.sweepSelfSentEvents(now); this.recentSelfSentEvents.set(key, { event, expiresAt: now + SELF_SENT_ECHO_WINDOW_MS }); } private isDuplicateSelfSentEcho(event: JsonObject): boolean { const key = selfSentEventKey(event); if (key === null) { return false; } const now = Date.now(); this.sweepSelfSentEvents(now); const pending = this.recentSelfSentEvents.get(key); if (!pending || !sameSelfSentMessage(pending.event, event)) { return false; } return true; } private sweepSelfSentEvents(now: number): void { for (const [key, pending] of this.recentSelfSentEvents) { if (pending.expiresAt <= now) { this.recentSelfSentEvents.delete(key); } } } /** * Built-in `#sl` status command. Returns `true` when the event matched AND * `swallow` is configured — the caller then skips `emitEvent` so downstream * adapters never see it. The reply itself is fired async and rate-limited * per conversation; matching and replying are independent of swallowing. */ private handleStatusCommand(event: JsonObject): boolean { const cfg = this.ctx.config.statusCommand; if (!cfg.enabled) { return false; } const postType = event.post_type; if (postType !== 'message' && postType !== 'message_sent') { return false; } if (!matchesStatusCommand(event.message, cfg.trigger)) { return false; } const isGroup = event.message_type === 'group'; const sessionId = isGroup ? toInt(event.group_id) : toInt(event.user_id); if (sessionId === 0) { return cfg.swallow; } const key = `${isGroup ? 'g' : 'p'}:${sessionId}`; const now = Date.now(); if (statusCooldownElapsed(this.statusCommandCooldown.get(key), now, cfg.cooldownSeconds)) { this.statusCommandCooldown.set(key, now); void this.trackAction('status command reply', () => this.replyStatus(isGroup, sessionId), ); } return cfg.swallow; } private async replyStatus(isGroup: boolean, sessionId: number): Promise { const text = buildStatusText({ version: APP_VERSION, platform: process.platform, arch: process.arch, uptimeMs: this.ctx.getUptimeMs(), }); if (isGroup) { await sendGroupMessage(this.ctx, sessionId, text, true); } else { await sendPrivateMessage(this.ctx, sessionId, text, true); } } private buildNetworkContext(): NetworkAdapterContext { return { uin: this.uin, api: this.apiHandler, buildLifecycleEvent: (subType) => this.makeLifecycleEvent(subType), buildHeartbeatEvent: () => this.makeHeartbeatEvent(), }; } private createNetworkAdapter(desired: DesiredNetworkAdapter, ctx: NetworkAdapterContext) { switch (desired.kind) { case 'httpServer': return new HttpServerAdapter(desired.name, desired.config, ctx); case 'httpClient': return new HttpPostAdapter(desired.name, desired.config, ctx); case 'wsServer': return new WsServerAdapter(desired.name, desired.config, ctx); case 'wsClient': return new WsClientAdapter(desired.name, desired.config, ctx); } } private enqueueLifecycle(operation: () => Promise): Promise { const result = this.lifecycleTail.then(operation); this.lifecycleTail = result.then( () => undefined, () => undefined, ); return result; } private trackAction(label: string, start: () => Promise): Promise { if (!this.acceptingActions) { return this.rejectedAction( label, new Error(`OneBot instance UIN=${this.uin} is disposing; ${label} rejected`), ); } let action: Promise; try { action = start(); } catch (error) { return this.rejectedAction(label, error); } const tracked = action.then( () => undefined, (error) => { this.log.error( '%s failed: %s', label, error instanceof Error ? (error.stack ?? error.message) : String(error), ); }, ); this.inFlightActions.add(tracked); void tracked.then(() => { this.inFlightActions.delete(tracked); }); return action; } private rejectedAction(label: string, error: unknown): Promise { const rejected = Promise.reject(error); void rejected.catch((reason) => { this.log.error( '%s rejected: %s', label, reason instanceof Error ? (reason.stack ?? reason.message) : String(reason), ); }); return rejected; } private async logReceivedMessage(event: JsonObject): Promise { const isSelf = event.post_type === 'message_sent'; if (event.post_type !== 'message' && !isSelf) { return; } const messageId = toInt(event.message_id); const isGroup = event.message_type === 'group'; const idStr = `ID:${messageId}`; const selfTag = isSelf ? '[自身] ' : ''; const identity = this.bridge.identity; // Walk the segment array once: render via the shared formatter for // non-reply segments, and resolve `reply` segments through the // message store so the chain reference becomes legible // ("[回复 : ...]" instead of "[回复:1234567890]"). const renderedParts: string[] = []; const message = event.message; if (Array.isArray(message)) { for (const seg of message) { if (typeof seg !== 'object' || seg === null || Array.isArray(seg)) { continue; } const type = String((seg as JsonObject).type ?? ''); const data = typeof (seg as JsonObject).data === 'object' && (seg as JsonObject).data !== null && !Array.isArray((seg as JsonObject).data) ? ((seg as JsonObject).data as Record) : {}; if (type === 'reply') { const replyId = toInt(data.id); renderedParts.push(await formatReply(this.messageStore, identity, replyId)); } else { renderedParts.push(formatMessageSegments([seg as JsonValue])); } } } else if (typeof message === 'string') { renderedParts.push(formatMessageSegments(message)); } const content = renderedParts.join(' ').trim() || '[空消息]'; if (isGroup) { const groupId = toInt(event.group_id); const userId = toInt(event.user_id); const sender = typeof event.sender === 'object' && event.sender !== null && !Array.isArray(event.sender) ? (event.sender as JsonObject) : {}; const nicknameFromEvent = (sender.card as string) || (sender.nickname as string) || ''; const userPart = nicknameFromEvent ? `[${nicknameFromEvent}(${userId})]` : formatUser(identity, groupId, userId); this.log.success( `${selfTag}群 ${formatGroup(identity, groupId)} | ${userPart}: ${idStr} ${content}`, ); } else { const userId = toInt(event.user_id); const sender = typeof event.sender === 'object' && event.sender !== null && !Array.isArray(event.sender) ? (event.sender as JsonObject) : {}; const nicknameFromEvent = (sender.nickname as string) || ''; const userPart = nicknameFromEvent ? `[${nicknameFromEvent}(${userId})]` : formatUser(identity, undefined, userId); this.log.success(`${selfTag}私聊 ${userPart}: ${idStr} ${content}`); } } private async cacheMessageEvent(event: JsonObject): Promise { if (event.post_type !== 'message' && event.post_type !== 'message_sent') { return true; } const messageId = toInt(event.message_id); if (messageId === 0) { return true; } const isGroup = event.message_type === 'group'; const sessionId = isGroup ? toInt(event.group_id) : event.post_type === 'message_sent' ? toInt(event.target_id) || toInt(event.user_id) : toInt(event.user_id); const sequence = toInt(event.message_seq); const eventName = isGroup ? GROUP_MESSAGE_EVENT : event.sub_type === 'friend' ? privateMessageEventName(event.post_type === 'message_sent', false) : PRIVATE_MESSAGE_EVENT; if (sessionId === 0) { return true; } return this.messageStore.storeEvent( messageId, isGroup, sessionId, sequence, eventName, event, ); } private async cacheMessageMeta(messageId: number, meta: MessageMeta): Promise { if (!Number.isInteger(messageId) || messageId === 0) { return; } await this.messageStore.storeMeta(messageId, meta); } private makeLifecycleEvent(subType: 'connect' | 'enable' | 'disable'): JsonObject { const selfId = parseInt(this.uin, 10) || 0; const time = Math.floor(Date.now() / 1000); return { time, self_id: selfId, post_type: 'meta_event', meta_event_type: 'lifecycle', sub_type: subType, status: { online: this.online, good: this.online && this.bridge.receiveHealthy, }, }; } private startHeartbeat(): void { this.heartbeatTimer = setInterval(() => { this.dispatchEvent(this.makeHeartbeatEvent()); }, OneBotInstance.HEARTBEAT_INTERVAL); this.heartbeatTimer.unref(); } private makeHeartbeatEvent(): JsonObject { const selfId = parseInt(this.uin, 10) || 0; const time = Math.floor(Date.now() / 1000); return { time, self_id: selfId, post_type: 'meta_event', meta_event_type: 'heartbeat', status: { online: this.online, good: this.online && this.bridge.receiveHealthy }, interval: OneBotInstance.HEARTBEAT_INTERVAL, }; } private stopHeartbeat(): void { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } } } export function makeBotStatusEvent( selfId: number, subType: 'online' | 'offline', time = Math.floor(Date.now() / 1000), ): JsonObject { return { time, self_id: selfId, post_type: 'notice', notice_type: 'bot_status', sub_type: subType, user_id: selfId, }; } function toInt(value: unknown): number { if (typeof value === 'number' && Number.isFinite(value)) { return Math.trunc(value); } if (typeof value === 'string' && value.trim()) { const parsed = Number(value); if (Number.isFinite(parsed)) { return Math.trunc(parsed); } } return 0; }