@zhin.js/adapter-icqq 7.0.0 → 7.0.2
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 +50 -0
- package/adapters/icqq.js +89 -0
- package/adapters/icqq.ts +15 -1
- package/commands/endpoint/add/[name:string=].js +3 -0
- package/commands/endpoint/add/[name:string=].ts +3 -0
- package/commands/endpoint/list.js +3 -0
- package/commands/endpoint/list.ts +3 -0
- package/commands/endpoint/remove/[name:string].js +3 -0
- package/commands/endpoint/remove/[name:string].ts +3 -0
- package/lib/endpoint.js +84 -8
- package/lib/icqq-endpoint-commands.d.ts +1 -0
- package/lib/icqq-endpoint-commands.js +35 -0
- package/lib/icqq-runtime-state.d.ts +1 -0
- package/lib/icqq-runtime-state.js +6 -0
- package/lib/ipc-client.js +2 -2
- package/lib/outbound-media.d.ts +3 -2
- package/lib/outbound-media.js +17 -5
- package/lib/protocol.d.ts +6 -0
- package/lib/protocol.js +20 -3
- package/package.json +19 -13
- package/plugin.js +37 -0
- package/src/endpoint.ts +91 -8
- package/src/icqq-endpoint-commands.ts +37 -0
- package/src/icqq-runtime-state.ts +7 -0
- package/src/ipc-client.ts +2 -2
- package/src/outbound-media.ts +23 -5
- package/src/protocol.ts +25 -3
- package/plugin.ts +0 -32
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
# @zhin.js/adapter-icqq
|
|
2
2
|
|
|
3
|
+
## 7.0.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d5cd4aa: Publish Plugin Runtime entry points and convention modules as JavaScript so
|
|
8
|
+
installed npm plugins load on Node without TypeScript stripping. Workspace
|
|
9
|
+
development continues to prefer TypeScript sources for local HMR.
|
|
10
|
+
|
|
11
|
+
Remove the unconsumed legacy game hub APIs from game-kit; game navigation and
|
|
12
|
+
records now use ordinary convention commands owned by the game hub plugin.
|
|
13
|
+
|
|
14
|
+
- Updated dependencies [d5cd4aa]
|
|
15
|
+
- @zhin.js/command@1.0.4
|
|
16
|
+
- zhin.js@5.0.2
|
|
17
|
+
- @zhin.js/adapter@1.1.2
|
|
18
|
+
- @zhin.js/core@1.4.2
|
|
19
|
+
- @zhin.js/agent@1.0.7
|
|
20
|
+
|
|
21
|
+
## 7.0.1
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- 74b035c: endpoint 管理命令扩展至 18/20 适配器:kook / discord / github(private_key 支持内联文件路径)/ icqq(bindFlow 登记式 add + `icqq login` 引导)/ dingtalk / lark / line / satori / wechat-mp / wecom / weixin-ilink 接入 `endpoint list/add/remove`(字段对齐各自 schema,凭据写 `.env`)。email(smtp/imap 嵌套对象)与 sandbox(无凭据)暂不接。
|
|
26
|
+
- 6cb6152: 统一消息元素通道(UNI-Channel)落地:
|
|
27
|
+
|
|
28
|
+
- **入站契约**:`IncomingMessage.segments`(canonical Segment[],与 content 纯文本视图同源双轨),Message 透传;AI 兜底链路经 `collectSegmentMedia` 把图片/语音/视频/文件 MediaRef 写入会话 extra——多模态输入不再丢失。
|
|
29
|
+
- **出站协商**:`normalizeOutboundPayload` 升级全量 canonical 归一(复用 generic-segment-mapper),html→image 按端点 `segments.outboundMedia` 声明降级(base64 直发 / url-or-text / passthrough 自行物化);`MediaRef.kind` 新增 `'file'` 承载平台不透明引用(file_id/resource_id)。
|
|
30
|
+
- **能力声明**:`defineAdapter.segments` policy(outboundMedia / interactive),三道段门禁复活(探测点改 adapters/\*.ts,豁免名单渐进收敛)。
|
|
31
|
+
- **首批迁移**:icqq 全保真出入站(CQ ↔ canonical,quote→reply 段);milky/telegram/discord 入站媒体段恢复(附件/贴纸/callback action);napcat/onebot11/onebot12 出站 canonical→OneBot 数组段;wechat-mp/wecom `/cgi-bin/media/upload` 与 lark `/im/v1/images` 上传通路(base64/URL 图片不再静默丢图,失败降级文本)。
|
|
32
|
+
|
|
33
|
+
- Updated dependencies [cdf64e7]
|
|
34
|
+
- Updated dependencies [2d0a159]
|
|
35
|
+
- Updated dependencies [5691aba]
|
|
36
|
+
- Updated dependencies [078e3f7]
|
|
37
|
+
- Updated dependencies [50497a5]
|
|
38
|
+
- Updated dependencies [9c997b2]
|
|
39
|
+
- Updated dependencies [09d4f25]
|
|
40
|
+
- Updated dependencies [43485a9]
|
|
41
|
+
- Updated dependencies [f0ec5ab]
|
|
42
|
+
- Updated dependencies [3e925d0]
|
|
43
|
+
- Updated dependencies [fa66c4c]
|
|
44
|
+
- Updated dependencies [fa66c4c]
|
|
45
|
+
- Updated dependencies [6cb6152]
|
|
46
|
+
- @zhin.js/command@1.0.3
|
|
47
|
+
- @zhin.js/agent@1.0.6
|
|
48
|
+
- @zhin.js/plugin-runtime@1.1.1
|
|
49
|
+
- zhin.js@5.0.1
|
|
50
|
+
- @zhin.js/adapter@1.1.1
|
|
51
|
+
- @zhin.js/core@1.4.1
|
|
52
|
+
|
|
3
53
|
## 7.0.0
|
|
4
54
|
|
|
5
55
|
### Patch Changes
|
package/adapters/icqq.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
/**
|
|
3
|
+
* Convention entry: discover `adapters/icqq.ts` → defineAdapter.
|
|
4
|
+
*/
|
|
5
|
+
import { defineAdapter } from '@zhin.js/adapter';
|
|
6
|
+
import { messageGatewayToken } from '@zhin.js/core/runtime';
|
|
7
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
8
|
+
import { databaseHostToken, INBOX_TABLE_NOTICE, INBOX_TABLE_REQUEST, runtimeEventPublisherToken, } from '@zhin.js/plugin-runtime';
|
|
9
|
+
import { IcqqIpcEndpoint } from "../lib/endpoint.js";
|
|
10
|
+
import { icqqRuntimeStateToken } from "../lib/icqq-runtime-state.js";
|
|
11
|
+
import { resolveIcqqConfig, } from "../lib/protocol.js";
|
|
12
|
+
export { IcqqIpcEndpoint } from "../lib/endpoint.js";
|
|
13
|
+
const logger = getLogger('icqq');
|
|
14
|
+
export default defineAdapter({
|
|
15
|
+
capabilities: ['inbound', 'outbound'],
|
|
16
|
+
// 统一消息元素通道(UNI-Channel):端点可消费 base64 / url / upload 媒体
|
|
17
|
+
// (path 由出站物化按 file/base64 模式转换,非声明消费形态);
|
|
18
|
+
// 卡片/按钮等富交互段无原生承载,降级纯文本。
|
|
19
|
+
segments: {
|
|
20
|
+
outboundMedia: ['base64', 'url', 'upload'],
|
|
21
|
+
interactive: 'text',
|
|
22
|
+
},
|
|
23
|
+
create(context) {
|
|
24
|
+
// Client-library / IPC daemon path — no httpHostToken.
|
|
25
|
+
// Console loginAssist + host-router routes deferred.
|
|
26
|
+
const config = resolveIcqqConfig(context.config);
|
|
27
|
+
// 注册到插件运行时状态(icqq endpoint list 的"运行中"数据源)
|
|
28
|
+
context.use(icqqRuntimeStateToken).endpoints.set(config.name, {
|
|
29
|
+
name: config.name,
|
|
30
|
+
mode: config.rpc ? 'rpc' : 'ipc',
|
|
31
|
+
});
|
|
32
|
+
const inbox = resolveInboxHooks(context);
|
|
33
|
+
return new IcqqIpcEndpoint({
|
|
34
|
+
id: context.id,
|
|
35
|
+
gateway: context.use(messageGatewayToken),
|
|
36
|
+
config,
|
|
37
|
+
...(inbox ? { inbox } : {}),
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
/** 资源可选(轻量 Root 无 DatabaseHost / console hub):缺失时降级为不写收件箱。 */
|
|
42
|
+
function tryUse(context, token) {
|
|
43
|
+
try {
|
|
44
|
+
return context.use(token);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 收件箱钩子:写 unified_inbox_request/notice(按 platform_*_id 去重,
|
|
52
|
+
* 推送事件与 GET_SYSTEM_MSG 首拉可能重复同一请求),并经 console hub 广播。
|
|
53
|
+
*/
|
|
54
|
+
function resolveInboxHooks(context) {
|
|
55
|
+
const host = tryUse(context, databaseHostToken);
|
|
56
|
+
const publisher = tryUse(context, runtimeEventPublisherToken);
|
|
57
|
+
if (!host && !publisher)
|
|
58
|
+
return undefined;
|
|
59
|
+
const insertDeduped = async (table, idField, row) => {
|
|
60
|
+
const model = host?.models.get(table);
|
|
61
|
+
if (!model)
|
|
62
|
+
return;
|
|
63
|
+
try {
|
|
64
|
+
const existing = await model.select().where({
|
|
65
|
+
adapter: row.adapter,
|
|
66
|
+
endpoint_id: row.endpoint_id,
|
|
67
|
+
[idField]: row[idField],
|
|
68
|
+
});
|
|
69
|
+
if (Array.isArray(existing) && existing.length > 0)
|
|
70
|
+
return;
|
|
71
|
+
await model.insert(row);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
logger.warn(formatCompact({
|
|
75
|
+
op: 'inbox_insert',
|
|
76
|
+
table,
|
|
77
|
+
error: error instanceof Error ? error.message : String(error),
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const publish = publisher;
|
|
82
|
+
return {
|
|
83
|
+
recordRequest: (row) => insertDeduped(INBOX_TABLE_REQUEST, 'platform_request_id', row),
|
|
84
|
+
recordNotice: (row) => insertDeduped(INBOX_TABLE_NOTICE, 'platform_notice_id', row),
|
|
85
|
+
...(publish
|
|
86
|
+
? { publish: (type, data) => publish.publish(type, data) }
|
|
87
|
+
: {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
package/adapters/icqq.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type Token,
|
|
13
13
|
} from '@zhin.js/plugin-runtime';
|
|
14
14
|
import { IcqqIpcEndpoint, type IcqqInboxHooks } from '../src/endpoint.js';
|
|
15
|
+
import { icqqRuntimeStateToken } from '../src/icqq-runtime-state.js';
|
|
15
16
|
import {
|
|
16
17
|
resolveIcqqConfig,
|
|
17
18
|
type IcqqAdapterConfig,
|
|
@@ -28,14 +29,27 @@ const logger = getLogger('icqq');
|
|
|
28
29
|
|
|
29
30
|
export default defineAdapter<IcqqAdapterConfig>({
|
|
30
31
|
capabilities: ['inbound', 'outbound'],
|
|
32
|
+
// 统一消息元素通道(UNI-Channel):端点可消费 base64 / url / upload 媒体
|
|
33
|
+
// (path 由出站物化按 file/base64 模式转换,非声明消费形态);
|
|
34
|
+
// 卡片/按钮等富交互段无原生承载,降级纯文本。
|
|
35
|
+
segments: {
|
|
36
|
+
outboundMedia: ['base64', 'url', 'upload'],
|
|
37
|
+
interactive: 'text',
|
|
38
|
+
},
|
|
31
39
|
create(context) {
|
|
32
40
|
// Client-library / IPC daemon path — no httpHostToken.
|
|
33
41
|
// Console loginAssist + host-router routes deferred.
|
|
42
|
+
const config = resolveIcqqConfig(context.config);
|
|
43
|
+
// 注册到插件运行时状态(icqq endpoint list 的"运行中"数据源)
|
|
44
|
+
context.use(icqqRuntimeStateToken).endpoints.set(config.name, {
|
|
45
|
+
name: config.name,
|
|
46
|
+
mode: config.rpc ? 'rpc' : 'ipc',
|
|
47
|
+
});
|
|
34
48
|
const inbox = resolveInboxHooks(context);
|
|
35
49
|
return new IcqqIpcEndpoint({
|
|
36
50
|
id: context.id,
|
|
37
51
|
gateway: context.use(messageGatewayToken),
|
|
38
|
-
config
|
|
52
|
+
config,
|
|
39
53
|
...(inbox ? { inbox } : {}),
|
|
40
54
|
});
|
|
41
55
|
},
|
package/lib/endpoint.js
CHANGED
|
@@ -494,14 +494,38 @@ export class IcqqIpcEndpoint {
|
|
|
494
494
|
}
|
|
495
495
|
/** Test / internal: admit when open. */
|
|
496
496
|
admit(msg) {
|
|
497
|
-
if (!this.#open)
|
|
497
|
+
if (!this.#open) {
|
|
498
|
+
logger.debug(formatCompact({
|
|
499
|
+
op: 'icqq_inbound_skip',
|
|
500
|
+
reason: 'endpoint_closed',
|
|
501
|
+
endpoint: this.name,
|
|
502
|
+
id: msg.id,
|
|
503
|
+
target: msg.target,
|
|
504
|
+
}));
|
|
498
505
|
return;
|
|
499
|
-
|
|
500
|
-
|
|
506
|
+
}
|
|
507
|
+
// 新 Runtime Message.content 为纯文本:@ 本机(uin = name)经结构化段 / metadata 传递
|
|
508
|
+
const mentioned = isIcqqBotMentioned({
|
|
509
|
+
uin: this.name,
|
|
510
|
+
content: msg.segments,
|
|
511
|
+
rawMessage: msg.content,
|
|
512
|
+
});
|
|
513
|
+
logger.debug(formatCompact({
|
|
514
|
+
op: 'icqq_inbound_admit',
|
|
515
|
+
endpoint: this.name,
|
|
516
|
+
id: msg.id,
|
|
517
|
+
target: msg.target,
|
|
518
|
+
sender: msg.sender,
|
|
519
|
+
channelType: msg.channelType,
|
|
520
|
+
mentioned: mentioned || undefined,
|
|
521
|
+
preview: msg.content.slice(0, 80),
|
|
522
|
+
segments: summarizeSegmentTypes(msg.segments),
|
|
523
|
+
}));
|
|
501
524
|
void this.#options.gateway.receive({
|
|
502
525
|
adapter: this.#options.id,
|
|
503
526
|
target: msg.target,
|
|
504
527
|
content: msg.content,
|
|
528
|
+
...(msg.segments ? { segments: msg.segments } : {}),
|
|
505
529
|
sender: msg.sender,
|
|
506
530
|
id: msg.id,
|
|
507
531
|
metadata: Object.freeze({
|
|
@@ -555,13 +579,36 @@ export class IcqqIpcEndpoint {
|
|
|
555
579
|
if (!isIcqqMessagePostType(payload))
|
|
556
580
|
return;
|
|
557
581
|
const data = payload;
|
|
558
|
-
if (shouldSkipSelfInboundMessage(data))
|
|
582
|
+
if (shouldSkipSelfInboundMessage(data)) {
|
|
583
|
+
logger.debug(formatCompact({
|
|
584
|
+
op: 'icqq_inbound_skip',
|
|
585
|
+
reason: 'self',
|
|
586
|
+
endpoint: this.name,
|
|
587
|
+
user_id: data.user_id,
|
|
588
|
+
self_id: data.self_id,
|
|
589
|
+
}));
|
|
559
590
|
return;
|
|
591
|
+
}
|
|
560
592
|
const normalized = normalizeIcqqInboundMessage(data);
|
|
561
|
-
if (!normalized)
|
|
593
|
+
if (!normalized) {
|
|
594
|
+
logger.debug(formatCompact({
|
|
595
|
+
op: 'icqq_inbound_skip',
|
|
596
|
+
reason: 'normalize_failed',
|
|
597
|
+
endpoint: this.name,
|
|
598
|
+
message_id: data.message_id,
|
|
599
|
+
message_type: data.message_type ?? data.type,
|
|
600
|
+
}));
|
|
562
601
|
return;
|
|
563
|
-
|
|
602
|
+
}
|
|
603
|
+
if (!this.#inboundDeduper.shouldProcess(normalized.messageId)) {
|
|
604
|
+
logger.debug(formatCompact({
|
|
605
|
+
op: 'icqq_inbound_skip',
|
|
606
|
+
reason: 'dedupe',
|
|
607
|
+
endpoint: this.name,
|
|
608
|
+
id: normalized.messageId,
|
|
609
|
+
}));
|
|
564
610
|
return;
|
|
611
|
+
}
|
|
565
612
|
this.admit({
|
|
566
613
|
id: normalized.messageId,
|
|
567
614
|
target: formatInboundTarget({
|
|
@@ -570,6 +617,7 @@ export class IcqqIpcEndpoint {
|
|
|
570
617
|
channelParentGroupId: normalized.channelParentGroupId,
|
|
571
618
|
}),
|
|
572
619
|
content: formatInboundContent(normalized.rawMessage),
|
|
620
|
+
segments: normalized.content,
|
|
573
621
|
sender: normalized.userId,
|
|
574
622
|
channelType: normalized.channelType,
|
|
575
623
|
metadata: buildIcqqQuoteMetadata(data, {
|
|
@@ -583,11 +631,24 @@ export class IcqqIpcEndpoint {
|
|
|
583
631
|
if (!payload || typeof payload !== 'object')
|
|
584
632
|
return;
|
|
585
633
|
const normalized = normalizeIcqqGuildInboundMessage(payload);
|
|
586
|
-
if (!normalized)
|
|
634
|
+
if (!normalized) {
|
|
635
|
+
logger.debug(formatCompact({
|
|
636
|
+
op: 'icqq_inbound_skip',
|
|
637
|
+
reason: 'guild_normalize_failed',
|
|
638
|
+
endpoint: this.name,
|
|
639
|
+
}));
|
|
587
640
|
return;
|
|
641
|
+
}
|
|
588
642
|
this.#guildCatalog.upsertFromInbound(normalized.raw);
|
|
589
|
-
if (!this.#inboundDeduper.shouldProcess(`guild:${normalized.messageId}`))
|
|
643
|
+
if (!this.#inboundDeduper.shouldProcess(`guild:${normalized.messageId}`)) {
|
|
644
|
+
logger.debug(formatCompact({
|
|
645
|
+
op: 'icqq_inbound_skip',
|
|
646
|
+
reason: 'dedupe',
|
|
647
|
+
endpoint: this.name,
|
|
648
|
+
id: `guild:${normalized.messageId}`,
|
|
649
|
+
}));
|
|
590
650
|
return;
|
|
651
|
+
}
|
|
591
652
|
this.admit({
|
|
592
653
|
id: normalized.messageId,
|
|
593
654
|
target: formatInboundTarget({
|
|
@@ -596,6 +657,7 @@ export class IcqqIpcEndpoint {
|
|
|
596
657
|
guildId: normalized.guildId,
|
|
597
658
|
}),
|
|
598
659
|
content: formatInboundContent(normalized.rawMessage),
|
|
660
|
+
segments: normalized.content,
|
|
599
661
|
sender: normalized.userId,
|
|
600
662
|
channelType: 'channel',
|
|
601
663
|
metadata: {
|
|
@@ -706,6 +768,20 @@ function toNumericId(value, label) {
|
|
|
706
768
|
}
|
|
707
769
|
return n;
|
|
708
770
|
}
|
|
771
|
+
function summarizeSegmentTypes(segments) {
|
|
772
|
+
if (!segments?.length)
|
|
773
|
+
return undefined;
|
|
774
|
+
return segments
|
|
775
|
+
.map((segment) => {
|
|
776
|
+
if (typeof segment.type === 'string')
|
|
777
|
+
return segment.type;
|
|
778
|
+
if (segment.type && typeof segment.type === 'object' && 'name' in segment.type) {
|
|
779
|
+
return String(segment.type.name);
|
|
780
|
+
}
|
|
781
|
+
return '?';
|
|
782
|
+
})
|
|
783
|
+
.join(',');
|
|
784
|
+
}
|
|
709
785
|
async function defaultCreateIpc(config) {
|
|
710
786
|
const uin = Number(config.name);
|
|
711
787
|
if (config.rpc) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const icqqEndpointCommands: import("@zhin.js/adapter").EndpointCommands<Readonly<import("@zhin.js/command").CommandDefinition<unknown, unknown, import("@zhin.js/command").CommandMessage>>>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `icqq endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
|
|
3
|
+
*
|
|
4
|
+
* ICQQ 的凭据由外部守护进程(@icqqjs/cli)按账号持有(~/.icqq/<uin>/),
|
|
5
|
+
* 其 IPC/RPC 协议是「按 uin 连接 + auth + 账号级 action」,没有 add-account /
|
|
6
|
+
* 触发 login 的 action——扫码登录只能经 `icqq login <uin>` CLI 完成。
|
|
7
|
+
* 因此 add 不走 kv 凭据录入,而是 bindFlow:校验 uin、把 { name: '<uin>' }
|
|
8
|
+
* 追加到 plugins.icqq.endpoints,并引导用户先完成守护进程登录。
|
|
9
|
+
*/
|
|
10
|
+
import { addEndpointToConfig, createEndpointCommands } from '@zhin.js/adapter';
|
|
11
|
+
import { defineCommand } from '@zhin.js/command';
|
|
12
|
+
import { icqqRuntimeStateToken } from './icqq-runtime-state.js';
|
|
13
|
+
export const icqqEndpointCommands = createEndpointCommands({
|
|
14
|
+
adapterKey: 'icqq',
|
|
15
|
+
adapterDisplayName: 'ICQQ',
|
|
16
|
+
running: (use) => use(icqqRuntimeStateToken).endpoints.values(),
|
|
17
|
+
describeEntry: (entry) => (entry.rpc ? 'rpc(远程守护进程)' : 'ipc(本地守护进程)'),
|
|
18
|
+
addDescription: '登记 ICQQ endpoint(uin 需先经 icqq login 完成扫码登录;重启 zhin 生效)',
|
|
19
|
+
bindFlow: ({ name }) => {
|
|
20
|
+
if (!name) {
|
|
21
|
+
return '用法:icqq endpoint add <uin>(uin 为纯数字 QQ 号,且需先执行 icqq login <uin> 完成扫码登录)';
|
|
22
|
+
}
|
|
23
|
+
if (!/^\d+$/.test(name)) {
|
|
24
|
+
return 'icqq endpoint 名必须是纯数字 QQ 号(uin)';
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const filePath = addEndpointToConfig('icqq', { name });
|
|
28
|
+
return (`✅ endpoint「${name}」已追加到 ${filePath} 的 plugins.icqq.endpoints。\n` +
|
|
29
|
+
`⚠️ 请确保已执行 icqq login ${name} 启动守护进程并完成扫码登录;重启 zhin 后生效。`);
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
return `添加失败:${error instanceof Error ? error.message : String(error)}`;
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
}, defineCommand);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const icqqRuntimeStateToken: import("zhin.js").Token<import("@zhin.js/adapter").EndpointRuntimeState>;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ICQQ 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
|
|
3
|
+
* 由 plugin.ts setup() provide,adapter create 与 `icqq endpoint` 命令共享(同一 owner generation)。
|
|
4
|
+
*/
|
|
5
|
+
import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
|
|
6
|
+
export const icqqRuntimeStateToken = defineEndpointRuntimeStateToken('icqq');
|
package/lib/ipc-client.js
CHANGED
|
@@ -216,7 +216,7 @@ export class IpcClient {
|
|
|
216
216
|
token = (await readFile(getTokenPath(uin), "utf-8")).trim();
|
|
217
217
|
}
|
|
218
218
|
catch {
|
|
219
|
-
throw new Error(
|
|
219
|
+
throw new Error(`无法读取认证 token,守护进程可能未运行:请先执行 icqq login ${uin} 完成登录并启动守护进程`);
|
|
220
220
|
}
|
|
221
221
|
let rpcInfo;
|
|
222
222
|
try {
|
|
@@ -224,7 +224,7 @@ export class IpcClient {
|
|
|
224
224
|
rpcInfo = JSON.parse(raw);
|
|
225
225
|
}
|
|
226
226
|
catch {
|
|
227
|
-
throw new Error(
|
|
227
|
+
throw new Error(`RPC 未启用或守护进程未运行:请先执行 icqq login ${uin},并确认守护进程已启用 RPC`);
|
|
228
228
|
}
|
|
229
229
|
return IpcClient.connectRpc({
|
|
230
230
|
host: rpcInfo.host,
|
package/lib/outbound-media.d.ts
CHANGED
|
@@ -7,7 +7,8 @@ export type IcqqOutboundMediaMode = "file" | "base64";
|
|
|
7
7
|
*/
|
|
8
8
|
export declare function resolveIcqqOutboundMediaMode(config: Pick<IcqqEndpointConfig, "rpc" | "outboundMedia">): IcqqOutboundMediaMode;
|
|
9
9
|
/**
|
|
10
|
-
* file 模式:将 segment.data.base64
|
|
11
|
-
* base64
|
|
10
|
+
* file 模式:将 segment.data.base64(legacy)或 canonical MediaRef
|
|
11
|
+
* (`data.media`,kind=base64)物化为本机路径,并把 media 改写为 path 引用。
|
|
12
|
+
* base64 模式:不改动(由 toCqString/formatOutboundBody 生成 base64://,供异机守护进程读取)。
|
|
12
13
|
*/
|
|
13
14
|
export declare function materializeOutboundBase64(content: SendContent, mode?: IcqqOutboundMediaMode): SendContent;
|
package/lib/outbound-media.js
CHANGED
|
@@ -33,8 +33,9 @@ export function resolveIcqqOutboundMediaMode(config) {
|
|
|
33
33
|
return config.rpc ? "base64" : "file";
|
|
34
34
|
}
|
|
35
35
|
/**
|
|
36
|
-
* file 模式:将 segment.data.base64
|
|
37
|
-
* base64
|
|
36
|
+
* file 模式:将 segment.data.base64(legacy)或 canonical MediaRef
|
|
37
|
+
* (`data.media`,kind=base64)物化为本机路径,并把 media 改写为 path 引用。
|
|
38
|
+
* base64 模式:不改动(由 toCqString/formatOutboundBody 生成 base64://,供异机守护进程读取)。
|
|
38
39
|
*/
|
|
39
40
|
export function materializeOutboundBase64(content, mode = "file") {
|
|
40
41
|
if (mode === "base64")
|
|
@@ -45,16 +46,27 @@ export function materializeOutboundBase64(content, mode = "file") {
|
|
|
45
46
|
return seg;
|
|
46
47
|
const { type, data } = seg;
|
|
47
48
|
const d = data;
|
|
48
|
-
const
|
|
49
|
-
|
|
49
|
+
const media = d.media;
|
|
50
|
+
const mediaBase64 = media && media.kind === "base64" && typeof media.value === "string" && media.value
|
|
51
|
+
? media.value.replace(/^base64:\/\//, "")
|
|
52
|
+
: undefined;
|
|
53
|
+
const legacyBase64 = d.base64 ?? d.data;
|
|
54
|
+
const b64 = mediaBase64 ??
|
|
55
|
+
(typeof legacyBase64 === "string" && legacyBase64 ? legacyBase64 : undefined);
|
|
56
|
+
if (!b64)
|
|
50
57
|
return seg;
|
|
51
|
-
const mime = String(d.mime ?? d.mimeType ?? "");
|
|
58
|
+
const mime = String(d.mime ?? d.mimeType ?? (typeof media?.mime_type === "string" ? media.mime_type : "") ?? "");
|
|
52
59
|
if (type === "image" || type === "record" || type === "audio" || type === "video") {
|
|
53
60
|
const filePath = spoolBase64ToFile(b64, mime || (type === "image" ? "image/jpeg" : type === "video" ? "video/mp4" : "audio/mpeg"), type === "record" || type === "audio" ? "audio" : type);
|
|
54
61
|
return {
|
|
55
62
|
type,
|
|
56
63
|
data: {
|
|
57
64
|
...d,
|
|
65
|
+
media: {
|
|
66
|
+
kind: "path",
|
|
67
|
+
value: filePath,
|
|
68
|
+
...(mime ? { mime_type: mime } : {}),
|
|
69
|
+
},
|
|
58
70
|
file: filePath,
|
|
59
71
|
url: filePath,
|
|
60
72
|
},
|
package/lib/protocol.d.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* 登录相关:**推送**走 IpcEvent(`system.login.*`)。
|
|
13
13
|
*/
|
|
14
|
+
import type { MessageSegment } from "zhin.js";
|
|
14
15
|
/** CLI → Daemon 请求 */
|
|
15
16
|
export type IpcRequest = {
|
|
16
17
|
id: string;
|
|
@@ -237,6 +238,11 @@ export interface IcqqInboundMessage {
|
|
|
237
238
|
readonly id: string;
|
|
238
239
|
readonly target: string;
|
|
239
240
|
readonly content: string;
|
|
241
|
+
/**
|
|
242
|
+
* 结构化段视图(canonical Segment,经 toCanonicalSegments 归一)。
|
|
243
|
+
* 与 content(CQ 原文纯文本视图)同源,随 gateway.receive 上送。
|
|
244
|
+
*/
|
|
245
|
+
readonly segments?: readonly MessageSegment[];
|
|
240
246
|
readonly sender: string;
|
|
241
247
|
readonly channelType: 'private' | 'group' | 'channel';
|
|
242
248
|
readonly metadata?: Record<string, unknown>;
|
package/lib/protocol.js
CHANGED
|
@@ -277,16 +277,16 @@ export function formatOutboundBody(payload) {
|
|
|
277
277
|
case 'mention':
|
|
278
278
|
return `[at:${item.data?.qq ?? item.data?.id ?? item.data?.target ?? ''}]`;
|
|
279
279
|
case 'image':
|
|
280
|
-
return `[image:${item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
|
|
280
|
+
return `[image:${mediaRefValue(item.data) || item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
|
|
281
281
|
case 'face':
|
|
282
282
|
return `[face:${item.data?.id ?? ''}]`;
|
|
283
283
|
case 'reply':
|
|
284
284
|
return `[reply:${item.data?.message_id ?? item.data?.id ?? ''}]`;
|
|
285
285
|
case 'record':
|
|
286
286
|
case 'audio':
|
|
287
|
-
return `[record:${item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
|
|
287
|
+
return `[record:${mediaRefValue(item.data) || item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
|
|
288
288
|
case 'video':
|
|
289
|
-
return `[video:${item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
|
|
289
|
+
return `[video:${mediaRefValue(item.data) || item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
|
|
290
290
|
default:
|
|
291
291
|
return String(item.data?.text ?? '');
|
|
292
292
|
}
|
|
@@ -301,6 +301,23 @@ function base64Media(data) {
|
|
|
301
301
|
const b64 = data?.base64;
|
|
302
302
|
return typeof b64 === 'string' && b64 ? `base64://${b64}` : undefined;
|
|
303
303
|
}
|
|
304
|
+
/**
|
|
305
|
+
* canonical MediaRef(`data.media`,见 built/segment-contract)→ CQ 媒体值:
|
|
306
|
+
* base64 → `base64://` 前缀(守护进程解码);url/path 直出。
|
|
307
|
+
* file 模式下 materializeOutboundBase64 已把 base64 落盘并把 media 改写为 path 引用。
|
|
308
|
+
*/
|
|
309
|
+
function mediaRefValue(data) {
|
|
310
|
+
const media = data?.media;
|
|
311
|
+
if (!media || typeof media !== 'object' || Array.isArray(media))
|
|
312
|
+
return undefined;
|
|
313
|
+
const { kind, value } = media;
|
|
314
|
+
if (typeof value !== 'string' || !value)
|
|
315
|
+
return undefined;
|
|
316
|
+
if (kind === 'base64') {
|
|
317
|
+
return value.startsWith('base64://') ? value : `base64://${value}`;
|
|
318
|
+
}
|
|
319
|
+
return value;
|
|
320
|
+
}
|
|
304
321
|
/** 段对象判定:`{ type: string, data? }`(非数组 payload 的防御入口)。 */
|
|
305
322
|
function isWireSegment(value) {
|
|
306
323
|
return typeof value === 'object'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-icqq",
|
|
3
|
-
"version": "7.0.
|
|
3
|
+
"version": "7.0.2",
|
|
4
4
|
"description": "Zhin.js ICQQ adapter for Plugin Runtime (IPC daemon client)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -34,18 +34,19 @@
|
|
|
34
34
|
"directory": "plugins/adapters/icqq"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@zhin.js/adapter": "1.1.
|
|
38
|
-
"@zhin.js/
|
|
37
|
+
"@zhin.js/adapter": "1.1.2",
|
|
38
|
+
"@zhin.js/command": "1.0.4",
|
|
39
|
+
"@zhin.js/core": "1.4.2",
|
|
39
40
|
"@zhin.js/logger": "1.0.75",
|
|
40
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
41
|
+
"@zhin.js/plugin-runtime": "1.1.1"
|
|
41
42
|
},
|
|
42
43
|
"peerDependencies": {
|
|
43
44
|
"zod": "^4.0.0",
|
|
44
|
-
"@zhin.js/adapter": "1.1.
|
|
45
|
-
"@zhin.js/agent": "1.0.
|
|
46
|
-
"@zhin.js/core": "1.4.
|
|
47
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
48
|
-
"zhin.js": "5.0.
|
|
45
|
+
"@zhin.js/adapter": "1.1.2",
|
|
46
|
+
"@zhin.js/agent": "1.0.7",
|
|
47
|
+
"@zhin.js/core": "1.4.2",
|
|
48
|
+
"@zhin.js/plugin-runtime": "1.1.1",
|
|
49
|
+
"zhin.js": "5.0.2"
|
|
49
50
|
},
|
|
50
51
|
"peerDependenciesMeta": {
|
|
51
52
|
"zhin.js": {
|
|
@@ -63,12 +64,13 @@
|
|
|
63
64
|
"typescript": "^6.0.3",
|
|
64
65
|
"vitest": "^4.1.10",
|
|
65
66
|
"zod": "^4.4.3",
|
|
66
|
-
"@zhin.js/agent": "1.0.
|
|
67
|
-
"zhin.js": "5.0.
|
|
67
|
+
"@zhin.js/agent": "1.0.7",
|
|
68
|
+
"zhin.js": "5.0.2"
|
|
68
69
|
},
|
|
69
70
|
"files": [
|
|
70
71
|
"adapters",
|
|
71
|
-
"
|
|
72
|
+
"commands",
|
|
73
|
+
"plugin.js",
|
|
72
74
|
"schema.json",
|
|
73
75
|
"src",
|
|
74
76
|
"lib",
|
|
@@ -86,13 +88,17 @@
|
|
|
86
88
|
"zhin": {
|
|
87
89
|
"protocol": 1,
|
|
88
90
|
"type": "plugin",
|
|
89
|
-
"entry": "./plugin.
|
|
91
|
+
"entry": "./plugin.js",
|
|
90
92
|
"engine": "^1.0.0",
|
|
91
93
|
"runtime": "trusted",
|
|
92
94
|
"features": [
|
|
93
95
|
{
|
|
94
96
|
"package": "@zhin.js/adapter",
|
|
95
97
|
"api": "^1.0.0"
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
"package": "@zhin.js/command",
|
|
101
|
+
"api": "^1.0.0"
|
|
96
102
|
}
|
|
97
103
|
],
|
|
98
104
|
"plugins": []
|
package/plugin.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { createEndpointRuntimeState } from '@zhin.js/adapter';
|
|
3
|
+
import { definePlugin } from '@zhin.js/plugin-runtime';
|
|
4
|
+
import { icqqRuntimeStateToken } from "./lib/icqq-runtime-state.js";
|
|
5
|
+
// Module-level guard: multiple icqq instances (icqq, icqq-2, …) share this
|
|
6
|
+
// module, but the prompt contributor is per-platform — register it once.
|
|
7
|
+
let promptContributorRegistered = false;
|
|
8
|
+
export default definePlugin({
|
|
9
|
+
name: 'icqq',
|
|
10
|
+
metadata: {
|
|
11
|
+
displayName: 'ICQQ Adapter',
|
|
12
|
+
},
|
|
13
|
+
setup(context) {
|
|
14
|
+
// 运行中 endpoint 注册表(icqq endpoint list 的"运行中"数据源)
|
|
15
|
+
context.resources.provide(icqqRuntimeStateToken, createEndpointRuntimeState());
|
|
16
|
+
// Agent prompt contributor (orchestrator/deferred-worker ICQQ guidance).
|
|
17
|
+
// `zhin.js/agent` is an optional peer — skip silently on IM-only installs.
|
|
18
|
+
if (promptContributorRegistered)
|
|
19
|
+
return;
|
|
20
|
+
let cancelled = false;
|
|
21
|
+
let unregister;
|
|
22
|
+
void Promise.all([
|
|
23
|
+
import('zhin.js/agent'),
|
|
24
|
+
import("./lib/agent-prompt.js"),
|
|
25
|
+
]).then(([agent, prompt]) => {
|
|
26
|
+
if (cancelled || promptContributorRegistered)
|
|
27
|
+
return;
|
|
28
|
+
agent.registerAgentPromptContributor(prompt.createIcqqAgentPromptContributor());
|
|
29
|
+
promptContributorRegistered = true;
|
|
30
|
+
unregister = () => agent.unregisterAgentPromptContributor('icqq');
|
|
31
|
+
}).catch(() => { });
|
|
32
|
+
return () => {
|
|
33
|
+
cancelled = true;
|
|
34
|
+
unregister?.();
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
});
|
package/src/endpoint.ts
CHANGED
|
@@ -624,13 +624,38 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
624
624
|
|
|
625
625
|
/** Test / internal: admit when open. */
|
|
626
626
|
admit(msg: IcqqInboundMessage): void {
|
|
627
|
-
if (!this.#open)
|
|
628
|
-
|
|
629
|
-
|
|
627
|
+
if (!this.#open) {
|
|
628
|
+
logger.debug(formatCompact({
|
|
629
|
+
op: 'icqq_inbound_skip',
|
|
630
|
+
reason: 'endpoint_closed',
|
|
631
|
+
endpoint: this.name,
|
|
632
|
+
id: msg.id,
|
|
633
|
+
target: msg.target,
|
|
634
|
+
}));
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
// 新 Runtime Message.content 为纯文本:@ 本机(uin = name)经结构化段 / metadata 传递
|
|
638
|
+
const mentioned = isIcqqBotMentioned({
|
|
639
|
+
uin: this.name,
|
|
640
|
+
content: msg.segments,
|
|
641
|
+
rawMessage: msg.content,
|
|
642
|
+
});
|
|
643
|
+
logger.debug(formatCompact({
|
|
644
|
+
op: 'icqq_inbound_admit',
|
|
645
|
+
endpoint: this.name,
|
|
646
|
+
id: msg.id,
|
|
647
|
+
target: msg.target,
|
|
648
|
+
sender: msg.sender,
|
|
649
|
+
channelType: msg.channelType,
|
|
650
|
+
mentioned: mentioned || undefined,
|
|
651
|
+
preview: msg.content.slice(0, 80),
|
|
652
|
+
segments: summarizeSegmentTypes(msg.segments),
|
|
653
|
+
}));
|
|
630
654
|
void this.#options.gateway.receive({
|
|
631
655
|
adapter: this.#options.id,
|
|
632
656
|
target: msg.target,
|
|
633
657
|
content: msg.content,
|
|
658
|
+
...(msg.segments ? { segments: msg.segments } : {}),
|
|
634
659
|
sender: msg.sender,
|
|
635
660
|
id: msg.id,
|
|
636
661
|
metadata: Object.freeze({
|
|
@@ -684,10 +709,36 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
684
709
|
}
|
|
685
710
|
if (!isIcqqMessagePostType(payload)) return;
|
|
686
711
|
const data = payload as IcqqIpcMessageEvent;
|
|
687
|
-
if (shouldSkipSelfInboundMessage(data))
|
|
712
|
+
if (shouldSkipSelfInboundMessage(data)) {
|
|
713
|
+
logger.debug(formatCompact({
|
|
714
|
+
op: 'icqq_inbound_skip',
|
|
715
|
+
reason: 'self',
|
|
716
|
+
endpoint: this.name,
|
|
717
|
+
user_id: data.user_id,
|
|
718
|
+
self_id: data.self_id,
|
|
719
|
+
}));
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
688
722
|
const normalized = normalizeIcqqInboundMessage(data);
|
|
689
|
-
if (!normalized)
|
|
690
|
-
|
|
723
|
+
if (!normalized) {
|
|
724
|
+
logger.debug(formatCompact({
|
|
725
|
+
op: 'icqq_inbound_skip',
|
|
726
|
+
reason: 'normalize_failed',
|
|
727
|
+
endpoint: this.name,
|
|
728
|
+
message_id: data.message_id,
|
|
729
|
+
message_type: data.message_type ?? data.type,
|
|
730
|
+
}));
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (!this.#inboundDeduper.shouldProcess(normalized.messageId)) {
|
|
734
|
+
logger.debug(formatCompact({
|
|
735
|
+
op: 'icqq_inbound_skip',
|
|
736
|
+
reason: 'dedupe',
|
|
737
|
+
endpoint: this.name,
|
|
738
|
+
id: normalized.messageId,
|
|
739
|
+
}));
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
691
742
|
this.admit({
|
|
692
743
|
id: normalized.messageId,
|
|
693
744
|
target: formatInboundTarget({
|
|
@@ -696,6 +747,7 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
696
747
|
channelParentGroupId: normalized.channelParentGroupId,
|
|
697
748
|
}),
|
|
698
749
|
content: formatInboundContent(normalized.rawMessage),
|
|
750
|
+
segments: normalized.content,
|
|
699
751
|
sender: normalized.userId,
|
|
700
752
|
channelType: normalized.channelType,
|
|
701
753
|
metadata: buildIcqqQuoteMetadata(data, {
|
|
@@ -711,9 +763,24 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
711
763
|
const normalized = normalizeIcqqGuildInboundMessage(
|
|
712
764
|
payload as Parameters<typeof normalizeIcqqGuildInboundMessage>[0],
|
|
713
765
|
);
|
|
714
|
-
if (!normalized)
|
|
766
|
+
if (!normalized) {
|
|
767
|
+
logger.debug(formatCompact({
|
|
768
|
+
op: 'icqq_inbound_skip',
|
|
769
|
+
reason: 'guild_normalize_failed',
|
|
770
|
+
endpoint: this.name,
|
|
771
|
+
}));
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
715
774
|
this.#guildCatalog.upsertFromInbound(normalized.raw);
|
|
716
|
-
if (!this.#inboundDeduper.shouldProcess(`guild:${normalized.messageId}`))
|
|
775
|
+
if (!this.#inboundDeduper.shouldProcess(`guild:${normalized.messageId}`)) {
|
|
776
|
+
logger.debug(formatCompact({
|
|
777
|
+
op: 'icqq_inbound_skip',
|
|
778
|
+
reason: 'dedupe',
|
|
779
|
+
endpoint: this.name,
|
|
780
|
+
id: `guild:${normalized.messageId}`,
|
|
781
|
+
}));
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
717
784
|
this.admit({
|
|
718
785
|
id: normalized.messageId,
|
|
719
786
|
target: formatInboundTarget({
|
|
@@ -722,6 +789,7 @@ export class IcqqIpcEndpoint implements EndpointInstance {
|
|
|
722
789
|
guildId: normalized.guildId,
|
|
723
790
|
}),
|
|
724
791
|
content: formatInboundContent(normalized.rawMessage),
|
|
792
|
+
segments: normalized.content,
|
|
725
793
|
sender: normalized.userId,
|
|
726
794
|
channelType: 'channel',
|
|
727
795
|
metadata: {
|
|
@@ -834,6 +902,21 @@ function toNumericId(value: number | string, label: string): number {
|
|
|
834
902
|
return n;
|
|
835
903
|
}
|
|
836
904
|
|
|
905
|
+
function summarizeSegmentTypes(
|
|
906
|
+
segments: readonly { readonly type?: unknown }[] | undefined,
|
|
907
|
+
): string | undefined {
|
|
908
|
+
if (!segments?.length) return undefined;
|
|
909
|
+
return segments
|
|
910
|
+
.map((segment) => {
|
|
911
|
+
if (typeof segment.type === 'string') return segment.type;
|
|
912
|
+
if (segment.type && typeof segment.type === 'object' && 'name' in segment.type) {
|
|
913
|
+
return String((segment.type as { name: unknown }).name);
|
|
914
|
+
}
|
|
915
|
+
return '?';
|
|
916
|
+
})
|
|
917
|
+
.join(',');
|
|
918
|
+
}
|
|
919
|
+
|
|
837
920
|
async function defaultCreateIpc(config: ResolvedIcqqConfig): Promise<IcqqIpcTransport> {
|
|
838
921
|
const uin = Number(config.name);
|
|
839
922
|
if (config.rpc) {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `icqq endpoint` 命令族:由 @zhin.js/adapter 的 createEndpointCommands 套件生成。
|
|
3
|
+
*
|
|
4
|
+
* ICQQ 的凭据由外部守护进程(@icqqjs/cli)按账号持有(~/.icqq/<uin>/),
|
|
5
|
+
* 其 IPC/RPC 协议是「按 uin 连接 + auth + 账号级 action」,没有 add-account /
|
|
6
|
+
* 触发 login 的 action——扫码登录只能经 `icqq login <uin>` CLI 完成。
|
|
7
|
+
* 因此 add 不走 kv 凭据录入,而是 bindFlow:校验 uin、把 { name: '<uin>' }
|
|
8
|
+
* 追加到 plugins.icqq.endpoints,并引导用户先完成守护进程登录。
|
|
9
|
+
*/
|
|
10
|
+
import { addEndpointToConfig, createEndpointCommands } from '@zhin.js/adapter';
|
|
11
|
+
import { defineCommand } from '@zhin.js/command';
|
|
12
|
+
import { icqqRuntimeStateToken } from './icqq-runtime-state.js';
|
|
13
|
+
|
|
14
|
+
export const icqqEndpointCommands = createEndpointCommands({
|
|
15
|
+
adapterKey: 'icqq',
|
|
16
|
+
adapterDisplayName: 'ICQQ',
|
|
17
|
+
running: (use) => use(icqqRuntimeStateToken).endpoints.values(),
|
|
18
|
+
describeEntry: (entry) => (entry.rpc ? 'rpc(远程守护进程)' : 'ipc(本地守护进程)'),
|
|
19
|
+
addDescription: '登记 ICQQ endpoint(uin 需先经 icqq login 完成扫码登录;重启 zhin 生效)',
|
|
20
|
+
bindFlow: ({ name }) => {
|
|
21
|
+
if (!name) {
|
|
22
|
+
return '用法:icqq endpoint add <uin>(uin 为纯数字 QQ 号,且需先执行 icqq login <uin> 完成扫码登录)';
|
|
23
|
+
}
|
|
24
|
+
if (!/^\d+$/.test(name)) {
|
|
25
|
+
return 'icqq endpoint 名必须是纯数字 QQ 号(uin)';
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const filePath = addEndpointToConfig('icqq', { name });
|
|
29
|
+
return (
|
|
30
|
+
`✅ endpoint「${name}」已追加到 ${filePath} 的 plugins.icqq.endpoints。\n` +
|
|
31
|
+
`⚠️ 请确保已执行 icqq login ${name} 启动守护进程并完成扫码登录;重启 zhin 后生效。`
|
|
32
|
+
);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
return `添加失败:${error instanceof Error ? error.message : String(error)}`;
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
}, defineCommand);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ICQQ 插件实例的运行时状态:adapter create() 注册的 endpoint 列表。
|
|
3
|
+
* 由 plugin.ts setup() provide,adapter create 与 `icqq endpoint` 命令共享(同一 owner generation)。
|
|
4
|
+
*/
|
|
5
|
+
import { defineEndpointRuntimeStateToken } from '@zhin.js/adapter';
|
|
6
|
+
|
|
7
|
+
export const icqqRuntimeStateToken = defineEndpointRuntimeStateToken('icqq');
|
package/src/ipc-client.ts
CHANGED
|
@@ -266,7 +266,7 @@ export class IpcClient {
|
|
|
266
266
|
try {
|
|
267
267
|
token = (await readFile(getTokenPath(uin), "utf-8")).trim();
|
|
268
268
|
} catch {
|
|
269
|
-
throw new Error(
|
|
269
|
+
throw new Error(`无法读取认证 token,守护进程可能未运行:请先执行 icqq login ${uin} 完成登录并启动守护进程`);
|
|
270
270
|
}
|
|
271
271
|
|
|
272
272
|
let rpcInfo: { host: string; port: number };
|
|
@@ -274,7 +274,7 @@ export class IpcClient {
|
|
|
274
274
|
const raw = await readFile(getRpcPortPath(uin), "utf-8");
|
|
275
275
|
rpcInfo = JSON.parse(raw);
|
|
276
276
|
} catch {
|
|
277
|
-
throw new Error(
|
|
277
|
+
throw new Error(`RPC 未启用或守护进程未运行:请先执行 icqq login ${uin},并确认守护进程已启用 RPC`);
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
return IpcClient.connectRpc({
|
package/src/outbound-media.ts
CHANGED
|
@@ -47,8 +47,9 @@ export function resolveIcqqOutboundMediaMode(
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* file 模式:将 segment.data.base64
|
|
51
|
-
* base64
|
|
50
|
+
* file 模式:将 segment.data.base64(legacy)或 canonical MediaRef
|
|
51
|
+
* (`data.media`,kind=base64)物化为本机路径,并把 media 改写为 path 引用。
|
|
52
|
+
* base64 模式:不改动(由 toCqString/formatOutboundBody 生成 base64://,供异机守护进程读取)。
|
|
52
53
|
*/
|
|
53
54
|
export function materializeOutboundBase64(
|
|
54
55
|
content: SendContent,
|
|
@@ -61,10 +62,22 @@ export function materializeOutboundBase64(
|
|
|
61
62
|
if (typeof seg === "string") return seg;
|
|
62
63
|
const { type, data } = seg as MessageSegment;
|
|
63
64
|
const d = data as Record<string, unknown>;
|
|
64
|
-
const
|
|
65
|
-
|
|
65
|
+
const media = d.media as
|
|
66
|
+
| { kind?: unknown; value?: unknown; mime_type?: unknown }
|
|
67
|
+
| undefined;
|
|
68
|
+
const mediaBase64 =
|
|
69
|
+
media && media.kind === "base64" && typeof media.value === "string" && media.value
|
|
70
|
+
? media.value.replace(/^base64:\/\//, "")
|
|
71
|
+
: undefined;
|
|
72
|
+
const legacyBase64 = d.base64 ?? d.data;
|
|
73
|
+
const b64 =
|
|
74
|
+
mediaBase64 ??
|
|
75
|
+
(typeof legacyBase64 === "string" && legacyBase64 ? legacyBase64 : undefined);
|
|
76
|
+
if (!b64) return seg;
|
|
66
77
|
|
|
67
|
-
const mime = String(
|
|
78
|
+
const mime = String(
|
|
79
|
+
d.mime ?? d.mimeType ?? (typeof media?.mime_type === "string" ? media.mime_type : "") ?? "",
|
|
80
|
+
);
|
|
68
81
|
if (type === "image" || type === "record" || type === "audio" || type === "video") {
|
|
69
82
|
const filePath = spoolBase64ToFile(
|
|
70
83
|
b64,
|
|
@@ -75,6 +88,11 @@ export function materializeOutboundBase64(
|
|
|
75
88
|
type,
|
|
76
89
|
data: {
|
|
77
90
|
...d,
|
|
91
|
+
media: {
|
|
92
|
+
kind: "path",
|
|
93
|
+
value: filePath,
|
|
94
|
+
...(mime ? { mime_type: mime } : {}),
|
|
95
|
+
},
|
|
78
96
|
file: filePath,
|
|
79
97
|
url: filePath,
|
|
80
98
|
},
|
package/src/protocol.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { pickCredential } from '@zhin.js/adapter';
|
|
16
|
+
import type { MessageSegment } from "zhin.js";
|
|
16
17
|
|
|
17
18
|
/** CLI → Daemon 请求 */
|
|
18
19
|
export type IpcRequest = {
|
|
@@ -313,6 +314,11 @@ export interface IcqqInboundMessage {
|
|
|
313
314
|
readonly id: string;
|
|
314
315
|
readonly target: string;
|
|
315
316
|
readonly content: string;
|
|
317
|
+
/**
|
|
318
|
+
* 结构化段视图(canonical Segment,经 toCanonicalSegments 归一)。
|
|
319
|
+
* 与 content(CQ 原文纯文本视图)同源,随 gateway.receive 上送。
|
|
320
|
+
*/
|
|
321
|
+
readonly segments?: readonly MessageSegment[];
|
|
316
322
|
readonly sender: string;
|
|
317
323
|
readonly channelType: 'private' | 'group' | 'channel';
|
|
318
324
|
readonly metadata?: Record<string, unknown>;
|
|
@@ -422,16 +428,16 @@ export function formatOutboundBody(payload: unknown): string {
|
|
|
422
428
|
case 'mention':
|
|
423
429
|
return `[at:${item.data?.qq ?? item.data?.id ?? item.data?.target ?? ''}]`;
|
|
424
430
|
case 'image':
|
|
425
|
-
return `[image:${item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
|
|
431
|
+
return `[image:${mediaRefValue(item.data) || item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
|
|
426
432
|
case 'face':
|
|
427
433
|
return `[face:${item.data?.id ?? ''}]`;
|
|
428
434
|
case 'reply':
|
|
429
435
|
return `[reply:${item.data?.message_id ?? item.data?.id ?? ''}]`;
|
|
430
436
|
case 'record':
|
|
431
437
|
case 'audio':
|
|
432
|
-
return `[record:${item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
|
|
438
|
+
return `[record:${mediaRefValue(item.data) || item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
|
|
433
439
|
case 'video':
|
|
434
|
-
return `[video:${item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
|
|
440
|
+
return `[video:${mediaRefValue(item.data) || item.data?.file || item.data?.url || base64Media(item.data) || ''}]`;
|
|
435
441
|
default:
|
|
436
442
|
return String(item.data?.text ?? '');
|
|
437
443
|
}
|
|
@@ -449,6 +455,22 @@ function base64Media(data: Record<string, unknown> | undefined): string | undefi
|
|
|
449
455
|
return typeof b64 === 'string' && b64 ? `base64://${b64}` : undefined;
|
|
450
456
|
}
|
|
451
457
|
|
|
458
|
+
/**
|
|
459
|
+
* canonical MediaRef(`data.media`,见 built/segment-contract)→ CQ 媒体值:
|
|
460
|
+
* base64 → `base64://` 前缀(守护进程解码);url/path 直出。
|
|
461
|
+
* file 模式下 materializeOutboundBase64 已把 base64 落盘并把 media 改写为 path 引用。
|
|
462
|
+
*/
|
|
463
|
+
function mediaRefValue(data: Record<string, unknown> | undefined): string | undefined {
|
|
464
|
+
const media = data?.media;
|
|
465
|
+
if (!media || typeof media !== 'object' || Array.isArray(media)) return undefined;
|
|
466
|
+
const { kind, value } = media as { kind?: unknown; value?: unknown };
|
|
467
|
+
if (typeof value !== 'string' || !value) return undefined;
|
|
468
|
+
if (kind === 'base64') {
|
|
469
|
+
return value.startsWith('base64://') ? value : `base64://${value}`;
|
|
470
|
+
}
|
|
471
|
+
return value;
|
|
472
|
+
}
|
|
473
|
+
|
|
452
474
|
/** 段对象判定:`{ type: string, data? }`(非数组 payload 的防御入口)。 */
|
|
453
475
|
function isWireSegment(value: unknown): value is IcqqWireSegment {
|
|
454
476
|
return typeof value === 'object'
|
package/plugin.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { definePlugin } from '@zhin.js/plugin-runtime';
|
|
2
|
-
|
|
3
|
-
// Module-level guard: multiple icqq instances (icqq, icqq-2, …) share this
|
|
4
|
-
// module, but the prompt contributor is per-platform — register it once.
|
|
5
|
-
let promptContributorRegistered = false;
|
|
6
|
-
|
|
7
|
-
export default definePlugin({
|
|
8
|
-
name: 'icqq',
|
|
9
|
-
metadata: {
|
|
10
|
-
displayName: 'ICQQ Adapter',
|
|
11
|
-
},
|
|
12
|
-
setup() {
|
|
13
|
-
// Agent prompt contributor (orchestrator/deferred-worker ICQQ guidance).
|
|
14
|
-
// `zhin.js/agent` is an optional peer — skip silently on IM-only installs.
|
|
15
|
-
if (promptContributorRegistered) return;
|
|
16
|
-
let cancelled = false;
|
|
17
|
-
let unregister: (() => void) | undefined;
|
|
18
|
-
void Promise.all([
|
|
19
|
-
import('zhin.js/agent'),
|
|
20
|
-
import('./src/agent-prompt.js'),
|
|
21
|
-
]).then(([agent, prompt]) => {
|
|
22
|
-
if (cancelled || promptContributorRegistered) return;
|
|
23
|
-
agent.registerAgentPromptContributor(prompt.createIcqqAgentPromptContributor());
|
|
24
|
-
promptContributorRegistered = true;
|
|
25
|
-
unregister = () => agent.unregisterAgentPromptContributor('icqq');
|
|
26
|
-
}).catch(() => { /* optional peer not installed */ });
|
|
27
|
-
return () => {
|
|
28
|
-
cancelled = true;
|
|
29
|
-
unregister?.();
|
|
30
|
-
};
|
|
31
|
-
},
|
|
32
|
-
});
|