@zhin.js/adapter-qq 5.0.2 → 6.0.0

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.
Files changed (130) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +75 -409
  3. package/adapters/qq.ts +51 -0
  4. package/agent/tools/add_role.ts +2 -2
  5. package/agent/tools/channel_info.ts +2 -2
  6. package/agent/tools/create_role.ts +2 -2
  7. package/agent/tools/list_channels.ts +2 -2
  8. package/agent/tools/list_guilds.ts +2 -2
  9. package/agent/tools/list_roles.ts +2 -2
  10. package/agent/tools/member_detail.ts +2 -2
  11. package/agent/tools/remove_role.ts +2 -2
  12. package/commands/endpoint/add/[name:string=].ts +21 -0
  13. package/commands/endpoint/cancel.ts +15 -0
  14. package/commands/endpoint/list.ts +10 -0
  15. package/commands/endpoint/remove/[name:string].ts +15 -0
  16. package/lib/endpoint.d.ts +75 -0
  17. package/lib/endpoint.js +358 -0
  18. package/lib/index.d.ts +6 -0
  19. package/lib/index.js +6 -0
  20. package/lib/platform-permit.d.ts +16 -0
  21. package/lib/{src/platform-permit.js → platform-permit.js} +1 -2
  22. package/lib/protocol.d.ts +96 -0
  23. package/lib/protocol.js +129 -0
  24. package/lib/qq-agent-deps.d.ts +21 -0
  25. package/lib/qq-agent-deps.js +30 -0
  26. package/lib/qq-bind-api.d.ts +36 -0
  27. package/lib/{src/qq-bind-api.js → qq-bind-api.js} +0 -1
  28. package/lib/qq-bind-flow.d.ts +19 -0
  29. package/lib/{src/qq-bind-flow.js → qq-bind-flow.js} +0 -1
  30. package/lib/qq-bind-persist.d.ts +12 -0
  31. package/lib/{src/qq-bind-persist.js → qq-bind-persist.js} +4 -5
  32. package/lib/qq-endpoint-commands.d.ts +25 -0
  33. package/lib/qq-endpoint-commands.js +147 -0
  34. package/lib/qq-endpoint-config.d.ts +17 -0
  35. package/lib/qq-endpoint-config.js +110 -0
  36. package/lib/qq-runtime-state.d.ts +17 -0
  37. package/lib/qq-runtime-state.js +13 -0
  38. package/lib/webhook.d.ts +18 -0
  39. package/lib/webhook.js +100 -0
  40. package/lib/ws.d.ts +24 -0
  41. package/lib/ws.js +118 -0
  42. package/package.json +51 -40
  43. package/plugin.ts +16 -0
  44. package/schema.json +85 -0
  45. package/src/endpoint.ts +362 -596
  46. package/src/index.ts +56 -186
  47. package/src/platform-permit.ts +1 -1
  48. package/src/protocol.ts +229 -0
  49. package/src/qq-agent-deps.ts +49 -12
  50. package/src/qq-bind-persist.ts +4 -4
  51. package/src/qq-endpoint-commands.ts +174 -0
  52. package/src/qq-endpoint-config.ts +135 -0
  53. package/src/qq-runtime-state.ts +36 -0
  54. package/src/webhook.ts +127 -0
  55. package/src/ws.ts +172 -0
  56. package/client/Dashboard.tsx +0 -230
  57. package/client/index.tsx +0 -11
  58. package/client/tsconfig.json +0 -7
  59. package/client/utils/api.ts +0 -30
  60. package/dist/index.js +0 -30
  61. package/lib/agent/tools/add_role.js +0 -23
  62. package/lib/agent/tools/add_role.js.map +0 -1
  63. package/lib/agent/tools/channel_info.js +0 -19
  64. package/lib/agent/tools/channel_info.js.map +0 -1
  65. package/lib/agent/tools/create_role.js +0 -22
  66. package/lib/agent/tools/create_role.js.map +0 -1
  67. package/lib/agent/tools/list_channels.js +0 -20
  68. package/lib/agent/tools/list_channels.js.map +0 -1
  69. package/lib/agent/tools/list_guilds.js +0 -17
  70. package/lib/agent/tools/list_guilds.js.map +0 -1
  71. package/lib/agent/tools/list_roles.js +0 -20
  72. package/lib/agent/tools/list_roles.js.map +0 -1
  73. package/lib/agent/tools/member_detail.js +0 -20
  74. package/lib/agent/tools/member_detail.js.map +0 -1
  75. package/lib/agent/tools/remove_role.js +0 -23
  76. package/lib/agent/tools/remove_role.js.map +0 -1
  77. package/lib/package.json +0 -105
  78. package/lib/src/adapter.js +0 -81
  79. package/lib/src/adapter.js.map +0 -1
  80. package/lib/src/endpoint.js +0 -600
  81. package/lib/src/endpoint.js.map +0 -1
  82. package/lib/src/gateway-config.js +0 -32
  83. package/lib/src/gateway-config.js.map +0 -1
  84. package/lib/src/group-at-normalize.js +0 -68
  85. package/lib/src/group-at-normalize.js.map +0 -1
  86. package/lib/src/inbound-action.js +0 -57
  87. package/lib/src/inbound-action.js.map +0 -1
  88. package/lib/src/inbound-normalize.js +0 -47
  89. package/lib/src/inbound-normalize.js.map +0 -1
  90. package/lib/src/index.js +0 -194
  91. package/lib/src/index.js.map +0 -1
  92. package/lib/src/mention-enrich.js +0 -52
  93. package/lib/src/mention-enrich.js.map +0 -1
  94. package/lib/src/outbound-keyboard.js +0 -100
  95. package/lib/src/outbound-keyboard.js.map +0 -1
  96. package/lib/src/outbound-markdown.js +0 -189
  97. package/lib/src/outbound-markdown.js.map +0 -1
  98. package/lib/src/outbound-media.js +0 -51
  99. package/lib/src/outbound-media.js.map +0 -1
  100. package/lib/src/outbound-mixed-media.js +0 -78
  101. package/lib/src/outbound-mixed-media.js.map +0 -1
  102. package/lib/src/platform-permit.js.map +0 -1
  103. package/lib/src/qq-agent-deps.js +0 -10
  104. package/lib/src/qq-agent-deps.js.map +0 -1
  105. package/lib/src/qq-bind-api.js.map +0 -1
  106. package/lib/src/qq-bind-flow.js.map +0 -1
  107. package/lib/src/qq-bind-persist.js.map +0 -1
  108. package/lib/src/qq-endpoint-manager.js +0 -159
  109. package/lib/src/qq-endpoint-manager.js.map +0 -1
  110. package/lib/src/sdk-version.js +0 -19
  111. package/lib/src/sdk-version.js.map +0 -1
  112. package/lib/src/segment-mapper.js +0 -2
  113. package/lib/src/segment-mapper.js.map +0 -1
  114. package/lib/src/types.js +0 -2
  115. package/lib/src/types.js.map +0 -1
  116. package/plugin.yml +0 -3
  117. package/src/adapter.ts +0 -97
  118. package/src/gateway-config.ts +0 -48
  119. package/src/group-at-normalize.ts +0 -75
  120. package/src/inbound-action.ts +0 -87
  121. package/src/inbound-normalize.ts +0 -57
  122. package/src/mention-enrich.ts +0 -71
  123. package/src/outbound-keyboard.ts +0 -117
  124. package/src/outbound-markdown.ts +0 -212
  125. package/src/outbound-media.ts +0 -58
  126. package/src/outbound-mixed-media.ts +0 -101
  127. package/src/qq-endpoint-manager.ts +0 -199
  128. package/src/sdk-version.ts +0 -21
  129. package/src/segment-mapper.ts +0 -1
  130. package/src/types.ts +0 -52
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `qq endpoint` 命令的业务逻辑(与命令定义文件分离,便于测试)。
3
+ */
4
+ import { startQqBindFlow } from './qq-bind-flow.js';
5
+ import { persistQqCredentialsToEnv } from './qq-bind-persist.js';
6
+ import {
7
+ addQqEndpointToConfig,
8
+ listQqEndpointEntries,
9
+ removeQqEndpointFromConfig,
10
+ } from './qq-endpoint-config.js';
11
+ import type { QqRuntimeState } from './qq-runtime-state.js';
12
+
13
+ export type QqCommandReply = (text: string) => Promise<unknown>;
14
+
15
+ /**
16
+ * endpoint 管理命令的操作者校验:实例配置声明了 master(顶层或任一端点项)时
17
+ * 仅 master 可执行 add/cancel/remove;未配置则放行(首个扫码绑定者即为 owner,
18
+ * legacy applyBindOwnership 会把 operator 写为新端点的 master)。
19
+ */
20
+ export function isQqEndpointOperator(config: unknown, input: unknown): boolean {
21
+ const cfg = (config ?? {}) as { master?: unknown; endpoints?: unknown };
22
+ const masters = new Set<string>();
23
+ const collect = (value: unknown) => {
24
+ if (value === undefined || value === null) return;
25
+ const text = String(value).trim();
26
+ if (text) masters.add(text);
27
+ };
28
+ collect(cfg.master);
29
+ if (Array.isArray(cfg.endpoints)) {
30
+ for (const entry of cfg.endpoints) {
31
+ collect((entry as { master?: unknown } | null | undefined)?.master);
32
+ }
33
+ }
34
+ if (masters.size === 0) return true;
35
+ const sender = String((input as { sender?: unknown } | null | undefined)?.sender ?? '').trim();
36
+ return !!sender && masters.has(sender);
37
+ }
38
+
39
+ export const QQ_ENDPOINT_FORBIDDEN = '仅 master 可执行 QQ endpoint 管理命令';
40
+
41
+ /**
42
+ * 从命令 input(Runtime Message)提取 $reply;非消息来源(如 Host API 调用)降级为 no-op。
43
+ */
44
+ export function extractQqCommandReply(input: unknown): QqCommandReply {
45
+ const reply = (input as { $reply?: unknown } | null | undefined)?.$reply;
46
+ if (typeof reply === 'function') {
47
+ return (text) => (reply as (content: string) => Promise<unknown>).call(input, text);
48
+ }
49
+ return async () => undefined;
50
+ }
51
+
52
+ /** `qq endpoint list`:运行中的 endpoints(本 generation adapter create 注册)+ 配置里的 endpoints */
53
+ export function runQqEndpointList(state: QqRuntimeState, projectRoot?: string): string {
54
+ const running = [...state.endpoints.values()];
55
+ const configured = listQqEndpointEntries(projectRoot);
56
+ const lines: string[] = [];
57
+ lines.push('【运行中的 QQ endpoints】');
58
+ if (running.length === 0) {
59
+ lines.push(' (无)');
60
+ } else {
61
+ for (const endpoint of running) {
62
+ lines.push(` - ${endpoint.name}(${endpoint.mode})`);
63
+ }
64
+ }
65
+ lines.push('【配置中的 QQ endpoints】(zhin.config.yml → plugins.qq.endpoints)');
66
+ if (configured.length === 0) {
67
+ lines.push(' (无)');
68
+ } else {
69
+ for (const entry of configured) {
70
+ lines.push(` - ${entry.name}(appid: ${entry.appid})`);
71
+ }
72
+ }
73
+ if (state.bindFlow) {
74
+ lines.push('⚠️ 有进行中的扫码绑定,可用 qq endpoint cancel 取消');
75
+ }
76
+ return lines.join('\n');
77
+ }
78
+
79
+ /**
80
+ * `qq endpoint add [name]`:启动扫码绑定流程。
81
+ * 返回的 Promise 在二维码链接就绪(或前置失败)时 resolve 为首条回复;
82
+ * 后续状态(已扫码 / 成功 / 失败 / 过期刷新)通过 reply 推回当前会话。
83
+ */
84
+ export function runQqEndpointAdd(
85
+ state: QqRuntimeState,
86
+ name: string | undefined,
87
+ reply: QqCommandReply,
88
+ projectRoot?: string,
89
+ ): Promise<string> {
90
+ if (state.bindFlow) {
91
+ return Promise.resolve('已有进行中的 QQ 机器人绑定,请先发送 qq endpoint cancel 取消后再试');
92
+ }
93
+ const endpointName = name?.trim() || undefined;
94
+ return new Promise<string>((resolve) => {
95
+ let firstReplied = false;
96
+ const settle = (text: string) => {
97
+ if (!firstReplied) {
98
+ firstReplied = true;
99
+ resolve(text);
100
+ return;
101
+ }
102
+ void reply(text);
103
+ };
104
+ const stop = startQqBindFlow(
105
+ {
106
+ onQrDisplayed: (url) => {
107
+ // QQ 出站当前仅支持纯文本(富媒体未迁移),二维码只能发链接文本
108
+ settle(
109
+ `请用手机 QQ 打开以下链接完成扫码绑定(二维码图片出站暂未支持,故发送链接):\n${url}`,
110
+ );
111
+ },
112
+ onQrExpired: async () => {
113
+ await reply('二维码已过期,正在刷新,请扫描新链接…');
114
+ },
115
+ onSuccess: async (credentials) => {
116
+ state.bindFlow = null;
117
+ try {
118
+ const [{ appId, appSecret }] = credentials;
119
+ const finalName = endpointName ?? appId;
120
+ const envKeys = persistQqCredentialsToEnv(finalName, appId, appSecret, projectRoot);
121
+ const filePath = addQqEndpointToConfig(
122
+ { name: finalName, appid: envKeys.appidRef, secret: envKeys.secretRef },
123
+ projectRoot,
124
+ );
125
+ settle(
126
+ `✅ 绑定成功!endpoint「${finalName}」的凭据已写入 .env,并已追加到 ${filePath} 的 plugins.qq.endpoints。\n` +
127
+ '⚠️ 需重启 zhin 后新 endpoint 才会生效。',
128
+ );
129
+ } catch (error) {
130
+ settle(`绑定成功但写入配置失败:${error instanceof Error ? error.message : String(error)}`);
131
+ }
132
+ },
133
+ onFailure: (error) => {
134
+ state.bindFlow = null;
135
+ settle(`❌ QQ 绑定失败:${error.message}`);
136
+ },
137
+ },
138
+ { source: 'zhin' },
139
+ );
140
+ state.bindFlow = { name: endpointName, stop };
141
+ });
142
+ }
143
+
144
+ /** `qq endpoint cancel`:中止进行中的绑定流程 */
145
+ export function runQqEndpointCancel(state: QqRuntimeState): string {
146
+ if (!state.bindFlow) {
147
+ return '当前没有进行中的 QQ 绑定流程';
148
+ }
149
+ state.bindFlow.stop();
150
+ state.bindFlow = null;
151
+ return '已取消进行中的 QQ 绑定流程';
152
+ }
153
+
154
+ /** `qq endpoint remove <name>`:从 zhin.config.yml 移除对应 endpoints 项 */
155
+ export function runQqEndpointRemove(
156
+ _state: QqRuntimeState,
157
+ name: string,
158
+ projectRoot?: string,
159
+ ): string {
160
+ const trimmed = name.trim();
161
+ if (!trimmed) return '用法:qq endpoint remove <name>';
162
+ try {
163
+ const { removed, filePath } = removeQqEndpointFromConfig(trimmed, projectRoot);
164
+ if (!removed) {
165
+ return `配置中不存在 qq endpoint「${trimmed}」(${filePath} → plugins.qq.endpoints)`;
166
+ }
167
+ return (
168
+ `已从 ${filePath} 的 plugins.qq.endpoints 移除「${trimmed}」。\n` +
169
+ '⚠️ 需重启 zhin 后生效(运行中的连接届时才会断开);.env 中的凭据键未删除,可手动清理。'
170
+ );
171
+ } catch (error) {
172
+ return `移除失败:${error instanceof Error ? error.message : String(error)}`;
173
+ }
174
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * zhin.config.yml 中 plugins.qq.endpoints 的读写(Plugin Runtime 版)。
3
+ * 用 yaml Document 操作以保留现有注释;仅支持 .yml/.yaml,其它格式报错提示手动处理。
4
+ */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { isMap, isSeq, parseDocument, type YAMLSeq } from 'yaml';
8
+ import { resolveProjectRoot } from './qq-bind-persist.js';
9
+
10
+ export interface QqEndpointConfigEntry {
11
+ name: string;
12
+ appid: string;
13
+ secret: string;
14
+ [key: string]: unknown;
15
+ }
16
+
17
+ const CONFIG_BASENAME = 'zhin.config';
18
+ const YAML_EXTENSIONS = ['.yml', '.yaml'] as const;
19
+
20
+ /** 定位项目配置文件:ZHIN_CONFIG 指定优先,否则发现 zhin.config.yml/.yaml,都没有则默认新建 zhin.config.yml */
21
+ export function findQqConfigFile(projectRoot?: string): string {
22
+ const root = projectRoot ?? resolveProjectRoot();
23
+ const envConfig = process.env.ZHIN_CONFIG?.trim();
24
+ if (envConfig) return path.resolve(root, envConfig);
25
+ for (const ext of YAML_EXTENSIONS) {
26
+ const candidate = path.join(root, `${CONFIG_BASENAME}${ext}`);
27
+ if (fs.existsSync(candidate)) return candidate;
28
+ }
29
+ for (const ext of ['.json', '.toml', '.ts'] as const) {
30
+ const candidate = path.join(root, `${CONFIG_BASENAME}${ext}`);
31
+ if (fs.existsSync(candidate)) {
32
+ throw new Error(`暂不支持写入 ${ext} 配置文件,请手动在 ${CONFIG_BASENAME}${ext} 的 plugins.qq.endpoints 中维护`);
33
+ }
34
+ }
35
+ return path.join(root, `${CONFIG_BASENAME}.yml`);
36
+ }
37
+
38
+ interface QqConfigDocument {
39
+ filePath: string;
40
+ doc: ReturnType<typeof parseDocument>;
41
+ }
42
+
43
+ function readConfigDocument(projectRoot?: string): QqConfigDocument {
44
+ const filePath = findQqConfigFile(projectRoot);
45
+ const content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf-8') : '';
46
+ const doc = parseDocument(content || '{}');
47
+ return { filePath, doc };
48
+ }
49
+
50
+ function writeConfigDocument({ filePath, doc }: QqConfigDocument): void {
51
+ fs.writeFileSync(filePath, doc.toString());
52
+ }
53
+
54
+ /** 读取 plugins.qq.endpoints(plain JS);plugins/qq 缺失或形态不符时返回 [] */
55
+ export function listQqEndpointEntries(projectRoot?: string): QqEndpointConfigEntry[] {
56
+ const { doc } = readConfigDocument(projectRoot);
57
+ const plugins = doc.toJS()?.plugins;
58
+ if (!plugins || typeof plugins !== 'object' || Array.isArray(plugins)) return [];
59
+ const endpoints = (plugins as { qq?: { endpoints?: unknown } }).qq?.endpoints;
60
+ if (!Array.isArray(endpoints)) return [];
61
+ return endpoints.filter(
62
+ (entry): entry is QqEndpointConfigEntry =>
63
+ !!entry && typeof entry === 'object' && typeof (entry as { name?: unknown }).name === 'string',
64
+ );
65
+ }
66
+
67
+ function entryName(item: unknown): string | undefined {
68
+ if (!isMap(item)) return undefined;
69
+ const name = item.get('name');
70
+ return typeof name === 'string' && name ? name : undefined;
71
+ }
72
+
73
+ /**
74
+ * 确保 plugins.qq.endpoints 存在并返回其 YAMLSeq(节点级操作,保留既有条目与注释)。
75
+ * `plugins: []`(legacy 空列表,Runtime 忽略)可直接替换为 map;非空数组拒绝写入。
76
+ */
77
+ function ensureEndpointsSeq(doc: ReturnType<typeof parseDocument>): YAMLSeq {
78
+ const plugins = doc.get('plugins');
79
+ if (isSeq(plugins) && plugins.items.length > 0) {
80
+ throw new Error('配置的 plugins 是数组形态(legacy 插件名列表),请手动迁移为 map 后再试');
81
+ }
82
+ if (plugins !== undefined && !isMap(plugins) && !isSeq(plugins)) {
83
+ throw new Error('配置的 plugins 字段形态异常,请手动检查 zhin.config.yml');
84
+ }
85
+ if (!isMap(doc.get('plugins'))) {
86
+ // 注意:空对象 {} 不会被 doc.set 自动包装为 YAMLMap,必须显式 createNode
87
+ doc.set('plugins', doc.createNode({}));
88
+ }
89
+ const qq = doc.getIn(['plugins', 'qq']);
90
+ if (qq !== undefined && !isMap(qq)) {
91
+ throw new Error('配置的 plugins.qq 字段形态异常,请手动检查 zhin.config.yml');
92
+ }
93
+ if (!isMap(doc.getIn(['plugins', 'qq']))) {
94
+ doc.setIn(['plugins', 'qq'], doc.createNode({}));
95
+ }
96
+ const endpoints = doc.getIn(['plugins', 'qq', 'endpoints']);
97
+ if (endpoints !== undefined && !isSeq(endpoints)) {
98
+ throw new Error('配置的 plugins.qq.endpoints 字段形态异常,请手动检查 zhin.config.yml');
99
+ }
100
+ if (!isSeq(doc.getIn(['plugins', 'qq', 'endpoints']))) {
101
+ doc.setIn(['plugins', 'qq', 'endpoints'], doc.createNode([]));
102
+ }
103
+ return doc.getIn(['plugins', 'qq', 'endpoints']) as YAMLSeq;
104
+ }
105
+
106
+ /** 追加 endpoint 到 plugins.qq.endpoints;name 已存在时报错 */
107
+ export function addQqEndpointToConfig(
108
+ entry: QqEndpointConfigEntry,
109
+ projectRoot?: string,
110
+ ): string {
111
+ const document = readConfigDocument(projectRoot);
112
+ const seq = ensureEndpointsSeq(document.doc);
113
+ if (seq.items.some((item) => entryName(item) === entry.name)) {
114
+ throw new Error(`配置中已存在 qq endpoint「${entry.name}」,可先 qq endpoint remove ${entry.name} 再重新添加`);
115
+ }
116
+ seq.items.push(document.doc.createNode(entry));
117
+ writeConfigDocument(document);
118
+ return document.filePath;
119
+ }
120
+
121
+ /** 按 name 移除 plugins.qq.endpoints 项;不存在返回 false */
122
+ export function removeQqEndpointFromConfig(
123
+ name: string,
124
+ projectRoot?: string,
125
+ ): { removed: boolean; filePath: string } {
126
+ const document = readConfigDocument(projectRoot);
127
+ const seq = ensureEndpointsSeq(document.doc);
128
+ const next = seq.items.filter((item) => entryName(item) !== name);
129
+ if (next.length === seq.items.length) {
130
+ return { removed: false, filePath: document.filePath };
131
+ }
132
+ seq.items = next;
133
+ writeConfigDocument(document);
134
+ return { removed: true, filePath: document.filePath };
135
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * QQ 插件实例的运行时状态:adapter create() 注册的 endpoint 列表 +
3
+ * 扫码绑定流程的全局单例。由 plugin.ts setup() provide,
4
+ * adapter create 与 commands 通过 use(qqRuntimeStateToken) 共享(同一 owner generation)。
5
+ */
6
+ import { createToken } from '@zhin.js/plugin-runtime';
7
+
8
+ export interface QqRunningEndpoint {
9
+ name: string;
10
+ mode: string;
11
+ }
12
+
13
+ export interface QqBindFlowHandle {
14
+ /** 绑定期望的 endpoint 名(未指定时为 undefined,成功后取 appId) */
15
+ name?: string;
16
+ stop: () => void;
17
+ }
18
+
19
+ export interface QqRuntimeState {
20
+ /** 当前 generation 已成功创建的 endpoint(name → 描述) */
21
+ readonly endpoints: Map<string, QqRunningEndpoint>;
22
+ /** 全局单例:同一时间只允许一个扫码绑定流程 */
23
+ bindFlow: QqBindFlowHandle | null;
24
+ }
25
+
26
+ export function createQqRuntimeState(): QqRuntimeState {
27
+ return {
28
+ endpoints: new Map(),
29
+ bindFlow: null,
30
+ };
31
+ }
32
+
33
+ export const qqRuntimeStateToken = createToken<QqRuntimeState>(
34
+ 'zhin.qq.runtime-state',
35
+ 'QQ adapter runtime state (running endpoints + bind flow singleton)',
36
+ );
package/src/webhook.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * QQ HTTP webhook / middleware inbound via httpHostToken POST.
3
+ */
4
+ import path from 'node:path';
5
+ import type { IncomingMessage, ServerResponse } from 'node:http';
6
+ import { Bot, ReceiverMode } from 'qq-official-bot';
7
+ import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
8
+ import { formatCompact, getLogger } from '@zhin.js/logger';
9
+ import type { ResolvedQqHttpConfig } from './protocol.js';
10
+ import type { QqBotTransport } from './ws.js';
11
+
12
+ const logger = getLogger('qq');
13
+
14
+ export type QqHttpBotTransport = QqBotTransport & {
15
+ middleware: (ctx: unknown, next: () => Promise<void>) => Promise<unknown>;
16
+ };
17
+
18
+ export type CreateQqHttpBot = (config: ResolvedQqHttpConfig) => QqHttpBotTransport;
19
+
20
+ export interface QqWebhookHandler {
21
+ readonly config: ResolvedQqHttpConfig;
22
+ getBot(): (QqHttpBotTransport & { middleware?: QqHttpBotTransport['middleware'] }) | null;
23
+ }
24
+
25
+ export function registerQqWebhookRoutes(
26
+ http: HttpHost,
27
+ handler: QqWebhookHandler,
28
+ ): HttpRouteRegistration[] {
29
+ const webhookPath = handler.config.webhookPath;
30
+ return [
31
+ http.route('POST', webhookPath, async (request, response) => {
32
+ await handleQqWebhookRequest(request, response, handler);
33
+ }, { summary: 'QQ webhook callback', tags: ['qq'] }),
34
+ ];
35
+ }
36
+
37
+ export async function handleQqWebhookRequest(
38
+ request: IncomingMessage,
39
+ response: ServerResponse,
40
+ handler: QqWebhookHandler,
41
+ ): Promise<void> {
42
+ const bot = handler.getBot();
43
+ if (!bot?.middleware) {
44
+ response.writeHead(503, { 'Content-Type': 'text/plain' });
45
+ response.end('QQ receiver not ready');
46
+ return;
47
+ }
48
+ try {
49
+ let parsedBody: unknown;
50
+ const raw = await readRequestBodyText(request);
51
+ if (raw) {
52
+ try {
53
+ parsedBody = JSON.parse(raw);
54
+ } catch {
55
+ parsedBody = undefined;
56
+ }
57
+ }
58
+ await bot.middleware({
59
+ req: request,
60
+ res: response,
61
+ request: { body: parsedBody },
62
+ }, async () => undefined);
63
+ } catch (error) {
64
+ logger.error('QQ webhook error:', error);
65
+ if (!response.headersSent) {
66
+ response.writeHead(500, { 'Content-Type': 'text/plain' });
67
+ response.end('Internal Server Error');
68
+ }
69
+ }
70
+ }
71
+
72
+ export async function readRequestBodyText(request: IncomingMessage): Promise<string> {
73
+ const chunks: Buffer[] = [];
74
+ let size = 0;
75
+ for await (const chunk of request) {
76
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
77
+ size += buffer.length;
78
+ if (size > 1_048_576) {
79
+ request.destroy();
80
+ throw new Error('Request body exceeds 1MB');
81
+ }
82
+ chunks.push(buffer);
83
+ }
84
+ return Buffer.concat(chunks).toString('utf8');
85
+ }
86
+
87
+ export function defaultCreateHttpBot(config: ResolvedQqHttpConfig): QqHttpBotTransport {
88
+ const bot = new Bot({
89
+ appid: config.appid,
90
+ secret: config.secret,
91
+ mode: ReceiverMode.MIDDLEWARE,
92
+ platform: 'koa',
93
+ sandbox: config.sandbox,
94
+ dataDir: path.join(process.cwd(), 'data', 'qq'),
95
+ } as never) as Bot<ReceiverMode.MIDDLEWARE, 'koa'> & QqBotTransport;
96
+
97
+ return {
98
+ start: () => bot.start().then(() => undefined),
99
+ stop: () => bot.stop().then(() => undefined),
100
+ on: (event, listener) => {
101
+ bot.on(event as never, listener as never);
102
+ },
103
+ removeAllListeners: () => {
104
+ bot.removeAllListeners(undefined as never);
105
+ },
106
+ sendPrivateMessage: (userId, message) => bot.sendPrivateMessage(userId, message),
107
+ sendGroupMessage: (groupId, message) => bot.sendGroupMessage(groupId, message),
108
+ sendGuildMessage: (channelId, message) => bot.sendGuildMessage(channelId, message),
109
+ sendDirectMessage: (guildId, message) => bot.sendDirectMessage(guildId, message),
110
+ getGuilds: () => bot.guildService.getList(),
111
+ getChannels: (guildId) => bot.channelService.getList(guildId),
112
+ getChannelInfo: (channelId) => bot.channelService.getInfo(channelId),
113
+ getGuildMember: (guildId, userId) => bot.memberService.getGuildMemberInfo(guildId, userId),
114
+ getGuildRoles: (guildId) => bot.guildService.getRoles(guildId),
115
+ createGuildRole: (guildId, name, color) =>
116
+ bot.guildService.createRole(guildId, { name, color: color || 0, hoist: 0 }),
117
+ addMemberRole: async (guildId, channelId, userId, roleId) => {
118
+ await bot.memberService.addMemberRole(guildId, channelId, userId, roleId);
119
+ return true;
120
+ },
121
+ removeMemberRole: async (guildId, channelId, userId, roleId) => {
122
+ await bot.memberService.removeMemberRole(guildId, channelId, userId, roleId);
123
+ return true;
124
+ },
125
+ middleware: (ctx, next) => bot.middleware(ctx as never, next as never),
126
+ };
127
+ }
package/src/ws.ts ADDED
@@ -0,0 +1,172 @@
1
+ /**
2
+ * QQ WebSocket transport: bot factory and inbound message normalization.
3
+ */
4
+ import path from 'node:path';
5
+ import { Bot, ReceiverMode } from 'qq-official-bot';
6
+ import { getLevel, toLog4jsLevel } from '@zhin.js/logger';
7
+ import type { QqChannelKind, QqInboundMessage, ResolvedQqWebsocketConfig } from './protocol.js';
8
+
9
+ /** Minimal bot surface used by the endpoint (real qq-official-bot or test mock). */
10
+ export interface QqBotTransport {
11
+ start(): Promise<void>;
12
+ stop(): Promise<void>;
13
+ on(event: string, listener: (...args: unknown[]) => void): void;
14
+ removeAllListeners(): void;
15
+ sendPrivateMessage(userId: string, message: string): Promise<unknown>;
16
+ sendGroupMessage(groupId: string, message: string): Promise<unknown>;
17
+ sendGuildMessage(channelId: string, message: string): Promise<unknown>;
18
+ sendDirectMessage?(guildId: string, message: string): Promise<unknown>;
19
+ getGuilds(): Promise<unknown[]>;
20
+ getChannels(guildId: string): Promise<unknown[]>;
21
+ getChannelInfo(channelId: string): Promise<unknown>;
22
+ getGuildMember(guildId: string, userId: string): Promise<unknown>;
23
+ getGuildRoles(guildId: string): Promise<unknown[]>;
24
+ createGuildRole(guildId: string, name: string, color?: number): Promise<unknown>;
25
+ addMemberRole(
26
+ guildId: string,
27
+ channelId: string,
28
+ userId: string,
29
+ roleId: string,
30
+ ): Promise<boolean>;
31
+ removeMemberRole(
32
+ guildId: string,
33
+ channelId: string,
34
+ userId: string,
35
+ roleId: string,
36
+ ): Promise<boolean>;
37
+ }
38
+
39
+ export type CreateQqBot = (config: ResolvedQqWebsocketConfig) => QqBotTransport;
40
+
41
+ export function bindQqBotInboundEvents(
42
+ bot: QqBotTransport,
43
+ admitRaw: (raw: unknown) => void,
44
+ ): void {
45
+ bot.on('message.group', admitRaw);
46
+ bot.on('message.guild', admitRaw);
47
+ bot.on('message.private', admitRaw);
48
+ }
49
+
50
+ export function normalizeQqMessage(raw: unknown): QqInboundMessage | null {
51
+ if (!raw || typeof raw !== 'object') return null;
52
+ const msg = raw as {
53
+ message_id?: string | number;
54
+ message_type?: string;
55
+ sub_type?: string;
56
+ user_id?: string | number;
57
+ group_id?: string | number;
58
+ group_openid?: string;
59
+ channel_id?: string | number;
60
+ guild_id?: string | number;
61
+ raw_message?: string;
62
+ message?: unknown;
63
+ sender?: { user_id?: string | number; user_name?: string; roles?: (string | number)[] };
64
+ member?: { roles?: (string | number)[] };
65
+ author?: { member_openid?: string; user_openid?: string; id?: string; username?: string };
66
+ /** AT_MESSAGE_CREATE 载荷:被 @ 的用户列表,bot=true 即机器人 */
67
+ mentions?: Array<{ id?: string | number; bot?: boolean }>;
68
+ };
69
+ if (msg.message_id == null) return null;
70
+
71
+ let channelKind: QqChannelKind = 'private';
72
+ let channelId = '';
73
+ if (msg.message_type === 'guild') {
74
+ channelKind = msg.sub_type === 'direct' ? 'direct' : 'channel';
75
+ channelId = channelKind === 'direct'
76
+ ? String(msg.guild_id ?? '')
77
+ : String(msg.channel_id ?? '');
78
+ } else if (msg.message_type === 'group') {
79
+ channelKind = 'group';
80
+ channelId = String(msg.group_id ?? msg.group_openid ?? '');
81
+ } else {
82
+ channelKind = 'private';
83
+ channelId = String(msg.user_id ?? msg.author?.user_openid ?? msg.author?.id ?? '');
84
+ }
85
+ if (!channelId) return null;
86
+
87
+ const authorId = String(
88
+ msg.sender?.user_id
89
+ ?? msg.user_id
90
+ ?? msg.author?.member_openid
91
+ ?? msg.author?.user_openid
92
+ ?? msg.author?.id
93
+ ?? '',
94
+ );
95
+ const content = extractTextContent(msg.message) || msg.raw_message || '';
96
+ const rawRoles = msg.member?.roles ?? msg.sender?.roles;
97
+ const authorRoles = Array.isArray(rawRoles) ? rawRoles.map(String) : undefined;
98
+ // 群 GROUP_AT_MESSAGE_CREATE 仅 @ 机器人时下发;频道 AT 事件看 mentions[].bot
99
+ const mentioned = channelKind === 'group'
100
+ || (Array.isArray(msg.mentions) && msg.mentions.some((m) => m?.bot === true));
101
+
102
+ return {
103
+ id: String(msg.message_id),
104
+ content,
105
+ channelKind,
106
+ channelId,
107
+ authorId,
108
+ authorName: msg.sender?.user_name || msg.author?.username || authorId,
109
+ authorRoles,
110
+ timestamp: Date.now(),
111
+ guildId: msg.guild_id != null ? String(msg.guild_id) : undefined,
112
+ rawMessage: msg.raw_message,
113
+ ...(mentioned ? { mentioned: true } : {}),
114
+ };
115
+ }
116
+
117
+ function extractTextContent(message: unknown): string {
118
+ if (typeof message === 'string') return message;
119
+ if (!Array.isArray(message)) return '';
120
+ return message
121
+ .map((seg) => {
122
+ if (!seg || typeof seg !== 'object') return '';
123
+ const item = seg as { type?: string; data?: { text?: string }; text?: string };
124
+ if (item.type === 'text') return String(item.data?.text ?? item.text ?? '');
125
+ return '';
126
+ })
127
+ .join('');
128
+ }
129
+
130
+ export function defaultCreateBot(config: ResolvedQqWebsocketConfig): QqBotTransport {
131
+ const bot = new Bot({
132
+ appid: config.appid,
133
+ secret: config.secret,
134
+ mode: ReceiverMode.WEBSOCKET,
135
+ sandbox: config.sandbox,
136
+ dataDir: path.join(process.cwd(), 'data', 'qq'),
137
+ logLevel: toLog4jsLevel(getLevel()),
138
+ ...(config.intents ? { intents: config.intents as never } : {}),
139
+ ...(config.accessTokenUrl ? { accessTokenUrl: config.accessTokenUrl } : {}),
140
+ ...(config.gatewayUrl ? { gatewayUrl: config.gatewayUrl } : {}),
141
+ });
142
+
143
+ return {
144
+ start: () => bot.start().then(() => undefined),
145
+ stop: () => bot.stop().then(() => undefined),
146
+ on: (event, listener) => {
147
+ bot.on(event as never, listener as never);
148
+ },
149
+ removeAllListeners: () => {
150
+ bot.removeAllListeners(undefined as never);
151
+ },
152
+ sendPrivateMessage: (userId, message) => bot.sendPrivateMessage(userId, message),
153
+ sendGroupMessage: (groupId, message) => bot.sendGroupMessage(groupId, message),
154
+ sendGuildMessage: (channelId, message) => bot.sendGuildMessage(channelId, message),
155
+ sendDirectMessage: (guildId, message) => bot.sendDirectMessage(guildId, message),
156
+ getGuilds: () => bot.guildService.getList(),
157
+ getChannels: (guildId) => bot.channelService.getList(guildId),
158
+ getChannelInfo: (channelId) => bot.channelService.getInfo(channelId),
159
+ getGuildMember: (guildId, userId) => bot.memberService.getGuildMemberInfo(guildId, userId),
160
+ getGuildRoles: (guildId) => bot.guildService.getRoles(guildId),
161
+ createGuildRole: (guildId, name, color) =>
162
+ bot.guildService.createRole(guildId, { name, color: color || 0, hoist: 0 }),
163
+ addMemberRole: async (guildId, channelId, userId, roleId) => {
164
+ await bot.memberService.addMemberRole(guildId, channelId, userId, roleId);
165
+ return true;
166
+ },
167
+ removeMemberRole: async (guildId, channelId, userId, roleId) => {
168
+ await bot.memberService.removeMemberRole(guildId, channelId, userId, roleId);
169
+ return true;
170
+ },
171
+ };
172
+ }