@zhin.js/adapter-qq 5.0.3 → 6.0.1

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 (44) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +36 -4
  3. package/adapters/qq.ts +27 -8
  4. package/commands/endpoint/add/[name:string=].ts +3 -0
  5. package/commands/endpoint/cancel.ts +15 -0
  6. package/commands/endpoint/list.ts +3 -0
  7. package/commands/endpoint/remove/[name:string].ts +3 -0
  8. package/lib/endpoint.d.ts +7 -1
  9. package/lib/endpoint.js +49 -4
  10. package/lib/index.d.ts +1 -0
  11. package/lib/index.js +1 -0
  12. package/lib/outbound.d.ts +17 -0
  13. package/lib/outbound.js +241 -0
  14. package/lib/protocol.js +15 -2
  15. package/lib/qq-bind-api.d.ts +36 -0
  16. package/lib/qq-bind-api.js +89 -0
  17. package/lib/qq-bind-flow.d.ts +19 -0
  18. package/lib/qq-bind-flow.js +85 -0
  19. package/lib/qq-bind-persist.d.ts +12 -0
  20. package/lib/qq-bind-persist.js +22 -0
  21. package/lib/qq-endpoint-commands.d.ts +38 -0
  22. package/lib/qq-endpoint-commands.js +122 -0
  23. package/lib/qq-endpoint-config.d.ts +17 -0
  24. package/lib/qq-endpoint-config.js +22 -0
  25. package/lib/qq-runtime-state.d.ts +17 -0
  26. package/lib/qq-runtime-state.js +13 -0
  27. package/lib/webhook.js +4 -11
  28. package/lib/ws.d.ts +25 -4
  29. package/lib/ws.js +18 -1
  30. package/package.json +19 -13
  31. package/plugin.ts +4 -1
  32. package/schema.json +47 -13
  33. package/src/endpoint.ts +61 -5
  34. package/src/index.ts +7 -0
  35. package/src/outbound.ts +262 -0
  36. package/src/protocol.ts +16 -2
  37. package/src/qq-bind-api.ts +145 -0
  38. package/src/qq-bind-flow.ts +127 -0
  39. package/src/qq-bind-persist.ts +44 -0
  40. package/src/qq-endpoint-commands.ts +160 -0
  41. package/src/qq-endpoint-config.ts +44 -0
  42. package/src/qq-runtime-state.ts +36 -0
  43. package/src/webhook.ts +9 -15
  44. package/src/ws.ts +37 -10
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-qq",
3
- "version": "5.0.3",
3
+ "version": "6.0.1",
4
4
  "description": "Zhin.js QQ Official adapter for Plugin Runtime (WebSocket gateway)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -35,19 +35,20 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "qq-official-bot": "^1.2.3",
38
- "@zhin.js/adapter": "1.0.1",
39
- "@zhin.js/core": "1.3.5",
40
- "@zhin.js/host-http": "1.0.1",
38
+ "@zhin.js/adapter": "1.1.1",
39
+ "@zhin.js/command": "1.0.3",
40
+ "@zhin.js/core": "1.4.1",
41
+ "@zhin.js/host-http": "1.0.3",
41
42
  "@zhin.js/logger": "1.0.75",
42
- "@zhin.js/plugin-runtime": "1.0.1"
43
+ "@zhin.js/plugin-runtime": "1.1.1"
43
44
  },
44
45
  "peerDependencies": {
45
46
  "zod": "^4.0.0",
46
- "@zhin.js/adapter": "1.0.1",
47
- "@zhin.js/agent": "1.0.4",
48
- "@zhin.js/core": "1.3.5",
49
- "@zhin.js/plugin-runtime": "1.0.1",
50
- "zhin.js": "4.1.3"
47
+ "@zhin.js/adapter": "1.1.1",
48
+ "@zhin.js/agent": "1.0.6",
49
+ "@zhin.js/core": "1.4.1",
50
+ "@zhin.js/plugin-runtime": "1.1.1",
51
+ "zhin.js": "5.0.1"
51
52
  },
