fix(onebot): key self-sent c2c messages by clientSequence so replies resolve

A friend replying to a bot self-sent private message → bot get_msg(quoted id)
returned "message not found" (bot then answered "无法解析申请信息"). c2c replies
reference a message by the sender's clientSequence, not the server sequence
(Lagrange: OrigSeqs = ClientSequence || Sequence), but self-sent private
messages were keyed by the server (private) sequence — so the incoming reply's
resolved id hash(clientSequence, peer, private) never matched the stored event.

Reply-SENDING still worked because it only needs the meta row (resolveReplySequence
queries by hash, no data), not the full event (findEvent requires data IS NOT
NULL) — which is exactly why get_msg / "引用解析" failed but replying didn't.

Key self-sent c2c messages by clientSequence (fallback to sequence, mirroring
Lagrange's `||`) via a shared c2cSelfMessageId helper, across all three c2c
self-sent paths (send_private_msg, private forward, forward-single-to-user).
Group messages unchanged (their replies reference the shared group sequence).
This commit is contained in:
DrSmoothl
2026-06-15 10:03:26 +08:00
parent 9dfa31bdbc
commit 457186419f
2 changed files with 93 additions and 3 deletions
+26 -3
View File
@@ -12,6 +12,29 @@ import type { JsonArray, JsonObject, JsonValue, MessageMeta } from '../types';
const log = createLogger('OneBot');
/**
* message_id for a self-sent **c2c (private)** message.
*
* QQ-NT/Lagrange reference a c2c message in replies by the sender's
* `clientSequence`, NOT the server sequence — Lagrange builds the reply source
* as `OrigSeqs = ClientSequence != 0 ? ClientSequence : Sequence`
* (`dev/Lagrange.Core/.../Message/Entity/ForwardEntity.cs`). So a self-sent
* message MUST be keyed by `clientSequence`; otherwise an incoming reply's
* resolved id (`hash(clientSequence, peer, private)`) never matches the stored
* event and `get_msg` on the quoted message fails ("引用消息" can't be read,
* even though replying still works because it only needs the meta row).
*
* Falls back to the server sequence when `clientSequence` is 0 (e.g. the c2c
* file-send receipt), mirroring Lagrange's `||` exactly. Group messages do NOT
* use this — their replies reference the (shared) group sequence.
*/
function c2cSelfMessageId(
receipt: { sequence: number; clientSequence: number },
userId: number,
): number {
return hashMessageIdInt32(receipt.clientSequence || receipt.sequence, userId, PRIVATE_MESSAGE_EVENT);
}
export async function getGroupMsgHistory(
messageStore: MessageStore,
groupId: number,
@@ -419,7 +442,7 @@ export async function sendPrivateMessage(
}
if (!lastReceipt) throw new Error('message is empty');
const messageId = hashMessageIdInt32(lastReceipt.sequence, userId, PRIVATE_MESSAGE_EVENT);
const messageId = c2cSelfMessageId(lastReceipt, userId);
ref.cacheMessageMeta(messageId, {
isGroup: false,
targetId: userId,
@@ -585,7 +608,7 @@ export async function sendPrivateForwardMessage(
const forwardId = await ref.bridge.apis.forward.upload(nodes, undefined, userId);
const previewElement = buildForwardPreviewElement(forwardId, nodes, false, meta);
const receipt = await ref.bridge.apis.message.sendPrivate(userId, [previewElement]);
const messageId = hashMessageIdInt32(receipt.sequence, userId, PRIVATE_MESSAGE_EVENT);
const messageId = c2cSelfMessageId(receipt, userId);
ref.cacheMessageMeta(messageId, {
isGroup: false,
@@ -672,7 +695,7 @@ export async function forwardSingleMessage(
});
} else {
receipt = await ref.bridge.apis.message.sendPrivate(target.userId!, elements);
messageIdOut = hashMessageIdInt32(receipt.sequence, target.userId!, PRIVATE_MESSAGE_EVENT);
messageIdOut = c2cSelfMessageId(receipt, target.userId!);
ref.cacheMessageMeta(messageIdOut, {
isGroup: false,
targetId: target.userId!,
@@ -18,6 +18,8 @@ import { describe, expect, it, vi } from 'vitest';
import type { BridgeInterface } from '../../src/bridge/bridge-interface';
import type { OneBotInstanceContext } from '../src/instance-context';
import { sendPrivateMessage } from '../src/modules/message-actions';
import { hashMessageIdInt32, PRIVATE_MESSAGE_EVENT } from '../src/message-id';
import { resolveReplyId } from '../src/event-converter/utils';
function fakeBridge(overrides: Partial<BridgeInterface> = {}): BridgeInterface {
return new Proxy(overrides as BridgeInterface, {
@@ -257,3 +259,68 @@ describe('send_private_msg with {type:"file"} segment', () => {
expect(sendC2cFileMessage).not.toHaveBeenCalled();
});
});
// Regression: a friend replied to a bot self-sent private message; the bot
// called get_msg on the quoted id and got "message not found" (then replied
// "无法解析申请信息"). c2c replies reference the message by clientSequence
// (Lagrange OrigSeqs = ClientSequence||Sequence), but the self-sent message was
// keyed by the server (private) sequence — so the reply's resolved id never
// matched the stored event. Self-sent c2c messages must be keyed by
// clientSequence so the two agree.
describe('send_private_msg self-sent message_id (c2c reply / get_msg resolution)', () => {
it('keys a self-sent private message by clientSequence, matching an incoming reply id', async () => {
// clientSequence and (server) sequence deliberately differ.
const receipt = { messageId: 1, sequence: 888, clientSequence: 21196, random: 7, timestamp: 1700000000 };
const sendPrivate = vi.fn(async () => receipt);
const cacheMessageMeta = vi.fn();
const bridge = fakeBridge({
apis: { message: { sendPrivate } } as any,
resolveUserUid: vi.fn(async () => 'u_peer'),
} as any);
const ctx = {
uin: '10001', selfId: 10001, bridge,
messageStore: { findEvent: () => null } as any,
cacheMessageMeta,
mediaStore: {} as any,
musicSignUrl: '',
} as unknown as OneBotInstanceContext;
const result = await sendPrivateMessage(ctx, 67890, 'hi', false);
// The id an incoming reply to this message resolves to — the peer quotes it
// by clientSequence, so resolveReplyId(...) hashes clientSequence. The
// resolver mirrors how instance.ts wires messageIdResolver (4-arg → hash).
const messageIdResolver = (_isGroup: boolean, sessionId: number, seq: number, eventName: string) =>
hashMessageIdInt32(seq, sessionId, eventName);
const incomingReplyId = resolveReplyId(false, 67890, 21196, messageIdResolver);
expect(result.messageId).toBe(incomingReplyId);
expect(result.messageId).toBe(hashMessageIdInt32(21196, 67890, PRIVATE_MESSAGE_EVENT));
// …and specifically NOT the server (private) sequence, which was the bug.
expect(result.messageId).not.toBe(hashMessageIdInt32(888, 67890, PRIVATE_MESSAGE_EVENT));
// Meta still carries both sequences (recall needs the server seq + clientSeq).
expect(cacheMessageMeta).toHaveBeenCalledWith(
incomingReplyId,
expect.objectContaining({ clientSequence: 21196, sequence: 888 }),
);
});
it('falls back to the server sequence when clientSequence is 0 (file-send receipt)', async () => {
const receipt = { messageId: 1, sequence: 888, clientSequence: 0, random: 7, timestamp: 1700000000 };
const sendPrivate = vi.fn(async () => receipt);
const cacheMessageMeta = vi.fn();
const bridge = fakeBridge({
apis: { message: { sendPrivate } } as any,
resolveUserUid: vi.fn(async () => 'u_peer'),
} as any);
const ctx = {
uin: '10001', selfId: 10001, bridge,
messageStore: { findEvent: () => null } as any,
cacheMessageMeta,
mediaStore: {} as any,
musicSignUrl: '',
} as unknown as OneBotInstanceContext;
const result = await sendPrivateMessage(ctx, 67890, 'hi', false);
expect(result.messageId).toBe(hashMessageIdInt32(888, 67890, PRIVATE_MESSAGE_EVENT));
});
});