fix(onebot): resolve private reply targets consistently

Refs #618
This commit is contained in:
DrSmoothl
2026-11-02 08:47:26 +08:00
parent a1688a33b0
commit 4a10f38743
17 changed files with 1221 additions and 608 deletions
+28
View File
@@ -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.
@@ -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) {
@@ -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<number>;
export type MediaSegmentSink = (
@@ -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,
@@ -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<JsonArray> {
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 (
+6 -1
View File
@@ -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<number> {
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;
+29 -27
View File
@@ -176,10 +176,38 @@ async function convertAndDispatch(
state: EventTraceState,
messageIdOverride?: number,
): Promise<void> {
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))) {
+3 -46
View File
@@ -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),
};
@@ -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<MessageStore, 'resolvePrivateReplyMessageId'>,
): 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,
);
};
}
+88 -276
View File
@@ -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<number | null> {
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<number | null> {
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<number | null> {
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<number | null> {
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<string, string> = {
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(
+259 -181
View File
@@ -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<void> {
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<QQEventVariant> {
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<number> {
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<number> {
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).
+194
View File
@@ -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<T extends PrivateQuoteCandidate>(
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<Record<string, string>> = {
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;
}
@@ -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' }),
},
);
});
@@ -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<string, unknown>).id).toBe('0');
expect(seg).toBeUndefined();
expect(resolverCalls).toBe(0);
});
+2 -1
View File
@@ -615,9 +615,10 @@ describe('registerEventPipeline', () => {
const backfillGate = new Promise<void>((resolve) => {
releaseBackfill = resolve;
});
vi.mocked(backfillReplyTarget).mockImplementationOnce(async () => {
vi.mocked(backfillReplyTarget).mockImplementationOnce(async (_ref, event) => {
markBackfillStarted();
await backfillGate;
return event;
});
const isPrivateMessageRecalled = vi
.fn()
@@ -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;
@@ -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<number> {
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<typeof backfillReplyTarget>[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<JsonObject | null> = [];
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();
}
});
});