@zhin.js/adapter-qq 1.1.17 → 1.1.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -0
- package/README.md +75 -21
- package/adapters/qq/index.js +4 -33
- package/adapters/qq/index.ts +3 -41
- package/agents/qq/boundaries.md +2 -0
- package/agents/qq/conversation.js +92 -0
- package/agents/qq/conversation.ts +99 -0
- package/agents/qq/messages.js +60 -0
- package/agents/qq/messages.ts +60 -0
- package/agents/qq/skills/qq/SKILL.md +8 -2
- package/agents/qq/skills/qq/tools/add_role/index.js +2 -1
- package/agents/qq/skills/qq/tools/add_role/index.ts +2 -1
- package/agents/qq/skills/qq/tools/channel_info/index.js +1 -1
- package/agents/qq/skills/qq/tools/channel_info/index.ts +1 -1
- package/agents/qq/skills/qq/tools/create_role/index.js +1 -1
- package/agents/qq/skills/qq/tools/create_role/index.ts +1 -1
- package/agents/qq/skills/qq/tools/guild_kick_member/index.js +30 -0
- package/agents/qq/skills/qq/tools/guild_kick_member/index.ts +29 -0
- package/agents/qq/skills/qq/tools/guild_mute_member/index.js +31 -0
- package/agents/qq/skills/qq/tools/guild_mute_member/index.ts +30 -0
- package/agents/qq/skills/qq/tools/list_channels/index.js +1 -1
- package/agents/qq/skills/qq/tools/list_channels/index.ts +1 -1
- package/agents/qq/skills/qq/tools/list_roles/index.js +1 -1
- package/agents/qq/skills/qq/tools/list_roles/index.ts +1 -1
- package/agents/qq/skills/qq/tools/member_detail/index.js +1 -1
- package/agents/qq/skills/qq/tools/member_detail/index.ts +1 -1
- package/agents/qq/skills/qq/tools/remove_role/index.js +2 -1
- package/agents/qq/skills/qq/tools/remove_role/index.ts +2 -1
- package/agents/qq/skills/qq-conversation/SKILL.md +24 -0
- package/agents/qq/skills/qq-conversation/tools/conversation_info/index.js +78 -0
- package/agents/qq/skills/qq-conversation/tools/conversation_info/index.ts +76 -0
- package/agents/qq/skills/qq-directory/tools/list_guilds/index.js +1 -1
- package/agents/qq/skills/qq-directory/tools/list_guilds/index.ts +1 -1
- package/agents/qq/skills/qq-group-admin/SKILL.md +34 -0
- package/agents/qq/skills/qq-group-admin/tools/group_kick/index.js +39 -0
- package/agents/qq/skills/qq-group-admin/tools/group_kick/index.ts +38 -0
- package/agents/qq/skills/qq-group-admin/tools/handle_join_request/index.js +33 -0
- package/agents/qq/skills/qq-group-admin/tools/handle_join_request/index.ts +32 -0
- package/agents/qq/skills/qq-group-admin/tools/recall_member_message/index.js +25 -0
- package/agents/qq/skills/qq-group-admin/tools/recall_member_message/index.ts +25 -0
- package/agents/qq/skills/qq-group-admin/tools/set_mute/index.js +37 -0
- package/agents/qq/skills/qq-group-admin/tools/set_mute/index.ts +36 -0
- package/agents/qq/skills/qq-group-directory/SKILL.md +24 -0
- package/agents/qq/skills/qq-group-directory/tools/group_info/index.js +16 -0
- package/agents/qq/skills/qq-group-directory/tools/group_info/index.ts +16 -0
- package/agents/qq/skills/qq-group-directory/tools/group_member_info/index.js +19 -0
- package/agents/qq/skills/qq-group-directory/tools/group_member_info/index.ts +19 -0
- package/agents/qq/skills/qq-group-directory/tools/group_members/index.js +19 -0
- package/agents/qq/skills/qq-group-directory/tools/group_members/index.ts +19 -0
- package/agents/qq/skills/qq-group-state/SKILL.md +21 -0
- package/agents/qq/skills/qq-group-state/tools/group_blacklist/index.js +19 -0
- package/agents/qq/skills/qq-group-state/tools/group_blacklist/index.ts +19 -0
- package/agents/qq/skills/qq-group-state/tools/list_join_requests/index.js +19 -0
- package/agents/qq/skills/qq-group-state/tools/list_join_requests/index.ts +19 -0
- package/agents/qq/skills/qq-group-state/tools/list_muted/index.js +21 -0
- package/agents/qq/skills/qq-group-state/tools/list_muted/index.ts +21 -0
- package/agents/qq/skills/qq-messages/SKILL.md +27 -0
- package/agents/qq/skills/qq-messages/tools/quote_reply/index.js +21 -0
- package/agents/qq/skills/qq-messages/tools/quote_reply/index.ts +21 -0
- package/agents/qq/skills/qq-messages/tools/recall_own_message/index.js +18 -0
- package/agents/qq/skills/qq-messages/tools/recall_own_message/index.ts +17 -0
- package/commands/qq/endpoint/definition.js +73 -55
- package/commands/qq/endpoint/definition.ts +75 -86
- package/lib/adapter.d.ts +13 -0
- package/lib/adapter.js +27 -0
- package/lib/client.d.ts +7 -9
- package/lib/client.js +1 -2
- package/lib/console.d.ts +3 -0
- package/lib/console.js +93 -0
- package/lib/endpoint.d.ts +17 -27
- package/lib/endpoint.js +325 -146
- package/lib/group-admin.d.ts +41 -0
- package/lib/group-admin.js +114 -0
- package/lib/i18n.js +4 -4
- package/lib/image-size.d.ts +6 -1
- package/lib/image-size.js +2 -3
- package/lib/index.d.ts +5 -4
- package/lib/index.js +3 -3
- package/lib/interaction.js +1 -1
- package/lib/outbound.d.ts +4 -1
- package/lib/outbound.js +3 -2
- package/lib/protocol.d.ts +7 -0
- package/lib/protocol.js +4 -0
- package/lib/qq-console-spec.d.ts +17 -0
- package/lib/qq-console-spec.js +21 -0
- package/lib/qq-console.d.ts +10 -0
- package/lib/qq-console.js +126 -0
- package/lib/qq-endpoint-write.d.ts +18 -0
- package/lib/qq-endpoint-write.js +7 -0
- package/lib/qq-runtime-state.d.ts +29 -9
- package/lib/qq-runtime-state.js +5 -5
- package/lib/qq-send.d.ts +7 -14
- package/lib/qq-send.js +20 -6
- package/lib/webhook.d.ts +3 -6
- package/lib/webhook.js +6 -49
- package/lib/ws.d.ts +10 -33
- package/lib/ws.js +21 -70
- package/middlewares/bot-kind-reply/index.js +1 -2
- package/middlewares/bot-kind-reply/index.ts +0 -2
- package/package.json +26 -18
- package/pages/index/index.js +15 -0
- package/pages/index/index.ts +16 -0
- package/plugin.js +9 -6
- package/schema.json +174 -0
- package/src/adapter.ts +23 -0
- package/src/client.ts +4 -12
- package/src/console.ts +103 -0
- package/src/endpoint.ts +328 -141
- package/src/group-admin.ts +155 -0
- package/src/i18n.ts +4 -4
- package/src/image-size.ts +7 -3
- package/src/index.ts +5 -5
- package/src/interaction.ts +1 -1
- package/src/outbound.ts +3 -2
- package/src/protocol.ts +10 -0
- package/src/qq-console-spec.ts +29 -0
- package/src/qq-console.ts +130 -0
- package/src/qq-endpoint-write.ts +24 -0
- package/src/qq-runtime-state.ts +31 -15
- package/src/qq-send.ts +28 -12
- package/src/webhook.ts +7 -57
- package/src/ws.ts +27 -105
package/lib/endpoint.js
CHANGED
|
@@ -1,99 +1,110 @@
|
|
|
1
|
+
import { Bot } from 'qq-official-bot';
|
|
1
2
|
import { installQqUploadDiagnostics } from './upload-diagnostics.js';
|
|
2
3
|
import { projectQqGuildSenderRoles } from './platform-permit.js';
|
|
3
4
|
import { qqDeliveryFailure } from './delivery-error.js';
|
|
4
5
|
import { normalizeQqInteraction } from './interaction.js';
|
|
5
6
|
import { awaitQqStartup } from './start-deadline.js';
|
|
6
7
|
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
8
|
+
import { inspectQqGroupMember, inspectQqGroupMemberProfile, inspectQqGroupScene, kickQqGroupMember, moderateQqGroupMember, muteQqGroupMember, qqGroupService, } from './group-admin.js';
|
|
7
9
|
/**
|
|
8
10
|
* QQ endpoints — lifecycle, outbound, admit, agent tool surface.
|
|
9
11
|
*/
|
|
10
|
-
import {
|
|
12
|
+
import { createEndpointSpecification, endpointSpec, createEndpointLifecycle, createEndpointOperation, } from 'zhin.js/adapter';
|
|
11
13
|
import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
|
|
12
14
|
import { collectOutboundImageSizes, formatOutboundMessages } from './outbound.js';
|
|
13
15
|
import { QqActiveMessageGate, QqPassiveReplyLedger } from './passive-reply.js';
|
|
14
16
|
import { sendQqMessages } from './qq-send.js';
|
|
15
17
|
import { formatInboundContent, parseCompoundMessageId, qqInboundConversation, qqOutboundKind, resolveOutboundMessageId, senderDisplayName, } from './protocol.js';
|
|
16
|
-
import {
|
|
17
|
-
import { bindQqBotInboundEvents,
|
|
18
|
+
import { qqHttpBotOptions, registerQqWebhookRoutes, } from './webhook.js';
|
|
19
|
+
import { bindQqBotInboundEvents, qqWebsocketBotOptions, configureQqWebsocketReceiver, disposeQqAuthorization, normalizeQqMessage, } from './ws.js';
|
|
18
20
|
import { bindQqBotSideEvents, receiveQqSideEvent, } from './side-event-dispatch.js';
|
|
19
|
-
export class QqWebsocketEndpoint extends
|
|
21
|
+
export class QqWebsocketEndpoint extends Bot {
|
|
22
|
+
[endpointSpec];
|
|
23
|
+
#receiverResources;
|
|
20
24
|
#logger;
|
|
21
25
|
#options;
|
|
22
|
-
#
|
|
23
|
-
#
|
|
26
|
+
#connection;
|
|
27
|
+
#stopping;
|
|
24
28
|
#open = false;
|
|
29
|
+
#executionSignal;
|
|
30
|
+
#offlineNoticeAt = 0;
|
|
25
31
|
#seenInteractions = new Map();
|
|
26
32
|
/** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
|
|
27
33
|
#passiveReplies = new QqPassiveReplyLedger();
|
|
28
34
|
#activeGate = new QqActiveMessageGate();
|
|
29
35
|
#lifecycle;
|
|
30
36
|
#cleanup;
|
|
31
|
-
management = createQqEndpointManagement(() => this.#requireBot());
|
|
32
|
-
control =
|
|
37
|
+
#management = createQqEndpointManagement(() => this.#requireBot(), () => this.#open && this.#connection !== undefined);
|
|
38
|
+
#control = createQqEndpointControl(() => this.#requireBot(), (id) => this.recallMessage(id));
|
|
33
39
|
constructor(options) {
|
|
34
|
-
super();
|
|
40
|
+
super(qqWebsocketBotOptions(options.config));
|
|
35
41
|
this.#logger = getAdapterLogger('qq', options.config.id);
|
|
36
42
|
this.#lifecycle = createEndpointLifecycle({ name: options.config.id });
|
|
37
43
|
this.#options = options;
|
|
38
|
-
this.#
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
this.#receiverResources = configureQqWebsocketReceiver(this, options.config);
|
|
45
|
+
const endpoint = this;
|
|
46
|
+
this[endpointSpec] = createEndpointSpecification(this, {
|
|
47
|
+
start: signal => this.#startFramework(signal), open: () => this.#openFramework(), close: () => this.#closeFramework(),
|
|
48
|
+
stop: () => this.#stopFramework(), send: request => this.#sendFramework(request), name: options.config.id,
|
|
49
|
+
get transportState() { return endpoint.#transportState; }, management: this.#management, control: this.#control,
|
|
50
|
+
});
|
|
51
|
+
this.#bindBot(this);
|
|
42
52
|
}
|
|
43
53
|
/** Live endpoint 名(Console/AdapterIndex 展示用,如 bot appid 别名)。 */
|
|
44
54
|
get name() {
|
|
45
55
|
return this.#options.config.id;
|
|
46
56
|
}
|
|
47
|
-
get transportState() {
|
|
57
|
+
get #transportState() {
|
|
48
58
|
if (this.#lifecycle.state !== 'open')
|
|
49
59
|
return this.#lifecycle.state;
|
|
50
|
-
return this.#
|
|
60
|
+
return this.#receiverResources.state();
|
|
51
61
|
}
|
|
52
|
-
async
|
|
62
|
+
async #startFramework(executionSignal) {
|
|
63
|
+
this.#executionSignal = executionSignal;
|
|
53
64
|
await this.#lifecycle.start(async (handle) => {
|
|
54
|
-
const bot = this
|
|
55
|
-
|
|
56
|
-
|
|
65
|
+
const bot = this;
|
|
66
|
+
const previousCleanup = this.#cleanup;
|
|
67
|
+
await previousCleanup?.();
|
|
68
|
+
const releaseUploadDiagnostics = installQqUploadDiagnostics(bot, metadata => this.#logger.debug(formatCompact(metadata)));
|
|
57
69
|
let cancelled = false;
|
|
58
70
|
const startupAbort = new AbortController();
|
|
71
|
+
this.#connection = startupAbort;
|
|
72
|
+
let nativeStart;
|
|
59
73
|
let stopping;
|
|
74
|
+
let transportClose;
|
|
60
75
|
const stopBot = async () => {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
catch { /* continue SDK cleanup */ }
|
|
65
|
-
try {
|
|
66
|
-
await bot.stop();
|
|
67
|
-
}
|
|
68
|
-
catch { /* best-effort SDK cleanup */ }
|
|
76
|
+
await super.stop();
|
|
77
|
+
await nativeStart?.catch(() => undefined);
|
|
69
78
|
};
|
|
70
79
|
const cleanup = () => {
|
|
71
80
|
cancelled = true;
|
|
72
81
|
releaseUploadDiagnostics();
|
|
73
82
|
startupAbort.abort();
|
|
74
|
-
if (
|
|
75
|
-
this
|
|
83
|
+
if (transportClose)
|
|
84
|
+
this.receiver.off('close', transportClose);
|
|
85
|
+
if (this.#connection === startupAbort)
|
|
86
|
+
this.#connection = undefined;
|
|
76
87
|
return stopping ??= stopBot();
|
|
77
88
|
};
|
|
78
89
|
this.#cleanup = cleanup;
|
|
79
|
-
handle.onForceClose(() => { void cleanup(); });
|
|
90
|
+
handle.onForceClose(() => { void cleanup().catch(() => undefined); });
|
|
80
91
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}), startupAbort.signal);
|
|
92
|
+
transportClose = () => {
|
|
93
|
+
if (cancelled || this.#connection === undefined)
|
|
94
|
+
return;
|
|
95
|
+
this.#notifyOffline('transport_close');
|
|
96
|
+
void cleanup().catch(() => undefined);
|
|
97
|
+
handle.notifyClosed();
|
|
98
|
+
};
|
|
99
|
+
this.receiver.on('close', transportClose);
|
|
100
|
+
nativeStart = super.start();
|
|
101
|
+
await awaitQqStartup(nativeStart.then(() => undefined), startupAbort.signal);
|
|
92
102
|
if (cancelled) {
|
|
93
103
|
// SDK startup may create resources after stop; close this exact old client again.
|
|
94
104
|
await stopping;
|
|
95
105
|
return;
|
|
96
106
|
}
|
|
107
|
+
this.#offlineNoticeAt = 0;
|
|
97
108
|
this.#logger.info(`connected (websocket) | appid: ${this.#options.config.appid}`);
|
|
98
109
|
}
|
|
99
110
|
catch (error) {
|
|
@@ -101,29 +112,41 @@ export class QqWebsocketEndpoint extends Endpoint {
|
|
|
101
112
|
await cleanup();
|
|
102
113
|
if (wasCancelled)
|
|
103
114
|
return;
|
|
115
|
+
this.#notifyOffline('start_failed');
|
|
104
116
|
const raw = error instanceof Error ? error.message : String(error);
|
|
105
117
|
throw new Error(`QQ WebSocket 连接失败:请检查 appid/secret 是否配对、网关地址(gatewayUrl/accessTokenUrl)是否可达(原始错误:${raw})`, { cause: error });
|
|
106
118
|
}
|
|
107
119
|
});
|
|
108
120
|
}
|
|
109
|
-
|
|
121
|
+
#openFramework() {
|
|
110
122
|
this.#open = true;
|
|
111
123
|
}
|
|
112
|
-
|
|
124
|
+
#closeFramework() {
|
|
113
125
|
this.#open = false;
|
|
114
126
|
}
|
|
115
|
-
async
|
|
116
|
-
this.#
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
127
|
+
async #stopFramework() {
|
|
128
|
+
this.#stopping ??= Promise.resolve().then(async () => {
|
|
129
|
+
this.#open = false;
|
|
130
|
+
const cleanup = this.#cleanup;
|
|
131
|
+
this.#cleanup = undefined;
|
|
132
|
+
try {
|
|
133
|
+
await this.#lifecycle.stop();
|
|
134
|
+
await cleanup?.();
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
this.removeAllListeners(undefined);
|
|
138
|
+
this.#receiverResources.dispose();
|
|
139
|
+
disposeQqAuthorization(this);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
await this.#stopping;
|
|
121
143
|
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
122
144
|
}
|
|
123
|
-
async
|
|
145
|
+
async #sendFramework({ conversation, payload, signal }) {
|
|
124
146
|
// 媒体与 markdown/按钮不能同条发送(msg_type 7 与 2 互斥):公网图片嵌入 markdown,
|
|
125
147
|
// 拿不到 URL/尺寸的媒体降级为独立消息先发
|
|
126
|
-
const
|
|
148
|
+
const operation = createEndpointOperation({ signal });
|
|
149
|
+
const messages = formatOutboundMessages(payload, { imageSizes: await operation.prepare(() => collectOutboundImageSizes(payload, { signal })) });
|
|
127
150
|
const body = messages[messages.length - 1];
|
|
128
151
|
const kind = qqOutboundKind(conversation);
|
|
129
152
|
const bot = this.#requireBot();
|
|
@@ -134,7 +157,7 @@ export class QqWebsocketEndpoint extends Endpoint {
|
|
|
134
157
|
ledger: this.#passiveReplies,
|
|
135
158
|
activeGate: this.#activeGate,
|
|
136
159
|
log: fields => this.#logger.warn(formatCompact(fields)),
|
|
137
|
-
}, conversation, messages);
|
|
160
|
+
}, { conversation, messages, signal });
|
|
138
161
|
}
|
|
139
162
|
catch (error) {
|
|
140
163
|
const { failure, diagnostic } = qqDeliveryFailure(error);
|
|
@@ -150,7 +173,7 @@ export class QqWebsocketEndpoint extends Endpoint {
|
|
|
150
173
|
async recallMessage(messageId) {
|
|
151
174
|
const { kind, channelId, qqMsgId } = parseCompoundMessageId(messageId);
|
|
152
175
|
const bot = this.#requireBot();
|
|
153
|
-
await recallQqMessage(bot, kind, channelId, qqMsgId);
|
|
176
|
+
await recallQqMessage(bot, { kind, channelId, messageId: qqMsgId });
|
|
154
177
|
this.#logger.debug(formatCompact({
|
|
155
178
|
op: 'qq_recall',
|
|
156
179
|
endpoint: this.#options.config.id,
|
|
@@ -171,8 +194,10 @@ export class QqWebsocketEndpoint extends Endpoint {
|
|
|
171
194
|
const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
|
|
172
195
|
if (msg.id)
|
|
173
196
|
this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { id: msg.id });
|
|
174
|
-
void this.emit('message.receive', {
|
|
197
|
+
void this[endpointSpec].events.emit('message.receive', {
|
|
175
198
|
conversation,
|
|
199
|
+
event: msg.event,
|
|
200
|
+
platformSentAt: msg.platformSentAt,
|
|
176
201
|
message: { conversation, id: msg.id },
|
|
177
202
|
content,
|
|
178
203
|
...(msg.segments?.length ? { segments: msg.segments } : {}),
|
|
@@ -216,13 +241,16 @@ export class QqWebsocketEndpoint extends Endpoint {
|
|
|
216
241
|
if (this.#seenInteractions.size > 10_000)
|
|
217
242
|
this.#seenInteractions.delete(this.#seenInteractions.keys().next().value);
|
|
218
243
|
// ACK owns only platform loading state, never implies business acceptance.
|
|
219
|
-
void action.ack().then(ok => { if (ok === false)
|
|
220
|
-
this.#logger.warn('qq_interaction_ack_failed'); }, () => this.#
|
|
244
|
+
void this[endpointSpec].events.run(() => awaitOwnedAck(action.ack(), this.#executionSignal)).then(ok => { if (ok === false)
|
|
245
|
+
this.#logger.warn('qq_interaction_ack_failed'); }, () => { if (this.#executionSignal?.aborted)
|
|
246
|
+
this.#logger.debug('QQ ACK wait cancelled');
|
|
247
|
+
else
|
|
248
|
+
this.#logger.warn('qq_interaction_ack_failed'); });
|
|
221
249
|
const conversation = qqInboundConversation(String(this.#options.id), action.message);
|
|
222
250
|
// INTERACTION_CREATE's id is an event_id the reply may answer passively.
|
|
223
251
|
this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { event_id: action.message.id });
|
|
224
|
-
void this.emit('message.receive', {
|
|
225
|
-
conversation, message: { conversation, id: action.message.id }, content: '',
|
|
252
|
+
void this[endpointSpec].events.emit('message.receive', {
|
|
253
|
+
event: action.message.event, conversation, message: { conversation, id: action.message.id }, content: '',
|
|
226
254
|
segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
|
|
227
255
|
metadata: Object.freeze({ eventType: 'INTERACTION_CREATE', sourceMessageIdAvailable: action.sourceMessageId !== undefined,
|
|
228
256
|
...(action.sourceMessageId ? { sourceMessageId: action.sourceMessageId } : {}),
|
|
@@ -230,26 +258,50 @@ export class QqWebsocketEndpoint extends Endpoint {
|
|
|
230
258
|
}).catch(() => this.#logger.warn('qq_interaction_receive_failed'));
|
|
231
259
|
return true;
|
|
232
260
|
}
|
|
261
|
+
#notifyOffline(reason) {
|
|
262
|
+
const notifier = this.#options.notifier;
|
|
263
|
+
if (!notifier?.configured)
|
|
264
|
+
return;
|
|
265
|
+
const now = Date.now();
|
|
266
|
+
if (now - this.#offlineNoticeAt < 60_000)
|
|
267
|
+
return;
|
|
268
|
+
this.#offlineNoticeAt = now;
|
|
269
|
+
const text = `[Zhin] QQ 机器人 ${this.#options.config.id} 掉线(${reason})。正在自动重连。\n`
|
|
270
|
+
+ '恢复:在 QQ 回复 `qq endpoint add` 重新扫码绑定,或打开 Console 的 QQ 页面。';
|
|
271
|
+
void notifier.send(text).then((sent) => {
|
|
272
|
+
if (sent)
|
|
273
|
+
return;
|
|
274
|
+
this.#logger.warn(formatCompact({
|
|
275
|
+
op: 'qq_notify_failed',
|
|
276
|
+
endpoint: this.#options.config.id,
|
|
277
|
+
reason,
|
|
278
|
+
}));
|
|
279
|
+
});
|
|
280
|
+
}
|
|
233
281
|
#bindBot(bot) {
|
|
234
282
|
bindQqBotInboundEvents(bot, (raw) => {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
283
|
+
void this[endpointSpec].events.run(() => {
|
|
284
|
+
if (this.#connection === undefined)
|
|
285
|
+
return;
|
|
286
|
+
this.#emitPlatformEvent('message', raw);
|
|
287
|
+
const msg = normalizeQqMessage(raw);
|
|
288
|
+
if (msg)
|
|
289
|
+
this.admit(msg);
|
|
290
|
+
}).catch(() => this.#logger.debug('Native callback was not admitted'));
|
|
241
291
|
});
|
|
242
292
|
bindQqBotSideEvents(bot, (eventName, raw) => {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
293
|
+
void this[endpointSpec].events.run(() => {
|
|
294
|
+
if (this.#connection === undefined)
|
|
295
|
+
return;
|
|
296
|
+
this.#emitPlatformEvent(eventName, raw);
|
|
297
|
+
if (this.admitInteraction(raw))
|
|
298
|
+
return;
|
|
299
|
+
receiveQqSideEvent((name, payload) => this[endpointSpec].events.emit(name, payload), this.#options.config.id, bot, eventName, raw, this.#logger);
|
|
300
|
+
}).catch(() => this.#logger.debug('Native callback was not admitted'));
|
|
249
301
|
});
|
|
250
302
|
}
|
|
251
303
|
#emitPlatformEvent(name, event) {
|
|
252
|
-
void this.
|
|
304
|
+
void this[endpointSpec].events.platform(name, event).catch((error) => {
|
|
253
305
|
this.#logger.warn(formatCompact({
|
|
254
306
|
op: 'qq_platform_event_failed',
|
|
255
307
|
event: name,
|
|
@@ -258,93 +310,92 @@ export class QqWebsocketEndpoint extends Endpoint {
|
|
|
258
310
|
});
|
|
259
311
|
}
|
|
260
312
|
#requireBot() {
|
|
261
|
-
if (!this.#
|
|
313
|
+
if (!this.#connection)
|
|
262
314
|
throw new EndpointDeliveryError('endpoint_disconnected', 'QQ bot not connected', 'not_sent');
|
|
263
|
-
return this
|
|
315
|
+
return this;
|
|
264
316
|
}
|
|
265
317
|
}
|
|
266
318
|
/** Webhook / middleware inbound via httpHostToken POST (qq-official-bot Middleware receiver). */
|
|
267
|
-
export class QqHttpEndpoint extends
|
|
319
|
+
export class QqHttpEndpoint extends Bot {
|
|
320
|
+
[endpointSpec];
|
|
268
321
|
#logger;
|
|
269
322
|
#options;
|
|
270
|
-
#
|
|
271
|
-
#
|
|
323
|
+
#connection;
|
|
324
|
+
#stopping;
|
|
272
325
|
#open = false;
|
|
326
|
+
#executionSignal;
|
|
327
|
+
#offlineNoticeAt = 0;
|
|
273
328
|
#seenInteractions = new Map();
|
|
274
329
|
/** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
|
|
275
330
|
#passiveReplies = new QqPassiveReplyLedger();
|
|
276
331
|
#activeGate = new QqActiveMessageGate();
|
|
277
332
|
#lifecycle;
|
|
278
333
|
#cleanup;
|
|
279
|
-
management = createQqEndpointManagement(() => this.#requireBot());
|
|
280
|
-
control =
|
|
334
|
+
#management = createQqEndpointManagement(() => this.#requireBot(), () => this.#open && this.#connection !== undefined);
|
|
335
|
+
#control = createQqEndpointControl(() => this.#requireBot(), (id) => this.recallMessage(id));
|
|
281
336
|
constructor(options) {
|
|
282
|
-
super();
|
|
337
|
+
super(qqHttpBotOptions(options.config));
|
|
283
338
|
this.#logger = getAdapterLogger('qq', options.config.id);
|
|
284
339
|
this.#lifecycle = createEndpointLifecycle({ name: options.config.id, reconnect: false });
|
|
285
340
|
this.#options = options;
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
341
|
+
const endpoint = this;
|
|
342
|
+
this[endpointSpec] = createEndpointSpecification(this, {
|
|
343
|
+
start: signal => this.#startFramework(signal), open: () => this.#openFramework(), close: () => this.#closeFramework(),
|
|
344
|
+
stop: () => this.#stopFramework(), send: request => this.#sendFramework(request), name: options.config.id,
|
|
345
|
+
get transportState() { return endpoint.#transportState; },
|
|
346
|
+
management: this.#management, control: this.#control,
|
|
347
|
+
});
|
|
348
|
+
this.#bindBot(this);
|
|
290
349
|
}
|
|
291
350
|
/** Live endpoint 名(Console/AdapterIndex 展示用)。 */
|
|
292
351
|
get name() {
|
|
293
352
|
return this.#options.config.id;
|
|
294
353
|
}
|
|
295
|
-
get transportState() {
|
|
354
|
+
get #transportState() {
|
|
296
355
|
return this.#lifecycle.state;
|
|
297
356
|
}
|
|
298
|
-
async
|
|
357
|
+
async #startFramework(executionSignal) {
|
|
358
|
+
this.#executionSignal = executionSignal;
|
|
299
359
|
await this.#lifecycle.start(async (handle) => {
|
|
300
|
-
const bot = this
|
|
301
|
-
|
|
302
|
-
|
|
360
|
+
const bot = this;
|
|
361
|
+
const previousCleanup = this.#cleanup;
|
|
362
|
+
await previousCleanup?.();
|
|
363
|
+
const releaseUploadDiagnostics = installQqUploadDiagnostics(bot, metadata => this.#logger.debug(formatCompact(metadata)));
|
|
303
364
|
let cancelled = false;
|
|
304
365
|
const startupAbort = new AbortController();
|
|
366
|
+
this.#connection = startupAbort;
|
|
367
|
+
let nativeStart;
|
|
305
368
|
let stopping;
|
|
306
369
|
const routes = [];
|
|
307
370
|
const stopBot = async () => {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
}
|
|
311
|
-
catch { /* continue SDK cleanup */ }
|
|
312
|
-
try {
|
|
313
|
-
await bot.stop();
|
|
314
|
-
}
|
|
315
|
-
catch { /* best-effort SDK cleanup */ }
|
|
371
|
+
await super.stop();
|
|
372
|
+
await nativeStart?.catch(() => undefined);
|
|
316
373
|
};
|
|
317
374
|
const cleanup = () => {
|
|
318
375
|
cancelled = true;
|
|
319
376
|
releaseUploadDiagnostics();
|
|
320
377
|
startupAbort.abort();
|
|
321
|
-
if (this.#
|
|
322
|
-
this.#
|
|
378
|
+
if (this.#connection === startupAbort)
|
|
379
|
+
this.#connection = undefined;
|
|
323
380
|
for (const release of routes.splice(0))
|
|
324
381
|
release();
|
|
325
382
|
return stopping ??= stopBot();
|
|
326
383
|
};
|
|
327
384
|
this.#cleanup = cleanup;
|
|
328
|
-
handle.onForceClose(() => { void cleanup(); });
|
|
385
|
+
handle.onForceClose(() => { void cleanup().catch(() => undefined); });
|
|
329
386
|
try {
|
|
330
|
-
this.#bindBot(bot);
|
|
331
387
|
routes.push(...registerQqWebhookRoutes(this.#options.http, {
|
|
332
388
|
config: this.#options.config,
|
|
333
389
|
getBot: () => cancelled ? null : bot,
|
|
334
390
|
}));
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
await stopBot();
|
|
338
|
-
}, async (error) => {
|
|
339
|
-
if (cancelled)
|
|
340
|
-
await stopBot();
|
|
341
|
-
throw error;
|
|
342
|
-
}), startupAbort.signal);
|
|
391
|
+
nativeStart = super.start();
|
|
392
|
+
await awaitQqStartup(nativeStart.then(() => undefined), startupAbort.signal);
|
|
343
393
|
if (cancelled) {
|
|
344
394
|
// SDK startup may create resources after stop; close this exact old client again.
|
|
345
395
|
await stopping;
|
|
346
396
|
return;
|
|
347
397
|
}
|
|
398
|
+
this.#offlineNoticeAt = 0;
|
|
348
399
|
this.#logger.info(`connected (${this.#options.config.mode}) | path: ${this.#options.config.webhookPath}`);
|
|
349
400
|
}
|
|
350
401
|
catch (error) {
|
|
@@ -352,28 +403,39 @@ export class QqHttpEndpoint extends Endpoint {
|
|
|
352
403
|
await cleanup();
|
|
353
404
|
if (wasCancelled)
|
|
354
405
|
return;
|
|
406
|
+
this.#notifyOffline('start_failed');
|
|
355
407
|
throw error;
|
|
356
408
|
}
|
|
357
409
|
});
|
|
358
410
|
}
|
|
359
|
-
|
|
411
|
+
#openFramework() {
|
|
360
412
|
this.#open = true;
|
|
361
413
|
}
|
|
362
|
-
|
|
414
|
+
#closeFramework() {
|
|
363
415
|
this.#open = false;
|
|
364
416
|
}
|
|
365
|
-
async
|
|
366
|
-
this.#
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
417
|
+
async #stopFramework() {
|
|
418
|
+
this.#stopping ??= Promise.resolve().then(async () => {
|
|
419
|
+
this.#open = false;
|
|
420
|
+
const cleanup = this.#cleanup;
|
|
421
|
+
this.#cleanup = undefined;
|
|
422
|
+
try {
|
|
423
|
+
await this.#lifecycle.stop();
|
|
424
|
+
await cleanup?.();
|
|
425
|
+
}
|
|
426
|
+
finally {
|
|
427
|
+
this.removeAllListeners(undefined);
|
|
428
|
+
disposeQqAuthorization(this);
|
|
429
|
+
}
|
|
430
|
+
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
431
|
+
});
|
|
432
|
+
await this.#stopping;
|
|
372
433
|
}
|
|
373
|
-
async
|
|
434
|
+
async #sendFramework({ conversation, payload, signal }) {
|
|
374
435
|
// 媒体与 markdown/按钮不能同条发送(msg_type 7 与 2 互斥):公网图片嵌入 markdown,
|
|
375
436
|
// 拿不到 URL/尺寸的媒体降级为独立消息先发
|
|
376
|
-
const
|
|
437
|
+
const operation = createEndpointOperation({ signal });
|
|
438
|
+
const messages = formatOutboundMessages(payload, { imageSizes: await operation.prepare(() => collectOutboundImageSizes(payload, { signal })) });
|
|
377
439
|
const body = messages[messages.length - 1];
|
|
378
440
|
const kind = qqOutboundKind(conversation);
|
|
379
441
|
const bot = this.#requireBot();
|
|
@@ -384,7 +446,7 @@ export class QqHttpEndpoint extends Endpoint {
|
|
|
384
446
|
ledger: this.#passiveReplies,
|
|
385
447
|
activeGate: this.#activeGate,
|
|
386
448
|
log: fields => this.#logger.warn(formatCompact(fields)),
|
|
387
|
-
}, conversation, messages);
|
|
449
|
+
}, { conversation, messages, signal });
|
|
388
450
|
}
|
|
389
451
|
catch (error) {
|
|
390
452
|
const { failure, diagnostic } = qqDeliveryFailure(error);
|
|
@@ -400,7 +462,7 @@ export class QqHttpEndpoint extends Endpoint {
|
|
|
400
462
|
async recallMessage(messageId) {
|
|
401
463
|
const { kind, channelId, qqMsgId } = parseCompoundMessageId(messageId);
|
|
402
464
|
const bot = this.#requireBot();
|
|
403
|
-
await recallQqMessage(bot, kind, channelId, qqMsgId);
|
|
465
|
+
await recallQqMessage(bot, { kind, channelId, messageId: qqMsgId });
|
|
404
466
|
this.#logger.debug(formatCompact({
|
|
405
467
|
op: 'qq_recall',
|
|
406
468
|
endpoint: this.#options.config.id,
|
|
@@ -420,8 +482,10 @@ export class QqHttpEndpoint extends Endpoint {
|
|
|
420
482
|
const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
|
|
421
483
|
if (msg.id)
|
|
422
484
|
this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { id: msg.id });
|
|
423
|
-
void this.emit('message.receive', {
|
|
485
|
+
void this[endpointSpec].events.emit('message.receive', {
|
|
424
486
|
conversation,
|
|
487
|
+
event: msg.event,
|
|
488
|
+
platformSentAt: msg.platformSentAt,
|
|
425
489
|
message: { conversation, id: msg.id },
|
|
426
490
|
content,
|
|
427
491
|
...(msg.segments?.length ? { segments: msg.segments } : {}),
|
|
@@ -465,13 +529,16 @@ export class QqHttpEndpoint extends Endpoint {
|
|
|
465
529
|
if (this.#seenInteractions.size > 10_000)
|
|
466
530
|
this.#seenInteractions.delete(this.#seenInteractions.keys().next().value);
|
|
467
531
|
// ACK owns only platform loading state, never implies business acceptance.
|
|
468
|
-
void action.ack().then(ok => { if (ok === false)
|
|
469
|
-
this.#logger.warn('qq_interaction_ack_failed'); }, () => this.#
|
|
532
|
+
void this[endpointSpec].events.run(() => awaitOwnedAck(action.ack(), this.#executionSignal)).then(ok => { if (ok === false)
|
|
533
|
+
this.#logger.warn('qq_interaction_ack_failed'); }, () => { if (this.#executionSignal?.aborted)
|
|
534
|
+
this.#logger.debug('QQ ACK wait cancelled');
|
|
535
|
+
else
|
|
536
|
+
this.#logger.warn('qq_interaction_ack_failed'); });
|
|
470
537
|
const conversation = qqInboundConversation(String(this.#options.id), action.message);
|
|
471
538
|
// INTERACTION_CREATE's id is an event_id the reply may answer passively.
|
|
472
539
|
this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { event_id: action.message.id });
|
|
473
|
-
void this.emit('message.receive', {
|
|
474
|
-
conversation, message: { conversation, id: action.message.id }, content: '',
|
|
540
|
+
void this[endpointSpec].events.emit('message.receive', {
|
|
541
|
+
event: action.message.event, conversation, message: { conversation, id: action.message.id }, content: '',
|
|
475
542
|
segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
|
|
476
543
|
metadata: Object.freeze({ eventType: 'INTERACTION_CREATE', sourceMessageIdAvailable: action.sourceMessageId !== undefined,
|
|
477
544
|
...(action.sourceMessageId ? { sourceMessageId: action.sourceMessageId } : {}),
|
|
@@ -479,26 +546,50 @@ export class QqHttpEndpoint extends Endpoint {
|
|
|
479
546
|
}).catch(() => this.#logger.warn('qq_interaction_receive_failed'));
|
|
480
547
|
return true;
|
|
481
548
|
}
|
|
549
|
+
#notifyOffline(reason) {
|
|
550
|
+
const notifier = this.#options.notifier;
|
|
551
|
+
if (!notifier?.configured)
|
|
552
|
+
return;
|
|
553
|
+
const now = Date.now();
|
|
554
|
+
if (now - this.#offlineNoticeAt < 60_000)
|
|
555
|
+
return;
|
|
556
|
+
this.#offlineNoticeAt = now;
|
|
557
|
+
const text = `[Zhin] QQ 机器人 ${this.#options.config.id} 掉线(${reason})。正在自动重连。\n`
|
|
558
|
+
+ '恢复:在 QQ 回复 `qq endpoint add` 重新扫码绑定,或打开 Console 的 QQ 页面。';
|
|
559
|
+
void notifier.send(text).then((sent) => {
|
|
560
|
+
if (sent)
|
|
561
|
+
return;
|
|
562
|
+
this.#logger.warn(formatCompact({
|
|
563
|
+
op: 'qq_notify_failed',
|
|
564
|
+
endpoint: this.#options.config.id,
|
|
565
|
+
reason,
|
|
566
|
+
}));
|
|
567
|
+
});
|
|
568
|
+
}
|
|
482
569
|
#bindBot(bot) {
|
|
483
570
|
bindQqBotInboundEvents(bot, (raw) => {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
571
|
+
void this[endpointSpec].events.run(() => {
|
|
572
|
+
if (this.#connection === undefined)
|
|
573
|
+
return;
|
|
574
|
+
this.#emitPlatformEvent('message', raw);
|
|
575
|
+
const msg = normalizeQqMessage(raw);
|
|
576
|
+
if (msg)
|
|
577
|
+
this.admit(msg);
|
|
578
|
+
}).catch(() => this.#logger.debug('Native callback was not admitted'));
|
|
490
579
|
});
|
|
491
580
|
bindQqBotSideEvents(bot, (eventName, raw) => {
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
581
|
+
void this[endpointSpec].events.run(() => {
|
|
582
|
+
if (this.#connection === undefined)
|
|
583
|
+
return;
|
|
584
|
+
this.#emitPlatformEvent(eventName, raw);
|
|
585
|
+
if (this.admitInteraction(raw))
|
|
586
|
+
return;
|
|
587
|
+
receiveQqSideEvent((name, payload) => this[endpointSpec].events.emit(name, payload), this.#options.config.id, bot, eventName, raw, this.#logger);
|
|
588
|
+
}).catch(() => this.#logger.debug('Native callback was not admitted'));
|
|
498
589
|
});
|
|
499
590
|
}
|
|
500
591
|
#emitPlatformEvent(name, event) {
|
|
501
|
-
void this.
|
|
592
|
+
void this[endpointSpec].events.platform(name, event).catch((error) => {
|
|
502
593
|
this.#logger.warn(formatCompact({
|
|
503
594
|
op: 'qq_platform_event_failed',
|
|
504
595
|
event: name,
|
|
@@ -507,24 +598,41 @@ export class QqHttpEndpoint extends Endpoint {
|
|
|
507
598
|
});
|
|
508
599
|
}
|
|
509
600
|
#requireBot() {
|
|
510
|
-
if (!this.#
|
|
601
|
+
if (!this.#connection)
|
|
511
602
|
throw new EndpointDeliveryError('endpoint_disconnected', 'QQ bot not connected', 'not_sent');
|
|
512
|
-
return this
|
|
603
|
+
return this;
|
|
513
604
|
}
|
|
514
605
|
}
|
|
515
|
-
function createQqEndpointManagement(requireClient) {
|
|
606
|
+
function createQqEndpointManagement(requireClient, isActive) {
|
|
607
|
+
const api = () => requireClient();
|
|
516
608
|
return Object.freeze({
|
|
517
|
-
|
|
609
|
+
// QQ 群(group_openid / member_openid):官方 v2 群管理接口,部分为白名单能力(11253)。
|
|
610
|
+
inspectGroupMember: request => inspectQqGroupMember(api(), request),
|
|
611
|
+
inspectGroupScene: request => inspectQqGroupScene(api(), request),
|
|
612
|
+
inspectGroupMemberProfile: request => inspectQqGroupMemberProfile(api(), request),
|
|
613
|
+
moderateGroupMember: request => moderateQqGroupMember({ api: api(), isActive }, request),
|
|
614
|
+
async listGroupMembers({ groupId, signal }) {
|
|
615
|
+
const operation = createEndpointOperation({ signal });
|
|
616
|
+
return operation.run(() => qqGroupService(api()).getMembers(groupId));
|
|
617
|
+
},
|
|
618
|
+
async kickGroupMember({ groupId, userId, signal }) {
|
|
619
|
+
await kickQqGroupMember(api(), { groupId, userId, signal });
|
|
620
|
+
},
|
|
621
|
+
async muteGroupMember({ groupId, userId, durationSeconds, signal }) {
|
|
622
|
+
await muteQqGroupMember(api(), { groupId, userId, durationSeconds, signal });
|
|
623
|
+
},
|
|
624
|
+
async listChannels(request = {}) {
|
|
625
|
+
const operation = createEndpointOperation(request);
|
|
518
626
|
const channels = [];
|
|
519
627
|
const client = requireClient();
|
|
520
|
-
const guilds = await client.
|
|
628
|
+
const guilds = await operation.run(() => client.guildService.getList());
|
|
521
629
|
for (const guildValue of Array.isArray(guilds) ? guilds : []) {
|
|
522
630
|
const guild = asRecord(guildValue);
|
|
523
631
|
const guildId = String(guild.id ?? guild.guild_id ?? guildValue ?? '');
|
|
524
632
|
if (!guildId)
|
|
525
633
|
continue;
|
|
526
634
|
const guildName = String(guild.name ?? guild.guild_name ?? guildId);
|
|
527
|
-
const rows = await client.
|
|
635
|
+
const rows = await operation.run(() => client.channelService.getList(guildId));
|
|
528
636
|
for (const channelValue of Array.isArray(rows) ? rows : []) {
|
|
529
637
|
const channel = asRecord(channelValue);
|
|
530
638
|
const id = String(channel.id ?? channel.channel_id ?? channelValue ?? '');
|
|
@@ -546,7 +654,66 @@ function asRecord(value) {
|
|
|
546
654
|
? value
|
|
547
655
|
: {};
|
|
548
656
|
}
|
|
549
|
-
|
|
657
|
+
/**
|
|
658
|
+
* Recall + 频道表情回应。recall 同时接受 send() 的复合 ID 与入站原生消息 ID(目标取自 MessageRef.conversation),
|
|
659
|
+
* 因此群管理员机器人可撤回普通成员 2 分钟内的消息(官方限制由平台执行)。
|
|
660
|
+
* 表情回应只有频道子频道消息支持(官方无群 / 单聊表情回应接口)。
|
|
661
|
+
*/
|
|
662
|
+
function createQqEndpointControl(requireBot, recallById) {
|
|
663
|
+
return Object.freeze({
|
|
664
|
+
recall: async ({ message, signal }) => {
|
|
665
|
+
if (isCompoundMessageId(message.id))
|
|
666
|
+
return createEndpointOperation({ signal }).run(() => recallById(message.id));
|
|
667
|
+
const kind = qqOutboundKind(message.conversation);
|
|
668
|
+
await createEndpointOperation({ signal }).run(() => recallQqMessage(requireBot(), { kind, channelId: message.conversation.id, messageId: message.id }));
|
|
669
|
+
},
|
|
670
|
+
addReaction: async ({ message, emoji, signal }) => {
|
|
671
|
+
const target = qqGuildReactionTarget(message, emoji);
|
|
672
|
+
if (!target)
|
|
673
|
+
return null;
|
|
674
|
+
await createEndpointOperation({ signal }).run(() => requireBot().reactionService.addGuildMessageReaction(target.channelId, target.messageId, target.type, target.id));
|
|
675
|
+
return `${target.type}:${target.id}`;
|
|
676
|
+
},
|
|
677
|
+
removeReaction: async ({ message, reactionId, signal }) => {
|
|
678
|
+
const target = qqGuildReactionTarget(message, reactionId);
|
|
679
|
+
if (!target)
|
|
680
|
+
return;
|
|
681
|
+
await createEndpointOperation({ signal }).run(() => requireBot().reactionService.deleteGuildMessageReaction(target.channelId, target.messageId, target.type, target.id));
|
|
682
|
+
},
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
function isCompoundMessageId(id) {
|
|
686
|
+
try {
|
|
687
|
+
parseCompoundMessageId(id);
|
|
688
|
+
return true;
|
|
689
|
+
}
|
|
690
|
+
catch {
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
/** `type:id`(1 系统表情 / 2 emoji)或单个 Unicode emoji(→ type 2 + 码点)。 */
|
|
695
|
+
export function parseQqEmoji(value) {
|
|
696
|
+
const explicit = /^([12]):(\d+)$/.exec(value.trim());
|
|
697
|
+
if (explicit)
|
|
698
|
+
return { type: Number(explicit[1]), id: explicit[2] };
|
|
699
|
+
const chars = [...value.trim().replace(/\uFE0F/g, '')];
|
|
700
|
+
if (chars.length !== 1)
|
|
701
|
+
return null;
|
|
702
|
+
const code = chars[0].codePointAt(0);
|
|
703
|
+
return code > 0x7f ? { type: 2, id: String(code) } : null;
|
|
704
|
+
}
|
|
705
|
+
function qqGuildReactionTarget(message, emoji) {
|
|
706
|
+
let kind = qqOutboundKind(message.conversation);
|
|
707
|
+
let channelId = message.conversation.id;
|
|
708
|
+
let messageId = message.id;
|
|
709
|
+
if (isCompoundMessageId(message.id))
|
|
710
|
+
({ kind, channelId, qqMsgId: messageId } = parseCompoundMessageId(message.id));
|
|
711
|
+
if (kind !== 'channel' || !channelId || !messageId)
|
|
712
|
+
return null;
|
|
713
|
+
const parsed = parseQqEmoji(emoji);
|
|
714
|
+
return parsed ? { channelId, messageId, ...parsed } : null;
|
|
715
|
+
}
|
|
716
|
+
async function recallQqMessage(bot, { kind, channelId, messageId }) {
|
|
550
717
|
const recall = { private: bot.recallPrivateMessage, group: bot.recallGroupMessage,
|
|
551
718
|
channel: bot.recallGuildMessage, direct: bot.recallDirectMessage }[kind];
|
|
552
719
|
if (!recall)
|
|
@@ -555,3 +722,15 @@ async function recallQqMessage(bot, kind, channelId, messageId) {
|
|
|
555
722
|
throw new EndpointDeliveryError('invalid_message_id', 'QQ recall requires a nonempty message ID', 'not_sent');
|
|
556
723
|
await recall.call(bot, channelId, messageId);
|
|
557
724
|
}
|
|
725
|
+
/** Logical cancellation only; the native SDK still owns its already-started ACK. */
|
|
726
|
+
function awaitOwnedAck(operation, signal) {
|
|
727
|
+
if (!signal)
|
|
728
|
+
return operation;
|
|
729
|
+
return new Promise((resolve, reject) => {
|
|
730
|
+
const abort = () => reject(signal.reason);
|
|
731
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
732
|
+
void operation.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
|
|
733
|
+
if (signal.aborted)
|
|
734
|
+
abort();
|
|
735
|
+
});
|
|
736
|
+
}
|