diff --git a/packages/onebot/src/modules/message-actions.ts b/packages/onebot/src/modules/message-actions.ts index b41ae29e..1cf03dce 100644 --- a/packages/onebot/src/modules/message-actions.ts +++ b/packages/onebot/src/modules/message-actions.ts @@ -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!, diff --git a/packages/onebot/tests/send-private-c2c-file.test.ts b/packages/onebot/tests/send-private-c2c-file.test.ts index fb1e8ab8..e160dc98 100644 --- a/packages/onebot/tests/send-private-c2c-file.test.ts +++ b/packages/onebot/tests/send-private-c2c-file.test.ts @@ -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 { 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)); + }); +});