@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user