fix(onebot): identify QQ automatic replies in private messages
Refs #663
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user