@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/src/endpoint.ts
CHANGED
|
@@ -1,20 +1,40 @@
|
|
|
1
|
+
import { Bot, ReceiverMode } 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
|
-
import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
7
|
+
import { EndpointDeliveryError, type MessageRef } from '@zhin.js/im-contract';
|
|
8
|
+
import {
|
|
9
|
+
inspectQqGroupMember,
|
|
10
|
+
inspectQqGroupMemberProfile,
|
|
11
|
+
inspectQqGroupScene,
|
|
12
|
+
kickQqGroupMember,
|
|
13
|
+
moderateQqGroupMember,
|
|
14
|
+
muteQqGroupMember,
|
|
15
|
+
qqGroupService,
|
|
16
|
+
} from './group-admin.js';
|
|
7
17
|
/**
|
|
8
18
|
* QQ endpoints — lifecycle, outbound, admit, agent tool surface.
|
|
9
19
|
*/
|
|
10
20
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
21
|
+
createEndpointSpecification,
|
|
22
|
+
endpointSpec,
|
|
23
|
+
type Endpoint,
|
|
24
|
+
type EndpointSpecification,
|
|
25
|
+
createEndpointLifecycle,
|
|
26
|
+
type EndpointLifecycle,
|
|
13
27
|
type EndpointControl,
|
|
14
28
|
type EndpointChannel,
|
|
15
29
|
type EndpointManagement,
|
|
16
30
|
type EndpointSendRequest,
|
|
17
31
|
type EndpointTransportState,
|
|
32
|
+
type AdapterOutageNotifier,
|
|
33
|
+
createEndpointOperation,
|
|
34
|
+
type EndpointGroupRequest,
|
|
35
|
+
type EndpointMemberRequest,
|
|
36
|
+
type EndpointMuteRequest,
|
|
37
|
+
type EndpointManagementRequest,
|
|
18
38
|
} from 'zhin.js/adapter';
|
|
19
39
|
import type { HttpHost } from '@zhin.js/host-http';
|
|
20
40
|
import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
|
|
@@ -34,16 +54,14 @@ import {
|
|
|
34
54
|
type ResolvedQqWebsocketConfig,
|
|
35
55
|
} from './protocol.js';
|
|
36
56
|
import {
|
|
37
|
-
|
|
57
|
+
qqHttpBotOptions,
|
|
38
58
|
registerQqWebhookRoutes,
|
|
39
|
-
type CreateQqHttpBot,
|
|
40
59
|
type QqHttpBotTransport,
|
|
41
60
|
} from './webhook.js';
|
|
42
61
|
import {
|
|
43
62
|
bindQqBotInboundEvents,
|
|
44
|
-
|
|
63
|
+
qqWebsocketBotOptions, configureQqWebsocketReceiver, disposeQqAuthorization,
|
|
45
64
|
normalizeQqMessage,
|
|
46
|
-
type CreateQqBot,
|
|
47
65
|
type QqBotTransport,
|
|
48
66
|
} from './ws.js';
|
|
49
67
|
import {
|
|
@@ -52,41 +70,49 @@ import {
|
|
|
52
70
|
type QqSideEventCaller,
|
|
53
71
|
} from './side-event-dispatch.js';
|
|
54
72
|
|
|
55
|
-
export type {
|
|
56
|
-
export type {
|
|
73
|
+
export type { QqBotTransport, QqOfficialApi } from './ws.js';
|
|
74
|
+
export type { QqHttpBotTransport } from './webhook.js';
|
|
57
75
|
|
|
58
76
|
export interface QqEndpointOptions {
|
|
59
77
|
readonly id: CapabilityId;
|
|
60
78
|
readonly config: ResolvedQqWebsocketConfig;
|
|
61
|
-
|
|
79
|
+
/** Out-of-band outage notifier; absent in tests/hosts without outbound. */
|
|
80
|
+
readonly notifier?: AdapterOutageNotifier;
|
|
62
81
|
}
|
|
63
82
|
|
|
64
|
-
export class QqWebsocketEndpoint extends
|
|
83
|
+
export class QqWebsocketEndpoint extends Bot<ReceiverMode.WEBSOCKET> implements Endpoint {
|
|
84
|
+
readonly [endpointSpec]: EndpointSpecification;
|
|
85
|
+
readonly #receiverResources: ReturnType<typeof configureQqWebsocketReceiver>;
|
|
65
86
|
readonly #logger!: ReturnType<typeof getAdapterLogger>;
|
|
66
87
|
|
|
67
88
|
readonly #options: QqEndpointOptions;
|
|
68
|
-
|
|
69
|
-
#
|
|
89
|
+
#connection?: AbortController;
|
|
90
|
+
#stopping?: Promise<void>;
|
|
70
91
|
#open = false;
|
|
92
|
+
#executionSignal?: AbortSignal;
|
|
93
|
+
#offlineNoticeAt = 0;
|
|
71
94
|
readonly #seenInteractions = new Map<string, number>();
|
|
72
95
|
/** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
|
|
73
96
|
readonly #passiveReplies = new QqPassiveReplyLedger();
|
|
74
97
|
readonly #activeGate = new QqActiveMessageGate();
|
|
75
98
|
readonly #lifecycle: EndpointLifecycle;
|
|
76
99
|
#cleanup?: () => Promise<void>;
|
|
77
|
-
readonly management: EndpointManagement = createQqEndpointManagement(() => this.#requireBot());
|
|
78
|
-
readonly control: EndpointControl =
|
|
100
|
+
readonly #management: EndpointManagement = createQqEndpointManagement(() => this.#requireBot(), () => this.#open && this.#connection !== undefined);
|
|
101
|
+
readonly #control: EndpointControl = createQqEndpointControl(() => this.#requireBot(), (id) => this.recallMessage(id));
|
|
79
102
|
|
|
80
103
|
constructor(options: QqEndpointOptions) {
|
|
81
|
-
super();
|
|
104
|
+
super(qqWebsocketBotOptions(options.config));
|
|
82
105
|
this.#logger = getAdapterLogger('qq', options.config.id);
|
|
83
106
|
this.#lifecycle = createEndpointLifecycle({ name: options.config.id });
|
|
84
107
|
this.#options = options;
|
|
85
|
-
this.#
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
108
|
+
this.#receiverResources = configureQqWebsocketReceiver(this, options.config);
|
|
109
|
+
const endpoint = this;
|
|
110
|
+
this[endpointSpec] = createEndpointSpecification(this, {
|
|
111
|
+
start: signal => this.#startFramework(signal), open: () => this.#openFramework(), close: () => this.#closeFramework(),
|
|
112
|
+
stop: () => this.#stopFramework(), send: request => this.#sendFramework(request), name: options.config.id,
|
|
113
|
+
get transportState() { return endpoint.#transportState; }, management: this.#management, control: this.#control,
|
|
114
|
+
});
|
|
115
|
+
this.#bindBot(this);
|
|
90
116
|
}
|
|
91
117
|
|
|
92
118
|
/** Live endpoint 名(Console/AdapterIndex 展示用,如 bot appid 别名)。 */
|
|
@@ -94,51 +120,60 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
|
|
|
94
120
|
return this.#options.config.id;
|
|
95
121
|
}
|
|
96
122
|
|
|
97
|
-
get transportState(): EndpointTransportState {
|
|
123
|
+
get #transportState(): EndpointTransportState {
|
|
98
124
|
if (this.#lifecycle.state !== 'open') return this.#lifecycle.state;
|
|
99
|
-
return this.#
|
|
125
|
+
return this.#receiverResources.state();
|
|
100
126
|
}
|
|
101
127
|
|
|
102
|
-
async
|
|
128
|
+
async #startFramework(executionSignal: AbortSignal): Promise<void> {
|
|
129
|
+
this.#executionSignal = executionSignal;
|
|
103
130
|
await this.#lifecycle.start(async (handle) => {
|
|
104
|
-
const bot = this
|
|
105
|
-
|
|
106
|
-
|
|
131
|
+
const bot = this;
|
|
132
|
+
const previousCleanup = this.#cleanup;
|
|
133
|
+
await previousCleanup?.();
|
|
134
|
+
const releaseUploadDiagnostics = installQqUploadDiagnostics(bot, metadata => this.#logger.debug(formatCompact(metadata)));
|
|
107
135
|
let cancelled = false;
|
|
108
136
|
const startupAbort = new AbortController();
|
|
137
|
+
this.#connection = startupAbort;
|
|
138
|
+
let nativeStart: Promise<unknown> | undefined;
|
|
109
139
|
let stopping: Promise<void> | undefined;
|
|
140
|
+
let transportClose: (() => void) | undefined;
|
|
110
141
|
const stopBot = async () => {
|
|
111
|
-
|
|
112
|
-
|
|
142
|
+
await super.stop();
|
|
143
|
+
await nativeStart?.catch(() => undefined);
|
|
113
144
|
};
|
|
114
145
|
const cleanup = () => {
|
|
115
146
|
cancelled = true;
|
|
116
147
|
releaseUploadDiagnostics();
|
|
117
148
|
startupAbort.abort();
|
|
118
|
-
if (
|
|
149
|
+
if (transportClose) this.receiver.off('close', transportClose);
|
|
150
|
+
if (this.#connection === startupAbort) this.#connection = undefined;
|
|
119
151
|
return stopping ??= stopBot();
|
|
120
152
|
};
|
|
121
153
|
this.#cleanup = cleanup;
|
|
122
|
-
handle.onForceClose(() => { void cleanup(); });
|
|
154
|
+
handle.onForceClose(() => { void cleanup().catch(() => undefined); });
|
|
123
155
|
try {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
156
|
+
transportClose = () => {
|
|
157
|
+
if (cancelled || this.#connection === undefined) return;
|
|
158
|
+
this.#notifyOffline('transport_close');
|
|
159
|
+
void cleanup().catch(() => undefined);
|
|
160
|
+
handle.notifyClosed();
|
|
161
|
+
};
|
|
162
|
+
this.receiver.on('close', transportClose);
|
|
163
|
+
nativeStart = super.start();
|
|
164
|
+
await awaitQqStartup(nativeStart.then(() => undefined), startupAbort.signal);
|
|
132
165
|
if (cancelled) {
|
|
133
166
|
// SDK startup may create resources after stop; close this exact old client again.
|
|
134
167
|
await stopping;
|
|
135
168
|
return;
|
|
136
169
|
}
|
|
170
|
+
this.#offlineNoticeAt = 0;
|
|
137
171
|
this.#logger.info(`connected (websocket) | appid: ${this.#options.config.appid}`);
|
|
138
172
|
} catch (error) {
|
|
139
173
|
const wasCancelled = cancelled;
|
|
140
174
|
await cleanup();
|
|
141
175
|
if (wasCancelled) return;
|
|
176
|
+
this.#notifyOffline('start_failed');
|
|
142
177
|
const raw = error instanceof Error ? error.message : String(error);
|
|
143
178
|
throw new Error(
|
|
144
179
|
`QQ WebSocket 连接失败:请检查 appid/secret 是否配对、网关地址(gatewayUrl/accessTokenUrl)是否可达(原始错误:${raw})`,
|
|
@@ -148,27 +183,31 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
|
|
|
148
183
|
});
|
|
149
184
|
}
|
|
150
185
|
|
|
151
|
-
|
|
186
|
+
#openFramework(): void {
|
|
152
187
|
this.#open = true;
|
|
153
188
|
}
|
|
154
189
|
|
|
155
|
-
|
|
190
|
+
#closeFramework(): void {
|
|
156
191
|
this.#open = false;
|
|
157
192
|
}
|
|
158
193
|
|
|
159
|
-
async
|
|
160
|
-
this.#
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
194
|
+
async #stopFramework(): Promise<void> {
|
|
195
|
+
this.#stopping ??= Promise.resolve().then(async () => {
|
|
196
|
+
this.#open = false;
|
|
197
|
+
const cleanup = this.#cleanup;
|
|
198
|
+
this.#cleanup = undefined;
|
|
199
|
+
try { await this.#lifecycle.stop(); await cleanup?.(); }
|
|
200
|
+
finally { this.removeAllListeners(undefined as never); this.#receiverResources.dispose(); disposeQqAuthorization(this); }
|
|
201
|
+
});
|
|
202
|
+
await this.#stopping;
|
|
165
203
|
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
166
204
|
}
|
|
167
205
|
|
|
168
|
-
async
|
|
206
|
+
async #sendFramework({ conversation, payload, signal }: EndpointSendRequest): Promise<string> {
|
|
169
207
|
// 媒体与 markdown/按钮不能同条发送(msg_type 7 与 2 互斥):公网图片嵌入 markdown,
|
|
170
208
|
// 拿不到 URL/尺寸的媒体降级为独立消息先发
|
|
171
|
-
const
|
|
209
|
+
const operation = createEndpointOperation({ signal });
|
|
210
|
+
const messages = formatOutboundMessages(payload, { imageSizes: await operation.prepare(() => collectOutboundImageSizes(payload, { signal })) });
|
|
172
211
|
const body = messages[messages.length - 1]!;
|
|
173
212
|
const kind = qqOutboundKind(conversation);
|
|
174
213
|
const bot = this.#requireBot();
|
|
@@ -179,7 +218,7 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
|
|
|
179
218
|
ledger: this.#passiveReplies,
|
|
180
219
|
activeGate: this.#activeGate,
|
|
181
220
|
log: fields => this.#logger.warn(formatCompact(fields)),
|
|
182
|
-
}, conversation, messages);
|
|
221
|
+
}, { conversation, messages, signal });
|
|
183
222
|
} catch (error) {
|
|
184
223
|
const { failure, diagnostic } = qqDeliveryFailure(error);
|
|
185
224
|
this.#logger.warn(formatCompact(diagnostic));
|
|
@@ -197,7 +236,7 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
|
|
|
197
236
|
async recallMessage(messageId: string): Promise<void> {
|
|
198
237
|
const { kind, channelId, qqMsgId } = parseCompoundMessageId(messageId);
|
|
199
238
|
const bot = this.#requireBot();
|
|
200
|
-
await recallQqMessage(bot, kind, channelId, qqMsgId);
|
|
239
|
+
await recallQqMessage(bot, { kind, channelId, messageId: qqMsgId });
|
|
201
240
|
this.#logger.debug(formatCompact({
|
|
202
241
|
op: 'qq_recall',
|
|
203
242
|
endpoint: this.#options.config.id,
|
|
@@ -219,8 +258,10 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
|
|
|
219
258
|
);
|
|
220
259
|
const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
|
|
221
260
|
if (msg.id) this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { id: msg.id });
|
|
222
|
-
void this.emit('message.receive', {
|
|
261
|
+
void this[endpointSpec].events.emit('message.receive', {
|
|
223
262
|
conversation,
|
|
263
|
+
event: msg.event,
|
|
264
|
+
platformSentAt: msg.platformSentAt,
|
|
224
265
|
message: { conversation, id: msg.id },
|
|
225
266
|
content,
|
|
226
267
|
...(msg.segments?.length ? { segments: msg.segments } : {}),
|
|
@@ -259,12 +300,15 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
|
|
|
259
300
|
this.#seenInteractions.set(action.message.id, now);
|
|
260
301
|
if (this.#seenInteractions.size > 10_000) this.#seenInteractions.delete(this.#seenInteractions.keys().next().value!);
|
|
261
302
|
// ACK owns only platform loading state, never implies business acceptance.
|
|
262
|
-
void
|
|
303
|
+
void this[endpointSpec].events.run(() => awaitOwnedAck(action.ack(), this.#executionSignal)).then(
|
|
304
|
+
ok => { if (ok === false) this.#logger.warn('qq_interaction_ack_failed'); },
|
|
305
|
+
() => { if (this.#executionSignal?.aborted) this.#logger.debug('QQ ACK wait cancelled'); else this.#logger.warn('qq_interaction_ack_failed'); },
|
|
306
|
+
);
|
|
263
307
|
const conversation = qqInboundConversation(String(this.#options.id), action.message);
|
|
264
308
|
// INTERACTION_CREATE's id is an event_id the reply may answer passively.
|
|
265
309
|
this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { event_id: action.message.id });
|
|
266
|
-
void this.emit('message.receive', {
|
|
267
|
-
conversation, message: { conversation, id: action.message.id }, content: '',
|
|
310
|
+
void this[endpointSpec].events.emit('message.receive', {
|
|
311
|
+
event: action.message.event, conversation, message: { conversation, id: action.message.id }, content: '',
|
|
268
312
|
segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
|
|
269
313
|
metadata: Object.freeze({ eventType: 'INTERACTION_CREATE', sourceMessageIdAvailable: action.sourceMessageId !== undefined,
|
|
270
314
|
...(action.sourceMessageId ? { sourceMessageId: action.sourceMessageId } : {}),
|
|
@@ -273,30 +317,52 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
|
|
|
273
317
|
return true;
|
|
274
318
|
}
|
|
275
319
|
|
|
320
|
+
#notifyOffline(reason: string): void {
|
|
321
|
+
const notifier = this.#options.notifier;
|
|
322
|
+
if (!notifier?.configured) return;
|
|
323
|
+
const now = Date.now();
|
|
324
|
+
if (now - this.#offlineNoticeAt < 60_000) return;
|
|
325
|
+
this.#offlineNoticeAt = now;
|
|
326
|
+
const text = `[Zhin] QQ 机器人 ${this.#options.config.id} 掉线(${reason})。正在自动重连。\n`
|
|
327
|
+
+ '恢复:在 QQ 回复 `qq endpoint add` 重新扫码绑定,或打开 Console 的 QQ 页面。';
|
|
328
|
+
void notifier.send(text).then((sent) => {
|
|
329
|
+
if (sent) return;
|
|
330
|
+
this.#logger.warn(formatCompact({
|
|
331
|
+
op: 'qq_notify_failed',
|
|
332
|
+
endpoint: this.#options.config.id,
|
|
333
|
+
reason,
|
|
334
|
+
}));
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
276
338
|
#bindBot(bot: QqBotTransport): void {
|
|
277
339
|
bindQqBotInboundEvents(bot, (raw) => {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
340
|
+
void this[endpointSpec].events.run(() => {
|
|
341
|
+
if (this.#connection === undefined) return;
|
|
342
|
+
this.#emitPlatformEvent('message', raw);
|
|
343
|
+
const msg = normalizeQqMessage(raw);
|
|
344
|
+
if (msg) this.admit(msg);
|
|
345
|
+
}).catch(() => this.#logger.debug('Native callback was not admitted'));
|
|
282
346
|
});
|
|
283
347
|
bindQqBotSideEvents(bot, (eventName, raw) => {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
(
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
348
|
+
void this[endpointSpec].events.run(() => {
|
|
349
|
+
if (this.#connection === undefined) return;
|
|
350
|
+
this.#emitPlatformEvent(eventName, raw);
|
|
351
|
+
if (this.admitInteraction(raw)) return;
|
|
352
|
+
receiveQqSideEvent(
|
|
353
|
+
(name, payload) => this[endpointSpec].events.emit(name, payload),
|
|
354
|
+
this.#options.config.id,
|
|
355
|
+
bot,
|
|
356
|
+
eventName,
|
|
357
|
+
raw,
|
|
358
|
+
this.#logger,
|
|
359
|
+
);
|
|
360
|
+
}).catch(() => this.#logger.debug('Native callback was not admitted'));
|
|
295
361
|
});
|
|
296
362
|
}
|
|
297
363
|
|
|
298
364
|
#emitPlatformEvent(name: string, event: unknown): void {
|
|
299
|
-
void this.
|
|
365
|
+
void this[endpointSpec].events.platform(name, event).catch((error) => {
|
|
300
366
|
this.#logger.warn(formatCompact({
|
|
301
367
|
op: 'qq_platform_event_failed',
|
|
302
368
|
event: name,
|
|
@@ -306,8 +372,8 @@ export class QqWebsocketEndpoint extends Endpoint<QqBotTransport> {
|
|
|
306
372
|
}
|
|
307
373
|
|
|
308
374
|
#requireBot(): QqBotTransport {
|
|
309
|
-
if (!this.#
|
|
310
|
-
return this
|
|
375
|
+
if (!this.#connection) throw new EndpointDeliveryError('endpoint_disconnected', 'QQ bot not connected', 'not_sent');
|
|
376
|
+
return this;
|
|
311
377
|
}
|
|
312
378
|
}
|
|
313
379
|
|
|
@@ -315,36 +381,43 @@ export interface QqHttpEndpointOptions {
|
|
|
315
381
|
readonly id: CapabilityId;
|
|
316
382
|
readonly http: HttpHost;
|
|
317
383
|
readonly config: ResolvedQqHttpConfig;
|
|
318
|
-
|
|
384
|
+
/** Out-of-band outage notifier; absent in tests/hosts without outbound. */
|
|
385
|
+
readonly notifier?: AdapterOutageNotifier;
|
|
319
386
|
}
|
|
320
387
|
|
|
321
388
|
/** Webhook / middleware inbound via httpHostToken POST (qq-official-bot Middleware receiver). */
|
|
322
|
-
export class QqHttpEndpoint extends
|
|
389
|
+
export class QqHttpEndpoint extends Bot<ReceiverMode.MIDDLEWARE, 'koa'> implements Endpoint {
|
|
390
|
+
readonly [endpointSpec]: EndpointSpecification;
|
|
323
391
|
readonly #logger!: ReturnType<typeof getAdapterLogger>;
|
|
324
392
|
|
|
325
393
|
readonly #options: QqHttpEndpointOptions;
|
|
326
|
-
|
|
327
|
-
#
|
|
394
|
+
#connection?: AbortController;
|
|
395
|
+
#stopping?: Promise<void>;
|
|
328
396
|
#open = false;
|
|
397
|
+
#executionSignal?: AbortSignal;
|
|
398
|
+
#offlineNoticeAt = 0;
|
|
329
399
|
readonly #seenInteractions = new Map<string, number>();
|
|
330
400
|
/** Recent msg_id / event_id per conversation, so replies go out as passive messages. */
|
|
331
401
|
readonly #passiveReplies = new QqPassiveReplyLedger();
|
|
332
402
|
readonly #activeGate = new QqActiveMessageGate();
|
|
333
403
|
readonly #lifecycle: EndpointLifecycle;
|
|
334
404
|
#cleanup?: () => Promise<void>;
|
|
335
|
-
readonly management: EndpointManagement = createQqEndpointManagement(() => this.#requireBot());
|
|
336
|
-
readonly control: EndpointControl =
|
|
405
|
+
readonly #management: EndpointManagement = createQqEndpointManagement(() => this.#requireBot(), () => this.#open && this.#connection !== undefined);
|
|
406
|
+
readonly #control: EndpointControl = createQqEndpointControl(() => this.#requireBot(), (id) => this.recallMessage(id));
|
|
337
407
|
|
|
338
408
|
constructor(options: QqHttpEndpointOptions) {
|
|
339
|
-
super();
|
|
409
|
+
super(qqHttpBotOptions(options.config));
|
|
340
410
|
this.#logger = getAdapterLogger('qq', options.config.id);
|
|
341
411
|
this.#lifecycle = createEndpointLifecycle({ name: options.config.id, reconnect: false });
|
|
342
412
|
this.#options = options;
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
413
|
+
const endpoint = this;
|
|
414
|
+
this[endpointSpec] = createEndpointSpecification(this, {
|
|
415
|
+
start: signal => this.#startFramework(signal), open: () => this.#openFramework(), close: () => this.#closeFramework(),
|
|
416
|
+
stop: () => this.#stopFramework(), send: request => this.#sendFramework(request), name: options.config.id,
|
|
417
|
+
get transportState() { return endpoint.#transportState; },
|
|
418
|
+
management: this.#management, control: this.#control,
|
|
419
|
+
});
|
|
420
|
+
this.#bindBot(this);
|
|
348
421
|
}
|
|
349
422
|
|
|
350
423
|
/** Live endpoint 名(Console/AdapterIndex 展示用)。 */
|
|
@@ -352,81 +425,86 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
|
|
|
352
425
|
return this.#options.config.id;
|
|
353
426
|
}
|
|
354
427
|
|
|
355
|
-
get transportState(): EndpointTransportState {
|
|
428
|
+
get #transportState(): EndpointTransportState {
|
|
356
429
|
return this.#lifecycle.state;
|
|
357
430
|
}
|
|
358
431
|
|
|
359
|
-
async
|
|
432
|
+
async #startFramework(executionSignal: AbortSignal): Promise<void> {
|
|
433
|
+
this.#executionSignal = executionSignal;
|
|
360
434
|
await this.#lifecycle.start(async (handle) => {
|
|
361
|
-
const bot = this
|
|
362
|
-
|
|
363
|
-
|
|
435
|
+
const bot = this;
|
|
436
|
+
const previousCleanup = this.#cleanup;
|
|
437
|
+
await previousCleanup?.();
|
|
438
|
+
const releaseUploadDiagnostics = installQqUploadDiagnostics(bot, metadata => this.#logger.debug(formatCompact(metadata)));
|
|
364
439
|
let cancelled = false;
|
|
365
440
|
const startupAbort = new AbortController();
|
|
441
|
+
this.#connection = startupAbort;
|
|
442
|
+
let nativeStart: Promise<unknown> | undefined;
|
|
366
443
|
let stopping: Promise<void> | undefined;
|
|
367
444
|
const routes: Array<() => void> = [];
|
|
368
445
|
const stopBot = async () => {
|
|
369
|
-
|
|
370
|
-
|
|
446
|
+
await super.stop();
|
|
447
|
+
await nativeStart?.catch(() => undefined);
|
|
371
448
|
};
|
|
372
449
|
const cleanup = () => {
|
|
373
450
|
cancelled = true;
|
|
374
451
|
releaseUploadDiagnostics();
|
|
375
452
|
startupAbort.abort();
|
|
376
|
-
if (this.#
|
|
453
|
+
if (this.#connection === startupAbort) this.#connection = undefined;
|
|
377
454
|
for (const release of routes.splice(0)) release();
|
|
378
455
|
return stopping ??= stopBot();
|
|
379
456
|
};
|
|
380
457
|
this.#cleanup = cleanup;
|
|
381
|
-
handle.onForceClose(() => { void cleanup(); });
|
|
458
|
+
handle.onForceClose(() => { void cleanup().catch(() => undefined); });
|
|
382
459
|
try {
|
|
383
|
-
this.#bindBot(bot);
|
|
384
460
|
routes.push(...registerQqWebhookRoutes(this.#options.http, {
|
|
385
461
|
config: this.#options.config,
|
|
386
462
|
getBot: () => cancelled ? null : bot,
|
|
387
463
|
}));
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
}, async (error) => {
|
|
391
|
-
if (cancelled) await stopBot();
|
|
392
|
-
throw error;
|
|
393
|
-
}), startupAbort.signal);
|
|
464
|
+
nativeStart = super.start();
|
|
465
|
+
await awaitQqStartup(nativeStart.then(() => undefined), startupAbort.signal);
|
|
394
466
|
if (cancelled) {
|
|
395
467
|
// SDK startup may create resources after stop; close this exact old client again.
|
|
396
468
|
await stopping;
|
|
397
469
|
return;
|
|
398
470
|
}
|
|
471
|
+
this.#offlineNoticeAt = 0;
|
|
399
472
|
this.#logger.info(`connected (${this.#options.config.mode}) | path: ${this.#options.config.webhookPath}`);
|
|
400
473
|
} catch (error) {
|
|
401
474
|
const wasCancelled = cancelled;
|
|
402
475
|
await cleanup();
|
|
403
476
|
if (wasCancelled) return;
|
|
477
|
+
this.#notifyOffline('start_failed');
|
|
404
478
|
throw error;
|
|
405
479
|
}
|
|
406
480
|
});
|
|
407
481
|
}
|
|
408
482
|
|
|
409
|
-
|
|
483
|
+
#openFramework(): void {
|
|
410
484
|
this.#open = true;
|
|
411
485
|
}
|
|
412
486
|
|
|
413
|
-
|
|
487
|
+
#closeFramework(): void {
|
|
414
488
|
this.#open = false;
|
|
415
489
|
}
|
|
416
490
|
|
|
417
|
-
async
|
|
418
|
-
this.#
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
491
|
+
async #stopFramework(): Promise<void> {
|
|
492
|
+
this.#stopping ??= Promise.resolve().then(async () => {
|
|
493
|
+
this.#open = false;
|
|
494
|
+
const cleanup = this.#cleanup;
|
|
495
|
+
this.#cleanup = undefined;
|
|
496
|
+
try { await this.#lifecycle.stop(); await cleanup?.(); }
|
|
497
|
+
finally { this.removeAllListeners(undefined as never); disposeQqAuthorization(this); }
|
|
498
|
+
this.#logger.debug(formatCompact({ op: 'disconnect' }));
|
|
499
|
+
});
|
|
500
|
+
await this.#stopping;
|
|
424
501
|
}
|
|
425
502
|
|
|
426
|
-
async
|
|
503
|
+
async #sendFramework({ conversation, payload, signal }: EndpointSendRequest): Promise<string> {
|
|
427
504
|
// 媒体与 markdown/按钮不能同条发送(msg_type 7 与 2 互斥):公网图片嵌入 markdown,
|
|
428
505
|
// 拿不到 URL/尺寸的媒体降级为独立消息先发
|
|
429
|
-
const
|
|
506
|
+
const operation = createEndpointOperation({ signal });
|
|
507
|
+
const messages = formatOutboundMessages(payload, { imageSizes: await operation.prepare(() => collectOutboundImageSizes(payload, { signal })) });
|
|
430
508
|
const body = messages[messages.length - 1]!;
|
|
431
509
|
const kind = qqOutboundKind(conversation);
|
|
432
510
|
const bot = this.#requireBot();
|
|
@@ -437,7 +515,7 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
|
|
|
437
515
|
ledger: this.#passiveReplies,
|
|
438
516
|
activeGate: this.#activeGate,
|
|
439
517
|
log: fields => this.#logger.warn(formatCompact(fields)),
|
|
440
|
-
}, conversation, messages);
|
|
518
|
+
}, { conversation, messages, signal });
|
|
441
519
|
} catch (error) {
|
|
442
520
|
const { failure, diagnostic } = qqDeliveryFailure(error);
|
|
443
521
|
this.#logger.warn(formatCompact(diagnostic));
|
|
@@ -455,7 +533,7 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
|
|
|
455
533
|
async recallMessage(messageId: string): Promise<void> {
|
|
456
534
|
const { kind, channelId, qqMsgId } = parseCompoundMessageId(messageId);
|
|
457
535
|
const bot = this.#requireBot();
|
|
458
|
-
await recallQqMessage(bot, kind, channelId, qqMsgId);
|
|
536
|
+
await recallQqMessage(bot, { kind, channelId, messageId: qqMsgId });
|
|
459
537
|
this.#logger.debug(formatCompact({
|
|
460
538
|
op: 'qq_recall',
|
|
461
539
|
endpoint: this.#options.config.id,
|
|
@@ -476,8 +554,10 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
|
|
|
476
554
|
);
|
|
477
555
|
const roleProjection = projectQqGuildSenderRoles(msg.authorRoles);
|
|
478
556
|
if (msg.id) this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { id: msg.id });
|
|
479
|
-
void this.emit('message.receive', {
|
|
557
|
+
void this[endpointSpec].events.emit('message.receive', {
|
|
480
558
|
conversation,
|
|
559
|
+
event: msg.event,
|
|
560
|
+
platformSentAt: msg.platformSentAt,
|
|
481
561
|
message: { conversation, id: msg.id },
|
|
482
562
|
content,
|
|
483
563
|
...(msg.segments?.length ? { segments: msg.segments } : {}),
|
|
@@ -516,12 +596,15 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
|
|
|
516
596
|
this.#seenInteractions.set(action.message.id, now);
|
|
517
597
|
if (this.#seenInteractions.size > 10_000) this.#seenInteractions.delete(this.#seenInteractions.keys().next().value!);
|
|
518
598
|
// ACK owns only platform loading state, never implies business acceptance.
|
|
519
|
-
void
|
|
599
|
+
void this[endpointSpec].events.run(() => awaitOwnedAck(action.ack(), this.#executionSignal)).then(
|
|
600
|
+
ok => { if (ok === false) this.#logger.warn('qq_interaction_ack_failed'); },
|
|
601
|
+
() => { if (this.#executionSignal?.aborted) this.#logger.debug('QQ ACK wait cancelled'); else this.#logger.warn('qq_interaction_ack_failed'); },
|
|
602
|
+
);
|
|
520
603
|
const conversation = qqInboundConversation(String(this.#options.id), action.message);
|
|
521
604
|
// INTERACTION_CREATE's id is an event_id the reply may answer passively.
|
|
522
605
|
this.#passiveReplies.record(qqOutboundKind(conversation), conversation.id, { event_id: action.message.id });
|
|
523
|
-
void this.emit('message.receive', {
|
|
524
|
-
conversation, message: { conversation, id: action.message.id }, content: '',
|
|
606
|
+
void this[endpointSpec].events.emit('message.receive', {
|
|
607
|
+
event: action.message.event, conversation, message: { conversation, id: action.message.id }, content: '',
|
|
525
608
|
segments: action.message.segments, sender: { id: action.message.authorId }, endpointId: this.#options.config.id,
|
|
526
609
|
metadata: Object.freeze({ eventType: 'INTERACTION_CREATE', sourceMessageIdAvailable: action.sourceMessageId !== undefined,
|
|
527
610
|
...(action.sourceMessageId ? { sourceMessageId: action.sourceMessageId } : {}),
|
|
@@ -530,30 +613,52 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
|
|
|
530
613
|
return true;
|
|
531
614
|
}
|
|
532
615
|
|
|
616
|
+
#notifyOffline(reason: string): void {
|
|
617
|
+
const notifier = this.#options.notifier;
|
|
618
|
+
if (!notifier?.configured) return;
|
|
619
|
+
const now = Date.now();
|
|
620
|
+
if (now - this.#offlineNoticeAt < 60_000) return;
|
|
621
|
+
this.#offlineNoticeAt = now;
|
|
622
|
+
const text = `[Zhin] QQ 机器人 ${this.#options.config.id} 掉线(${reason})。正在自动重连。\n`
|
|
623
|
+
+ '恢复:在 QQ 回复 `qq endpoint add` 重新扫码绑定,或打开 Console 的 QQ 页面。';
|
|
624
|
+
void notifier.send(text).then((sent) => {
|
|
625
|
+
if (sent) return;
|
|
626
|
+
this.#logger.warn(formatCompact({
|
|
627
|
+
op: 'qq_notify_failed',
|
|
628
|
+
endpoint: this.#options.config.id,
|
|
629
|
+
reason,
|
|
630
|
+
}));
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
533
634
|
#bindBot(bot: QqBotTransport): void {
|
|
534
635
|
bindQqBotInboundEvents(bot, (raw) => {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
636
|
+
void this[endpointSpec].events.run(() => {
|
|
637
|
+
if (this.#connection === undefined) return;
|
|
638
|
+
this.#emitPlatformEvent('message', raw);
|
|
639
|
+
const msg = normalizeQqMessage(raw);
|
|
640
|
+
if (msg) this.admit(msg);
|
|
641
|
+
}).catch(() => this.#logger.debug('Native callback was not admitted'));
|
|
539
642
|
});
|
|
540
643
|
bindQqBotSideEvents(bot, (eventName, raw) => {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
(
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
644
|
+
void this[endpointSpec].events.run(() => {
|
|
645
|
+
if (this.#connection === undefined) return;
|
|
646
|
+
this.#emitPlatformEvent(eventName, raw);
|
|
647
|
+
if (this.admitInteraction(raw)) return;
|
|
648
|
+
receiveQqSideEvent(
|
|
649
|
+
(name, payload) => this[endpointSpec].events.emit(name, payload),
|
|
650
|
+
this.#options.config.id,
|
|
651
|
+
bot,
|
|
652
|
+
eventName,
|
|
653
|
+
raw,
|
|
654
|
+
this.#logger,
|
|
655
|
+
);
|
|
656
|
+
}).catch(() => this.#logger.debug('Native callback was not admitted'));
|
|
552
657
|
});
|
|
553
658
|
}
|
|
554
659
|
|
|
555
660
|
#emitPlatformEvent(name: string, event: unknown): void {
|
|
556
|
-
void this.
|
|
661
|
+
void this[endpointSpec].events.platform(name, event).catch((error) => {
|
|
557
662
|
this.#logger.warn(formatCompact({
|
|
558
663
|
op: 'qq_platform_event_failed',
|
|
559
664
|
event: name,
|
|
@@ -563,25 +668,43 @@ export class QqHttpEndpoint extends Endpoint<QqHttpBotTransport> {
|
|
|
563
668
|
}
|
|
564
669
|
|
|
565
670
|
#requireBot(): QqHttpBotTransport {
|
|
566
|
-
if (!this.#
|
|
567
|
-
return this
|
|
671
|
+
if (!this.#connection) throw new EndpointDeliveryError('endpoint_disconnected', 'QQ bot not connected', 'not_sent');
|
|
672
|
+
return this;
|
|
568
673
|
}
|
|
569
674
|
}
|
|
570
675
|
|
|
571
676
|
function createQqEndpointManagement(
|
|
572
|
-
requireClient: () =>
|
|
677
|
+
requireClient: () => QqBotTransport,
|
|
678
|
+
isActive: () => boolean,
|
|
573
679
|
): EndpointManagement {
|
|
680
|
+
const api = () => requireClient();
|
|
574
681
|
return Object.freeze<EndpointManagement>({
|
|
575
|
-
|
|
682
|
+
// QQ 群(group_openid / member_openid):官方 v2 群管理接口,部分为白名单能力(11253)。
|
|
683
|
+
inspectGroupMember: request => inspectQqGroupMember(api(), request),
|
|
684
|
+
inspectGroupScene: request => inspectQqGroupScene(api(), request),
|
|
685
|
+
inspectGroupMemberProfile: request => inspectQqGroupMemberProfile(api(), request),
|
|
686
|
+
moderateGroupMember: request => moderateQqGroupMember({ api: api(), isActive }, request),
|
|
687
|
+
async listGroupMembers({ groupId, signal }: EndpointGroupRequest): Promise<readonly unknown[]> {
|
|
688
|
+
const operation = createEndpointOperation({ signal });
|
|
689
|
+
return operation.run(() => qqGroupService(api()).getMembers(groupId));
|
|
690
|
+
},
|
|
691
|
+
async kickGroupMember({ groupId, userId, signal }: EndpointMemberRequest): Promise<void> {
|
|
692
|
+
await kickQqGroupMember(api(), { groupId, userId, signal });
|
|
693
|
+
},
|
|
694
|
+
async muteGroupMember({ groupId, userId, durationSeconds, signal }: EndpointMuteRequest): Promise<void> {
|
|
695
|
+
await muteQqGroupMember(api(), { groupId, userId, durationSeconds, signal });
|
|
696
|
+
},
|
|
697
|
+
async listChannels(request: EndpointManagementRequest = {}): Promise<readonly EndpointChannel[]> {
|
|
698
|
+
const operation = createEndpointOperation(request);
|
|
576
699
|
const channels: EndpointChannel[] = [];
|
|
577
700
|
const client = requireClient();
|
|
578
|
-
const guilds = await client.
|
|
701
|
+
const guilds = await operation.run(() => client.guildService.getList());
|
|
579
702
|
for (const guildValue of Array.isArray(guilds) ? guilds : []) {
|
|
580
703
|
const guild = asRecord(guildValue);
|
|
581
704
|
const guildId = String(guild.id ?? guild.guild_id ?? guildValue ?? '');
|
|
582
705
|
if (!guildId) continue;
|
|
583
706
|
const guildName = String(guild.name ?? guild.guild_name ?? guildId);
|
|
584
|
-
const rows = await client.
|
|
707
|
+
const rows = await operation.run(() => client.channelService.getList(guildId));
|
|
585
708
|
for (const channelValue of Array.isArray(rows) ? rows : []) {
|
|
586
709
|
const channel = asRecord(channelValue);
|
|
587
710
|
const id = String(channel.id ?? channel.channel_id ?? channelValue ?? '');
|
|
@@ -604,10 +727,74 @@ function asRecord(value: unknown): Record<string, unknown> {
|
|
|
604
727
|
: {};
|
|
605
728
|
}
|
|
606
729
|
|
|
607
|
-
|
|
730
|
+
/**
|
|
731
|
+
* Recall + 频道表情回应。recall 同时接受 send() 的复合 ID 与入站原生消息 ID(目标取自 MessageRef.conversation),
|
|
732
|
+
* 因此群管理员机器人可撤回普通成员 2 分钟内的消息(官方限制由平台执行)。
|
|
733
|
+
* 表情回应只有频道子频道消息支持(官方无群 / 单聊表情回应接口)。
|
|
734
|
+
*/
|
|
735
|
+
function createQqEndpointControl(
|
|
736
|
+
requireBot: () => QqBotTransport,
|
|
737
|
+
recallById: (messageId: string) => Promise<void>,
|
|
738
|
+
): EndpointControl {
|
|
739
|
+
return Object.freeze<EndpointControl>({
|
|
740
|
+
recall: async ({ message, signal }) => {
|
|
741
|
+
if (isCompoundMessageId(message.id)) return createEndpointOperation({ signal }).run(() => recallById(message.id));
|
|
742
|
+
const kind = qqOutboundKind(message.conversation);
|
|
743
|
+
await createEndpointOperation({ signal }).run(() => recallQqMessage(requireBot(), { kind, channelId: message.conversation.id, messageId: message.id }));
|
|
744
|
+
},
|
|
745
|
+
addReaction: async ({ message, emoji, signal }) => {
|
|
746
|
+
const target = qqGuildReactionTarget(message, emoji);
|
|
747
|
+
if (!target) return null;
|
|
748
|
+
await createEndpointOperation({ signal }).run(() => requireBot().reactionService.addGuildMessageReaction(target.channelId, target.messageId, target.type, target.id));
|
|
749
|
+
return `${target.type}:${target.id}`;
|
|
750
|
+
},
|
|
751
|
+
removeReaction: async ({ message, reactionId, signal }) => {
|
|
752
|
+
const target = qqGuildReactionTarget(message, reactionId);
|
|
753
|
+
if (!target) return;
|
|
754
|
+
await createEndpointOperation({ signal }).run(() => requireBot().reactionService.deleteGuildMessageReaction(target.channelId, target.messageId, target.type, target.id));
|
|
755
|
+
},
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function isCompoundMessageId(id: string): boolean {
|
|
760
|
+
try { parseCompoundMessageId(id); return true; } catch { return false; }
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** `type:id`(1 系统表情 / 2 emoji)或单个 Unicode emoji(→ type 2 + 码点)。 */
|
|
764
|
+
export function parseQqEmoji(value: string): { type: 1 | 2; id: `${number}` } | null {
|
|
765
|
+
const explicit = /^([12]):(\d+)$/.exec(value.trim());
|
|
766
|
+
if (explicit) return { type: Number(explicit[1]) as 1 | 2, id: explicit[2] as `${number}` };
|
|
767
|
+
const chars = [...value.trim().replace(/\uFE0F/g, '')];
|
|
768
|
+
if (chars.length !== 1) return null;
|
|
769
|
+
const code = chars[0]!.codePointAt(0)!;
|
|
770
|
+
return code > 0x7f ? { type: 2, id: String(code) as `${number}` } : null;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function qqGuildReactionTarget(message: MessageRef, emoji: string) {
|
|
774
|
+
let kind = qqOutboundKind(message.conversation);
|
|
775
|
+
let channelId = message.conversation.id;
|
|
776
|
+
let messageId = message.id;
|
|
777
|
+
if (isCompoundMessageId(message.id)) ({ kind, channelId, qqMsgId: messageId } = parseCompoundMessageId(message.id));
|
|
778
|
+
if (kind !== 'channel' || !channelId || !messageId) return null;
|
|
779
|
+
const parsed = parseQqEmoji(emoji);
|
|
780
|
+
return parsed ? { channelId, messageId, ...parsed } : null;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
async function recallQqMessage(bot: QqBotTransport, { kind, channelId, messageId }: { readonly kind: ReturnType<typeof qqOutboundKind>; readonly channelId: string; readonly messageId: string }): Promise<void> {
|
|
608
784
|
const recall = { private: bot.recallPrivateMessage, group: bot.recallGroupMessage,
|
|
609
785
|
channel: bot.recallGuildMessage, direct: bot.recallDirectMessage }[kind];
|
|
610
786
|
if (!recall) throw new EndpointDeliveryError('recall_unsupported', 'QQ transport does not support recall for this conversation', 'not_sent');
|
|
611
787
|
if (!messageId) throw new EndpointDeliveryError('invalid_message_id', 'QQ recall requires a nonempty message ID', 'not_sent');
|
|
612
788
|
await recall.call(bot, channelId, messageId);
|
|
613
789
|
}
|
|
790
|
+
|
|
791
|
+
/** Logical cancellation only; the native SDK still owns its already-started ACK. */
|
|
792
|
+
function awaitOwnedAck<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
793
|
+
if (!signal) return operation;
|
|
794
|
+
return new Promise((resolve, reject) => {
|
|
795
|
+
const abort = () => reject(signal.reason);
|
|
796
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
797
|
+
void operation.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
|
|
798
|
+
if (signal.aborted) abort();
|
|
799
|
+
});
|
|
800
|
+
}
|