52
53
  "peerDependenciesMeta": {
53
54
  "zhin.js": {
@@ -65,12 +66,13 @@
65
66
  "typescript": "^6.0.3",
66
67
  "vitest": "^4.1.10",
67
68
  "zod": "^4.4.3",
68
- "@zhin.js/agent": "1.0.4",
69
- "@zhin.js/host-http": "1.0.1",
70
- "zhin.js": "4.1.3"
69
+ "@zhin.js/agent": "1.0.6",
70
+ "@zhin.js/host-http": "1.0.3",
71
+ "zhin.js": "5.0.1"
71
72
  },
72
73
  "files": [
73
74
  "adapters",
75
+ "commands",
74
76
  "plugin.ts",
75
77
  "schema.json",
76
78
  "src",
@@ -96,6 +98,10 @@
96
98
  {
97
99
  "package": "@zhin.js/adapter",
98
100
  "api": "^1.0.0"
101
+ },
102
+ {
103
+ "package": "@zhin.js/command",
104
+ "api": "^1.0.0"
99
105
  }
100
106
  ],
101
107
  "plugins": []
package/plugin.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  import { definePlugin } from '@zhin.js/plugin-runtime';
2
2
  import { registerQqPlatformPermitChecker } from './src/platform-permit.js';
3
+ import { createQqRuntimeState, qqRuntimeStateToken } from './src/qq-runtime-state.js';
3
4
 
4
5
  export default definePlugin({
5
6
  name: 'qq',
6
7
  metadata: {
7
8
  displayName: 'QQ Official WebSocket Adapter',
8
9
  },
9
- setup() {
10
+ setup(context) {
11
+ // 运行中 endpoint 注册表 + 扫码绑定单例,供 adapter create 与 `qq endpoint` 命令共享
12
+ context.resources.provide(qqRuntimeStateToken, createQqRuntimeState());
10
13
  // 平台权限门禁:guild_owner / guild_admin / manage_roles / manage_channels(agent 工具 platformPermit)
11
14
  return registerQqPlatformPermitChecker();
12
15
  },
package/schema.json CHANGED
@@ -3,19 +3,13 @@
3
3
  "type": "object",
4
4
  "additionalProperties": false,
5
5
  "properties": {
6
- "name": {
7
- "type": "string",
8
- "default": "qq-bot"
9
- },
10
- "appid": {
11
- "type": "string"
12
- },
13
- "secret": {
14
- "type": "string"
15
- },
16
6
  "mode": {
17
7
  "type": "string",
18
- "enum": ["websocket", "webhook", "middleware"],
8
+ "enum": [
9
+ "websocket",
10
+ "webhook",
11
+ "middleware"
12
+ ],
19
13
  "default": "websocket",
20
14
  "description": "WebSocket gateway (default). webhook/middleware use httpHostToken POST."
21
15
  },
@@ -23,9 +17,15 @@
23
17
  "type": "boolean",
24
18
  "default": false
25
19
  },
20
+ "master": {
21
+ "type": ["string", "number"],
22
+ "description": "Endpoint Owner(qq endpoint 管理命令权限、AI master 角色);endpoints[i] 可逐项覆盖"
23
+ },
26
24
  "intents": {
27
25
  "type": "array",
28
- "items": { "type": "string" }
26
+ "items": {
27
+ "type": "string"
28
+ }
29
29
  },
30
30
  "accessTokenUrl": {
31
31
  "type": "string"
@@ -45,7 +45,41 @@
45
45
  "path": {
46
46
  "type": "string",
47
47
  "description": "Legacy standalone webhook path (unused with httpHostToken)."
48
+ },
49
+ "endpoints": {
50
+ "type": "array",
51
+ "description": "多账号:一个插件实例挂多个 endpoint。每项与顶层字段同构(name 必填,其余覆盖顶层)",
52
+ "items": {
53
+ "type": "object",
54
+ "additionalProperties": true,
55
+ "properties": {
56
+ "name": {
57
+ "type": "string",
58
+ "description": "QQ bot name"
59
+ },
60
+ "appid": {
61
+ "type": "string",
62
+ "description": "QQ official bot app ID"
63
+ },
64
+ "secret": {
65
+ "type": "string",
66
+ "description": "QQ official bot secret"
67
+ }
68
+ },
69
+ "required": [
70
+ "name",
71
+ "appid",
72
+ "secret"
73
+ ]
74
+ }
75
+ },
76
+ "commandPrefix": {
77
+ "type": "string",
78
+ "default": "",
79
+ "description": "命令前缀(默认 '' 无前缀,任意文本按命令匹配;如 '/' 要求 / 开头)。endpoints[i] 可逐项覆盖"
48
80
  }
49
81
  },
50
- "required": ["appid", "secret"]
82
+ "required": [
83
+ "endpoints"
84
+ ]
51
85
  }
package/src/endpoint.ts CHANGED
@@ -1,16 +1,20 @@
1
1
  /**
2
2
  * QQ endpoints — lifecycle, outbound, admit, agent tool surface.
3
3
  */
4
- import type { EndpointInstance } from '@zhin.js/adapter';
4
+ import type {
5
+ EndpointChannel,
6
+ EndpointInstance,
7
+ EndpointManagement,
8
+ } from '@zhin.js/adapter';
5
9
  import type { MessageGateway } from '@zhin.js/core/runtime';
6
10
  import type { HttpHost } from '@zhin.js/host-http';
7
11
  import { formatCompact, getLogger } from '@zhin.js/logger';
8
12
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
13
+ import { formatOutbound } from './outbound.js';
9
14
  import { registerQqAgentEndpoint } from './qq-agent-deps.js';
10
15
  import {
11
16
  formatInboundContent,
12
17
  formatInboundTarget,
13
- formatOutboundText,
14
18
  parseSendTarget,
15
19
  resolveOutboundMessageId,
16
20
  senderDisplayName,
@@ -51,12 +55,18 @@ export class QqWebsocketEndpoint implements EndpointInstance {
51
55
  #open = false;
52
56
  #started = false;
53
57
  #unregisterAgent?: () => void;
58
+ readonly management: EndpointManagement = createQqEndpointManagement(this);
54
59
 
55
60
  constructor(options: QqEndpointOptions) {
56
61
  this.#options = options;
57
62
  this.#createBot = options.createBot ?? defaultCreateBot;
58
63
  }
59
64
 
65
+ /** Live endpoint 名(Console/AdapterIndex 展示用,如 bot appid 别名)。 */
66
+ get name(): string {
67
+ return this.#options.config.name;
68
+ }
69
+
60
70
  async start(): Promise<void> {
61
71
  if (this.#started) return;
62
72
  this.#started = true;
@@ -74,7 +84,11 @@ export class QqWebsocketEndpoint implements EndpointInstance {
74
84
  } catch (error) {
75
85
  await this.stop();
76
86
  logger.error('Failed to connect QQ websocket:', error);
77
- throw error;
87
+ const raw = error instanceof Error ? error.message : String(error);
88
+ throw new Error(
89
+ `QQ WebSocket 连接失败:请检查 appid/secret 是否配对、网关地址(gatewayUrl/accessTokenUrl)是否可达(原始错误:${raw})`,
90
+ { cause: error },
91
+ );
78
92
  }
79
93
  }
80
94
 
@@ -104,7 +118,7 @@ export class QqWebsocketEndpoint implements EndpointInstance {
104
118
  }
105
119
 
106
120
  async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
107
- const body = formatOutboundText(payload);
121
+ const body = formatOutbound(payload);
108
122
  const parsed = parseSendTarget(target);
109
123
  const bot = this.#requireBot();
110
124
  let result: unknown;
@@ -227,12 +241,18 @@ export class QqHttpEndpoint implements EndpointInstance {
227
241
  #open = false;
228
242
  #started = false;
229
243
  #unregisterAgent?: () => void;
244
+ readonly management: EndpointManagement = createQqEndpointManagement(this);
230
245
 
231
246
  constructor(options: QqHttpEndpointOptions) {
232
247
  this.#options = options;
233
248
  this.#createBot = options.createBot ?? defaultCreateHttpBot;
234
249
  }
235
250
 
251
+ /** Live endpoint 名(Console/AdapterIndex 展示用)。 */
252
+ get name(): string {
253
+ return this.#options.config.name;
254
+ }
255
+
236
256
  async start(): Promise<void> {
237
257
  if (this.#started) return;
238
258
  this.#started = true;
@@ -285,7 +305,7 @@ export class QqHttpEndpoint implements EndpointInstance {
285
305
  }
286
306
 
287
307
  async send({ target, payload }: { readonly target: string; readonly payload: unknown }): Promise<string> {
288
- const body = formatOutboundText(payload);
308
+ const body = formatOutbound(payload);
289
309
  const parsed = parseSendTarget(target);
290
310
  const bot = this.#requireBot();
291
311
  let result: unknown;
@@ -387,3 +407,39 @@ export class QqHttpEndpoint implements EndpointInstance {
387
407
  return this.#bot;
388
408
  }
389
409
  }
410
+
411
+ function createQqEndpointManagement(endpoint: {
412
+ getGuilds(): Promise<unknown[]>;
413
+ getChannels(guildId: string): Promise<unknown[]>;
414
+ }): EndpointManagement {
415
+ return Object.freeze<EndpointManagement>({
416
+ async listChannels(): Promise<readonly EndpointChannel[]> {
417
+ const channels: EndpointChannel[] = [];
418
+ const guilds = await endpoint.getGuilds();
419
+ for (const guildValue of Array.isArray(guilds) ? guilds : []) {
420
+ const guild = asRecord(guildValue);
421
+ const guildId = String(guild.id ?? guild.guild_id ?? guildValue ?? '');
422
+ if (!guildId) continue;
423
+ const guildName = String(guild.name ?? guild.guild_name ?? guildId);
424
+ const rows = await endpoint.getChannels(guildId);
425
+ for (const channelValue of Array.isArray(rows) ? rows : []) {
426
+ const channel = asRecord(channelValue);
427
+ const id = String(channel.id ?? channel.channel_id ?? channelValue ?? '');
428
+ if (!id) continue;
429
+ channels.push({
430
+ id,
431
+ name: String(channel.name ?? channel.channel_name ?? id),
432
+ parent: { type: 'guild', id: guildId, name: guildName },
433
+ });
434
+ }
435
+ }
436
+ return channels;
437
+ },
438
+ });
439
+ }
440
+
441
+ function asRecord(value: unknown): Record<string, unknown> {
442
+ return value !== null && typeof value === 'object'
443
+ ? value as Record<string, unknown>
444
+ : {};
445
+ }
package/src/index.ts CHANGED
@@ -16,6 +16,13 @@ export {
16
16
  type ResolvedQqWebsocketConfig,
17
17
  } from './protocol.js';
18
18
 
19
+ export {
20
+ formatOutbound,
21
+ resolveMediaFile,
22
+ type QqOutboundElem,
23
+ type QqOutboundMessage,
24
+ } from './outbound.js';
25
+
19
26
  export {
20
27
  QqHttpEndpoint,
21
28
  QqWebsocketEndpoint,
@@ -0,0 +1,262 @@
1
+ /**
2
+ * QQ 官方出站富媒体归一化:把渲染后的 payload 转为 qq-official-bot 可识别的
3
+ * Sendable 结构(文本串 + 消息段)。移植自 legacy 版 outbound-media /
4
+ * outbound-markdown / outbound-keyboard。
5
+ *
6
+ * 移植面:
7
+ * - image:http(s) URL 直发(data.url);base64://、data:...;base64、本地路径
8
+ * 归一到 data.file,由 SDK formatMediaData 走媒体上传(v2 /files)。
9
+ * - markdown:data.content 原文,或 custom_template_id + params 模板。
10
+ * - keyboard:data.rows 按钮矩阵展开为 button 段(SDK processButtons 组装
11
+ * keyboard 载荷);按钮消息必须带 markdown,故同行文本合并为 markdown 段。
12
+ * data.id 模板键盘原样透传。
13
+ * 不可映射的段一律降级为文本并 warn(保持既有 fallback 行为)。
14
+ */
15
+ import { formatCompact, getLogger } from '@zhin.js/logger';
16
+ import type { QqWireSegment } from './protocol.js';
17
+
18
+ const logger = getLogger('qq');
19
+
20
+ /** qq-official-bot Sendable 的最小结构子集(字符串模板或消息段)。 */
21
+ export interface QqOutboundElem {
22
+ readonly type: string;
23
+ readonly data: Record<string, unknown>;
24
+ }
25
+
26
+ export type QqOutboundMessage = string | QqOutboundElem | readonly (string | QqOutboundElem)[];
27
+
28
+ interface QqOutboundButtonSpec {
29
+ readonly id?: string;
30
+ readonly label?: string;
31
+ readonly payload?: string;
32
+ readonly disabled?: boolean;
33
+ readonly mode?: string;
34
+ readonly command?: { readonly enter?: boolean; readonly reply?: boolean };
35
+ }
36
+
37
+ function isRemoteUrl(value: string): boolean {
38
+ return /^https?:\/\//i.test(value);
39
+ }
40
+
41
+ function isInlineBase64(value: string): boolean {
42
+ return value.startsWith('base64://') || /^data:[^/]+\/[^;]+;base64,/i.test(value);
43
+ }
44
+
45
+ /**
46
+ * 解析 image/audio/video/file 段的本地或内联 payload。
47
+ * qq-official-bot 的 formatMediaData 对 data.url 直接当远程 URL;
48
+ * base64:// / data:...;base64 / 本地路径须走 data.file 才会解析为 file_data。
49
+ */
50
+ export function resolveMediaFile(data: Record<string, unknown>): string | undefined {
51
+ const url = typeof data.url === 'string' ? data.url : undefined;
52
+ const file = typeof data.file === 'string' ? data.file : undefined;
53
+ const base64 = typeof data.base64 === 'string' ? data.base64 : undefined;
54
+
55
+ if (file && !isRemoteUrl(file)) return file;
56
+ if (url && isInlineBase64(url)) return url;
57
+ if (url && !isRemoteUrl(url) && (url.startsWith('file://') || url.startsWith('/'))) return url;
58
+ if (base64) return base64.startsWith('base64://') ? base64 : `base64://${base64}`;
59
+ return undefined;
60
+ }
61
+
62
+ function normalizeMediaSegment(seg: QqWireSegment): QqOutboundElem | null {
63
+ const data = { ...(seg.data ?? {}) };
64
+ const file = resolveMediaFile(data);
65
+ if (file) {
66
+ delete data.url;
67
+ delete data.base64;
68
+ data.file = file;
69
+ return { type: seg.type, data };
70
+ }
71
+ const url = typeof data.url === 'string' ? data.url
72
+ : typeof data.file === 'string' ? data.file
73
+ : undefined;
74
+ if (url && isRemoteUrl(url)) {
75
+ delete data.base64;
76
+ return { type: seg.type, data: { ...data, url } };
77
+ }
78
+ // 既无远程 URL 也无本地上传源:无法投递,降级留痕
79
+ logger.warn(formatCompact({
80
+ op: 'qq_outbound_media_dropped',
81
+ type: seg.type,
82
+ reason: 'missing_source',
83
+ }));
84
+ return null;
85
+ }
86
+
87
+ function normalizeMarkdownSegment(seg: QqWireSegment): QqOutboundElem | null {
88
+ const data = seg.data ?? {};
89
+ const templateId = typeof data.custom_template_id === 'string' && data.custom_template_id
90
+ ? data.custom_template_id
91
+ : typeof data.template_id === 'string' && data.template_id
92
+ ? data.template_id
93
+ : undefined;
94
+ if (templateId) {
95
+ return {
96
+ type: 'markdown',
97
+ data: {
98
+ custom_template_id: templateId,
99
+ params: Array.isArray(data.params) ? data.params : [],
100
+ },
101
+ };
102
+ }
103
+ const content = String(data.content ?? data.text ?? '');
104
+ if (!content.trim()) {
105
+ logger.warn(formatCompact({ op: 'qq_outbound_markdown_dropped', reason: 'empty_content' }));
106
+ return null;
107
+ }
108
+ return { type: 'markdown', data: { content } };
109
+ }
110
+
111
+ function coreButtonToQq(btn: QqOutboundButtonSpec): Record<string, unknown> {
112
+ const isCommand = btn.mode === 'command';
113
+ const action: Record<string, unknown> = {
114
+ type: isCommand ? 2 : 1,
115
+ permission: { type: 2 },
116
+ data: btn.payload ?? '',
117
+ click_limit: btn.disabled ? 0 : 10,
118
+ unsupport_tips: btn.disabled ? '该按钮不可用' : '',
119
+ };
120
+ if (isCommand) {
121
+ if (btn.command?.enter != null) action.enter = btn.command.enter;
122
+ if (btn.command?.reply != null) action.reply = btn.command.reply;
123
+ }
124
+ return {
125
+ id: btn.id ?? '',
126
+ render_data: {
127
+ label: btn.label ?? '',
128
+ visited_label: btn.label ?? '',
129
+ style: 0,
130
+ },
131
+ action,
132
+ };
133
+ }
134
+
135
+ /** keyboard 段 → 模板键盘(data.id)或 button 行段(data.rows) */
136
+ function expandKeyboardSegment(seg: QqWireSegment): QqOutboundElem[] {
137
+ const data = seg.data ?? {};
138
+ if (typeof data.id === 'string' && data.id) {
139
+ return [{ type: 'keyboard', data: { id: data.id } }];
140
+ }
141
+ if (!Array.isArray(data.rows)) {
142
+ logger.warn(formatCompact({ op: 'qq_outbound_keyboard_dropped', reason: 'missing_rows' }));
143
+ return [];
144
+ }
145
+ const out: QqOutboundElem[] = [];
146
+ for (const row of data.rows) {
147
+ if (!Array.isArray(row)) continue;
148
+ out.push({
149
+ type: 'button',
150
+ data: {
151
+ buttons: row.map((btn) => coreButtonToQq((btn ?? {}) as QqOutboundButtonSpec)),
152
+ },
153
+ });
154
+ }
155
+ return out;
156
+ }
157
+
158
+ function toSegmentList(payload: unknown): Array<string | QqWireSegment> {
159
+ if (Array.isArray(payload)) return payload as Array<string | QqWireSegment>;
160
+ if (payload && typeof payload === 'object' && 'type' in payload) {
161
+ return [payload as QqWireSegment];
162
+ }
163
+ return [];
164
+ }
165
+
166
+ function textFromUnknownSegment(seg: QqWireSegment): string {
167
+ const data = seg.data ?? {};
168
+ return data.text != null ? String(data.text) : '';
169
+ }
170
+
171
+ /**
172
+ * 渲染后 payload → QQ Sendable。
173
+ * 纯文本输入原样返回(与既有行为一致);含富媒体段时返回段数组。
174
+ */
175
+ export function formatOutbound(payload: unknown): QqOutboundMessage {
176
+ if (typeof payload === 'string') return payload;
177
+
178
+ const segments = toSegmentList(payload);
179
+ if (segments.length === 0) {
180
+ return payload == null
181
+ ? ''
182
+ : typeof payload === 'object'
183
+ ? JSON.stringify(payload)
184
+ : String(payload);
185
+ }
186
+
187
+ // QQ 按钮消息必须携带 markdown(msg_type=2),keyboard 存在时文本合并为 markdown 段
188
+ const hasKeyboard = segments.some(
189
+ (item) => typeof item !== 'string' && item.type === 'keyboard',
190
+ );
191
+
192
+ const parts: Array<string | QqOutboundElem> = [];
193
+ let textBuf = '';
194
+ const flushText = (): void => {
195
+ if (!textBuf) return;
196
+ parts.push(hasKeyboard
197
+ ? { type: 'markdown', data: { content: textBuf } }
198
+ : textBuf);
199
+ textBuf = '';
200
+ };
201
+
202
+ for (const item of segments) {
203
+ if (typeof item === 'string') {
204
+ textBuf += item;
205
+ continue;
206
+ }
207
+ const data = item.data ?? {};
208
+ switch (item.type) {
209
+ case 'text':
210
+ textBuf += String(data.text ?? data.content ?? '');
211
+ break;
212
+ case 'at': {
213
+ const id = String(data.user_id ?? data.qq ?? data.id ?? '');
214
+ if (!id) break;
215
+ flushText();
216
+ parts.push({ type: 'at', data: { user_id: id } });
217
+ break;
218
+ }
219
+ case 'image':
220
+ case 'audio':
221
+ case 'video':
222
+ case 'file': {
223
+ flushText();
224
+ const media = normalizeMediaSegment(item);
225
+ if (media) parts.push(media);
226
+ break;
227
+ }
228
+ case 'markdown': {
229
+ flushText();
230
+ const markdown = normalizeMarkdownSegment(item);
231
+ if (markdown) parts.push(markdown);
232
+ break;
233
+ }
234
+ case 'keyboard': {
235
+ flushText();
236
+ parts.push(...expandKeyboardSegment(item));
237
+ break;
238
+ }
239
+ case 'reply': {
240
+ const id = data.id ?? data.event_id;
241
+ if (id == null || id === '') break;
242
+ flushText();
243
+ parts.push({
244
+ type: 'reply',
245
+ data: data.event_id != null
246
+ ? { event_id: String(data.event_id) }
247
+ : { id: String(id) },
248
+ });
249
+ break;
250
+ }
251
+ default:
252
+ // 未识别段:保持既有文本降级
253
+ textBuf += textFromUnknownSegment(item);
254
+ break;
255
+ }
256
+ }
257
+ flushText();
258
+
259
+ if (parts.length === 0) return '';
260
+ if (parts.length === 1) return parts[0]!;
261
+ return parts;
262
+ }
package/src/protocol.ts CHANGED
@@ -4,6 +4,9 @@
4
4
  */
5
5
 
6
6
  import { pickCredential } from '@zhin.js/adapter';
7
+ import { formatCompact, getLogger } from '@zhin.js/logger';
8
+
9
+ const logger = getLogger('qq');
7
10
 
8
11
  /** Plugin Runtime owner config (`plugins.<instanceKey>` / schema.json). */
9
12
  export interface QqAdapterConfig {
@@ -193,8 +196,19 @@ export function formatOutboundText(payload: unknown): string {
193
196
  const id = String(data.user_id ?? data.qq ?? data.id ?? '');
194
197
  return id ? `<@!${id}>` : '';
195
198
  }
196
- case 'image':
197
- return String(data.url || data.file || '');
199
+ case 'image': {
200
+ const src = String(data.url || data.file || '');
201
+ // base64/本地路径由 formatOutbound 走媒体上传,文本视图静默省略;
202
+ // 仅当彻底缺少来源时留痕,避免出站静默降级排障无头绪。
203
+ if (!src) {
204
+ logger.warn(formatCompact({
205
+ op: 'qq_outbound_image_dropped',
206
+ reason: 'missing_source',
207
+ }));
208
+ return '';
209
+ }
210
+ return /^https?:\/\//.test(src) ? src : '';
211
+ }
198
212
  case 'reply':
199
213
  return '';
200
214
  default: