@zhin.js/adapter-icqq 1.1.6 → 1.1.8
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 +51 -0
- package/lib/endpoint.d.ts +1 -4
- package/lib/endpoint.js +35 -13
- package/lib/icqq-inbound.d.ts +4 -0
- package/lib/icqq-inbound.js +10 -0
- package/lib/managed-client.d.ts +11 -0
- package/lib/managed-client.js +46 -0
- package/lib/protocol.d.ts +6 -2
- package/lib/protocol.js +23 -2
- package/package.json +15 -15
- package/src/endpoint.ts +32 -10
- package/src/icqq-inbound.ts +9 -0
- package/src/managed-client.ts +52 -0
- package/src/protocol.ts +23 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# @zhin.js/adapter-icqq
|
|
2
2
|
|
|
3
|
+
## 1.1.8
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 9891e66: Endpoint 新增运行期 `selfUserId` 访问器与受保护的 `reportSelfUserId()`(`EndpointImplementation` 另可声明 `selfUserId`,`EndpointActivationContext` 提供 `reportSelfUserId`),并新增进程级 Peer Registry(`getProcessPeerRegistry()` / `createPeerRegistry()`)。账号仅在 generation active 后登记,回滚 / retire / stop 后移除;`EndpointIdentity` 保持不可变。ICQQ 在 `system.online` 上报 uin;OneBot11 ws/wss 从事件 `self_id` 与 `get_login_info` 上报;Sandbox 以配置的 bot id 作为合成账号。未上报的旧 Endpoint 不进入 Registry(fail closed),不会用配置 id 兜底。OneBot12 / napcat / milky 等其余适配器留待后续。
|
|
8
|
+
|
|
9
|
+
Peer Registry 使用登记序号(新 generation 胜出),旧实例在新实例登记后再次上报不会覆盖新条目;空值或非字符串的读取查询返回未命中而不抛错;OneBot11 在 stop 时清空 `selfUserId`。
|
|
10
|
+
|
|
11
|
+
登记序号在 Endpoint 激活时通过 `PeerRegistry.reserve()` 分配,新实例先上报、旧实例晚到的首次上报不会覆盖新条目。
|
|
12
|
+
|
|
13
|
+
- 386150d: Unify command parameters, options, validation and help; provide IM middleware defaults, exact persistent delivery targets and owner-scoped atomic state. Derive plugin configuration types, defaults, validation and Console metadata from one declaration, preserve plugin ownership and generation admission, and keep process database, scheduler and outbound resources private to the root. Reject inline configuration in isolated plugins inside their own realm; isolated configuration uses schema.json.
|
|
14
|
+
|
|
15
|
+
Reject ambiguous optional/rest routes and undefined dynamic defaults, preserve optional parameter types and help handling, and validate Console edits with the Runtime configuration engine. Keep unrelated plugin entries cached during helper reloads, expose initial configuration metadata before login completes, and drain admitted Host operations while rejecting retired or failed-generation access.
|
|
16
|
+
|
|
17
|
+
Keep schedule activation and hot reload atomic, preserving published jobs when a candidate fails; preserve immutable database query branches and path-only command parameters. Report background unhandled rejections for the Runtime Process Host, observe ICQQ heartbeat and reconnect login failures through the SDK reconnect policy, and normalize inline image data URLs before ICQQ file or base64 delivery.
|
|
18
|
+
|
|
19
|
+
- 4778587: 补齐 Console 安装配置闭环所需的精确配置路径、端点身份、平台就绪与验证计划失效语义,并保存历史消息的方向及实际投递诊断。
|
|
20
|
+
|
|
21
|
+
修复真实 ICQQ 入站目标保存了插件 owner 而非平台标识、导致提醒投递被身份校验拒绝的问题;无关插件配置变更保留原有适配器连接。
|
|
22
|
+
|
|
23
|
+
保证候选事件在当前代快照发布后准入,并避免安装过程中依赖文件变动提前中断插件管理操作。
|
|
24
|
+
|
|
25
|
+
明确 Adapter/MCP 客户端创建仅提供所属 owner 的配置与资源,跨 Feature 查询由当前 operation 上下文负责;入站诊断记录接收时刻,避免慢指令的回复在历史中先于指令。
|
|
26
|
+
|
|
27
|
+
- Updated dependencies [9891e66]
|
|
28
|
+
- Updated dependencies [bf52b40]
|
|
29
|
+
- Updated dependencies [bf52b40]
|
|
30
|
+
- Updated dependencies [bf52b40]
|
|
31
|
+
- Updated dependencies [386150d]
|
|
32
|
+
- Updated dependencies [9891e66]
|
|
33
|
+
- Updated dependencies [9891e66]
|
|
34
|
+
- Updated dependencies [4778587]
|
|
35
|
+
- Updated dependencies [40537e3]
|
|
36
|
+
- Updated dependencies [681abf3]
|
|
37
|
+
- Updated dependencies [9891e66]
|
|
38
|
+
- Updated dependencies [9891e66]
|
|
39
|
+
- @zhin.js/adapter@1.1.18
|
|
40
|
+
- @zhin.js/core@1.1.43
|
|
41
|
+
- @zhin.js/permission@1.1.5
|
|
42
|
+
- @zhin.js/feature-kit@1.1.4
|
|
43
|
+
- @zhin.js/agent-feature@1.1.5
|
|
44
|
+
- @zhin.js/skill@1.1.5
|
|
45
|
+
- @zhin.js/tool@1.1.5
|
|
46
|
+
|
|
47
|
+
## 1.1.7
|
|
48
|
+
|
|
49
|
+
### Patch Changes
|
|
50
|
+
|
|
51
|
+
- Updated dependencies [5ab6307]
|
|
52
|
+
- @zhin.js/core@1.1.42
|
|
53
|
+
|
|
3
54
|
## 1.1.6
|
|
4
55
|
|
|
5
56
|
### Patch Changes
|
package/lib/endpoint.d.ts
CHANGED
|
@@ -4,14 +4,12 @@ import type { EndpointContentPort } from '@zhin.js/adapter';
|
|
|
4
4
|
import { type LoginAssist } from '@zhin.js/core';
|
|
5
5
|
import type { CapabilityId } from 'zhin.js';
|
|
6
6
|
import { type IcqqInboundMessage, type ResolvedIcqqConfig } from './protocol.js';
|
|
7
|
+
import { ManagedIcqqClient } from './managed-client.js';
|
|
7
8
|
export interface IcqqEndpointOptions {
|
|
8
9
|
readonly id: CapabilityId;
|
|
9
10
|
readonly config: ResolvedIcqqConfig;
|
|
10
11
|
readonly loginAssist: LoginAssist;
|
|
11
12
|
}
|
|
12
|
-
declare class ManagedIcqqClient extends Client {
|
|
13
|
-
cancelReconnect(): void;
|
|
14
|
-
}
|
|
15
13
|
export declare class IcqqEndpoint extends Endpoint<Client> {
|
|
16
14
|
#private;
|
|
17
15
|
readonly client: ManagedIcqqClient;
|
|
@@ -28,4 +26,3 @@ export declare class IcqqEndpoint extends Endpoint<Client> {
|
|
|
28
26
|
send({ conversation, payload }: EndpointSendRequest): Promise<string>;
|
|
29
27
|
admit(msg: IcqqInboundMessage): Promise<void>;
|
|
30
28
|
}
|
|
31
|
-
export {};
|
package/lib/endpoint.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseGroupMessageId, } from '@icqqjs/icqq';
|
|
2
2
|
import { inspect } from 'node:util';
|
|
3
3
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
4
|
import { Endpoint, } from 'zhin.js/adapter';
|
|
@@ -7,11 +7,12 @@ import { EndpointDeliveryError } from '@zhin.js/im-contract';
|
|
|
7
7
|
import { formatCompact, getAdapterLogger, truncatePreview } from '@zhin.js/logger';
|
|
8
8
|
import { runIcqqLoginAssistStep } from './icqq-login-assist.js';
|
|
9
9
|
import { IcqqContentResolver } from './content-resolver.js';
|
|
10
|
-
import { InboundMessageDeduper, findIcqqNestedMessageSource, isIcqqBotMentioned, isIcqqMessagePostType, normalizeIcqqInboundMessage, messageLookupFromIcqqSource, resolveIcqqQuoteIdFromEvent, resolveIcqqInboundMedia, shouldSkipSelfInboundMessage, } from './icqq-inbound.js';
|
|
10
|
+
import { InboundMessageDeduper, findIcqqNestedMessageSource, isIcqqBotMentioned, isIcqqMessagePostType, normalizeIcqqInboundMessage, messageLookupFromIcqqSource, resolveIcqqQuoteIdFromEvent, resolveIcqqInboundMedia, resolveIcqqSelfUserId, shouldSkipSelfInboundMessage, } from './icqq-inbound.js';
|
|
11
11
|
import { isIcqqNoticePayload, isIcqqRequestPayload, } from './icqq-inbox.js';
|
|
12
12
|
import { IcqqGuildCatalog, normalizeIcqqGuildInboundMessage, } from './icqq-guild.js';
|
|
13
13
|
import { prepareIcqqOutboundMedia, resolveIcqqOutboundMediaMode, } from './outbound-media.js';
|
|
14
|
-
import { formatInboundContent, formatOutboundBody, icqqInboundConversation, icqqOutboundTarget, } from './protocol.js';
|
|
14
|
+
import { formatInboundContent, formatOutboundBody, normalizeIcqqImageMedia, icqqInboundConversation, icqqOutboundTarget, } from './protocol.js';
|
|
15
|
+
import { ManagedIcqqClient } from './managed-client.js';
|
|
15
16
|
const BOUND_EVENTS = [
|
|
16
17
|
'message.private',
|
|
17
18
|
'message.group',
|
|
@@ -30,14 +31,6 @@ const BOUND_EVENTS = [
|
|
|
30
31
|
'system.login.error',
|
|
31
32
|
'system.login.auth',
|
|
32
33
|
];
|
|
33
|
-
class ManagedIcqqClient extends Client {
|
|
34
|
-
cancelReconnect() {
|
|
35
|
-
if (!this.login_timer)
|
|
36
|
-
return;
|
|
37
|
-
clearTimeout(this.login_timer);
|
|
38
|
-
this.login_timer = null;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
34
|
export class IcqqEndpoint extends Endpoint {
|
|
42
35
|
client;
|
|
43
36
|
#logger;
|
|
@@ -132,13 +125,31 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
132
125
|
constructor(options) {
|
|
133
126
|
super();
|
|
134
127
|
const nativeConfig = resolveNativeClientConfig(options.config);
|
|
135
|
-
this.client = new ManagedIcqqClient(Number(options.config.id), nativeConfig);
|
|
128
|
+
this.client = new ManagedIcqqClient(Number(options.config.id), nativeConfig, (operation, error) => this.#handleBackgroundFailure(operation, error));
|
|
136
129
|
this.#contentResolver = new IcqqContentResolver(this.client);
|
|
137
130
|
this.content = this.#contentResolver.port;
|
|
138
131
|
this.#logger = getAdapterLogger('icqq', options.config.id);
|
|
139
132
|
this.#options = options;
|
|
140
133
|
this.endpointName = options.config.id;
|
|
141
134
|
}
|
|
135
|
+
#handleBackgroundFailure(operation, error) {
|
|
136
|
+
if (!this.#started || this.#stopped || this.#retiring)
|
|
137
|
+
return;
|
|
138
|
+
this.#logger.warn(formatCompact({
|
|
139
|
+
op: `icqq_${operation}_failed`, endpoint: this.endpointName,
|
|
140
|
+
error: error instanceof Error ? error.message
|
|
141
|
+
: error && typeof error === 'object' && 'message' in error && typeof error.message === 'string' ? error.message
|
|
142
|
+
: typeof error === 'string' ? error : 'Unknown ICQQ SDK background failure',
|
|
143
|
+
...(error && typeof error === 'object' && 'code' in error && typeof error.code === 'number' ? { code: error.code } : {}),
|
|
144
|
+
}));
|
|
145
|
+
if (operation === 'sso-heartbeat' && !this.client.isOnline())
|
|
146
|
+
return;
|
|
147
|
+
if (operation === 'login' && !this.#everOnline)
|
|
148
|
+
return;
|
|
149
|
+
// Delegate to the SDK's existing transport termination, offline event and
|
|
150
|
+
// reconn_interval policy. No account-level logout or second retry timer.
|
|
151
|
+
this.client.emit('internal.error.network', -2, `ICQQ ${operation} failed`);
|
|
152
|
+
}
|
|
142
153
|
#ignoreReactionAck(action, task) {
|
|
143
154
|
void Promise.resolve(task).catch((error) => {
|
|
144
155
|
this.#logger.debug(formatCompact({
|
|
@@ -278,7 +289,7 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
278
289
|
throw new EndpointDeliveryError('endpoint_disconnected', 'icqq endpoint 未连接', 'not_sent');
|
|
279
290
|
const mediaMode = resolveIcqqOutboundMediaMode(this.#options.config);
|
|
280
291
|
const prepared = isIcqqSegmentPayload(payload)
|
|
281
|
-
? prepareIcqqOutboundMedia(payload, mediaMode)
|
|
292
|
+
? prepareIcqqOutboundMedia(normalizeIcqqImageMedia(payload), mediaMode)
|
|
282
293
|
: { content: payload, dispose() { } };
|
|
283
294
|
try {
|
|
284
295
|
const message = formatOutboundBody(prepared.content);
|
|
@@ -513,6 +524,17 @@ export class IcqqEndpoint extends Endpoint {
|
|
|
513
524
|
try {
|
|
514
525
|
this.#resolveStartOnline?.();
|
|
515
526
|
this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'online');
|
|
527
|
+
try {
|
|
528
|
+
// transport 重连不清除(账号不变);stop/retire 由基类撤销登记。上报失败不影响上线流程。
|
|
529
|
+
this.reportSelfUserId(resolveIcqqSelfUserId(this.client));
|
|
530
|
+
}
|
|
531
|
+
catch (error) {
|
|
532
|
+
this.#logger.warn(formatCompact({
|
|
533
|
+
op: 'icqq_report_self_user_id_failed',
|
|
534
|
+
endpoint: this.endpointName,
|
|
535
|
+
error: error instanceof Error ? error.message : String(error),
|
|
536
|
+
}));
|
|
537
|
+
}
|
|
516
538
|
this.#logSystemEvent('online', event);
|
|
517
539
|
}
|
|
518
540
|
catch (error) {
|
package/lib/icqq-inbound.d.ts
CHANGED
|
@@ -134,6 +134,10 @@ export declare function unwrapIcqqEventPayload(event: {
|
|
|
134
134
|
export declare function resolveIcqqInboundUserId(data: Record<string, unknown>): number | undefined;
|
|
135
135
|
export declare function isIcqqMessagePostType(data: unknown): data is IcqqMessageEvent;
|
|
136
136
|
export declare function shouldSkipSelfInboundMessage(data: IcqqMessageEvent): boolean;
|
|
137
|
+
/** 登录后的机器人 QQ 号;仅正整数有效(mock/未登录的 0、NaN、空均视为未知)。 */
|
|
138
|
+
export declare function resolveIcqqSelfUserId(client: {
|
|
139
|
+
uin?: number | string;
|
|
140
|
+
}): string | undefined;
|
|
137
141
|
/** 群临时会话私聊:icqq message_type=private & sub_type=group */
|
|
138
142
|
export declare function isIcqqGroupTempPrivateMessage(data: IcqqMessageEvent): boolean;
|
|
139
143
|
export declare function resolveChannelFromIcqqMessage(data: IcqqMessageEvent): {
|
package/lib/icqq-inbound.js
CHANGED
|
@@ -154,6 +154,16 @@ export function shouldSkipSelfInboundMessage(data) {
|
|
|
154
154
|
return false;
|
|
155
155
|
return Number(data.self_id) === Number(data.user_id);
|
|
156
156
|
}
|
|
157
|
+
/** 登录后的机器人 QQ 号;仅正整数有效(mock/未登录的 0、NaN、空均视为未知)。 */
|
|
158
|
+
export function resolveIcqqSelfUserId(client) {
|
|
159
|
+
const raw = client?.uin;
|
|
160
|
+
if (typeof raw !== "number" && typeof raw !== "string")
|
|
161
|
+
return undefined;
|
|
162
|
+
if (typeof raw === "string" && !/^\d+$/.test(raw.trim()))
|
|
163
|
+
return undefined;
|
|
164
|
+
const uin = Number(raw);
|
|
165
|
+
return Number.isSafeInteger(uin) && uin > 0 ? String(uin) : undefined;
|
|
166
|
+
}
|
|
157
167
|
/** 群临时会话私聊:icqq message_type=private & sub_type=group */
|
|
158
168
|
export function isIcqqGroupTempPrivateMessage(data) {
|
|
159
169
|
const messageType = data.message_type ??
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Client, type Config } from '@icqqjs/icqq';
|
|
2
|
+
export type IcqqBackgroundOperation = 'sso-heartbeat' | 'login';
|
|
3
|
+
/** Own SDK promises also invoked by timers that discard their return value. */
|
|
4
|
+
export declare class ManagedIcqqClient extends Client {
|
|
5
|
+
#private;
|
|
6
|
+
constructor(uin: number, config: Config, onFailure: (operation: IcqqBackgroundOperation, error: unknown) => void);
|
|
7
|
+
sendSsoHeartBeat(): false | Promise<unknown>;
|
|
8
|
+
login(password?: string | Buffer): Promise<void>;
|
|
9
|
+
login(uin?: number, password?: string | Buffer): Promise<void>;
|
|
10
|
+
cancelReconnect(): void;
|
|
11
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Client } from '@icqqjs/icqq';
|
|
2
|
+
/** Own SDK promises also invoked by timers that discard their return value. */
|
|
3
|
+
export class ManagedIcqqClient extends Client {
|
|
4
|
+
#onFailure;
|
|
5
|
+
constructor(uin, config, onFailure) {
|
|
6
|
+
super(uin, config);
|
|
7
|
+
this.#onFailure = onFailure;
|
|
8
|
+
}
|
|
9
|
+
sendSsoHeartBeat() {
|
|
10
|
+
const task = super.sendSsoHeartBeat();
|
|
11
|
+
if (task !== false)
|
|
12
|
+
this.#observe('sso-heartbeat', task);
|
|
13
|
+
return task;
|
|
14
|
+
}
|
|
15
|
+
login(uinOrPassword, password) {
|
|
16
|
+
const task = typeof uinOrPassword === 'number' || password !== undefined
|
|
17
|
+
? super.login(typeof uinOrPassword === 'number' ? uinOrPassword : undefined, password)
|
|
18
|
+
: super.login(uinOrPassword);
|
|
19
|
+
this.#observe('login', task);
|
|
20
|
+
return task;
|
|
21
|
+
}
|
|
22
|
+
#observe(operation, task) {
|
|
23
|
+
// Observe the original promise instead of replacing it: explicit SDK
|
|
24
|
+
// callers still receive the rejection and may fail their own operation.
|
|
25
|
+
void task.catch(error => {
|
|
26
|
+
try {
|
|
27
|
+
this.#onFailure(operation, error);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
try {
|
|
31
|
+
this.logger.error('ICQQ background failure handler failed');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// The rejection is already observed. A broken diagnostic sink must
|
|
35
|
+
// not create another unhandled promise or expose the error payload.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
cancelReconnect() {
|
|
41
|
+
if (!this.login_timer)
|
|
42
|
+
return;
|
|
43
|
+
clearTimeout(this.login_timer);
|
|
44
|
+
this.login_timer = null;
|
|
45
|
+
}
|
|
46
|
+
}
|
package/lib/protocol.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Sendable } from '@icqqjs/icqq';
|
|
2
|
-
import type
|
|
3
|
-
import type { MessageSegment } from "zhin.js";
|
|
2
|
+
import { type ConversationRef } from '@zhin.js/im-contract';
|
|
3
|
+
import type { MessageSegment, SendContent } from "zhin.js";
|
|
4
4
|
export type ActionResult = {
|
|
5
5
|
id: string;
|
|
6
6
|
ok: boolean;
|
|
@@ -238,6 +238,8 @@ export declare function resolveIcqqConfig(config: IcqqEndpointConfig): ResolvedI
|
|
|
238
238
|
* 入站归一化 → ConversationRef:群临时会话(temp)映射为群容器内的 private
|
|
239
239
|
* 会话(parent kind 'group');频道消息的所属 guild 进 `parent`(kind 'channel')。
|
|
240
240
|
*/
|
|
241
|
+
/** ICQQ 平台标识,与 Endpoint identity.adapter / clientAdapter 保持一致。 */
|
|
242
|
+
export declare const ICQQ_ADAPTER_NAME: "icqq";
|
|
241
243
|
export declare function icqqInboundConversation(endpointKey: string, input: {
|
|
242
244
|
readonly channelType: 'private' | 'group' | 'channel';
|
|
243
245
|
readonly channelId: string;
|
|
@@ -249,6 +251,8 @@ export declare function icqqInboundConversation(endpointKey: string, input: {
|
|
|
249
251
|
* 群容器(parent kind 'group')内的 private 会话即 ICQQ 群临时会话(temp)。
|
|
250
252
|
*/
|
|
251
253
|
export declare function icqqOutboundTarget(conversation: ConversationRef): ParsedIcqqSendTarget;
|
|
254
|
+
/** Normalize inline image bytes before either temporary-file or native-base64 delivery. */
|
|
255
|
+
export declare function normalizeIcqqImageMedia(content: SendContent): SendContent;
|
|
252
256
|
/**
|
|
253
257
|
* Project canonical Zhin segments directly into the native ICQQ Sendable
|
|
254
258
|
* algebra. ICQQ's direct client does not parse Zhin's historical `[image:x]`
|
package/lib/protocol.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isMediaRef } from '@zhin.js/im-contract';
|
|
1
2
|
import { resolveIcqqMediaFile } from "./cq-message.js";
|
|
2
3
|
export { resolveIcqqInboundMessageId } from "./icqq-inbound.js";
|
|
3
4
|
/**
|
|
@@ -211,8 +212,10 @@ export function resolveIcqqConfig(config) {
|
|
|
211
212
|
* 入站归一化 → ConversationRef:群临时会话(temp)映射为群容器内的 private
|
|
212
213
|
* 会话(parent kind 'group');频道消息的所属 guild 进 `parent`(kind 'channel')。
|
|
213
214
|
*/
|
|
215
|
+
/** ICQQ 平台标识,与 Endpoint identity.adapter / clientAdapter 保持一致。 */
|
|
216
|
+
export const ICQQ_ADAPTER_NAME = 'icqq';
|
|
214
217
|
export function icqqInboundConversation(endpointKey, input) {
|
|
215
|
-
const endpoint = { id: endpointKey, adapter:
|
|
218
|
+
const endpoint = { id: endpointKey, adapter: ICQQ_ADAPTER_NAME };
|
|
216
219
|
if (input.channelType === 'private' && input.channelParentGroupId) {
|
|
217
220
|
return {
|
|
218
221
|
endpoint,
|
|
@@ -263,6 +266,24 @@ const ICQQ_STANDALONE_ELEMENTS = new Set([
|
|
|
263
266
|
'share',
|
|
264
267
|
'long_msg',
|
|
265
268
|
]);
|
|
269
|
+
/** Normalize inline image bytes before either temporary-file or native-base64 delivery. */
|
|
270
|
+
export function normalizeIcqqImageMedia(content) {
|
|
271
|
+
if (Array.isArray(content))
|
|
272
|
+
return content.map(normalizeIcqqImageMedia);
|
|
273
|
+
if (!isWireSegment(content) || content.type !== 'image')
|
|
274
|
+
return content;
|
|
275
|
+
const data = normalizeIcqqImageData(content.data);
|
|
276
|
+
return data === content.data ? content : { ...content, data };
|
|
277
|
+
}
|
|
278
|
+
function normalizeIcqqImageData(data) {
|
|
279
|
+
const media = data?.media;
|
|
280
|
+
if (!isMediaRef(media) || !/^data:/i.test(media.value))
|
|
281
|
+
return data;
|
|
282
|
+
const parsed = /^data:(image\/[^;,]+)(?:;[^;,]*)*;base64,([\s\S]+)$/i.exec(media.value);
|
|
283
|
+
if (!parsed)
|
|
284
|
+
throw new TypeError('ICQQ image data URL must contain base64 image data');
|
|
285
|
+
return { ...data, media: { ...media, kind: 'base64', value: parsed[2], mime_type: (media.mime_type ?? parsed[1]).toLowerCase() } };
|
|
286
|
+
}
|
|
266
287
|
/**
|
|
267
288
|
* Project canonical Zhin segments directly into the native ICQQ Sendable
|
|
268
289
|
* algebra. ICQQ's direct client does not parse Zhin's historical `[image:x]`
|
|
@@ -306,7 +327,7 @@ export function formatOutboundBody(payload) {
|
|
|
306
327
|
return { type: 'at', qq };
|
|
307
328
|
}
|
|
308
329
|
case 'image': {
|
|
309
|
-
const arg = resolveIcqqMediaFile(item.data);
|
|
330
|
+
const arg = resolveIcqqMediaFile(normalizeIcqqImageData(item.data));
|
|
310
331
|
if (!arg)
|
|
311
332
|
throw new TypeError('ICQQ image segment requires canonical data.media');
|
|
312
333
|
return { type: 'image', file: arg };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-icqq",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.8",
|
|
4
4
|
"description": "Zhin.js ICQQ adapter — direct connection to @icqqjs/icqq",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -34,24 +34,24 @@
|
|
|
34
34
|
"directory": "plugins/adapters/icqq"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@zhin.js/adapter": "1.1.
|
|
38
|
-
"@zhin.js/agent-feature": "1.1.
|
|
39
|
-
"@zhin.js/core": "1.1.
|
|
40
|
-
"@zhin.js/feature-kit": "1.1.
|
|
37
|
+
"@zhin.js/adapter": "1.1.18",
|
|
38
|
+
"@zhin.js/agent-feature": "1.1.5",
|
|
39
|
+
"@zhin.js/core": "1.1.43",
|
|
40
|
+
"@zhin.js/feature-kit": "1.1.4",
|
|
41
41
|
"@zhin.js/im-contract": "1.1.3",
|
|
42
42
|
"@zhin.js/logger": "1.1.1",
|
|
43
|
-
"@zhin.js/permission": "1.1.
|
|
44
|
-
"@zhin.js/skill": "1.1.
|
|
45
|
-
"@zhin.js/tool": "1.1.
|
|
43
|
+
"@zhin.js/permission": "1.1.5",
|
|
44
|
+
"@zhin.js/skill": "1.1.5",
|
|
45
|
+
"@zhin.js/tool": "1.1.5"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"@icqqjs/icqq": "^1.12.2",
|
|
49
49
|
"zod": "^4.0.0",
|
|
50
|
-
"@zhin.js/adapter": "^1.1.
|
|
51
|
-
"@zhin.js/command": "^1.1.
|
|
52
|
-
"@zhin.js/core": "^1.1.
|
|
53
|
-
"@zhin.js/prompt-section": "^1.1.
|
|
54
|
-
"zhin.js": "^1.1.
|
|
50
|
+
"@zhin.js/adapter": "^1.1.18",
|
|
51
|
+
"@zhin.js/command": "^1.1.6",
|
|
52
|
+
"@zhin.js/core": "^1.1.43",
|
|
53
|
+
"@zhin.js/prompt-section": "^1.1.4",
|
|
54
|
+
"zhin.js": "^1.1.8"
|
|
55
55
|
},
|
|
56
56
|
"peerDependenciesMeta": {
|
|
57
57
|
"@icqqjs/icqq": {
|
|
@@ -76,8 +76,8 @@
|
|
|
76
76
|
"typescript": "^6.0.3",
|
|
77
77
|
"vitest": "^4.1.10",
|
|
78
78
|
"zod": "^4.4.3",
|
|
79
|
-
"@zhin.js/prompt-section": "1.1.
|
|
80
|
-
"zhin.js": "1.1.
|
|
79
|
+
"@zhin.js/prompt-section": "1.1.4",
|
|
80
|
+
"zhin.js": "1.1.8"
|
|
81
81
|
},
|
|
82
82
|
"files": [
|
|
83
83
|
"adapters",
|
package/src/endpoint.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
messageLookupFromIcqqSource,
|
|
35
35
|
resolveIcqqQuoteIdFromEvent,
|
|
36
36
|
resolveIcqqInboundMedia,
|
|
37
|
+
resolveIcqqSelfUserId,
|
|
37
38
|
shouldSkipSelfInboundMessage,
|
|
38
39
|
type IcqqMessageEvent,
|
|
39
40
|
} from './icqq-inbound.js';
|
|
@@ -52,12 +53,14 @@ import {
|
|
|
52
53
|
import {
|
|
53
54
|
formatInboundContent,
|
|
54
55
|
formatOutboundBody,
|
|
56
|
+
normalizeIcqqImageMedia,
|
|
55
57
|
icqqInboundConversation,
|
|
56
58
|
icqqOutboundTarget,
|
|
57
59
|
type IcqqInboundMessage,
|
|
58
60
|
type ResolvedIcqqConfig,
|
|
59
61
|
} from './protocol.js';
|
|
60
62
|
import type { SystemMessage as IcqqSystemMessage } from './types.js';
|
|
63
|
+
import { ManagedIcqqClient, type IcqqBackgroundOperation } from './managed-client.js';
|
|
61
64
|
|
|
62
65
|
export interface IcqqEndpointOptions {
|
|
63
66
|
readonly id: CapabilityId;
|
|
@@ -84,14 +87,6 @@ const BOUND_EVENTS = [
|
|
|
84
87
|
'system.login.auth',
|
|
85
88
|
] as const;
|
|
86
89
|
|
|
87
|
-
class ManagedIcqqClient extends Client {
|
|
88
|
-
cancelReconnect(): void {
|
|
89
|
-
if (!this.login_timer) return;
|
|
90
|
-
clearTimeout(this.login_timer);
|
|
91
|
-
this.login_timer = null;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
90
|
export class IcqqEndpoint extends Endpoint<Client> {
|
|
96
91
|
readonly client: ManagedIcqqClient;
|
|
97
92
|
readonly #logger: ReturnType<typeof getAdapterLogger>;
|
|
@@ -193,7 +188,8 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
193
188
|
constructor(options: IcqqEndpointOptions) {
|
|
194
189
|
super();
|
|
195
190
|
const nativeConfig = resolveNativeClientConfig(options.config);
|
|
196
|
-
this.client = new ManagedIcqqClient(Number(options.config.id), nativeConfig
|
|
191
|
+
this.client = new ManagedIcqqClient(Number(options.config.id), nativeConfig,
|
|
192
|
+
(operation, error) => this.#handleBackgroundFailure(operation, error));
|
|
197
193
|
this.#contentResolver = new IcqqContentResolver(this.client);
|
|
198
194
|
this.content = this.#contentResolver.port;
|
|
199
195
|
this.#logger = getAdapterLogger('icqq', options.config.id);
|
|
@@ -201,6 +197,22 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
201
197
|
this.endpointName = options.config.id;
|
|
202
198
|
}
|
|
203
199
|
|
|
200
|
+
#handleBackgroundFailure(operation: IcqqBackgroundOperation, error: unknown): void {
|
|
201
|
+
if (!this.#started || this.#stopped || this.#retiring) return;
|
|
202
|
+
this.#logger.warn(formatCompact({
|
|
203
|
+
op: `icqq_${operation}_failed`, endpoint: this.endpointName,
|
|
204
|
+
error: error instanceof Error ? error.message
|
|
205
|
+
: error && typeof error === 'object' && 'message' in error && typeof error.message === 'string' ? error.message
|
|
206
|
+
: typeof error === 'string' ? error : 'Unknown ICQQ SDK background failure',
|
|
207
|
+
...(error && typeof error === 'object' && 'code' in error && typeof error.code === 'number' ? { code: error.code } : {}),
|
|
208
|
+
}));
|
|
209
|
+
if (operation === 'sso-heartbeat' && !this.client.isOnline()) return;
|
|
210
|
+
if (operation === 'login' && !this.#everOnline) return;
|
|
211
|
+
// Delegate to the SDK's existing transport termination, offline event and
|
|
212
|
+
// reconn_interval policy. No account-level logout or second retry timer.
|
|
213
|
+
this.client.emit('internal.error.network', -2, `ICQQ ${operation} failed`);
|
|
214
|
+
}
|
|
215
|
+
|
|
204
216
|
#ignoreReactionAck(action: 'add' | 'remove', task: Promise<unknown> | unknown): void {
|
|
205
217
|
void Promise.resolve(task).catch((error) => {
|
|
206
218
|
this.#logger.debug(formatCompact({
|
|
@@ -335,7 +347,7 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
335
347
|
if (!this.#started) throw new EndpointDeliveryError('endpoint_disconnected', 'icqq endpoint 未连接', 'not_sent');
|
|
336
348
|
const mediaMode = resolveIcqqOutboundMediaMode(this.#options.config);
|
|
337
349
|
const prepared = isIcqqSegmentPayload(payload)
|
|
338
|
-
? prepareIcqqOutboundMedia(payload, mediaMode)
|
|
350
|
+
? prepareIcqqOutboundMedia(normalizeIcqqImageMedia(payload), mediaMode)
|
|
339
351
|
: { content: payload, dispose() {} };
|
|
340
352
|
try {
|
|
341
353
|
const message = formatOutboundBody(prepared.content);
|
|
@@ -581,6 +593,16 @@ export class IcqqEndpoint extends Endpoint<Client> {
|
|
|
581
593
|
try {
|
|
582
594
|
this.#resolveStartOnline?.();
|
|
583
595
|
this.#options.loginAssist.cancelOwned(this.#loginAssistOwner, 'online');
|
|
596
|
+
try {
|
|
597
|
+
// transport 重连不清除(账号不变);stop/retire 由基类撤销登记。上报失败不影响上线流程。
|
|
598
|
+
this.reportSelfUserId(resolveIcqqSelfUserId(this.client));
|
|
599
|
+
} catch (error) {
|
|
600
|
+
this.#logger.warn(formatCompact({
|
|
601
|
+
op: 'icqq_report_self_user_id_failed',
|
|
602
|
+
endpoint: this.endpointName,
|
|
603
|
+
error: error instanceof Error ? error.message : String(error),
|
|
604
|
+
}));
|
|
605
|
+
}
|
|
584
606
|
this.#logSystemEvent('online', event);
|
|
585
607
|
} catch (error) {
|
|
586
608
|
this.#logger.warn(formatCompact({
|
package/src/icqq-inbound.ts
CHANGED
|
@@ -282,6 +282,15 @@ export function shouldSkipSelfInboundMessage(
|
|
|
282
282
|
return Number(data.self_id) === Number(data.user_id);
|
|
283
283
|
}
|
|
284
284
|
|
|
285
|
+
/** 登录后的机器人 QQ 号;仅正整数有效(mock/未登录的 0、NaN、空均视为未知)。 */
|
|
286
|
+
export function resolveIcqqSelfUserId(client: { uin?: number | string }): string | undefined {
|
|
287
|
+
const raw = client?.uin;
|
|
288
|
+
if (typeof raw !== "number" && typeof raw !== "string") return undefined;
|
|
289
|
+
if (typeof raw === "string" && !/^\d+$/.test(raw.trim())) return undefined;
|
|
290
|
+
const uin = Number(raw);
|
|
291
|
+
return Number.isSafeInteger(uin) && uin > 0 ? String(uin) : undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
285
294
|
/** 群临时会话私聊:icqq message_type=private & sub_type=group */
|
|
286
295
|
export function isIcqqGroupTempPrivateMessage(data: IcqqMessageEvent): boolean {
|
|
287
296
|
const messageType =
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Client, type Config } from '@icqqjs/icqq';
|
|
2
|
+
|
|
3
|
+
export type IcqqBackgroundOperation = 'sso-heartbeat' | 'login';
|
|
4
|
+
|
|
5
|
+
/** Own SDK promises also invoked by timers that discard their return value. */
|
|
6
|
+
export class ManagedIcqqClient extends Client {
|
|
7
|
+
readonly #onFailure: (operation: IcqqBackgroundOperation, error: unknown) => void;
|
|
8
|
+
|
|
9
|
+
constructor(uin: number, config: Config, onFailure: (operation: IcqqBackgroundOperation, error: unknown) => void) {
|
|
10
|
+
super(uin, config);
|
|
11
|
+
this.#onFailure = onFailure;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
override sendSsoHeartBeat(): false | Promise<unknown> {
|
|
15
|
+
const task = super.sendSsoHeartBeat();
|
|
16
|
+
if (task !== false) this.#observe('sso-heartbeat', task);
|
|
17
|
+
return task;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
override login(password?: string | Buffer): Promise<void>;
|
|
21
|
+
override login(uin?: number, password?: string | Buffer): Promise<void>;
|
|
22
|
+
override login(uinOrPassword?: number | string | Buffer, password?: string | Buffer): Promise<void> {
|
|
23
|
+
const task = typeof uinOrPassword === 'number' || password !== undefined
|
|
24
|
+
? super.login(typeof uinOrPassword === 'number' ? uinOrPassword : undefined, password)
|
|
25
|
+
: super.login(uinOrPassword);
|
|
26
|
+
this.#observe('login', task);
|
|
27
|
+
return task;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#observe(operation: IcqqBackgroundOperation, task: Promise<unknown>): void {
|
|
31
|
+
// Observe the original promise instead of replacing it: explicit SDK
|
|
32
|
+
// callers still receive the rejection and may fail their own operation.
|
|
33
|
+
void task.catch(error => {
|
|
34
|
+
try {
|
|
35
|
+
this.#onFailure(operation, error);
|
|
36
|
+
} catch {
|
|
37
|
+
try {
|
|
38
|
+
this.logger.error('ICQQ background failure handler failed');
|
|
39
|
+
} catch {
|
|
40
|
+
// The rejection is already observed. A broken diagnostic sink must
|
|
41
|
+
// not create another unhandled promise or expose the error payload.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
cancelReconnect(): void {
|
|
48
|
+
if (!this.login_timer) return;
|
|
49
|
+
clearTimeout(this.login_timer);
|
|
50
|
+
this.login_timer = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { MessageElem, Sendable, ShareElem } from '@icqqjs/icqq';
|
|
2
|
-
import type
|
|
3
|
-
import type { MessageSegment } from "zhin.js";
|
|
2
|
+
import { isMediaRef, type ConversationRef } from '@zhin.js/im-contract';
|
|
3
|
+
import type { MessageSegment, SendContent } from "zhin.js";
|
|
4
4
|
import { resolveIcqqMediaFile } from "./cq-message.js";
|
|
5
5
|
|
|
6
6
|
export type ActionResult = {
|
|
@@ -333,13 +333,16 @@ export function resolveIcqqConfig(config: IcqqEndpointConfig): ResolvedIcqqConfi
|
|
|
333
333
|
* 入站归一化 → ConversationRef:群临时会话(temp)映射为群容器内的 private
|
|
334
334
|
* 会话(parent kind 'group');频道消息的所属 guild 进 `parent`(kind 'channel')。
|
|
335
335
|
*/
|
|
336
|
+
/** ICQQ 平台标识,与 Endpoint identity.adapter / clientAdapter 保持一致。 */
|
|
337
|
+
export const ICQQ_ADAPTER_NAME = 'icqq' as const;
|
|
338
|
+
|
|
336
339
|
export function icqqInboundConversation(endpointKey: string, input: {
|
|
337
340
|
readonly channelType: 'private' | 'group' | 'channel';
|
|
338
341
|
readonly channelId: string;
|
|
339
342
|
readonly channelParentGroupId?: string;
|
|
340
343
|
readonly guildId?: string;
|
|
341
344
|
}): ConversationRef {
|
|
342
|
-
const endpoint = { id: endpointKey, adapter:
|
|
345
|
+
const endpoint = { id: endpointKey, adapter: ICQQ_ADAPTER_NAME };
|
|
343
346
|
if (input.channelType === 'private' && input.channelParentGroupId) {
|
|
344
347
|
return {
|
|
345
348
|
endpoint,
|
|
@@ -393,6 +396,22 @@ const ICQQ_STANDALONE_ELEMENTS = new Set([
|
|
|
393
396
|
'long_msg',
|
|
394
397
|
] as const);
|
|
395
398
|
|
|
399
|
+
/** Normalize inline image bytes before either temporary-file or native-base64 delivery. */
|
|
400
|
+
export function normalizeIcqqImageMedia(content: SendContent): SendContent {
|
|
401
|
+
if (Array.isArray(content)) return content.map(normalizeIcqqImageMedia);
|
|
402
|
+
if (!isWireSegment(content) || content.type !== 'image') return content;
|
|
403
|
+
const data = normalizeIcqqImageData(content.data);
|
|
404
|
+
return data === content.data ? content : { ...content, data } as MessageSegment;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function normalizeIcqqImageData(data: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
|
|
408
|
+
const media = data?.media;
|
|
409
|
+
if (!isMediaRef(media) || !/^data:/i.test(media.value)) return data;
|
|
410
|
+
const parsed = /^data:(image\/[^;,]+)(?:;[^;,]*)*;base64,([\s\S]+)$/i.exec(media.value);
|
|
411
|
+
if (!parsed) throw new TypeError('ICQQ image data URL must contain base64 image data');
|
|
412
|
+
return { ...data, media: { ...media, kind: 'base64', value: parsed[2]!, mime_type: (media.mime_type ?? parsed[1]!).toLowerCase() } };
|
|
413
|
+
}
|
|
414
|
+
|
|
396
415
|
/**
|
|
397
416
|
* Project canonical Zhin segments directly into the native ICQQ Sendable
|
|
398
417
|
* algebra. ICQQ's direct client does not parse Zhin's historical `[image:x]`
|
|
@@ -433,7 +452,7 @@ export function formatOutboundBody(payload: unknown): Sendable {
|
|
|
433
452
|
return { type: 'at', qq };
|
|
434
453
|
}
|
|
435
454
|
case 'image': {
|
|
436
|
-
const arg = resolveIcqqMediaFile(item.data);
|
|
455
|
+
const arg = resolveIcqqMediaFile(normalizeIcqqImageData(item.data));
|
|
437
456
|
if (!arg) throw new TypeError('ICQQ image segment requires canonical data.media');
|
|
438
457
|
return { type: 'image', file: arg };
|
|
439
458
|
}
|