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:
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user