diff --git a/packages/mcp/src/generated/catalog.json b/packages/mcp/src/generated/catalog.json index bb36ca2a..08229002 100644 --- a/packages/mcp/src/generated/catalog.json +++ b/packages/mcp/src/generated/catalog.json @@ -6521,6 +6521,10 @@ } }, "additionalProperties": true + }, + "auto_reply": { + "const": true, + "description": "仅当 QQ 标记该私聊消息为自动回复时提供" } }, "additionalProperties": true @@ -10710,6 +10714,10 @@ } }, "additionalProperties": true + }, + "auto_reply": { + "const": true, + "description": "仅当 QQ 标记该私聊消息为自动回复时提供" } }, "additionalProperties": true diff --git a/packages/mcp/src/generated/catalog.ts b/packages/mcp/src/generated/catalog.ts index 01031328..a5142323 100644 --- a/packages/mcp/src/generated/catalog.ts +++ b/packages/mcp/src/generated/catalog.ts @@ -6524,6 +6524,10 @@ export const ACTIONS: CatalogAction[] = [ } }, "additionalProperties": true + }, + "auto_reply": { + "const": true, + "description": "仅当 QQ 标记该私聊消息为自动回复时提供" } }, "additionalProperties": true @@ -10713,6 +10717,10 @@ export const ACTIONS: CatalogAction[] = [ } }, "additionalProperties": true + }, + "auto_reply": { + "const": true, + "description": "仅当 QQ 标记该私聊消息为自动回复时提供" } }, "additionalProperties": true diff --git a/packages/onebot/src/action-result-schemas.ts b/packages/onebot/src/action-result-schemas.ts index f900272e..77938037 100644 --- a/packages/onebot/src/action-result-schemas.ts +++ b/packages/onebot/src/action-result-schemas.ts @@ -62,6 +62,14 @@ export const messageDataSchema = { additionalProperties: true, } satisfies JsonSchema; +export const messageWithAutoReplySchema = { + ...messageDataSchema, + properties: { + ...messageDataSchema.properties, + auto_reply: { const: true, description: '仅当 QQ 标记该私聊消息为自动回复时提供' }, + }, +} satisfies JsonSchema; + // Forward nodes carry their original identifiers, not locally hashed message IDs. export const forwardNodeSchema: JsonSchema = { ...messageDataSchema, diff --git a/packages/onebot/src/actions/extended.ts b/packages/onebot/src/actions/extended.ts index d026879c..c744b989 100644 --- a/packages/onebot/src/actions/extended.ts +++ b/packages/onebot/src/actions/extended.ts @@ -27,6 +27,7 @@ import { GROUP_MESSAGE_EVENT, hashMessageIdInt32 } from '../message-id'; import { messageIdSchema, messageDataSchema, + messageWithAutoReplySchema, forwardNodeSchema, messageSegmentSchema, mediaInfoSchema, @@ -1039,7 +1040,7 @@ export const actions = [ messages: { type: 'array', description: '消息对象数组;去除 post_type/self_id,附带 real_id', - items: messageDataSchema, + items: messageWithAutoReplySchema, }, }, required: ['messages'], diff --git a/packages/onebot/src/actions/message.ts b/packages/onebot/src/actions/message.ts index deffb91c..c43b462b 100644 --- a/packages/onebot/src/actions/message.ts +++ b/packages/onebot/src/actions/message.ts @@ -2,7 +2,11 @@ import { createLogger } from '@snowluma/common/logger'; import type { JsonObject, JsonValue } from '../types'; import { defineAction, groupAction, f, type Field } from '../action-kit'; import { RETCODE, failedResponse, okResponse } from '../types'; -import { messageResultSchema, messageIdSchema, messageDataSchema } from '../action-result-schemas'; +import { + messageResultSchema, + messageIdSchema, + messageWithAutoReplySchema, +} from '../action-result-schemas'; /** send_msg only (#426). Some clients send `group_id: ""` on private chats. * Treat that exact empty string as absent. Do not change `f.groupId()`. */ @@ -210,7 +214,7 @@ export const actions = [ ], returnsSchema: { allOf: [ - messageDataSchema, + messageWithAutoReplySchema, { type: 'object', properties: { diff --git a/packages/onebot/src/event-converter/to-message.ts b/packages/onebot/src/event-converter/to-message.ts index 920b91c8..29722dab 100644 --- a/packages/onebot/src/event-converter/to-message.ts +++ b/packages/onebot/src/event-converter/to-message.ts @@ -89,6 +89,10 @@ async function convertPrivateMessage( sender, }; + if (event.autoReply) { + fields.auto_reply = true; + } + if (isSelf && sessionId > 0 && sessionId !== ctx.selfId) { fields.target_id = sessionId; } diff --git a/packages/onebot/tests/event-converter.test.ts b/packages/onebot/tests/event-converter.test.ts index b7cf0335..4a2375c7 100644 --- a/packages/onebot/tests/event-converter.test.ts +++ b/packages/onebot/tests/event-converter.test.ts @@ -112,6 +112,28 @@ describe('convertEvent — message kinds', () => { expect(out!.message_id).toBe(PEER_UIN + 1); }); + it.each([makeFriendMessage(PEER_UIN), makeTempMessage(PEER_UIN)])( + 'exposes only QQ-marked auto replies in $kind', + async (event) => { + const manual = await convertEvent(bareCtx(), { + ...event, + elements: [{ type: 'text', text: '[自动回复] 稍后联系' }], + }); + const automatic = await convertEvent(bareCtx(), { ...event, autoReply: true }); + + expect(manual).toMatchObject({ + message: [{ type: 'text', data: { text: '[自动回复] 稍后联系' } }], + raw_message: '[自动回复] 稍后联系', + }); + expect(manual).not.toHaveProperty('auto_reply'); + expect(automatic).toMatchObject({ + auto_reply: true, + message: [{ type: 'text', data: { text: 'hi' } }], + raw_message: 'hi', + }); + }, + ); + it('group_message peer → post_type "message", sub_type "normal", group_id set', async () => { const out = await convertEvent(bareCtx(), makeGroupMessage(PEER_UIN)); expect(out!.post_type).toBe('message'); diff --git a/packages/onebot/tests/history-direction.test.ts b/packages/onebot/tests/history-direction.test.ts index 4ae957ef..5cea8f84 100644 --- a/packages/onebot/tests/history-direction.test.ts +++ b/packages/onebot/tests/history-direction.test.ts @@ -137,7 +137,7 @@ describe('history direction plumbing', () => { it('uses C2C roaming for an unanchored latest page and ignores a stale local sequence', async () => { const fetchRangeHistory = vi.fn(async () => [friendMessage(99999, FRIEND_ID)]); const fetchLatestHistory = vi.fn(async () => [ - friendMessage(63213, FRIEND_ID, 32742), + { ...friendMessage(63213, FRIEND_ID, 32742), autoReply: true }, friendMessage(63214, SELF_ID, 32743), ]); await messageStore.storeMeta(-222222222, { @@ -169,6 +169,12 @@ describe('history direction plumbing', () => { expect(fetchLatestHistory).toHaveBeenCalledWith('u_friend', 20, SELF_ID); expect(fetchRangeHistory).not.toHaveBeenCalled(); + expect(messages[0]).toMatchObject({ auto_reply: true, raw_message: '63213' }); + expect(messages[1]).not.toHaveProperty('auto_reply'); + expect(await messageStore.findEvent(Number(messages[0]!.message_id))).toMatchObject({ + auto_reply: true, + raw_message: '63213', + }); expect( messages.map((message) => ({ sender: message.user_id, diff --git a/packages/proto-defs/src/message.ts b/packages/proto-defs/src/message.ts index 31978981..acd901b8 100644 --- a/packages/proto-defs/src/message.ts +++ b/packages/proto-defs/src/message.ts @@ -60,6 +60,8 @@ export interface ContentHead { sequence?: pb<5, uint_32>; timestamp?: pb<6, uint_32>; field7?: pb<7, uint_64>; + /** QQ sets a nonzero value for automatic replies. */ + autoReply?: pb<10, uint_32>; /** Conversation-wide C2C sequence. Unlike field 5, this advances across * messages sent by either participant and is the sequence accepted by * SsoGetC2cMsg. It is absent on group messages. */ diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 840abce6..b3ada9e9 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -283,6 +283,7 @@ export interface QQEvent { export interface FriendMessage extends QQEvent { kind: 'friend_message'; + autoReply?: true; senderUin: number; /** The other participant in this private conversation. Differs from * senderUin for messages sent by the logged-in account. */ @@ -326,6 +327,7 @@ export interface GroupMessage extends QQEvent { export interface TempMessage extends QQEvent { kind: 'temp_message'; + autoReply?: true; senderUin: number; /** Source group of the temp session (from responseHead.forward), or 0. */ groupId: number; diff --git a/packages/protocol/src/msg-push/context.ts b/packages/protocol/src/msg-push/context.ts index 8911b871..38fd2d1f 100644 --- a/packages/protocol/src/msg-push/context.ts +++ b/packages/protocol/src/msg-push/context.ts @@ -21,6 +21,7 @@ export interface MsgPushHead { readonly c2cCmd: number; /** Sender-local C2C client sequence (field 5), or the group sequence. */ readonly sequence: number; + readonly autoReply?: true; /** Bidirectional C2C sequence (field 11); zero on groups/legacy packets. */ readonly ntMsgSeq: number; readonly timestamp: number; @@ -94,6 +95,7 @@ export function buildContextFromMessage( subType: msg.contentHead.subType ?? 0, c2cCmd: msg.contentHead.c2cCmd ?? 0, sequence: msg.contentHead.sequence ?? 0, + autoReply: msg.contentHead.autoReply ? true : undefined, ntMsgSeq: msg.contentHead.ntMsgSeq ?? 0, timestamp: msg.contentHead.timestamp ?? 0, msgId: msg.contentHead.msgId ?? 0, diff --git a/packages/protocol/src/msg-push/decoders/friend-message.ts b/packages/protocol/src/msg-push/decoders/friend-message.ts index 16d87e5e..598edb31 100644 --- a/packages/protocol/src/msg-push/decoders/friend-message.ts +++ b/packages/protocol/src/msg-push/decoders/friend-message.ts @@ -80,6 +80,7 @@ export const decodeFriendMessage: MsgPushDecoder = (ctx) => { const ev: FriendMessage = { kind: 'friend_message', + autoReply: ctx.head.autoReply, time: ctx.head.timestamp, selfUin: ctx.selfUin, senderUin: ctx.fromUin, diff --git a/packages/protocol/src/msg-push/decoders/temp-message.ts b/packages/protocol/src/msg-push/decoders/temp-message.ts index 90399587..2c61943b 100644 --- a/packages/protocol/src/msg-push/decoders/temp-message.ts +++ b/packages/protocol/src/msg-push/decoders/temp-message.ts @@ -6,6 +6,7 @@ export const decodeTempMessage: MsgPushDecoder = (ctx) => { const sequenceAuthoritative = ctx.head.ntMsgSeq > 0; const ev: TempMessage = { kind: 'temp_message', + autoReply: ctx.head.autoReply, time: ctx.head.timestamp, selfUin: ctx.selfUin, senderUin: ctx.fromUin, diff --git a/packages/protocol/tests/msg-push/fetch-c2c-history.test.ts b/packages/protocol/tests/msg-push/fetch-c2c-history.test.ts index b0edb35f..04b68e32 100644 --- a/packages/protocol/tests/msg-push/fetch-c2c-history.test.ts +++ b/packages/protocol/tests/msg-push/fetch-c2c-history.test.ts @@ -18,12 +18,14 @@ import type { IdentityService } from '../../src/identity-service'; import { buildContextFromMessage } from '../../src/msg-push/context'; import { decodeFriendMessage } from '../../src/msg-push/decoders/friend-message'; import { + MSG_PUSH_CMD, SSO_GET_C2C_MSG_CMD, SSO_GET_ROAM_MSG_CMD, fetchC2cMessageRange, fetchC2cRoamMessagePage, + parseMsgPushOrThrow, } from '../../src/msg-push'; -import { s, v } from '../oidb-services/_pb-oracle'; +import { m, s, v } from '../oidb-services/_pb-oracle'; const identity = { findFriend: () => undefined } as unknown as IdentityService; const previousLogLevel = getLogLevel(); @@ -36,6 +38,52 @@ function okResult(data: Uint8Array): SendPacketResult { return { success: true, gotResponse: true, responseData: data } as SendPacketResult; } +describe('private auto replies', () => { + it.each([ + { name: 'missing flag', flag: [], text: '稍后联系', expected: undefined }, + { name: 'explicit zero', flag: v(10, 0), text: '[自动回复] 稍后联系', expected: undefined }, + { name: 'set flag', flag: v(10, 1), text: '稍后联系', expected: true }, + { name: 'other nonzero flag', flag: v(10, 2), text: '[自动回复] 稍后联系', expected: true }, + ])( + 'decodes $name from a live push and both history responses', + async ({ flag, text, expected }) => { + const head = [...v(1, 166), ...v(5, 7), ...flag, ...v(11, 42)]; + const textElement = m(1, s(1, text)); + const wireMessage = [ + ...m(1, v(1, 222)), + ...m(2, head), + ...m(3, m(1, m(2, textElement))), + ]; + const live = parseMsgPushOrThrow( + { + pid: 0, + uin: '10001', + serviceCmd: MSG_PUSH_CMD, + seqId: 1, + retCode: 0, + fromClient: false, + body: Buffer.from(m(1, wireMessage)), + }, + identity, + ); + // Both history endpoints carry repeated messages in field 7. + const sender = { sendRawPacket: async () => okResult(Buffer.from(m(7, wireMessage))) }; + const range = await fetchC2cMessageRange(sender, identity, 10001, 'u_friend', 42, 42); + const roam = await fetchC2cRoamMessagePage(sender, identity, 10001, 'u_friend', 100, 1); + + for (const messages of [live, range, roam.messages]) { + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + kind: 'friend_message', + elements: [{ type: 'text', text }], + }); + const [event] = messages; + expect(event.kind === 'friend_message' && event.autoReply).toBe(expected); + } + }, + ); +}); + function inviteCardMessage(sequence: number): PushMsgBody { const json = JSON.stringify({ app: 'com.tencent.qun.invite', diff --git a/packages/sdk/MESSAGING.md b/packages/sdk/MESSAGING.md index 60420de6..3737a2fb 100644 --- a/packages/sdk/MESSAGING.md +++ b/packages/sdk/MESSAGING.md @@ -73,6 +73,8 @@ Old `SnowLumaEventContext` objects and explicitly typed handlers remain valid. S ## Wait for a future event +Private messages marked by QQ as automatic replies carry `auto_reply: true` in live events and friend history. Check `event.auto_reply === true` to filter them. SnowLuma preserves the reply text, including any literal `[自动回复]` the sender typed. Older locally stored messages may lack the flag. + `waitForEvent(predicate?, options?)` and `waitForMessage(predicate?, options?)` are available on the WebSocket client. Both also accept just the options object. Synchronous type-guard predicates narrow the resolved type; boolean predicates filter without claiming a narrower type. ```ts diff --git a/packages/sdk/src/types/events.ts b/packages/sdk/src/types/events.ts index 45184c29..a8cda714 100644 --- a/packages/sdk/src/types/events.ts +++ b/packages/sdk/src/types/events.ts @@ -18,6 +18,8 @@ export interface OneBotSender extends JsonObject { export interface OneBotPrivateMessageEvent extends OneBotBaseEvent { post_type: 'message' | 'message_sent'; message_type: 'private'; + /** Present only when QQ marks this message as an automatic reply. */ + auto_reply?: true; sub_type: string; message_id: number; message_seq?: number; diff --git a/packages/sdk/src/types/results.ts b/packages/sdk/src/types/results.ts index a218963a..f7245480 100644 --- a/packages/sdk/src/types/results.ts +++ b/packages/sdk/src/types/results.ts @@ -1,3 +1,4 @@ +import type { OneBotPrivateMessageEvent } from './events.js'; import type { JsonArray, JsonObject, JsonValue } from './json.js'; export interface QzoneCommentReply { @@ -85,7 +86,7 @@ export interface GroupMessageHistory { } export interface FriendMessageHistory { - messages: JsonObject[]; + messages: Array>; } export interface GroupNoticeInfo {