fix(onebot): identify QQ automatic replies in private messages

Refs #663
This commit is contained in:
DrSmoothl
2026-11-06 22:49:52 +08:00
parent e031cd4108
commit b0612d8d12
17 changed files with 128 additions and 6 deletions
+8
View File
@@ -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
+8
View File
@@ -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
@@ -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,
+2 -1
View File
@@ -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'],
+6 -2
View File
@@ -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: {
@@ -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;
}
@@ -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');
@@ -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,
+2
View File
@@ -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. */
+2
View File
@@ -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;
@@ -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,
@@ -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,
@@ -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,
@@ -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',
+2
View File
@@ -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
+2
View File
@@ -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;
+2 -1
View File
@@ -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<JsonObject & Pick<OneBotPrivateMessageEvent, 'auto_reply'>>;
}
export interface GroupNoticeInfo {