import { mapWithConcurrency } from '@snowluma/common/concurrency'; import { createLogger } from '@snowluma/common/logger'; import type { BridgeInterface } from '@snowluma/core/bridge-interface'; import { LOGIN_HISTORY_SYNC_PROTOCOL_LIMITS, type GroupHistorySyncState, type PrivateHistorySyncState, } from '@snowluma/core/apis'; import type { FriendMessage, GroupMessage } from '@snowluma/protocol/events'; import { convertEvent, type ConverterContext } from './event-converter'; import { persistHistoryEvent, toHistoryInt } from './history-persistence'; import type { MessageStore } from './message-store'; export const LOGIN_HISTORY_SYNC_LIMITS = { startupDelayMs: 30_000, scannedGroups: LOGIN_HISTORY_SYNC_PROTOCOL_LIMITS.maxTargetsPerKind, scannedPrivateUsers: LOGIN_HISTORY_SYNC_PROTOCOL_LIMITS.maxTargetsPerKind, maxGroups: 3, maxPrivateUsers: 3, messagesPerSession: LOGIN_HISTORY_SYNC_PROTOCOL_LIMITS.maxMessagesPerSession, } as const; export interface LoginHistorySyncClock { sleep(ms: number, signal: AbortSignal): Promise; } export interface LoginHistorySyncRef { selfId: number; bridge: BridgeInterface; messageStore: MessageStore; converterCtx: ConverterContext; } export interface LoginHistorySyncResult { scannedGroups: number; scannedPrivateUsers: number; selectedGroups: number; selectedPrivateUsers: number; fetchedMessages: number; storedMessages: number; failedSessions: number; truncatedGroups: boolean; truncatedPrivateUsers: boolean; } interface GroupCandidate { state: GroupHistorySyncState; localSequence: number | null; priority: number; knownGap: boolean; } interface PrivateCandidate { state: PrivateHistorySyncState; localSequence: number | null; priority: number; knownGap: boolean; } const log = createLogger('OneBot.HistorySync'); const SYSTEM_CLOCK: LoginHistorySyncClock = { sleep: (ms, signal) => abortableSleep(ms, signal), }; /** * Process-wide coordinator for automatic login backfill. * * The 30-second grace period happens before entering the global FIFO, so one * account waiting out startup does not block another account that is already * ready. The complete per-account operation is serialized so conversion and * persistence cannot overlap another account's automatic sync. */ export class LoginHistorySyncCoordinator { private tail: Promise = Promise.resolve(); constructor(private readonly clock: LoginHistorySyncClock = SYSTEM_CLOCK) {} async schedule(ref: LoginHistorySyncRef, signal: AbortSignal): Promise { await this.clock.sleep(LOGIN_HISTORY_SYNC_LIMITS.startupDelayMs, signal); throwIfAborted(signal); let operationStarted = false; const operation = this.tail.then(async () => { throwIfAborted(signal); operationStarted = true; return this.runAccount(ref, signal); }); this.tail = operation.then( () => undefined, () => undefined, ); return rejectWhileQueuedOnAbort(operation, signal, () => operationStarted); } private async runAccount( ref: LoginHistorySyncRef, signal: AbortSignal, ): Promise { const groups = ref.bridge.identity.groups.filter( (group) => Number.isSafeInteger(group.groupId) && group.groupId > 0, ); const privateUsers = ref.bridge.identity.friends.filter( (friend) => Number.isSafeInteger(friend.uin) && friend.uin > 0 && friend.uin !== ref.selfId && typeof friend.uid === 'string' && friend.uid.trim().length > 0, ); const truncatedGroups = groups.length > LOGIN_HISTORY_SYNC_LIMITS.scannedGroups; const truncatedPrivateUsers = privateUsers.length > LOGIN_HISTORY_SYNC_LIMITS.scannedPrivateUsers; const scannedGroups = groups.slice(0, LOGIN_HISTORY_SYNC_LIMITS.scannedGroups); const scannedPrivateUsers = privateUsers.slice( 0, LOGIN_HISTORY_SYNC_LIMITS.scannedPrivateUsers, ); if (truncatedGroups || truncatedPrivateUsers) { log.warn( 'login history sync roster truncated: UIN=%d groups=%d/%d private=%d/%d', ref.selfId, scannedGroups.length, groups.length, scannedPrivateUsers.length, privateUsers.length, ); } throwIfAborted(signal); const state = await ref.bridge.apis.message.probeHistorySyncState( scannedGroups.map((group) => group.groupId), scannedPrivateUsers.map((friend) => ({ userId: friend.uin, uid: friend.uid, })), signal, ); const groupCandidates = await selectGroupCandidates(ref.messageStore, state.groups); const privateCandidates = await selectPrivateCandidates( ref.messageStore, state.privateUsers, ); // URL resolvers may issue additional QQ requests for rkeys or media // download addresses. Automatic backfill must stay inside its one-probe // plus six-page packet budget, so persist only URLs already present in the // history response. Message-id resolution and local media indexing remain // enabled. const mediaSegmentSink = ref.converterCtx.mediaSegmentSink; const persistenceRef: LoginHistorySyncRef = { ...ref, converterCtx: { ...ref.converterCtx, imageUrlResolver: null, mediaUrlResolver: null, mediaSegmentSink: mediaSegmentSink ? async (mediaType, element, data, isGroup, sessionId) => { throwIfAborted(signal); await mediaSegmentSink(mediaType, element, data, isGroup, sessionId); } : null, }, }; const result: LoginHistorySyncResult = { scannedGroups: scannedGroups.length, scannedPrivateUsers: scannedPrivateUsers.length, selectedGroups: groupCandidates.length, selectedPrivateUsers: privateCandidates.length, fetchedMessages: 0, storedMessages: 0, failedSessions: 0, truncatedGroups, truncatedPrivateUsers, }; log.info( 'login history sync selected: UIN=%d scanned=%d/%d selected=%d/%d', ref.selfId, result.scannedGroups, result.scannedPrivateUsers, result.selectedGroups, result.selectedPrivateUsers, ); for (const candidate of groupCandidates) { throwIfAborted(signal); try { const messages = await fetchGroupCandidate(persistenceRef, candidate, signal); throwIfAborted(signal); result.fetchedMessages += messages.length; for (const message of messages) { throwIfAborted(signal); await persistGroupMessage(persistenceRef, message, signal); result.storedMessages += 1; } } catch (error) { if (signal.aborted) { throwIfAborted(signal); } result.failedSessions += 1; log.warn( 'login history sync group failed: UIN=%d group=%d error=%s', ref.selfId, candidate.state.groupId, errorText(error), ); } } for (const candidate of privateCandidates) { throwIfAborted(signal); try { const messages = await fetchPrivateCandidate(persistenceRef, candidate, signal); throwIfAborted(signal); result.fetchedMessages += messages.length; for (const message of messages) { throwIfAborted(signal); if ( await persistPrivateMessage( persistenceRef, candidate.state.userId, message, signal, ) ) { result.storedMessages += 1; } } } catch (error) { if (signal.aborted) { throwIfAborted(signal); } result.failedSessions += 1; log.warn( 'login history sync private failed: UIN=%d peer=%d error=%s', ref.selfId, candidate.state.userId, errorText(error), ); } } log.info( 'login history sync complete: UIN=%d fetched=%d stored=%d failed=%d', ref.selfId, result.fetchedMessages, result.storedMessages, result.failedSessions, ); return result; } } export const loginHistorySyncCoordinator = new LoginHistorySyncCoordinator(); async function selectGroupCandidates( store: MessageStore, states: readonly GroupHistorySyncState[], ): Promise { return ( await mapWithConcurrency(states, 8, async (state): Promise => { if (!Number.isSafeInteger(state.latestSeq) || state.latestSeq <= 0) { return []; } const observedSequence = await store.findLatestAuthoritativeSequence( true, state.groupId, ); const localSequence = await store.findLatestPersistedAuthoritativeSequence( true, state.groupId, ); if (localSequence !== null) { if (state.latestSeq <= localSequence) { return []; } return [ { state, localSequence, priority: state.latestSeq - localSequence, knownGap: true, }, ]; } if (observedSequence !== null) { return [ { state, localSequence: null, priority: state.latestSeq, knownGap: true, }, ]; } if (state.latestSeq <= state.readSeq) { return []; } return [ { state, localSequence: null, priority: state.latestSeq - state.readSeq, knownGap: false, }, ]; }) ) .flat() .sort(compareCandidates) .slice(0, LOGIN_HISTORY_SYNC_LIMITS.maxGroups); } async function selectPrivateCandidates( store: MessageStore, states: readonly PrivateHistorySyncState[], ): Promise { return ( await mapWithConcurrency(states, 8, async (state): Promise => { if (!Number.isSafeInteger(state.latestSeq) || state.latestSeq <= 0) { return []; } const observedSequence = await store.findLatestAuthoritativeSequence( false, state.userId, ); const localSequence = await store.findLatestPersistedAuthoritativeSequence( false, state.userId, ); if (localSequence !== null) { if (state.latestSeq <= localSequence) { return []; } return [ { state, localSequence, priority: state.latestSeq - localSequence, knownGap: true, }, ]; } if (!Number.isSafeInteger(state.lastMsgTime) || state.lastMsgTime <= 0) { return []; } return [ { state, localSequence: null, priority: state.lastMsgTime, knownGap: observedSequence !== null, }, ]; }) ) .flat() .sort(compareCandidates) .slice(0, LOGIN_HISTORY_SYNC_LIMITS.maxPrivateUsers); } function compareCandidates< T extends { localSequence: number | null; priority: number; knownGap: boolean; }, >(left: T, right: T): number { if (left.knownGap !== right.knownGap) { return left.knownGap ? -1 : 1; } if ((left.localSequence !== null) !== (right.localSequence !== null)) { return left.localSequence !== null ? -1 : 1; } return right.priority - left.priority; } function fetchGroupCandidate( ref: LoginHistorySyncRef, candidate: GroupCandidate, signal: AbortSignal, ): Promise { const endSeq = candidate.localSequence === null ? candidate.state.latestSeq : Math.min( candidate.state.latestSeq, candidate.localSequence + LOGIN_HISTORY_SYNC_LIMITS.messagesPerSession, ); const startSeq = candidate.localSequence === null ? Math.max(1, endSeq - LOGIN_HISTORY_SYNC_LIMITS.messagesPerSession + 1) : candidate.localSequence + 1; return ref.bridge.apis.message.getGroupHistorySyncPage( candidate.state.groupId, startSeq, endSeq, ref.selfId, signal, ); } function fetchPrivateCandidate( ref: LoginHistorySyncRef, candidate: PrivateCandidate, signal: AbortSignal, ): Promise { if (candidate.localSequence !== null) { const endSeq = Math.min( candidate.state.latestSeq, candidate.localSequence + LOGIN_HISTORY_SYNC_LIMITS.messagesPerSession, ); return ref.bridge.apis.message.getC2cHistorySyncPage( candidate.state.uid, candidate.localSequence + 1, endSeq, ref.selfId, signal, ); } const beforeTime = Math.min(0xffff_ffff, candidate.state.lastMsgTime + 1); return ref.bridge.apis.message.getC2cLatestHistorySyncPage( candidate.state.uid, LOGIN_HISTORY_SYNC_LIMITS.messagesPerSession, ref.selfId, beforeTime, signal, ); } async function persistGroupMessage( ref: LoginHistorySyncRef, message: GroupMessage, signal: AbortSignal, ): Promise { throwIfAborted(signal); const converted = await convertEvent(ref.converterCtx, message); throwIfAborted(signal); if (!converted || converted.message_type !== 'group') { throw new Error( `group history conversion failed: group=${message.groupId} seq=${message.msgSeq} ` + `messageType=${String(converted?.message_type)}`, ); } await persistHistoryEvent(ref.messageStore, converted); } async function persistPrivateMessage( ref: LoginHistorySyncRef, peerUin: number, message: FriendMessage, signal: AbortSignal, ): Promise { throwIfAborted(signal); const receivedPeer = message.peerUin ?? 0; if (receivedPeer > 0 && receivedPeer !== peerUin) { throw new Error( `private history peer mismatch: requested=${peerUin} received=${receivedPeer} ` + `seq=${String(message.ntMsgSeq ?? message.msgSeq)}`, ); } if (receivedPeer <= 0) { message.peerUin = peerUin; } const clientSequence = message.clientSeq ?? message.msgSeq; const sentBySelf = message.senderUin === ref.selfId; const converted = await convertEvent(ref.converterCtx, message); throwIfAborted(signal); if (!converted || converted.message_type !== 'private') { throw new Error( `private history conversion failed: peer=${peerUin} ` + `seq=${String(message.ntMsgSeq ?? message.msgSeq)} ` + `messageType=${String(converted?.message_type)}`, ); } if ( await ref.messageStore.isPrivateMessageRecalled( peerUin, clientSequence, sentBySelf, message.time, ) ) { log.debug( 'login history sync omitted recalled private message: UIN=%d peer=%d clientSeq=%d self=%s', ref.selfId, peerUin, clientSequence, String(sentBySelf), ); return false; } if (converted.post_type === 'message_sent' && toHistoryInt(converted.target_id) === 0) { converted.target_id = peerUin; } return persistHistoryEvent(ref.messageStore, converted, peerUin, message); } function abortableSleep(ms: number, signal: AbortSignal): Promise { throwIfAborted(signal); return new Promise((resolve, reject) => { const timer = setTimeout(() => { signal.removeEventListener('abort', onAbort); resolve(); }, ms); timer.unref(); const onAbort = () => { clearTimeout(timer); reject(signal.reason ?? new Error('login history sync aborted')); }; signal.addEventListener('abort', onAbort, { once: true }); }); } function throwIfAborted(signal: AbortSignal): void { if (signal.aborted) { throw signal.reason ?? new Error('login history sync aborted'); } } function rejectWhileQueuedOnAbort( result: Promise, signal: AbortSignal, hasStarted: () => boolean, ): Promise { if (signal.aborted && !hasStarted()) { return Promise.reject(signal.reason ?? new Error('login history sync aborted')); } return new Promise((resolve, reject) => { const onAbort = () => { if (!hasStarted()) { signal.removeEventListener('abort', onAbort); reject(signal.reason ?? new Error('login history sync aborted')); } }; signal.addEventListener('abort', onAbort); result.then(resolve, reject).finally(() => { signal.removeEventListener('abort', onAbort); }); }); } function errorText(error: unknown): string { return error instanceof Error ? (error.stack ?? error.message) : String(error); }