diff --git a/packages/onebot/REPLIES.md b/packages/onebot/REPLIES.md new file mode 100644 index 00000000..31819cc0 --- /dev/null +++ b/packages/onebot/REPLIES.md @@ -0,0 +1,28 @@ +# Reply targets + +A normal OneBot `reply` segment identifies a message by its non-zero, signed +`message_id`. Negative identifiers are valid. The identifier generation rules +for ordinary incoming messages and send results are unchanged. + +Private QQ quotes can use a sender-local sequence instead of the conversation +sequence used for message identifiers. SnowLuma checks the conversation, sender +direction, available timestamp and quoted text against stored messages. Matching +numbers from different sequence namespaces do not by themselves prove that two +records identify the same message. The current message is excluded from its own +quote lookup. + +When the target is not available locally, live event processing requests bounded +private history before converting and dispatching the event. A verified history +message is stored under its actual identifier, so an immediate `get_msg` can +retrieve it. Existing verified identifiers are preserved when filling a missing +body, and recalled messages are not restored. + +An unresolved or ambiguous private target does not produce a guessed identifier +or a new placeholder record. The normal event omits that `reply` segment and +keeps the rest of the message. Warnings identify unresolved targets; trace logs +record the lookup result, conversation, sequence, timestamp and selected +identifier without logging quoted content. Fetch failures remain visible. + +Group quotes retain their group-sequence lookup. Forwarded nodes retain their +inline quote content, which can be resent even when the original message is not +available in the local account's history. diff --git a/packages/onebot/src/event-converter/element-codecs.ts b/packages/onebot/src/event-converter/element-codecs.ts index f8719cfa..d349f1aa 100644 --- a/packages/onebot/src/event-converter/element-codecs.ts +++ b/packages/onebot/src/event-converter/element-codecs.ts @@ -15,6 +15,7 @@ import type { } from './index'; import { GROUP_MESSAGE_EVENT, privateMessageEventName } from '../message-id'; import { resolveReplyId } from './utils'; +import type { PrivateQuoteSource } from '../private-quote'; // ───────────────────────────────────────────────────────────────────────── // 消息元素 codec 表(onebot 侧)—— 把「同一种元素」的两个 onebot 方向并到一处: @@ -42,6 +43,7 @@ export interface ToSegmentContext { mediaUrlResolver?: MediaUrlResolver | null; messageIdResolver?: MessageIdResolver | null; mediaSegmentSink?: MediaSegmentSink | null; + currentMessage?: PrivateQuoteSource; } // The image segment is the one intentional triangular mapping: an image with @@ -295,17 +297,20 @@ export const ELEMENT_CODECS = { const eventName = ctx.isGroup ? GROUP_MESSAGE_EVENT : privateMessageEventName(sentBySelf, false); - const id = await resolveReplyId( - ctx.isGroup, - ctx.sessionId, - element.replySeq ?? 0, - ctx.messageIdResolver, - eventName, - element.replyTime && element.replyTime > 0 - ? element.replyTime - : Number.MAX_SAFE_INTEGER, - element.replyElements, - ); + const id = + element.replyMessageId ?? + (await resolveReplyId( + ctx.isGroup, + ctx.sessionId, + element.replySeq ?? 0, + ctx.messageIdResolver, + eventName, + element.replyTime && element.replyTime > 0 + ? element.replyTime + : Number.MAX_SAFE_INTEGER, + element.replyElements, + ctx.currentMessage, + )); return { type: 'reply', data: { id: String(id) } }; }, async fromSegment(data, options) { diff --git a/packages/onebot/src/event-converter/index.ts b/packages/onebot/src/event-converter/index.ts index 87821592..32da409f 100644 --- a/packages/onebot/src/event-converter/index.ts +++ b/packages/onebot/src/event-converter/index.ts @@ -1,5 +1,6 @@ import type { MessageElement, QQEventVariant } from '@snowluma/protocol/events'; import type { JsonObject } from '../types'; +import type { PrivateQuoteSource } from '../private-quote'; import { convertFriendMessage, convertGroupMessage, convertTempMessage } from './to-message'; import { convertBotOffline, @@ -40,6 +41,7 @@ export type MessageIdResolver = ( eventName: string, timestamp?: number, replyElements?: readonly MessageElement[], + excludedMessage?: PrivateQuoteSource, ) => number | Promise; export type MediaSegmentSink = ( diff --git a/packages/onebot/src/event-converter/to-message.ts b/packages/onebot/src/event-converter/to-message.ts index 0756c2c9..920b91c8 100644 --- a/packages/onebot/src/event-converter/to-message.ts +++ b/packages/onebot/src/event-converter/to-message.ts @@ -53,7 +53,16 @@ async function convertPrivateMessage( messageIdSequence, eventName, ); - const segments = await toSegments(ctx, event.elements, false, sessionId); + const segments = await elementsToOneBotSegments(ctx, event.elements, false, sessionId, { + currentMessage: { + id: messageId, + sequence: hasCanonicalNtSequence ? ntSequence : 0, + clientSequence: + event.kind === 'friend_message' ? (event.clientSeq ?? event.msgSeq) : event.msgSeq, + timestamp: event.time, + sentBySelf: isSelf, + }, + }); const sender: JsonObject = { user_id: event.senderUin, nickname: event.senderNick, diff --git a/packages/onebot/src/event-converter/to-segment.ts b/packages/onebot/src/event-converter/to-segment.ts index c1c561c1..ca1d6a5a 100644 --- a/packages/onebot/src/event-converter/to-segment.ts +++ b/packages/onebot/src/event-converter/to-segment.ts @@ -5,6 +5,7 @@ import { getElementCodec } from './element-codecs'; import { createLogger } from '@snowluma/common/logger'; import type { ConverterContext } from './index'; import { attachMessageMedia } from '../message-media'; +import type { PrivateQuoteSource } from '../private-quote'; const log = createLogger('OneBot'); @@ -13,9 +14,15 @@ export async function elementsToOneBotSegments( elements: MessageElement[], isGroup: boolean, sessionId: number, - options: { includeReplyContent?: boolean } = {}, + options: { includeReplyContent?: boolean; currentMessage?: PrivateQuoteSource } = {}, ): Promise { - const segmentCtx: ToSegmentContext = { ...ctx, isGroup, sessionId, mediaSegmentSink: null }; + const segmentCtx: ToSegmentContext = { + ...ctx, + isGroup, + sessionId, + mediaSegmentSink: null, + currentMessage: options.currentMessage, + }; const result: JsonArray = []; for (const element of elements) { @@ -34,6 +41,21 @@ export async function elementsToOneBotSegments( continue; } + if ( + element.type === 'reply' && + (segment.data as JsonObject).id === '0' && + !options.includeReplyContent + ) { + log.warn( + 'quote target unresolved: group=%s session=%d seq=%d time=%s; omitting reply id', + isGroup, + sessionId, + element.replySeq, + element.replyTime, + ); + continue; + } + // Forward quotes often refer to messages outside the local account's // history. Carry their inline content so consumers can resend the node. if ( diff --git a/packages/onebot/src/event-converter/utils.ts b/packages/onebot/src/event-converter/utils.ts index 8e9b3363..4bd88359 100644 --- a/packages/onebot/src/event-converter/utils.ts +++ b/packages/onebot/src/event-converter/utils.ts @@ -1,6 +1,7 @@ import { GROUP_MESSAGE_EVENT, PRIVATE_MESSAGE_EVENT } from '../message-id'; import type { MessageIdResolver } from './index'; import type { MessageElement } from '@snowluma/protocol/events'; +import type { PrivateQuoteSource } from '../private-quote'; export function isSameActor( leftUin: number, @@ -43,6 +44,7 @@ export async function resolveReplyId( eventName = isGroup ? GROUP_MESSAGE_EVENT : PRIVATE_MESSAGE_EVENT, timestamp?: number, replyElements?: readonly MessageElement[], + excludedMessage?: PrivateQuoteSource, ): Promise { const seq = Math.trunc(sequence); @@ -58,11 +60,14 @@ export async function resolveReplyId( eventName, timestamp, replyElements, + excludedMessage, ); - if (Number.isInteger(resolved) && resolved !== 0) { + if (Number.isInteger(resolved)) { return resolved; } + + throw new Error('reply resolver returned an invalid message id'); } return seq; diff --git a/packages/onebot/src/event-pipeline.ts b/packages/onebot/src/event-pipeline.ts index 6124298d..d490e86d 100644 --- a/packages/onebot/src/event-pipeline.ts +++ b/packages/onebot/src/event-pipeline.ts @@ -176,10 +176,38 @@ async function convertAndDispatch( state: EventTraceState, messageIdOverride?: number, ): Promise { + let preparedEvent = event; + + if (event.kind === 'friend_message' && (await isFriendMessageRecalled(ctx, log, event))) { + traceEventTerminal(log, event.kind, state, 'dropped', 'recalled_before_backfill'); + return; + } + + // If this message quotes one we don't have, fetch + persist it first (gated + + // throttled) so a consumer's get_msg on the quote resolves. No-op for the + // common case (no reply, or the quoted message is already stored). Never let a + // back-fill failure block delivery of the live message. + try { + preparedEvent = await backfillReplyTarget(ctx, event); + } catch (error) { + // Best-effort — dispatch the live event regardless, but keep the failure + // attributable so a repeated store/server miss is diagnosable. + log.warn( + 'reply backfill failed kind=%s: %s', + event.kind, + error instanceof Error ? (error.stack ?? error.message) : String(error), + ); + log.trace(() => [ + 'event_branch kind=%s reason=reply_backfill_failed error=%s', + event.kind, + error instanceof Error ? (error.stack ?? error.message) : String(error), + ]); + } + let converted; try { - converted = await convertEvent(ctx.converterCtx, event); + converted = await convertEvent(ctx.converterCtx, preparedEvent); } catch (error) { traceEventTerminal(log, event.kind, state, 'failed', 'converter_threw', error); throw error; @@ -200,32 +228,6 @@ async function convertAndDispatch( renderParamsVerbose(converted), ]); - if (event.kind === 'friend_message' && (await isFriendMessageRecalled(ctx, log, event))) { - traceEventTerminal(log, event.kind, state, 'dropped', 'recalled_before_backfill'); - return; - } - - // If this message quotes one we don't have, fetch + persist it first (gated + - // throttled) so a consumer's get_msg on the quote resolves. No-op for the - // common case (no reply, or the quoted message is already stored). Never let a - // back-fill failure block delivery of the live message. - try { - await backfillReplyTarget(ctx, event); - } catch (error) { - // Best-effort — dispatch the live event regardless, but keep the failure - // attributable so a repeated store/server miss is diagnosable. - log.warn( - 'reply backfill failed kind=%s: %s', - event.kind, - error instanceof Error ? (error.stack ?? error.message) : String(error), - ); - log.trace(() => [ - 'event_branch kind=%s reason=reply_backfill_failed error=%s', - event.kind, - error instanceof Error ? (error.stack ?? error.message) : String(error), - ]); - } - // Backfill can await several network/media operations. Re-check immediately // before dispatch so a recall that arrived during that gap wins the race. if (event.kind === 'friend_message' && (await isFriendMessageRecalled(ctx, log, event))) { diff --git a/packages/onebot/src/instance.ts b/packages/onebot/src/instance.ts index c41bf297..001357b6 100644 --- a/packages/onebot/src/instance.ts +++ b/packages/onebot/src/instance.ts @@ -18,15 +18,9 @@ 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, - PRIVATE_NT_MESSAGE_EVENT, - PRIVATE_SENT_MESSAGE_EVENT, - hashMessageIdInt32, - privateMessageEventName, -} from './message-id'; +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, @@ -159,44 +153,7 @@ export class OneBotInstance { this.rkeyCache.resolveImageUrl(this.bridge, element, isGroup), mediaUrlResolver: (element, isGroup, sessionId) => mediaUrlResolver.resolve(element, isGroup, sessionId), - messageIdResolver: async ( - isGroup, - sessionId, - sequence, - eventName, - timestamp, - replyElements, - ) => { - const resolvedEventName = - eventName || (isGroup ? GROUP_MESSAGE_EVENT : PRIVATE_MESSAGE_EVENT); - - if ( - !isGroup && - timestamp !== undefined && - (resolvedEventName === PRIVATE_MESSAGE_EVENT || - resolvedEventName === PRIVATE_SENT_MESSAGE_EVENT) - ) { - const storedId = await this.messageStore.resolvePrivateReplyMessageId( - sessionId, - sequence, - resolvedEventName === PRIVATE_SENT_MESSAGE_EVENT, - timestamp, - replyElements, - ); - - if (storedId !== null) { - return storedId; - } - } - - return hashMessageIdInt32( - sequence, - sessionId, - resolvedEventName === PRIVATE_SENT_MESSAGE_EVENT - ? PRIVATE_NT_MESSAGE_EVENT - : resolvedEventName, - ); - }, + messageIdResolver: createMessageIdResolver(this.messageStore), mediaSegmentSink: async (mediaType, element, data, isGroup, sessionId) => await mediaIndexer.remember(mediaType, element, data, isGroup, sessionId), }; diff --git a/packages/onebot/src/message-id-resolver.ts b/packages/onebot/src/message-id-resolver.ts new file mode 100644 index 00000000..72681e67 --- /dev/null +++ b/packages/onebot/src/message-id-resolver.ts @@ -0,0 +1,54 @@ +import type { MessageIdResolver } from './event-converter'; +import type { MessageStore } from './message-store'; +import { + GROUP_MESSAGE_EVENT, + PRIVATE_MESSAGE_EVENT, + PRIVATE_NT_MESSAGE_EVENT, + PRIVATE_SENT_MESSAGE_EVENT, + hashMessageIdInt32, +} from './message-id'; + +export function createMessageIdResolver( + store: Pick, +): MessageIdResolver { + return async ( + isGroup, + sessionId, + sequence, + eventName, + timestamp, + replyElements, + excludedMessage, + ) => { + const resolvedEventName = + eventName || (isGroup ? GROUP_MESSAGE_EVENT : PRIVATE_MESSAGE_EVENT); + + if ( + !isGroup && + timestamp !== undefined && + (resolvedEventName === PRIVATE_MESSAGE_EVENT || + resolvedEventName === PRIVATE_SENT_MESSAGE_EVENT) + ) { + const storedId = await store.resolvePrivateReplyMessageId( + sessionId, + sequence, + resolvedEventName === PRIVATE_SENT_MESSAGE_EVENT, + timestamp, + replyElements, + excludedMessage, + ); + + // Quote sequences are not canonical message-id inputs. A miss must + // remain unresolved so the live pipeline can fetch and verify history. + return storedId ?? 0; + } + + return hashMessageIdInt32( + sequence, + sessionId, + resolvedEventName === PRIVATE_SENT_MESSAGE_EVENT + ? PRIVATE_NT_MESSAGE_EVENT + : resolvedEventName, + ); + }; +} diff --git a/packages/onebot/src/message-store.ts b/packages/onebot/src/message-store.ts index 8d238e2d..8d112153 100644 --- a/packages/onebot/src/message-store.ts +++ b/packages/onebot/src/message-store.ts @@ -1,8 +1,14 @@ -import { SqliteDatabase, type SqliteStatement } from '@snowluma/sqlite'; +import { SqliteDatabase, type SqliteRow, type SqliteStatement } from '@snowluma/sqlite'; import { createLogger } from '@snowluma/common/logger'; import type { JsonObject, MessageMeta } from './types'; import type { MessageElement } from '@snowluma/protocol/events'; -import { cqUnescape } from './helper/cq'; +import { + cachedQuotePreview, + isUsableQuoteTime, + PRIVATE_QUOTE_MAX_DRIFT_SECONDS, + selectPrivateQuote, + type PrivateQuoteSource, +} from './private-quote'; import { openSqliteDb } from './sqlite-open'; import { mergeMessageMedia, @@ -49,9 +55,8 @@ export class MessageStore { private stmtFindLatestPersistedAuthoritativeSequence!: SqliteStatement; private stmtListIncomingC2CSessions!: SqliteStatement; private stmtFindPrivateRecall!: SqliteStatement; - private stmtFindPrivateMessageAtTime!: SqliteStatement; - private stmtFindPrivateMessageBySequence!: SqliteStatement; - private stmtFindPrivateOutgoingNearTime!: SqliteStatement; + private stmtFindPrivateReplyBySequence!: SqliteStatement; + private stmtFindPrivateReplyByTime!: SqliteStatement; private stmtFindPrivateRecallTombstone!: SqliteStatement; private stmtUpsertPrivateRecallTombstone!: SqliteStatement; private stmtDeleteMessage!: SqliteStatement; @@ -205,27 +210,22 @@ export class MessageStore { LIMIT 1`, ); - this.stmtFindPrivateMessageAtTime = this.db.prepare( - `SELECT message_hash, data - FROM messages - WHERE is_group = 0 AND session_id = ? AND private_direction = ? AND timestamp = ?`, + this.stmtFindPrivateReplyBySequence = this.db.prepare( + `SELECT message_hash, sequence, sequence_authoritative, client_sequence, timestamp, data + FROM messages + WHERE is_group = 0 AND session_id = ? AND private_direction = ? + AND client_sequence = ? AND timestamp BETWEEN ? AND ? + UNION + SELECT message_hash, sequence, sequence_authoritative, client_sequence, timestamp, data + FROM messages + WHERE is_group = 0 AND session_id = ? AND private_direction = ? + AND sequence = ? AND sequence_authoritative = 1 AND timestamp BETWEEN ? AND ?`, ); - - this.stmtFindPrivateMessageBySequence = this.db.prepare( - `SELECT message_hash - FROM messages - WHERE is_group = 0 AND session_id = ? AND sequence = ? - AND private_direction = ? AND sequence_authoritative = 1 - LIMIT 1`, - ); - - this.stmtFindPrivateOutgoingNearTime = this.db.prepare( - `SELECT message_hash - FROM messages - WHERE is_group = 0 AND session_id = ? AND private_direction = ? - AND timestamp BETWEEN ? AND ? - ORDER BY ABS(timestamp - ?) ASC, sequence DESC - LIMIT 2`, + this.stmtFindPrivateReplyByTime = this.db.prepare( + `SELECT message_hash, sequence, sequence_authoritative, client_sequence, timestamp, data + FROM messages + WHERE is_group = 0 AND session_id = ? AND private_direction = ? + AND timestamp BETWEEN ? AND ?`, ); this.stmtFindPrivateRecallTombstone = this.db.prepare( @@ -618,217 +618,66 @@ export class MessageStore { }); } - /** - * Resolve a private quote to a stored OneBot id. Prefer the quoted - * sender-local sequence, then the conversation sequence. For a - * self-sent target, also match the send receipt by time because QQ's - * quote sequence is often neither of those stored values, and the - * quote time can drift a few seconds from the send receipt. - */ + /** Resolve quotes independently of recall lookup: a reused sequence is not an identity. */ async resolvePrivateReplyMessageId( sessionId: number, replySequence: number, sentBySelf: boolean, timestamp?: number, replyElements?: readonly MessageElement[], + excludedMessage?: PrivateQuoteSource, ): Promise { return this.db.serial(async () => { await this.initialization; - - if (Number.isSafeInteger(replySequence) && replySequence > 0) { - const byClientSequence = await this.findPrivateMessageId( - sessionId, - replySequence, - sentBySelf, - timestamp, - ); - - if (byClientSequence !== null) { - return byClientSequence; - } - - const byConversationSequence = await this.findPrivateMessageIdBySequence( - sessionId, - replySequence, - sentBySelf, - ); - - if (byConversationSequence !== null) { - return byConversationSequence; - } - } - - if (!sentBySelf) { - return null; - } - - const byExactTime = await this.findPrivateMessageIdAtTime( - sessionId, - true, + const hasTime = isUsableQuoteTime(timestamp); + const lower = hasTime ? timestamp - PRIVATE_QUOTE_MAX_DRIFT_SECONDS : 0; + const upper = hasTime + ? timestamp + PRIVATE_QUOTE_MAX_DRIFT_SECONDS + : Number.MAX_SAFE_INTEGER; + const key = { + sequence: replySequence, + sentBySelf, timestamp, - replyElements, - ); - - if (byExactTime !== null) { - return byExactTime; - } - - const byNearbyTime = await this.findUniquePrivateOutgoingNearTime(sessionId, timestamp); - - if (byNearbyTime !== null) { - return byNearbyTime; - } - - return null; - }); - } - - async findPrivateMessageIdAtTime( - sessionId: number, - sentBySelf: boolean, - timestamp?: number, - replyElements?: readonly MessageElement[], - ): Promise { - return this.db.serial(async () => { - await this.initialization; - - if (!Number.isSafeInteger(sessionId) || sessionId <= 0) { - return null; - } - - if (!isUsablePrivateQuoteTime(timestamp)) { - return null; - } - - const rows = (await this.stmtFindPrivateMessageAtTime.all( - sessionId, - sentBySelf ? 1 : 0, - timestamp, - )) as { message_hash: number; data: string | null }[]; - - if (rows.length === 0) { - return null; - } - - let matches = rows; - - if (rows.length > 1) { - const text = - replyElements?.length && replyElements.every((e) => e.type === 'text') - ? replyElements.map((e) => e.text ?? '').join('') - : null; - const candidates = text - ? rows - .map((row) => ({ row, preview: cachedQuoteText(row.data) })) - .filter( - ({ preview }) => preview === null || preview.text.startsWith(text), - ) + elements: replyElements, + exclude: excludedMessage, + }; + const rows = + Number.isSafeInteger(replySequence) && replySequence > 0 + ? ((await this.stmtFindPrivateReplyBySequence.all( + sessionId, + sentBySelf ? 1 : 0, + replySequence, + lower, + upper, + sessionId, + sentBySelf ? 1 : 0, + replySequence, + lower, + upper, + )) as PrivateReplyRow[]) : []; - matches = - candidates.length === 1 && - candidates[0]!.preview?.plain && - candidates[0]!.preview.text === text - ? [candidates[0]!.row] - : []; + let selected = selectPrivateQuote(rows.map(privateReplyCandidate), key); - if (matches.length !== 1) { - log.debug( - 'private quote time is ambiguous: peer=%d time=%d candidates=%d contentMatches=%d', - sessionId, - timestamp, - rows.length, - matches.length, - ); - return null; - } - } - - const row = matches[0]!; - - if (!isValidMessageId(row.message_hash)) { - throw new Error( - `private message lookup matched invalid message id ${String(row.message_hash)}`, - ); - } - - return row.message_hash; - }); - } - - async findPrivateMessageIdBySequence( - sessionId: number, - sequence: number, - sentBySelf: boolean, - ): Promise { - return this.db.serial(async () => { - await this.initialization; - - if (!Number.isSafeInteger(sessionId) || sessionId <= 0) { - return null; - } - - if (!Number.isSafeInteger(sequence) || sequence <= 0) { - return null; - } - - const row = (await this.stmtFindPrivateMessageBySequence.get( - sessionId, - sequence, - sentBySelf ? 1 : 0, - )) as { message_hash: number } | undefined; - - if (!row) { - return null; - } - - if (!isValidMessageId(row.message_hash)) { - throw new Error( - `private message lookup matched invalid message id ${String(row.message_hash)}`, - ); - } - - return row.message_hash; - }); - } - - async findUniquePrivateOutgoingNearTime( - sessionId: number, - timestamp?: number, - ): Promise { - return this.db.serial(async () => { - await this.initialization; - - if (!Number.isSafeInteger(sessionId) || sessionId <= 0) { - return null; - } - - if (!isUsablePrivateQuoteTime(timestamp)) { - return null; - } - - for (const windowSeconds of PRIVATE_REPLY_TIME_WINDOWS_SECONDS) { - const rows = (await this.stmtFindPrivateOutgoingNearTime.all( + if (!selected.candidate && selected.reason !== 'ambiguous' && sentBySelf && hasTime) { + const nearby = (await this.stmtFindPrivateReplyByTime.all( sessionId, 1, - timestamp - windowSeconds, - timestamp + windowSeconds, - timestamp, - )) as Array<{ message_hash: number }>; - - if (rows.length !== 1) { - continue; - } - - if (!isValidMessageId(rows[0].message_hash)) { - throw new Error( - `private message lookup matched invalid message id ${String(rows[0].message_hash)}`, - ); - } - - return rows[0].message_hash; + lower, + upper, + )) as PrivateReplyRow[]; + selected = selectPrivateQuote(nearby.map(privateReplyCandidate), key); } - return null; + log.trace( + 'private quote lookup: peer=%d seq=%d outgoing=%s time=%s result=%s messageId=%s', + sessionId, + replySequence, + sentBySelf, + timestamp, + selected.reason, + selected.candidate?.id ?? 'unknown', + ); + return selected.candidate?.id ?? null; }); } @@ -1148,64 +997,27 @@ function eventPrivateDirection(isGroup: boolean, event: JsonObject): number { return -1; } -const PRIVATE_REPLY_TIME_WINDOWS_SECONDS = [5, 30] as const; - -/** Unknown, truncated, and media previews remain possible competing matches. */ -function cachedQuoteText(data: string | null): { text: string; plain: boolean } | null { - if (!data) { - return null; - } - - const event = JSON.parse(data) as JsonObject; - - if (typeof event.message === 'string') { - return event.message.includes('[CQ:') - ? null - : { text: cqUnescape(event.message), plain: true }; - } - - if (!Array.isArray(event.message) || event.message.length === 0) { - return null; - } - - const segments = event.message as JsonObject[]; - const labels: Record = { - image: '[图片]', - record: '[语音]', - video: '[视频]', - file: '[文件]', - face: '[表情]', - mface: '[表情]', - forward: '[聊天记录]', - json: '[卡片]', - xml: '[卡片]', - }; - const parts: string[] = []; - let plain = true; - - for (const segment of segments) { - const body = segment?.data as JsonObject | undefined; - - if (segment?.type === 'text' && typeof body?.text === 'string') { - parts.push(body.text); - } else if (typeof segment?.type === 'string' && labels[segment.type]) { - parts.push(typeof body?.summary === 'string' ? body.summary : labels[segment.type]!); - plain = false; - } else { - return null; - } - } - - return { text: parts.join(''), plain }; +interface PrivateReplyRow extends SqliteRow { + message_hash: number; + sequence: number; + sequence_authoritative: number; + client_sequence: number; + timestamp: number; + data: string | null; } -function isUsablePrivateQuoteTime(timestamp: number | undefined): timestamp is number { - return ( - timestamp !== undefined && - Number.isSafeInteger(timestamp) && - timestamp > 0 && - timestamp < Number.MAX_SAFE_INTEGER - ); +function privateReplyCandidate(row: PrivateReplyRow) { + if (!isValidMessageId(row.message_hash)) { + throw new Error(`private quote matched invalid message id ${String(row.message_hash)}`); + } + + return { + id: row.message_hash, + sequence: row.sequence_authoritative === 1 ? row.sequence : 0, + clientSequence: row.client_sequence, + timestamp: row.timestamp, + preview: cachedQuotePreview(row.data), + }; } function validatePrivateRecallKey( diff --git a/packages/onebot/src/modules/message-actions.ts b/packages/onebot/src/modules/message-actions.ts index fe6e31d4..fc6bb0af 100644 --- a/packages/onebot/src/modules/message-actions.ts +++ b/packages/onebot/src/modules/message-actions.ts @@ -15,6 +15,7 @@ import { getVideoSourceSize, MAX_VIDEO_SIZE } from '@snowluma/protocol/highway/v import { guessFileNameFromUrl } from '@snowluma/protocol/highway/utils'; import type { MessageSendResult } from '../api-handler'; import { convertEvent, elementsToOneBotSegments, type ConverterContext } from '../event-converter'; +import { applyMessageIdResolver } from '../event-converter/utils'; import { segmentsToRawMessage } from '../helper/cq'; import { persistHistoryEvent, toHistoryInt } from '../history-persistence'; import type { OneBotInstanceContext } from '../instance-context'; @@ -28,6 +29,12 @@ import { import type { MessageStore } from '../message-store'; import { sameSelfSentMessage } from '../self-sent-event'; import { replyPreviewElements } from '../reply-preview'; +import { + isUsableQuoteTime, + messageQuotePreview, + PRIVATE_QUOTE_MAX_DRIFT_SECONDS, + selectPrivateQuote, +} from '../private-quote'; import { hasAuthoritativeSequence, type JsonArray, @@ -424,225 +431,296 @@ export async function getFriendHistory( return out; } -/** - * Back-fill the message a freshly-received reply points to, when the local store - * doesn't have the full event (e.g. a message from before SnowLuma was running, - * or one it never observed). Fetches the quoted message from the server — - * group via `SsoGetGroupMsg`, C2C via timestamp roaming, both through the shared - * history throttle gate — and persists it under the SAME id the reply resolves - * to, so a subsequent `get_msg` (or quote lookup) hits. Private replies carry - * the quoted sender's local sequence, so they are matched through a timestamp - * roam page rather than incorrectly used as an NT-sequence range anchor. - * - * No-op when: the event isn't a group/friend message, has no reply, the target - * is already stored, the uid can't be resolved, the fetch returns nothing, or - * it errors. Meant to be awaited before dispatch so the consumer's get_msg — - * which an approval bot fires right after seeing the reply — sees the message. - */ -export async function backfillReplyTarget(ref: HistoryRef, event: QQEventVariant): Promise { - let isGroup: boolean; - let session: number; - - if (event.kind === 'group_message') { - isGroup = true; - session = event.groupId; - } else if (event.kind === 'friend_message') { - isGroup = false; - session = event.peerUin ?? event.senderUin; - } else { - return; +/** Resolve and prepare quotes before conversion, without changing the protocol event. */ +export async function backfillReplyTarget( + ref: HistoryRef, + event: QQEventVariant, +): Promise { + if (event.kind !== 'group_message' && event.kind !== 'friend_message') { + return event; } + const session = + event.kind === 'group_message' ? event.groupId : (event.peerUin ?? event.senderUin); + if (!Number.isInteger(session) || session <= 0) { - return; + return event; } - const reply = event.elements.find( - (e: MessageElement) => - e.type === 'reply' && Number.isInteger(e.replySeq) && (e.replySeq ?? 0) > 0, - ); - const replySeq = reply?.replySeq ?? 0; + const elements: MessageElement[] = []; + let changed = false; - if (replySeq <= 0) { - return; + for (const element of event.elements) { + if ( + element.type !== 'reply' || + !Number.isSafeInteger(element.replySeq) || + element.replySeq <= 0 + ) { + elements.push(element); + continue; + } + + const messageId = + event.kind === 'group_message' + ? await backfillGroupReply(ref, session, element) + : await backfillPrivateReply(ref, session, event, element); + elements.push({ ...element, replyMessageId: messageId }); + changed = true; } - const quotedSender = reply?.replySenderUin ?? (isGroup ? 0 : session); - const eventName = isGroup - ? GROUP_MESSAGE_EVENT - : privateMessageEventName(quotedSender === ref.selfId, false); - const resolvedId = await ref.converterCtx.messageIdResolver?.( - isGroup, + return changed ? { ...event, elements } : event; +} + +async function backfillPrivateReply( + ref: HistoryRef, + session: number, + event: FriendMessage, + reply: MessageElementOf<'reply'>, +): Promise { + const sender = reply.replySenderUin ?? session; + const sentBySelf = sender === ref.selfId; + const hasNtSequence = (event.ntMsgSeq ?? 0) > 0 && event.sequenceAuthoritative !== false; + const currentId = await applyMessageIdResolver( + ref.converterCtx.messageIdResolver, + false, session, - replySeq, - eventName, - reply?.replyTime && reply.replyTime > 0 ? reply.replyTime : Number.MAX_SAFE_INTEGER, + hasNtSequence ? event.ntMsgSeq! : event.msgSeq, + privateMessageEventName(event.senderUin === ref.selfId, hasNtSequence), + ); + const exclude = { + id: currentId, + sequence: hasNtSequence ? event.ntMsgSeq! : 0, + clientSequence: event.clientSeq ?? event.msgSeq, + timestamp: event.time, + sentBySelf: event.senderUin === ref.selfId, + }; + const storedId = await ref.messageStore.resolvePrivateReplyMessageId( + session, + reply.replySeq, + sentBySelf, + reply.replyTime, + reply.replyElements, + exclude, + ); + + if (storedId !== null && (await ref.messageStore.findEvent(storedId))) { + return storedId; + } + + let history: FriendMessage[] = []; + + try { + const uid = await ref.bridge.resolveUserUid(session); + + if (uid) { + const time = isUsableQuoteTime(reply.replyTime) ? reply.replyTime : event.time; + history = await ref.bridge.apis.message.getC2cLatestHistory( + uid, + 20, + ref.selfId, + Math.min(0xffff_ffff, Math.max(1, time + 1)), + ); + } + } catch (error) { + log.warn( + 'private quote history failed: peer=%d seq=%d error=%s', + session, + reply.replySeq, + error instanceof Error ? error.message : String(error), + ); + } + + const selection = selectPrivateQuote( + history + .filter( + (candidate) => + candidate.senderUin === sender && + candidate.selfUin === ref.selfId && + (candidate.peerUin ?? + (candidate.senderUin === ref.selfId ? session : candidate.senderUin)) === + session, + ) + .map((source) => ({ + source: { ...source, peerUin: session }, + sequence: source.sequenceAuthoritative !== false ? (source.ntMsgSeq ?? 0) : 0, + clientSequence: source.clientSeq ?? source.msgSeq, + timestamp: source.time, + preview: messageQuotePreview(source.elements), + })), + { + sequence: reply.replySeq, + sentBySelf, + timestamp: reply.replyTime, + elements: reply.replyElements, + exclude, + }, + ); + const fetched = selection.candidate?.source; + const knownMeta = storedId !== null ? await ref.messageStore.findMeta(storedId) : null; + + if (fetched) { + const json = await convertEvent(ref.converterCtx, fetched); + + if (!json) { + throw new Error('private quote history could not be converted'); + } + + // Preserve an existing legacy id only when the fetched message confirms + // the same stored identity, rather than assigning a quote-sequence hash. + if ( + storedId !== null && + knownMeta && + Math.abs(knownMeta.timestamp - fetched.time) <= PRIVATE_QUOTE_MAX_DRIFT_SECONDS && + (knownMeta.sequenceAuthoritative !== false && + knownMeta.sequence > 0 && + fetched.sequenceAuthoritative !== false && + (fetched.ntMsgSeq ?? 0) > 0 + ? knownMeta.sequence === fetched.ntMsgSeq + : knownMeta.clientSequence > 0 && + knownMeta.clientSequence === (fetched.clientSeq ?? fetched.msgSeq)) + ) { + json.message_id = storedId; + } + + const persisted = await persistHistoryEvent(ref.messageStore, json, session, fetched); + const id = persisted ? toHistoryInt(json.message_id) : 0; + log.trace( + 'private quote history resolved: peer=%d seq=%d result=%s messageId=%d', + session, + reply.replySeq, + persisted ? selection.reason : 'recalled', + id, + ); + return id; + } + + // Embedded text may fill the body of a verified existing identity. It cannot + // create a new private-message identity or overwrite an unrelated record. + if (storedId !== null && knownMeta && reply.replyElements?.length) { + const segments = await elementsToOneBotSegments( + ref.converterCtx, + reply.replyElements.filter((element) => element.type !== 'reply'), + false, + session, + ); + const json = buildBackfillEvent( + storedId, + knownMeta.clientSequence || knownMeta.sequence, + sender, + knownMeta.timestamp, + segments, + ref.selfId, + false, + session, + ); + const persisted = await ref.messageStore.storeHistoryEvent( + storedId, + false, + session, + knownMeta.sequence, + knownMeta.eventName, + json, + { sequenceAuthoritative: knownMeta.sequenceAuthoritative, meta: knownMeta }, + ); + return persisted ? storedId : 0; + } + + log.trace( + 'private quote unresolved: peer=%d seq=%d time=%s result=%s knownId=%s', + session, + reply.replySeq, + reply.replyTime, + selection.reason, + storedId ?? 'unknown', + ); + return storedId ?? 0; +} + +async function backfillGroupReply( + ref: HistoryRef, + session: number, + reply: MessageElementOf<'reply'>, +): Promise { + const resolvedId = await ref.converterCtx.messageIdResolver?.( + true, + session, + reply.replySeq, + GROUP_MESSAGE_EVENT, + reply.replyTime && reply.replyTime > 0 ? reply.replyTime : Number.MAX_SAFE_INTEGER, + reply.replyElements, ); const targetId = Number.isInteger(resolvedId) && resolvedId !== 0 ? (resolvedId as number) - : hashMessageIdInt32(replySeq, session, eventName); + : hashMessageIdInt32(reply.replySeq, session, GROUP_MESSAGE_EVENT); if (await ref.messageStore.findEvent(targetId)) { - return; - } // Tier 0: already have the full event + return targetId; + } + + let fetched: GroupMessage | null = null; - // Tier 1: fetch the quoted message from the server, keyed under the exact id - // the reply resolves to. Groups can query `replySeq` directly. C2C replies - // carry a sender-local client sequence, while SsoGetC2cMsg ranges use the - // conversation-wide NT sequence; use the quote time as a roam cursor and - // match both direction and client sequence instead. try { - let fetched: GroupMessage | FriendMessage | null = null; - - if (isGroup) { - fetched = await ref.bridge.apis.message.getGroupMessageBySeq( - session, - replySeq, - ref.selfId, - ); - } else { - const friendUid = await ref.bridge.resolveUserUid(session); - - if (friendUid) { - const quoteTime = - reply?.replyTime && reply.replyTime > 0 ? reply.replyTime : event.time; - const beforeTime = Math.min(0xffff_ffff, Math.max(1, quoteTime + 1)); - const candidates = await ref.bridge.apis.message.getC2cLatestHistory( - friendUid, - 20, - ref.selfId, - beforeTime, - ); - const quotedSentBySelf = quotedSender === ref.selfId; - fetched = - [...candidates] - .reverse() - .find( - (candidate) => - (candidate.clientSeq ?? candidate.msgSeq) === replySeq && - (candidate.senderUin === ref.selfId) === quotedSentBySelf && - (!reply?.replyTime || candidate.time <= reply.replyTime), - ) ?? null; - } - } - - if (fetched) { - const json = await convertEvent(ref.converterCtx, fetched); - - if (json) { - json.message_id = targetId; - - if (fetched.kind === 'group_message') { - await ref.messageStore.storeEvent( - targetId, - true, - session, - replySeq, - eventName, - json, - ); - } else { - const serverSequence = fetched.ntMsgSeq ?? 0; - const sequenceAuthoritative = - fetched.sequenceAuthoritative !== false && serverSequence > 0; - await ref.messageStore.storeMeta(targetId, { - isGroup: false, - targetId: session, - sequence: serverSequence, - sequenceAuthoritative, - eventName, - clientSequence: fetched.clientSeq ?? fetched.msgSeq, - privateDirection: - fetched.senderUin === ref.selfId ? 'outgoing' : 'incoming', - random: fetched.msgId, - timestamp: fetched.time, - }); - await ref.messageStore.storeEvent( - targetId, - false, - session, - serverSequence, - eventName, - json, - { sequenceAuthoritative }, - ); - } - - return; - } - } - } catch (err) { + fetched = await ref.bridge.apis.message.getGroupMessageBySeq( + session, + reply.replySeq, + ref.selfId, + ); + } catch (error) { log.warn( 'reply-target backfill tier-1 failed (%s)', - err instanceof Error ? err.message : String(err), + error instanceof Error ? error.message : String(error), ); } - // Tier 2: reconstruct from the quoted message's own elements, which the push - // embeds in SrcMsg.elems — no server round-trip. Covers messages the server - // won't return (expired, self-c2c, file-only) but whose content rode along. - if (reply?.replyElements?.length) { - try { - const segments = (await elementsToOneBotSegments( - ref.converterCtx, - reply.replyElements, - isGroup, - session, - )) as JsonArray; - const fallback = buildBackfillEvent( - targetId, - replySeq, - quotedSender, - reply.replyTime ?? 0, - segments, - ref.selfId, - isGroup, - session, - ); - // Inline quote metadata proves what was displayed, but not that replySeq - // still names a server-backed roam message. Keep it queryable via - // get_msg without letting it become a history/recall/reply anchor. + if (fetched) { + const json = await convertEvent(ref.converterCtx, fetched); + + if (json) { + json.message_id = targetId; await ref.messageStore.storeEvent( targetId, - isGroup, + true, session, - replySeq, - eventName, - fallback, - { sequenceAuthoritative: false }, - ); - return; - } catch (err) { - log.warn( - 'reply-target backfill tier-2 failed (%s)', - err instanceof Error ? err.message : String(err), + reply.replySeq, + GROUP_MESSAGE_EVENT, + json, ); + return targetId; } } - // Tier 3: minimal `[引用消息]` placeholder so get_msg(reply_id) never returns - // "message not found" — an approval bot that fires get_msg right after seeing - // the reply gets a well-formed (if sparse) event instead of an error. - const placeholder = buildBackfillEvent( + let segments: JsonArray = [{ type: 'text', data: { text: '[引用消息]' } }]; + + if (reply.replyElements?.length) { + segments = await elementsToOneBotSegments( + ref.converterCtx, + reply.replyElements, + true, + session, + ); + } + + const fallback = buildBackfillEvent( targetId, - replySeq, - quotedSender, - reply?.replyTime ?? 0, - [{ type: 'text', data: { text: '[引用消息]' } }], + reply.replySeq, + reply.replySenderUin ?? 0, + reply.replyTime ?? 0, + segments, ref.selfId, - isGroup, + true, session, ); await ref.messageStore.storeEvent( targetId, - isGroup, + true, session, - replySeq, - eventName, - placeholder, + reply.replySeq, + GROUP_MESSAGE_EVENT, + fallback, { sequenceAuthoritative: false }, ); + return targetId; } // Build a stored-message event for a backfilled reply target (Tier 2/3). diff --git a/packages/onebot/src/private-quote.ts b/packages/onebot/src/private-quote.ts new file mode 100644 index 00000000..771428f2 --- /dev/null +++ b/packages/onebot/src/private-quote.ts @@ -0,0 +1,194 @@ +import type { MessageElement } from '@snowluma/protocol/events'; +import type { JsonObject } from './types'; +import { cqUnescape } from './helper/cq'; + +// Send receipts and quoted timestamps can differ; never search outside the +// existing bounded drift window when resolving a quoted message. +export const PRIVATE_QUOTE_MAX_DRIFT_SECONDS = 30; +const TIME_WINDOWS = [0, 5, PRIVATE_QUOTE_MAX_DRIFT_SECONDS] as const; + +interface QuotePreview { + text: string; + plain: boolean; +} + +export interface PrivateQuoteCandidate { + id?: number; + sequence: number; + clientSequence: number; + timestamp: number; + preview: QuotePreview | null; +} + +/** Identity of the containing message, which can never be its own quote target. */ +export interface PrivateQuoteSource { + id: number; + sequence: number; + clientSequence: number; + timestamp: number; + sentBySelf: boolean; +} + +export interface PrivateQuoteKey { + sequence: number; + sentBySelf: boolean; + timestamp?: number; + elements?: readonly MessageElement[]; + exclude?: PrivateQuoteSource; +} + +export function isUsableQuoteTime(timestamp: number | undefined): timestamp is number { + return ( + timestamp !== undefined && + Number.isSafeInteger(timestamp) && + timestamp > 0 && + timestamp < Number.MAX_SAFE_INTEGER + ); +} + +/** Sequence namespaces may overlap. Only a unique, consistent candidate wins. */ +export function selectPrivateQuote( + candidates: readonly T[], + quote: PrivateQuoteKey, +): { candidate: T | null; reason: 'sequence' | 'time' | 'ambiguous' | 'missing' } { + const time = isUsableQuoteTime(quote.timestamp) ? quote.timestamp : null; + const text = + quote.elements?.length && quote.elements.every((element) => element.type === 'text') + ? quote.elements.map((element) => element.text ?? '').join('') + : ''; + const nearby = candidates.filter((candidate) => { + const source = quote.exclude; + + if ( + source && + (candidate.id === source.id || + (source.sentBySelf === quote.sentBySelf && + ((source.sequence > 0 && candidate.sequence === source.sequence) || + (source.clientSequence > 0 && + candidate.clientSequence === source.clientSequence && + candidate.timestamp === source.timestamp)))) + ) { + return false; + } + + return ( + time === null || Math.abs(candidate.timestamp - time) <= PRIVATE_QUOTE_MAX_DRIFT_SECONDS + ); + }); + // QQ can truncate the quote. Unknown and prefix-only previews remain + // competing candidates; only contradictory known content is excluded. + const compatible = nearby.filter( + (candidate) => !text || !candidate.preview || candidate.preview.text.startsWith(text), + ); + const bySequence = compatible.filter( + (candidate) => + quote.sequence > 0 && + (candidate.clientSequence === quote.sequence || candidate.sequence === quote.sequence), + ); + + if (bySequence.length > 0) { + return { + candidate: bySequence.length === 1 ? bySequence[0]! : null, + reason: bySequence.length === 1 ? 'sequence' : 'ambiguous', + }; + } + + if (quote.sentBySelf && time !== null) { + for (const window of TIME_WINDOWS) { + const matches = compatible.filter( + (candidate) => Math.abs(candidate.timestamp - time) <= window, + ); + + if (matches.length > 0) { + const match = matches.length === 1 ? matches[0]! : null; + const windowCount = nearby.filter( + (candidate) => Math.abs(candidate.timestamp - time) <= window, + ).length; + // A missing body cannot confirm quoted text. When content is the + // only discriminator, a truncated/media preview cannot prove it. + const confirmed = + match && + (!text || + (match.preview && + (windowCount === 1 || + (match.preview.plain && match.preview.text === text)))); + return { + candidate: confirmed ? match : null, + reason: confirmed ? 'time' : 'ambiguous', + }; + } + } + } + + return { candidate: null, reason: 'missing' }; +} + +const labels: Readonly> = { + image: '[图片]', + record: '[语音]', + video: '[视频]', + file: '[文件]', + face: '[表情]', + mface: '[表情]', + forward: '[聊天记录]', + json: '[卡片]', + xml: '[卡片]', +}; + +export function cachedQuotePreview(data: string | null): QuotePreview | null { + if (!data) { + return null; + } + + const event = JSON.parse(data) as JsonObject; + + if (typeof event.message === 'string') { + return event.message.includes('[CQ:') + ? null + : { text: cqUnescape(event.message), plain: true }; + } + + if (!Array.isArray(event.message) || event.message.length === 0) { + return null; + } + + const parts: string[] = []; + let plain = true; + + for (const segment of event.message as JsonObject[]) { + const body = segment?.data as JsonObject | undefined; + + if (segment?.type === 'text' && typeof body?.text === 'string') { + parts.push(body.text); + } else if (typeof segment?.type === 'string' && Object.hasOwn(labels, segment.type)) { + parts.push(typeof body?.summary === 'string' ? body.summary : labels[segment.type]!); + plain = false; + } else { + return null; + } + } + + return { text: parts.join(''), plain }; +} + +export function messageQuotePreview(elements: readonly MessageElement[]): QuotePreview | null { + const parts: string[] = []; + let plain = true; + + for (const element of elements) { + if (element.type === 'text') { + parts.push(element.text ?? ''); + } else if (Object.hasOwn(labels, element.type)) { + parts.push( + 'summary' in element && typeof element.summary === 'string' + ? element.summary + : labels[element.type]!, + ); + plain = false; + } else { + return null; + } + } + + return parts.length > 0 ? { text: parts.join(''), plain } : null; +} diff --git a/packages/onebot/tests/backfill-reply-target.test.ts b/packages/onebot/tests/backfill-reply-target.test.ts index 88d18eb4..50b69d50 100644 --- a/packages/onebot/tests/backfill-reply-target.test.ts +++ b/packages/onebot/tests/backfill-reply-target.test.ts @@ -8,10 +8,10 @@ import { describe, expect, it, vi } from 'vitest'; import { backfillReplyTarget } from '../src/modules/message-actions'; import { GROUP_MESSAGE_EVENT, - PRIVATE_MESSAGE_EVENT, - PRIVATE_SENT_MESSAGE_EVENT, + PRIVATE_NT_MESSAGE_EVENT, hashMessageIdInt32, } from '../src/message-id'; +import { createMessageIdResolver } from '../src/message-id-resolver'; const SELF = 10001; @@ -19,7 +19,7 @@ const converterCtx = { selfId: SELF, imageUrlResolver: null, mediaUrlResolver: null, - messageIdResolver: null, + messageIdResolver: createMessageIdResolver({ resolvePrivateReplyMessageId: async () => null }), mediaSegmentSink: null, } as any; @@ -28,6 +28,9 @@ function fakeStore(has = false) { findEvent: vi.fn(() => (has ? ({ message_id: 1 } as any) : null)), storeMeta: vi.fn(), storeEvent: vi.fn(), + findMeta: vi.fn(async () => null), + resolvePrivateReplyMessageId: vi.fn(async () => null), + storeHistoryEvent: vi.fn(async () => true), }; } @@ -137,31 +140,29 @@ describe('backfillReplyTarget', () => { expect(resolveUserUid).toHaveBeenCalledWith(448671521); expect(getC2cLatestHistory).toHaveBeenCalledWith('u_friend', 20, SELF, 1700000101); - const targetId = hashMessageIdInt32(456, 448671521, PRIVATE_MESSAGE_EVENT); - expect(store.storeMeta).toHaveBeenCalledWith( - targetId, - expect.objectContaining({ - sequence: 1456, - clientSequence: 456, - privateDirection: 'incoming', - sequenceAuthoritative: true, - }), - ); - expect(store.storeEvent).toHaveBeenCalledWith( + const targetId = hashMessageIdInt32(1456, 448671521, PRIVATE_NT_MESSAGE_EVENT); + expect(store.storeHistoryEvent).toHaveBeenCalledWith( targetId, false, 448671521, 1456, - PRIVATE_MESSAGE_EVENT, + PRIVATE_NT_MESSAGE_EVENT, expect.objectContaining({ message_id: targetId, message_type: 'private' }), - { sequenceAuthoritative: true }, + { + sequenceAuthoritative: true, + meta: expect.objectContaining({ + sequence: 1456, + clientSequence: 456, + privateDirection: 'incoming', + sequenceAuthoritative: true, + }), + }, ); }); it('c2c: a bot self-sent quoted message is keyed under the peer (not self)', async () => { // The fetched message was sent by us (senderUin = self) — it converts with - // user_id=self, but must still be findable under hash(seq, peer, private), - // which is what the incoming reply resolves to. + // user_id=self, but its canonical id still belongs to the peer conversation. const store = fakeStore(false); const getC2cLatestHistory = vi.fn(async () => [fetchedFriendMessage(456, SELF)]); const ref = { @@ -186,15 +187,18 @@ describe('backfillReplyTarget', () => { await backfillReplyTarget(ref, event); - const targetId = hashMessageIdInt32(456, 448671521, PRIVATE_SENT_MESSAGE_EVENT); - expect(store.storeEvent).toHaveBeenCalledWith( + const targetId = hashMessageIdInt32(1456, 448671521, PRIVATE_NT_MESSAGE_EVENT); + expect(store.storeHistoryEvent).toHaveBeenCalledWith( targetId, false, 448671521, 1456, - PRIVATE_SENT_MESSAGE_EVENT, + PRIVATE_NT_MESSAGE_EVENT, expect.objectContaining({ message_id: targetId }), - { sequenceAuthoritative: true }, + { + sequenceAuthoritative: true, + meta: expect.objectContaining({ privateDirection: 'outgoing' }), + }, ); }); @@ -225,15 +229,18 @@ describe('backfillReplyTarget', () => { await backfillReplyTarget(ref, event); - const targetId = hashMessageIdInt32(456, peerUin, PRIVATE_MESSAGE_EVENT); - expect(store.storeEvent).toHaveBeenCalledWith( + const targetId = hashMessageIdInt32(1456, peerUin, PRIVATE_NT_MESSAGE_EVENT); + expect(store.storeHistoryEvent).toHaveBeenCalledWith( targetId, false, peerUin, 1456, - PRIVATE_MESSAGE_EVENT, + PRIVATE_NT_MESSAGE_EVENT, expect.objectContaining({ message_id: targetId, message_type: 'private' }), - { sequenceAuthoritative: true }, + { + sequenceAuthoritative: true, + meta: expect.objectContaining({ privateDirection: 'incoming' }), + }, ); }); diff --git a/packages/onebot/tests/event-converter.test.ts b/packages/onebot/tests/event-converter.test.ts index e57e773d..b7cf0335 100644 --- a/packages/onebot/tests/event-converter.test.ts +++ b/packages/onebot/tests/event-converter.test.ts @@ -673,10 +673,11 @@ describe('convertEvent — message elements', () => { PRIVATE_SENT_MESSAGE_EVENT, 1234, [{ type: 'text', text: 'quoted text' }], + undefined, ]); }); - it('reply: replySeq=0 -> id "0", no resolver call', async () => { + it('reply: replySeq=0 is omitted without calling the resolver', async () => { let resolverCalls = 0; const seg = await segment( { type: 'reply', replySeq: 0 }, @@ -687,7 +688,7 @@ describe('convertEvent — message elements', () => { }, }, ); - expect((seg.data as Record).id).toBe('0'); + expect(seg).toBeUndefined(); expect(resolverCalls).toBe(0); }); diff --git a/packages/onebot/tests/event-pipeline.test.ts b/packages/onebot/tests/event-pipeline.test.ts index a1d1ea25..edd35d92 100644 --- a/packages/onebot/tests/event-pipeline.test.ts +++ b/packages/onebot/tests/event-pipeline.test.ts @@ -615,9 +615,10 @@ describe('registerEventPipeline', () => { const backfillGate = new Promise((resolve) => { releaseBackfill = resolve; }); - vi.mocked(backfillReplyTarget).mockImplementationOnce(async () => { + vi.mocked(backfillReplyTarget).mockImplementationOnce(async (_ref, event) => { markBackfillStarted(); await backfillGate; + return event; }); const isPrivateMessageRecalled = vi .fn() diff --git a/packages/onebot/tests/private-reply-id-matches-send.test.ts b/packages/onebot/tests/private-reply-id-matches-send.test.ts index f59d8fdb..078d4532 100644 --- a/packages/onebot/tests/private-reply-id-matches-send.test.ts +++ b/packages/onebot/tests/private-reply-id-matches-send.test.ts @@ -5,50 +5,17 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { convertFriendMessage } from '../src/event-converter/to-message'; import { hashMessageIdInt32, - PRIVATE_MESSAGE_EVENT, PRIVATE_NT_MESSAGE_EVENT, PRIVATE_SENT_MESSAGE_EVENT, } from '../src/message-id'; import { MessageStore } from '../src/message-store'; +import { createMessageIdResolver as productionResolver } from '../src/message-id-resolver'; import type { ConverterContext } from '../src/event-converter'; import type { FriendMessage } from '@snowluma/protocol/events'; const SELF_ID = 3961840894; const PEER_ID = 2705892349; -function productionResolver(store: MessageStore): ConverterContext['messageIdResolver'] { - return async (isGroup, sessionId, sequence, eventName, timestamp, replyElements) => { - const resolvedEventName = eventName || (isGroup ? 'group_message' : PRIVATE_MESSAGE_EVENT); - - if ( - !isGroup && - timestamp !== undefined && - (resolvedEventName === PRIVATE_MESSAGE_EVENT || - resolvedEventName === PRIVATE_SENT_MESSAGE_EVENT) - ) { - const storedId = await store.resolvePrivateReplyMessageId( - sessionId, - sequence, - resolvedEventName === PRIVATE_SENT_MESSAGE_EVENT, - timestamp, - replyElements, - ); - - if (storedId !== null) { - return storedId; - } - } - - return hashMessageIdInt32( - sequence, - sessionId, - resolvedEventName === PRIVATE_SENT_MESSAGE_EVENT - ? PRIVATE_NT_MESSAGE_EVENT - : resolvedEventName, - ); - }; -} - describe('private reply id matches send receipt (#417, #433)', () => { let dir: string; let store: MessageStore; diff --git a/packages/onebot/tests/private-reply-resolution.test.ts b/packages/onebot/tests/private-reply-resolution.test.ts new file mode 100644 index 00000000..013d4f64 --- /dev/null +++ b/packages/onebot/tests/private-reply-resolution.test.ts @@ -0,0 +1,469 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FriendMessage, MessageElement } from '@snowluma/protocol/events'; +import { convertFriendMessage } from '../src/event-converter/to-message'; +import { resolveReplyId } from '../src/event-converter/utils'; +import type { ConverterContext } from '../src/event-converter'; +import { + hashMessageIdInt32, + PRIVATE_NT_MESSAGE_EVENT, + privateMessageEventName, +} from '../src/message-id'; +import { createMessageIdResolver } from '../src/message-id-resolver'; +import { MessageStore } from '../src/message-store'; +import { backfillReplyTarget } from '../src/modules/message-actions'; +import { BridgeEventBus } from '@snowluma/protocol/event-bus'; +import { registerEventPipeline } from '../src/event-pipeline'; +import type { OneBotInstanceContext } from '../src/instance-context'; +import type { JsonObject } from '../src/types'; + +const self = 10001; +const peer = 20002; +const time = 1_700_000_000; +const quoted = [{ type: 'text', text: 'actual quoted text' }] satisfies MessageElement[]; + +describe('private quote identity (#618)', () => { + let dir: string; + let store: MessageStore; + let converter: ConverterContext; + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), 'snowluma-quote-')); + store = new MessageStore(path.join(dir, 'messages.db')); + await store.initialization; + converter = { + selfId: self, + imageUrlResolver: null, + mediaUrlResolver: null, + mediaSegmentSink: null, + messageIdResolver: createMessageIdResolver(store), + }; + }); + + afterEach(async () => { + await store.close(); + await fs.rm(dir, { recursive: true, force: true }); + }); + + async function save( + sequence: number, + clientSequence: number, + timestamp: number, + text: string, + outgoing = true, + targetId = peer, + ): Promise { + const id = hashMessageIdInt32(sequence, targetId, PRIVATE_NT_MESSAGE_EVENT); + await store.storeMeta(id, { + isGroup: false, + targetId, + sequence, + sequenceAuthoritative: true, + eventName: PRIVATE_NT_MESSAGE_EVENT, + clientSequence, + privateDirection: outgoing ? 'outgoing' : 'incoming', + timestamp, + random: sequence, + }); + await store.storeEvent(id, false, targetId, sequence, PRIVATE_NT_MESSAGE_EVENT, { + time: timestamp, + post_type: outgoing ? 'message_sent' : 'message', + message_type: 'private', + sub_type: 'friend', + user_id: outgoing ? self : targetId, + message: [{ type: 'text', data: { text } }], + }); + return id; + } + + function message(replySeq = 444, replyTime = time): FriendMessage { + return { + kind: 'friend_message', + selfUin: self, + senderUin: peer, + peerUin: peer, + senderNick: 'peer', + time: time + 2, + msgSeq: 900, + clientSeq: 900, + ntMsgSeq: 9900, + sequenceAuthoritative: true, + msgId: 55, + elements: [ + { type: 'reply', replySeq, replyTime, replySenderUin: self, replyElements: quoted }, + { type: 'text', text: 'new reply' }, + ], + }; + } + + function historyRef(history: FriendMessage[] = []) { + const getC2cLatestHistory = vi.fn(async () => history); + const ref = { + selfId: self, + converterCtx: converter, + messageStore: store, + bridge: { + resolveUserUid: vi.fn(async () => 'u_peer'), + apis: { message: { getC2cLatestHistory } }, + }, + } as unknown as Parameters[0]; + return { ref, getC2cLatestHistory }; + } + + it.each([-3600, 3600])( + 'rejects a conversation-sequence match %i seconds away from the quote', + async (delta) => { + const unrelated = await save(444, 1111, time + delta, 'unrelated text'); + const target = await save(500, 2222, time, 'actual quoted text'); + expect(await store.resolvePrivateReplyMessageId(peer, 444, true, time, quoted)).toBe( + target, + ); + expect((await store.findEvent(unrelated))?.message).toEqual([ + { type: 'text', data: { text: 'unrelated text' } }, + ]); + }, + ); + + it('does not let an older reused client sequence override the quoted time and text', async () => { + await save(100, 444, time - 3600, 'old message'); + const target = await save(500, 2222, time, 'actual quoted text'); + expect(await store.resolvePrivateReplyMessageId(peer, 444, true, time, quoted)).toBe( + target, + ); + }); + + it('rejects even a sole same-second candidate when its text contradicts the quote', async () => { + await save(300, 3333, time, 'different text'); + expect(await store.resolvePrivateReplyMessageId(peer, 9999, true, time, quoted)).toBeNull(); + }); + + it('does not prefer one sequence namespace when both identify different compatible messages', async () => { + await save(100, 444, time, 'same'); + await save(444, 2222, time, 'same'); + expect(await store.resolvePrivateReplyMessageId(peer, 444, true, time)).toBeNull(); + }); + + it('keeps reused sequences ambiguous when the quote has no usable timestamp', async () => { + await save(100, 444, time - 3600, 'old'); + await save(500, 444, time, 'new'); + expect(await store.resolvePrivateReplyMessageId(peer, 444, true)).toBeNull(); + }); + + it('uses quoted content to check a nearby-time candidate too', async () => { + await save(300, 3333, time - 2, 'different text'); + expect(await store.resolvePrivateReplyMessageId(peer, 9999, true, time, quoted)).toBeNull(); + }); + + it('keeps peer and sender direction isolated when matching by time', async () => { + await save(300, 444, time, 'actual quoted text', false); + await save(300, 444, time, 'actual quoted text', true, 30003); + expect(await store.resolvePrivateReplyMessageId(peer, 444, true, time, quoted)).toBeNull(); + }); + + it('preserves an explicitly unresolved quote instead of using its raw sequence', async () => { + expect(await resolveReplyId(false, peer, 8888, () => 0)).toBe(0); + }); + + it('omits an unresolved reply identifier while retaining the live message and its stable id', async () => { + const json = await convertFriendMessage(converter, message()); + expect(json.message_id).toBe(hashMessageIdInt32(9900, peer, PRIVATE_NT_MESSAGE_EVENT)); + expect(json.message).toEqual([{ type: 'text', data: { text: 'new reply' } }]); + }); + + it('uses the canonical history id after backfill instead of a hash of the quote sequence', async () => { + const fetched: FriendMessage = { + kind: 'friend_message', + selfUin: self, + senderUin: self, + peerUin: peer, + senderNick: 'self', + time, + msgSeq: 444, + clientSeq: 444, + ntMsgSeq: 500, + sequenceAuthoritative: true, + msgId: 77, + elements: quoted, + }; + const { ref, getC2cLatestHistory } = historyRef([fetched]); + const event = message(); + const prepared = await backfillReplyTarget(ref, event); + const json = await convertFriendMessage(converter, prepared as FriendMessage); + const target = hashMessageIdInt32(500, peer, PRIVATE_NT_MESSAGE_EVENT); + expect(getC2cLatestHistory).toHaveBeenCalledExactlyOnceWith('u_peer', 20, self, time + 1); + expect(json.message).toEqual([ + { type: 'reply', data: { id: String(target) } }, + { type: 'text', data: { text: 'new reply' } }, + ]); + expect((await store.findEvent(target))?.message_id).toBe(target); + expect( + await store.findEvent(hashMessageIdInt32(444, peer, PRIVATE_NT_MESSAGE_EVENT)), + ).toBeNull(); + expect(event.elements[0]).not.toHaveProperty('replyMessageId'); + }); + + it('does not store an invented private message when history cannot identify the target', async () => { + const { ref } = historyRef(); + const event = message(); + const prepared = await backfillReplyTarget(ref, event); + const json = await convertFriendMessage(converter, prepared as FriendMessage); + expect(json.message).toEqual([{ type: 'text', data: { text: 'new reply' } }]); + expect( + await store.findEvent(hashMessageIdInt32(444, peer, PRIVATE_NT_MESSAGE_EVENT)), + ).toBeNull(); + }); + + it.each([ + { hasContent: true, ntMsgSeq: 9900 }, + { hasContent: false, ntMsgSeq: 9900 }, + { hasContent: true, ntMsgSeq: 0 }, + { hasContent: false, ntMsgSeq: 0 }, + ])( + 'never resolves a same-second self-sent reply to itself: %j', + async ({ hasContent, ntMsgSeq }) => { + const event = { ...message(9999), senderUin: self, time, ntMsgSeq }; + + if (!hasContent && event.elements[0]?.type === 'reply') { + event.elements[0].replyElements = undefined; + } + + const eventName = privateMessageEventName(true, ntMsgSeq > 0); + const currentId = hashMessageIdInt32(ntMsgSeq || event.msgSeq, peer, eventName); + await store.storeMeta(currentId, { + isGroup: false, + targetId: peer, + sequence: event.ntMsgSeq!, + sequenceAuthoritative: ntMsgSeq > 0, + clientSequence: event.clientSeq!, + eventName, + privateDirection: 'outgoing', + random: event.msgId, + timestamp: time, + }); + const directlyConverted = await convertFriendMessage(converter, event); + expect(directlyConverted.message).toEqual([ + { type: 'text', data: { text: 'new reply' } }, + ]); + const { ref } = historyRef([event]); + const prepared = await backfillReplyTarget(ref, event); + const json = await convertFriendMessage(converter, prepared as FriendMessage); + expect(json.message).toEqual([{ type: 'text', data: { text: 'new reply' } }]); + expect(await store.findEvent(currentId)).toBeNull(); + }, + ); + + it('does not use a bodyless time-only candidate to confirm known quoted content', async () => { + await store.storeMeta(-55, { + isGroup: false, + targetId: peer, + sequence: 300, + sequenceAuthoritative: true, + clientSequence: 100, + eventName: PRIVATE_NT_MESSAGE_EVENT, + privateDirection: 'outgoing', + random: 1, + timestamp: time, + }); + expect(await store.resolvePrivateReplyMessageId(peer, 444, true, time, quoted)).toBeNull(); + }); + + it('does not fetch history for an already verified cached target', async () => { + const target = await save(500, 444, time, 'actual quoted text'); + const { ref, getC2cLatestHistory } = historyRef(); + const prepared = await backfillReplyTarget(ref, message()); + const json = await convertFriendMessage(converter, prepared as FriendMessage); + expect((json.message as JsonObject[])[0]).toEqual({ + type: 'reply', + data: { id: String(target) }, + }); + expect(getC2cLatestHistory).not.toHaveBeenCalled(); + }); + + it('keys outgoing history without a peer field under the requested conversation', async () => { + const fetched: FriendMessage = { + ...message(), + peerUin: undefined, + senderUin: self, + time, + msgSeq: 444, + clientSeq: 444, + ntMsgSeq: 500, + elements: quoted, + }; + const { ref } = historyRef([fetched]); + const prepared = await backfillReplyTarget(ref, message()); + const json = await convertFriendMessage(converter, prepared as FriendMessage); + const target = hashMessageIdInt32(500, peer, PRIVATE_NT_MESSAGE_EVENT); + expect((json.message as JsonObject[])[0]).toEqual({ + type: 'reply', + data: { id: String(target) }, + }); + expect((await store.findEvent(target))?.target_id).toBe(peer); + expect( + await store.findEvent(hashMessageIdInt32(500, self, PRIVATE_NT_MESSAGE_EVENT)), + ).toBeNull(); + expect(fetched.peerUin).toBeUndefined(); + }); + + it.each([ + { senderUin: peer }, + { peerUin: 30003 }, + { selfUin: 30003 }, + { elements: [{ type: 'text', text: 'different text' }] satisfies MessageElement[] }, + { time: time - 3600 }, + ])('rejects incompatible server history: %j', async (override) => { + const fetched: FriendMessage = { + ...message(), + senderUin: self, + time, + msgSeq: 444, + clientSeq: 444, + ntMsgSeq: 500, + elements: quoted, + ...override, + }; + const { ref } = historyRef([fetched]); + const prepared = await backfillReplyTarget(ref, message()); + const json = await convertFriendMessage(converter, prepared as FriendMessage); + expect(json.message).toEqual([{ type: 'text', data: { text: 'new reply' } }]); + expect( + await store.findEvent(hashMessageIdInt32(500, peer, PRIVATE_NT_MESSAGE_EVENT)), + ).toBeNull(); + }); + + it('does not restore or reference a recalled target returned by server history', async () => { + await store.recordPrivateRecall(peer, 444, true, time + 1); + const fetched: FriendMessage = { + ...message(), + senderUin: self, + time, + msgSeq: 444, + clientSeq: 444, + ntMsgSeq: 500, + elements: quoted, + }; + const { ref } = historyRef([fetched]); + const prepared = await backfillReplyTarget(ref, message()); + const json = await convertFriendMessage(converter, prepared as FriendMessage); + expect(json.message).toEqual([{ type: 'text', data: { text: 'new reply' } }]); + expect( + await store.findEvent(hashMessageIdInt32(500, peer, PRIVATE_NT_MESSAGE_EVENT)), + ).toBeNull(); + }); + + it('preserves the live message on a history fetch failure without inventing a target', async () => { + const { ref, getC2cLatestHistory } = historyRef(); + getC2cLatestHistory.mockRejectedValueOnce(new Error('isolated history transport failure')); + const prepared = await backfillReplyTarget(ref, message()); + const json = await convertFriendMessage(converter, prepared as FriendMessage); + expect(json.message).toEqual([{ type: 'text', data: { text: 'new reply' } }]); + expect(getC2cLatestHistory).toHaveBeenCalledOnce(); + expect(await store.listSessionEvents(false, peer)).toEqual([]); + }); + + it('keeps a verified legacy negative id when filling its missing body from quoted content', async () => { + const target = -12345; + await store.storeMeta(target, { + isGroup: false, + targetId: peer, + sequence: 500, + sequenceAuthoritative: true, + clientSequence: 444, + eventName: PRIVATE_NT_MESSAGE_EVENT, + privateDirection: 'outgoing', + random: 7, + timestamp: time, + }); + const { ref } = historyRef(); + const prepared = await backfillReplyTarget(ref, message()); + const json = await convertFriendMessage(converter, prepared as FriendMessage); + expect((json.message as JsonObject[])[0]).toEqual({ + type: 'reply', + data: { id: String(target) }, + }); + expect((await store.findEvent(target))?.message).toEqual([ + { type: 'text', data: { text: 'actual quoted text' } }, + ]); + }); + + it('does not overwrite an old identity when history reuses its client sequence with a different server sequence', async () => { + const oldId = -12345; + await store.storeMeta(oldId, { + isGroup: false, + targetId: peer, + sequence: 499, + sequenceAuthoritative: true, + clientSequence: 444, + eventName: PRIVATE_NT_MESSAGE_EVENT, + privateDirection: 'outgoing', + random: 7, + timestamp: time - 1, + }); + const fetched: FriendMessage = { + ...message(), + senderUin: self, + time, + msgSeq: 444, + clientSeq: 444, + ntMsgSeq: 500, + elements: quoted, + }; + const { ref } = historyRef([fetched]); + const prepared = await backfillReplyTarget(ref, message()); + const json = await convertFriendMessage(converter, prepared as FriendMessage); + const target = hashMessageIdInt32(500, peer, PRIVATE_NT_MESSAGE_EVENT); + expect((json.message as JsonObject[])[0]).toEqual({ + type: 'reply', + data: { id: String(target) }, + }); + expect(await store.findEvent(oldId)).toBeNull(); + expect((await store.findMeta(oldId))?.sequence).toBe(499); + expect((await store.findEvent(target))?.message_id).toBe(target); + }); + + it('dispatches only after canonical backfill is available to an immediate get_msg', async () => { + const fetched: FriendMessage = { + ...message(), + senderUin: self, + time, + msgSeq: 444, + clientSeq: 444, + ntMsgSeq: 500, + elements: quoted, + }; + const { ref } = historyRef([fetched]); + const bus = new BridgeEventBus(); + const dispatched: JsonObject[] = []; + const targets: Array = []; + const ctx = { + ...ref, + uin: String(self), + bridge: { ...ref.bridge, events: bus }, + cacheMessageMeta: store.storeMeta.bind(store), + dispatchEvent: async (json: JsonObject) => { + dispatched.push(json); + const reply = (json.message as JsonObject[]).find( + (segment) => segment.type === 'reply', + ); + targets.push(await store.findEvent(Number((reply?.data as JsonObject)?.id))); + }, + } as unknown as OneBotInstanceContext; + const handle = registerEventPipeline(ctx); + + try { + await bus.emit(message()); + expect(dispatched).toHaveLength(1); + const target = hashMessageIdInt32(500, peer, PRIVATE_NT_MESSAGE_EVENT); + expect((dispatched[0]!.message as JsonObject[])[0]).toEqual({ + type: 'reply', + data: { id: String(target) }, + }); + expect(targets[0]?.message_id).toBe(target); + } finally { + handle.stop(); + await handle.drain(); + } + }); +});