@zhin.js/adapter-qq 6.0.6 → 7.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.
- package/CHANGELOG.md +37 -0
- package/README.md +5 -5
- package/adapters/qq.js +2 -2
- package/adapters/qq.ts +2 -2
- package/lib/endpoint.js +2 -0
- package/lib/outbound.d.ts +3 -3
- package/lib/outbound.js +34 -41
- package/lib/protocol.d.ts +6 -2
- package/lib/qq-endpoint-commands.d.ts +5 -5
- package/lib/qq-endpoint-commands.js +8 -8
- package/lib/ws.js +90 -3
- package/package.json +10 -10
- package/plugin.js +1 -1
- package/src/endpoint.ts +2 -0
- package/src/outbound.ts +32 -40
- package/src/protocol.ts +6 -2
- package/src/qq-endpoint-commands.ts +8 -8
- package/src/ws.ts +84 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# @zhin.js/adapter-qq
|
|
2
2
|
|
|
3
|
+
## 7.0.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 7c1e63a: feat(command)!: 命令名前缀分隔符由空格改为点号(BREAKING)
|
|
8
|
+
|
|
9
|
+
非 root 插件命令的自动前缀从空格改为点号:`qq endpoint list` → `qq.endpoint list`,多级挂载 `b a foo` → `b.a.foo`;root 插件命令不变。点号前缀同时消除了旧规则下 root 目录段与子插件 owner 段的命名冲突(`child status` vs `child.status` 现在可共存)。配套的 endpoint 管理命令套件(`createEndpointCommands`)及 QQ/ICQQ 适配器的用户可见用法文案同步更新为新风格。迁移:用户与文档中所有 `<adapter> <command>` 形式的指令改为 `<adapter>.<command>`。
|
|
10
|
+
|
|
11
|
+
- 4fbff5d: feat!: 多模态双向 Segment 一贯制(BREAKING,无兼容层)
|
|
12
|
+
|
|
13
|
+
全框架唯一媒体表达统一为 canonical `Segment` + `MediaRef{kind: url|path|base64|file, value, mime_type?, file_name?, size?}`,新增 audio/video/file 段类型;所有第二形状(legacy `data.url/file/base64` 字段、`mediaRefFromLegacyData`/`mediaRefToLegacyFields` 桥、双写)全部删除。
|
|
14
|
+
|
|
15
|
+
- **core**:`SendContent` 一等支持 `Segment[]`;endpoint 出站载荷只含 canonical 段;`resolveOutboundMediaPolicy` 改为纯声明驱动(adapter definition `segments.outboundMedia`),内置策略表删除,未声明回退 `url-or-text`;`ImageContent` 旧桥删除。
|
|
16
|
+
- **ai**:新增 `MediaContentBlock`/`MediaBlockRef`(Segment 同构)与 `UserMessage.media`(当前 turn 媒体,**不持久化**——存储层自动剥离);`createUserMessage(text, media?)` 签名变更(`ImageContent` 删除);provider 边界序列化器 `filterMediaBlocksForProvider` + 能力表(缺省 image-only,不支持类型降级占位文本);ai-sdk 桥媒体块 → SDK image/file parts。
|
|
17
|
+
- **agent**:入站 turn 注入(`turn/inbound-media.ts`)——commMessage 媒体段 → 当前 turn `UserMessage.media`;图片 path 物化、音频默认 STT(`@zhin.js/speech` 可选,失败降级占位)、视频/文件占位;`publishOutboundElements` 产出 canonical Segment;`transcribeAudioPayload` 导出。
|
|
18
|
+
- **cli**:`bridgeRuntimeMessage` 回复链路媒体段透传,不再压平为文本(`$reply` 直达 normalize → adapter)。
|
|
19
|
+
- **全部 20 个平台适配器**:出站媒体只消费 `data.media`(url 直发 / base64 直发 / 平台上传 / 读盘),入站媒体产出 canonical `data.media`;`segments.outboundMedia` 声明与实际消费逐一核对修正;QQ 入站新增 canonical segments(image/audio/video/file/mention/face/reply),图片/语音/视频不再丢失。
|
|
20
|
+
|
|
21
|
+
迁移:适配器/插件产媒体一律用 `{ type, data: { media: MediaRef } }`;发送 legacy `data.url/file/base64` 形状的段会被 warn 丢弃。
|
|
22
|
+
|
|
23
|
+
- Updated dependencies [7c1e63a]
|
|
24
|
+
- Updated dependencies [4fbff5d]
|
|
25
|
+
- Updated dependencies [5b94d9c]
|
|
26
|
+
- @zhin.js/command@1.0.5
|
|
27
|
+
- @zhin.js/adapter@1.1.3
|
|
28
|
+
- @zhin.js/core@1.5.0
|
|
29
|
+
- @zhin.js/agent@1.1.0
|
|
30
|
+
- zhin.js@6.0.0
|
|
31
|
+
|
|
32
|
+
## 6.0.7
|
|
33
|
+
|
|
34
|
+
### Patch Changes
|
|
35
|
+
|
|
36
|
+
- 7249733: fix(qq): 群消息 @ 判定改为统一按 mentions 检查,不再按 channelKind 直接视为 @
|
|
37
|
+
|
|
38
|
+
`GROUP_MESSAGE_CREATE` 与 `GROUP_AT_MESSAGE_CREATE` 在 SDK 中都映射为 `message.group`,前者会下发非 @ 的普通群消息;此前 `channelKind === 'group'` 一律置 `mentioned`,导致所有群消息都触发 AI。现统一按 `mentions` 判定:群场景只认 `is_you === true`(精确标识当前机器人;群里 @ 另一个机器人时 `bot: true` 但无 `is_you`,不误判),`bot === true` 回退仅限频道 `AT_MESSAGE_CREATE`(该事件仅 @ 当前机器人时下发,mentions 无 `is_you`)。非 @ 群消息将回到旁听(Passive Group Context)路径。
|
|
39
|
+
|
|
3
40
|
## 6.0.6
|
|
4
41
|
|
|
5
42
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -71,14 +71,14 @@ plugins:
|
|
|
71
71
|
|
|
72
72
|
## Endpoint 管理命令
|
|
73
73
|
|
|
74
|
-
适配器自带 `qq
|
|
74
|
+
适配器自带 `qq.endpoint` 命令组(聊天内直接使用,默认无前缀;受 `commandPrefix` 影响):
|
|
75
75
|
|
|
76
76
|
| 命令 | 说明 |
|
|
77
77
|
|------|------|
|
|
78
|
-
| `qq
|
|
79
|
-
| `qq
|
|
80
|
-
| `qq
|
|
81
|
-
| `qq
|
|
78
|
+
| `qq.endpoint add [name]` | 手机 QQ 扫码绑定:下发二维码链接 → 确认后凭据写入 `.env`(`QQ_<NAME>_APPID/SECRET`),并追加 `plugins.qq.endpoints`(重启生效) |
|
|
79
|
+
| `qq.endpoint cancel` | 取消进行中的扫码绑定(同时只允许一个流程) |
|
|
80
|
+
| `qq.endpoint list` | 列出运行中与配置中的 endpoints |
|
|
81
|
+
| `qq.endpoint remove <name>` | 从配置移除 endpoint(`.env` 键保留,可手动清理) |
|
|
82
82
|
|
|
83
83
|
add/cancel/remove 受 `master` 限制:实例配置声明了 `master`(顶层或 `endpoints[i]`)时仅
|
|
84
84
|
master 可执行;未配置则放行(首个扫码绑定者即 owner)。二维码当前以链接文本下发
|
package/adapters/qq.js
CHANGED
|
@@ -20,7 +20,7 @@ export default defineAdapter({
|
|
|
20
20
|
create(context) {
|
|
21
21
|
const config = resolveQqConfig(context.config);
|
|
22
22
|
const gateway = context.use(messageGatewayToken);
|
|
23
|
-
// 注册到插件运行时状态(qq
|
|
23
|
+
// 注册到插件运行时状态(qq.endpoint list 的"运行中"数据源)
|
|
24
24
|
const state = context.use(qqRuntimeStateToken);
|
|
25
25
|
state.endpoints.set(config.name, {
|
|
26
26
|
name: config.name,
|
|
@@ -38,7 +38,7 @@ export default defineAdapter({
|
|
|
38
38
|
gateway,
|
|
39
39
|
config,
|
|
40
40
|
});
|
|
41
|
-
// 运行状态表只增不减:stop 时同步摘除,避免 stop 后 endpoint list 仍显示运行中。
|
|
41
|
+
// 运行状态表只增不减:stop 时同步摘除,避免 stop 后 qq.endpoint list 仍显示运行中。
|
|
42
42
|
const stopEndpoint = endpoint.stop.bind(endpoint);
|
|
43
43
|
endpoint.stop = async () => {
|
|
44
44
|
state.endpoints.delete(config.name);
|
package/adapters/qq.ts
CHANGED
|
@@ -35,7 +35,7 @@ export default defineAdapter<QqAdapterConfig>({
|
|
|
35
35
|
create(context) {
|
|
36
36
|
const config = resolveQqConfig(context.config);
|
|
37
37
|
const gateway = context.use(messageGatewayToken);
|
|
38
|
-
// 注册到插件运行时状态(qq
|
|
38
|
+
// 注册到插件运行时状态(qq.endpoint list 的"运行中"数据源)
|
|
39
39
|
const state = context.use(qqRuntimeStateToken);
|
|
40
40
|
state.endpoints.set(config.name, {
|
|
41
41
|
name: config.name,
|
|
@@ -53,7 +53,7 @@ export default defineAdapter<QqAdapterConfig>({
|
|
|
53
53
|
gateway,
|
|
54
54
|
config,
|
|
55
55
|
});
|
|
56
|
-
// 运行状态表只增不减:stop 时同步摘除,避免 stop 后 endpoint list 仍显示运行中。
|
|
56
|
+
// 运行状态表只增不减:stop 时同步摘除,避免 stop 后 qq.endpoint list 仍显示运行中。
|
|
57
57
|
const stopEndpoint = endpoint.stop.bind(endpoint);
|
|
58
58
|
endpoint.stop = async () => {
|
|
59
59
|
state.endpoints.delete(config.name);
|
package/lib/endpoint.js
CHANGED
|
@@ -132,6 +132,7 @@ export class QqWebsocketEndpoint {
|
|
|
132
132
|
adapter: this.#options.id,
|
|
133
133
|
target,
|
|
134
134
|
content: formatInboundContent(msg),
|
|
135
|
+
...(msg.segments?.length ? { segments: msg.segments } : {}),
|
|
135
136
|
sender: senderDisplayName(msg),
|
|
136
137
|
id: msg.id,
|
|
137
138
|
metadata: Object.freeze({
|
|
@@ -311,6 +312,7 @@ export class QqHttpEndpoint {
|
|
|
311
312
|
adapter: this.#options.id,
|
|
312
313
|
target,
|
|
313
314
|
content: formatInboundContent(msg),
|
|
315
|
+
...(msg.segments?.length ? { segments: msg.segments } : {}),
|
|
314
316
|
sender: senderDisplayName(msg),
|
|
315
317
|
id: msg.id,
|
|
316
318
|
metadata: Object.freeze({
|
package/lib/outbound.d.ts
CHANGED
|
@@ -5,9 +5,9 @@ export interface QqOutboundElem {
|
|
|
5
5
|
}
|
|
6
6
|
export type QqOutboundMessage = string | QqOutboundElem | readonly (string | QqOutboundElem)[];
|
|
7
7
|
/**
|
|
8
|
-
* 解析 image/audio/video/file
|
|
9
|
-
*
|
|
10
|
-
* base64
|
|
8
|
+
* 解析 image/audio/video/file 段的投递源(canonical MediaRef 唯一来源):
|
|
9
|
+
* - kind=url → data.url 直发;
|
|
10
|
+
* - kind=base64 / path / file → data.file,由 SDK formatMediaData 走媒体上传(v2 /files)。
|
|
11
11
|
*/
|
|
12
12
|
export declare function resolveMediaFile(data: Record<string, unknown>): string | undefined;
|
|
13
13
|
/**
|
package/lib/outbound.js
CHANGED
|
@@ -13,55 +13,48 @@
|
|
|
13
13
|
* 不可映射的段一律降级为文本并 warn(保持既有 fallback 行为)。
|
|
14
14
|
*/
|
|
15
15
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
16
|
+
import { isMediaRef } from '@zhin.js/core';
|
|
16
17
|
const logger = getLogger('qq');
|
|
17
|
-
function isRemoteUrl(value) {
|
|
18
|
-
return /^https?:\/\//i.test(value);
|
|
19
|
-
}
|
|
20
|
-
function isInlineBase64(value) {
|
|
21
|
-
return value.startsWith('base64://') || /^data:[^/]+\/[^;]+;base64,/i.test(value);
|
|
22
|
-
}
|
|
23
18
|
/**
|
|
24
|
-
* 解析 image/audio/video/file
|
|
25
|
-
*
|
|
26
|
-
* base64
|
|
19
|
+
* 解析 image/audio/video/file 段的投递源(canonical MediaRef 唯一来源):
|
|
20
|
+
* - kind=url → data.url 直发;
|
|
21
|
+
* - kind=base64 / path / file → data.file,由 SDK formatMediaData 走媒体上传(v2 /files)。
|
|
27
22
|
*/
|
|
28
23
|
export function resolveMediaFile(data) {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (
|
|
33
|
-
return
|
|
34
|
-
if (
|
|
35
|
-
return
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (base64)
|
|
39
|
-
return base64.startsWith('base64://') ? base64 : `base64://${base64}`;
|
|
40
|
-
return undefined;
|
|
24
|
+
const media = data.media;
|
|
25
|
+
if (!isMediaRef(media))
|
|
26
|
+
return undefined;
|
|
27
|
+
if (media.kind === 'url')
|
|
28
|
+
return undefined;
|
|
29
|
+
if (media.kind === 'base64') {
|
|
30
|
+
return media.value.startsWith('base64://') ? media.value : `base64://${media.value}`;
|
|
31
|
+
}
|
|
32
|
+
return media.value;
|
|
41
33
|
}
|
|
42
34
|
function normalizeMediaSegment(seg) {
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
35
|
+
const media = (seg.data ?? {}).media;
|
|
36
|
+
if (!isMediaRef(media)) {
|
|
37
|
+
// 无 canonical 媒体引用:无法投递,降级留痕
|
|
38
|
+
logger.warn(formatCompact({
|
|
39
|
+
op: 'qq_outbound_media_dropped',
|
|
40
|
+
type: seg.type,
|
|
41
|
+
reason: 'missing_media_ref',
|
|
42
|
+
}));
|
|
43
|
+
return null;
|
|
50
44
|
}
|
|
51
|
-
|
|
52
|
-
:
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
45
|
+
if (media.kind === 'url') {
|
|
46
|
+
return { type: seg.type, data: { url: media.value } };
|
|
47
|
+
}
|
|
48
|
+
const file = resolveMediaFile(seg.data ?? {});
|
|
49
|
+
if (!file) {
|
|
50
|
+
logger.warn(formatCompact({
|
|
51
|
+
op: 'qq_outbound_media_dropped',
|
|
52
|
+
type: seg.type,
|
|
53
|
+
reason: 'missing_source',
|
|
54
|
+
}));
|
|
55
|
+
return null;
|
|
57
56
|
}
|
|
58
|
-
|
|
59
|
-
logger.warn(formatCompact({
|
|
60
|
-
op: 'qq_outbound_media_dropped',
|
|
61
|
-
type: seg.type,
|
|
62
|
-
reason: 'missing_source',
|
|
63
|
-
}));
|
|
64
|
-
return null;
|
|
57
|
+
return { type: seg.type, data: { file } };
|
|
65
58
|
}
|
|
66
59
|
function normalizeMarkdownSegment(seg) {
|
|
67
60
|
const data = seg.data ?? {};
|
package/lib/protocol.d.ts
CHANGED
|
@@ -65,9 +65,13 @@ export interface QqInboundMessage {
|
|
|
65
65
|
readonly timestamp: number;
|
|
66
66
|
readonly guildId?: string;
|
|
67
67
|
readonly rawMessage?: string;
|
|
68
|
+
/** canonical 入站段(text/mention/face/image/audio/video/file/reply),与 content 同源 */
|
|
69
|
+
readonly segments?: readonly import('@zhin.js/core').Segment[];
|
|
68
70
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
+
* 入站是否 @ 当前机器人:统一按 mentions 判定。群场景只认 is_you(@ 另一个
|
|
72
|
+
* 机器人时 bot:true 但无 is_you,不误判);bot 回退仅限频道 AT 事件。
|
|
73
|
+
* 群 GROUP_MESSAGE_CREATE 的非 @ 消息不置位。新 Runtime 纯文本 content 需经
|
|
74
|
+
* metadata 传递。
|
|
71
75
|
*/
|
|
72
76
|
readonly mentioned?: boolean;
|
|
73
77
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `qq
|
|
2
|
+
* `qq.endpoint` 命令的业务逻辑(与命令定义文件分离,便于测试)。
|
|
3
3
|
* 通用部分(权限 / list / remove / 配置写回 / .env 持久化)已迁移到
|
|
4
4
|
* @zhin.js/adapter 的 createEndpointCommands 套件;本文件保留 QQ 特化的
|
|
5
5
|
* 扫码绑定 add 流程(经套件 bindFlow 钩子接入)与 cancel,并导出
|
|
@@ -19,17 +19,17 @@ export declare const QQ_ENDPOINT_FORBIDDEN: string;
|
|
|
19
19
|
* 从命令 input(Runtime Message)提取 $reply;非消息来源(如 Host API 调用)降级为 no-op。
|
|
20
20
|
*/
|
|
21
21
|
export declare function extractQqCommandReply(input: unknown): QqCommandReply;
|
|
22
|
-
/** `qq
|
|
22
|
+
/** `qq.endpoint list`:运行中的 endpoints(本 generation adapter create 注册)+ 配置里的 endpoints */
|
|
23
23
|
export declare function runQqEndpointList(state: QqRuntimeState, projectRoot?: string): string;
|
|
24
24
|
/**
|
|
25
|
-
* `qq
|
|
25
|
+
* `qq.endpoint add [name]`:启动扫码绑定流程。
|
|
26
26
|
* 返回的 Promise 在二维码链接就绪(或前置失败)时 resolve 为首条回复;
|
|
27
27
|
* 后续状态(已扫码 / 成功 / 失败 / 过期刷新)通过 reply 推回当前会话。
|
|
28
28
|
*/
|
|
29
29
|
export declare function runQqEndpointAdd(state: QqRuntimeState, name: string | undefined, reply: QqCommandReply, projectRoot?: string): Promise<string>;
|
|
30
|
-
/** `qq
|
|
30
|
+
/** `qq.endpoint cancel`:中止进行中的绑定流程 */
|
|
31
31
|
export declare function runQqEndpointCancel(state: QqRuntimeState): string;
|
|
32
|
-
/** `qq
|
|
32
|
+
/** `qq.endpoint remove <name>`:从 zhin.config.yml 移除对应 endpoints 项 */
|
|
33
33
|
export declare function runQqEndpointRemove(_state: QqRuntimeState, name: string, projectRoot?: string): string;
|
|
34
34
|
/**
|
|
35
35
|
* 通用套件生成的 QQ endpoint 命令(add 经 bindFlow 钩子走扫码绑定)。
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `qq
|
|
2
|
+
* `qq.endpoint` 命令的业务逻辑(与命令定义文件分离,便于测试)。
|
|
3
3
|
* 通用部分(权限 / list / remove / 配置写回 / .env 持久化)已迁移到
|
|
4
4
|
* @zhin.js/adapter 的 createEndpointCommands 套件;本文件保留 QQ 特化的
|
|
5
5
|
* 扫码绑定 add 流程(经套件 bindFlow 钩子接入)与 cancel,并导出
|
|
@@ -26,12 +26,12 @@ export const QQ_ENDPOINT_FORBIDDEN = endpointCommandForbidden('QQ');
|
|
|
26
26
|
export function extractQqCommandReply(input) {
|
|
27
27
|
return extractEndpointCommandReply(input);
|
|
28
28
|
}
|
|
29
|
-
/** `qq
|
|
29
|
+
/** `qq.endpoint list`:运行中的 endpoints(本 generation adapter create 注册)+ 配置里的 endpoints */
|
|
30
30
|
export function runQqEndpointList(state, projectRoot) {
|
|
31
31
|
return formatEndpointList(qqEndpointListSpec, {
|
|
32
32
|
running: state.endpoints.values(),
|
|
33
33
|
configured: listQqEndpointEntries(projectRoot),
|
|
34
|
-
footer: state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq
|
|
34
|
+
footer: state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq.endpoint cancel 取消' : undefined,
|
|
35
35
|
});
|
|
36
36
|
}
|
|
37
37
|
const qqEndpointListSpec = {
|
|
@@ -40,13 +40,13 @@ const qqEndpointListSpec = {
|
|
|
40
40
|
describeEntry: (entry) => `appid: ${String(entry.appid)}`,
|
|
41
41
|
};
|
|
42
42
|
/**
|
|
43
|
-
* `qq
|
|
43
|
+
* `qq.endpoint add [name]`:启动扫码绑定流程。
|
|
44
44
|
* 返回的 Promise 在二维码链接就绪(或前置失败)时 resolve 为首条回复;
|
|
45
45
|
* 后续状态(已扫码 / 成功 / 失败 / 过期刷新)通过 reply 推回当前会话。
|
|
46
46
|
*/
|
|
47
47
|
export function runQqEndpointAdd(state, name, reply, projectRoot) {
|
|
48
48
|
if (state.bindFlow) {
|
|
49
|
-
return Promise.resolve('已有进行中的 QQ 机器人绑定,请先发送 qq
|
|
49
|
+
return Promise.resolve('已有进行中的 QQ 机器人绑定,请先发送 qq.endpoint cancel 取消后再试');
|
|
50
50
|
}
|
|
51
51
|
const endpointName = name?.trim() || undefined;
|
|
52
52
|
return new Promise((resolve) => {
|
|
@@ -89,7 +89,7 @@ export function runQqEndpointAdd(state, name, reply, projectRoot) {
|
|
|
89
89
|
state.bindFlow = { name: endpointName, stop };
|
|
90
90
|
});
|
|
91
91
|
}
|
|
92
|
-
/** `qq
|
|
92
|
+
/** `qq.endpoint cancel`:中止进行中的绑定流程 */
|
|
93
93
|
export function runQqEndpointCancel(state) {
|
|
94
94
|
if (!state.bindFlow) {
|
|
95
95
|
return '当前没有进行中的 QQ 绑定流程';
|
|
@@ -98,7 +98,7 @@ export function runQqEndpointCancel(state) {
|
|
|
98
98
|
state.bindFlow = null;
|
|
99
99
|
return '已取消进行中的 QQ 绑定流程';
|
|
100
100
|
}
|
|
101
|
-
/** `qq
|
|
101
|
+
/** `qq.endpoint remove <name>`:从 zhin.config.yml 移除对应 endpoints 项 */
|
|
102
102
|
export function runQqEndpointRemove(_state, name, projectRoot) {
|
|
103
103
|
return removeEndpointByName(qqEndpointListSpec, name, projectRoot);
|
|
104
104
|
}
|
|
@@ -115,7 +115,7 @@ export const qqEndpointCommands = createEndpointCommands({
|
|
|
115
115
|
running: (use) => use(qqRuntimeStateToken).endpoints.values(),
|
|
116
116
|
listFooter: (use) => {
|
|
117
117
|
const state = use(qqRuntimeStateToken);
|
|
118
|
-
return state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq
|
|
118
|
+
return state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq.endpoint cancel 取消' : undefined;
|
|
119
119
|
},
|
|
120
120
|
addDescription: '手机 QQ 扫码绑定机器人,凭据写入 .env 并追加到 zhin.config.yml(重启生效)',
|
|
121
121
|
bindFlow: ({ name, reply, use }) => runQqEndpointAdd(use(qqRuntimeStateToken), name, reply),
|
package/lib/ws.js
CHANGED
|
@@ -61,11 +61,15 @@ export function normalizeQqMessage(raw) {
|
|
|
61
61
|
?? msg.author?.id
|
|
62
62
|
?? '');
|
|
63
63
|
const content = extractTextContent(msg.message) || msg.raw_message || '';
|
|
64
|
+
const segments = extractCanonicalSegments(msg.message);
|
|
64
65
|
const rawRoles = msg.member?.roles ?? msg.sender?.roles;
|
|
65
66
|
const authorRoles = Array.isArray(rawRoles) ? rawRoles.map(String) : undefined;
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
// 统一按 mentions 判定 @:is_you 精确标识当前机器人(群载荷实测字段)。
|
|
68
|
+
// 群里 @ 另一个机器人时 mentions 带 bot:true 但无 is_you,不能误判为 @ 当前机器人,
|
|
69
|
+
// 故群场景只认 is_you;bot 回退仅限频道 AT_MESSAGE_CREATE(该事件仅 @ 当前
|
|
70
|
+
// 机器人时下发,其 mentions 无 is_you)。
|
|
71
|
+
const mentioned = Array.isArray(msg.mentions)
|
|
72
|
+
&& msg.mentions.some((m) => m?.is_you === true || (m?.bot === true && channelKind !== 'group'));
|
|
69
73
|
return {
|
|
70
74
|
id: String(msg.message_id),
|
|
71
75
|
content,
|
|
@@ -77,9 +81,92 @@ export function normalizeQqMessage(raw) {
|
|
|
77
81
|
timestamp: Date.now(),
|
|
78
82
|
guildId: msg.guild_id != null ? String(msg.guild_id) : undefined,
|
|
79
83
|
rawMessage: msg.raw_message,
|
|
84
|
+
...(segments.length > 0 ? { segments } : {}),
|
|
80
85
|
...(mentioned ? { mentioned: true } : {}),
|
|
81
86
|
};
|
|
82
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* SDK 解析后的消息段(text/at/face/image/video/audio/file/reply)→ canonical Segment[]。
|
|
90
|
+
* 附件段的 url 已由 SDK 归一(补 https:// 前缀);mime 无法从载荷得知时按类型给默认。
|
|
91
|
+
*/
|
|
92
|
+
function extractCanonicalSegments(message) {
|
|
93
|
+
if (!Array.isArray(message))
|
|
94
|
+
return [];
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const seg of message) {
|
|
97
|
+
if (!seg || typeof seg !== 'object')
|
|
98
|
+
continue;
|
|
99
|
+
const item = seg;
|
|
100
|
+
const type = item.type ?? '';
|
|
101
|
+
const data = item.data ?? {};
|
|
102
|
+
switch (type) {
|
|
103
|
+
case 'text': {
|
|
104
|
+
const text = String(data.text ?? '');
|
|
105
|
+
if (text)
|
|
106
|
+
out.push({ type: 'text', data: { text } });
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
case 'at': {
|
|
110
|
+
const target = String(data.user_id ?? data.id ?? '');
|
|
111
|
+
if (!target)
|
|
112
|
+
break;
|
|
113
|
+
const name = typeof data.username === 'string' && data.username
|
|
114
|
+
? data.username
|
|
115
|
+
: undefined;
|
|
116
|
+
out.push({ type: 'mention', data: name ? { target, name } : { target } });
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
case 'face': {
|
|
120
|
+
const id = data.id;
|
|
121
|
+
if (id == null)
|
|
122
|
+
break;
|
|
123
|
+
out.push({ type: 'face', data: { id: id } });
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
case 'image':
|
|
127
|
+
case 'video':
|
|
128
|
+
case 'audio':
|
|
129
|
+
case 'file': {
|
|
130
|
+
const url = typeof data.url === 'string' ? data.url : '';
|
|
131
|
+
if (!url)
|
|
132
|
+
break;
|
|
133
|
+
const media = {
|
|
134
|
+
kind: 'url',
|
|
135
|
+
value: url,
|
|
136
|
+
mime_type: defaultMimeForSegment(type),
|
|
137
|
+
...(typeof data.name === 'string' && data.name ? { file_name: data.name } : {}),
|
|
138
|
+
...(typeof data.size === 'number' ? { size: data.size } : {}),
|
|
139
|
+
};
|
|
140
|
+
if (type === 'image')
|
|
141
|
+
out.push({ type: 'image', data: { media } });
|
|
142
|
+
else if (type === 'video')
|
|
143
|
+
out.push({ type: 'video', data: { media } });
|
|
144
|
+
else if (type === 'audio')
|
|
145
|
+
out.push({ type: 'audio', data: { media } });
|
|
146
|
+
else
|
|
147
|
+
out.push({ type: 'file', data: { media, ...(media.file_name ? { name: media.file_name } : {}) } });
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
case 'reply': {
|
|
151
|
+
const messageId = String(data.id ?? data.message_id ?? '');
|
|
152
|
+
if (messageId)
|
|
153
|
+
out.push({ type: 'reply', data: { message_id: messageId } });
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
default:
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
function defaultMimeForSegment(type) {
|
|
163
|
+
switch (type) {
|
|
164
|
+
case 'image': return 'image/jpeg';
|
|
165
|
+
case 'video': return 'video/mp4';
|
|
166
|
+
case 'audio': return 'audio/mpeg';
|
|
167
|
+
default: return undefined;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
83
170
|
function extractTextContent(message) {
|
|
84
171
|
if (typeof message === 'string')
|
|
85
172
|
return message;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/adapter-qq",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"description": "Zhin.js QQ Official adapter for Plugin Runtime (WebSocket gateway)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -35,20 +35,20 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"qq-official-bot": "^1.2.3",
|
|
38
|
-
"@zhin.js/adapter": "1.1.
|
|
39
|
-
"@zhin.js/command": "1.0.
|
|
40
|
-
"@zhin.js/core": "1.
|
|
38
|
+
"@zhin.js/adapter": "1.1.3",
|
|
39
|
+
"@zhin.js/command": "1.0.5",
|
|
40
|
+
"@zhin.js/core": "1.5.0",
|
|
41
41
|
"@zhin.js/host-http": "1.0.4",
|
|
42
42
|
"@zhin.js/logger": "1.0.75",
|
|
43
43
|
"@zhin.js/plugin-runtime": "1.1.1"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"zod": "^4.0.0",
|
|
47
|
-
"@zhin.js/adapter": "1.1.
|
|
48
|
-
"@zhin.js/agent": "1.0
|
|
49
|
-
"@zhin.js/core": "1.
|
|
47
|
+
"@zhin.js/adapter": "1.1.3",
|
|
48
|
+
"@zhin.js/agent": "1.1.0",
|
|
49
|
+
"@zhin.js/core": "1.5.0",
|
|
50
50
|
"@zhin.js/plugin-runtime": "1.1.1",
|
|
51
|
-
"zhin.js": "
|
|
51
|
+
"zhin.js": "6.0.0"
|
|
52
52
|
},
|
|
53
53
|
"peerDependenciesMeta": {
|
|
54
54
|
"zhin.js": {
|
|
@@ -66,9 +66,9 @@
|
|
|
66
66
|
"typescript": "^6.0.3",
|
|
67
67
|
"vitest": "^4.1.10",
|
|
68
68
|
"zod": "^4.4.3",
|
|
69
|
-
"@zhin.js/agent": "1.0
|
|
69
|
+
"@zhin.js/agent": "1.1.0",
|
|
70
70
|
"@zhin.js/host-http": "1.0.4",
|
|
71
|
-
"zhin.js": "
|
|
71
|
+
"zhin.js": "6.0.0"
|
|
72
72
|
},
|
|
73
73
|
"files": [
|
|
74
74
|
"adapters",
|
package/plugin.js
CHANGED
|
@@ -8,7 +8,7 @@ export default definePlugin({
|
|
|
8
8
|
displayName: 'QQ Official WebSocket Adapter',
|
|
9
9
|
},
|
|
10
10
|
setup(context) {
|
|
11
|
-
// 运行中 endpoint 注册表 + 扫码绑定单例,供 adapter create 与 `qq
|
|
11
|
+
// 运行中 endpoint 注册表 + 扫码绑定单例,供 adapter create 与 `qq.endpoint` 命令共享
|
|
12
12
|
context.resources.provide(qqRuntimeStateToken, createQqRuntimeState());
|
|
13
13
|
// 平台权限门禁:guild_owner / guild_admin / manage_roles / manage_channels(agent 工具 platformPermit)
|
|
14
14
|
return registerQqPlatformPermitChecker();
|
package/src/endpoint.ts
CHANGED
|
@@ -183,6 +183,7 @@ export class QqWebsocketEndpoint implements EndpointInstance {
|
|
|
183
183
|
adapter: this.#options.id,
|
|
184
184
|
target,
|
|
185
185
|
content: formatInboundContent(msg),
|
|
186
|
+
...(msg.segments?.length ? { segments: msg.segments } : {}),
|
|
186
187
|
sender: senderDisplayName(msg),
|
|
187
188
|
id: msg.id,
|
|
188
189
|
metadata: Object.freeze({
|
|
@@ -387,6 +388,7 @@ export class QqHttpEndpoint implements EndpointInstance {
|
|
|
387
388
|
adapter: this.#options.id,
|
|
388
389
|
target,
|
|
389
390
|
content: formatInboundContent(msg),
|
|
391
|
+
...(msg.segments?.length ? { segments: msg.segments } : {}),
|
|
390
392
|
sender: senderDisplayName(msg),
|
|
391
393
|
id: msg.id,
|
|
392
394
|
metadata: Object.freeze({
|
package/src/outbound.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* 不可映射的段一律降级为文本并 warn(保持既有 fallback 行为)。
|
|
14
14
|
*/
|
|
15
15
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
16
|
+
import { isMediaRef } from '@zhin.js/core';
|
|
16
17
|
import type { QqWireSegment } from './protocol.js';
|
|
17
18
|
|
|
18
19
|
const logger = getLogger('qq');
|
|
@@ -34,54 +35,45 @@ interface QqOutboundButtonSpec {
|
|
|
34
35
|
readonly command?: { readonly enter?: boolean; readonly reply?: boolean };
|
|
35
36
|
}
|
|
36
37
|
|
|
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
38
|
/**
|
|
46
|
-
* 解析 image/audio/video/file
|
|
47
|
-
*
|
|
48
|
-
* base64
|
|
39
|
+
* 解析 image/audio/video/file 段的投递源(canonical MediaRef 唯一来源):
|
|
40
|
+
* - kind=url → data.url 直发;
|
|
41
|
+
* - kind=base64 / path / file → data.file,由 SDK formatMediaData 走媒体上传(v2 /files)。
|
|
49
42
|
*/
|
|
50
43
|
export function resolveMediaFile(data: Record<string, unknown>): string | undefined {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
if (base64) return base64.startsWith('base64://') ? base64 : `base64://${base64}`;
|
|
59
|
-
return undefined;
|
|
44
|
+
const media = data.media;
|
|
45
|
+
if (!isMediaRef(media)) return undefined;
|
|
46
|
+
if (media.kind === 'url') return undefined;
|
|
47
|
+
if (media.kind === 'base64') {
|
|
48
|
+
return media.value.startsWith('base64://') ? media.value : `base64://${media.value}`;
|
|
49
|
+
}
|
|
50
|
+
return media.value;
|
|
60
51
|
}
|
|
61
52
|
|
|
62
53
|
function normalizeMediaSegment(seg: QqWireSegment): QqOutboundElem | null {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
54
|
+
const media = (seg.data ?? {}).media;
|
|
55
|
+
if (!isMediaRef(media)) {
|
|
56
|
+
// 无 canonical 媒体引用:无法投递,降级留痕
|
|
57
|
+
logger.warn(formatCompact({
|
|
58
|
+
op: 'qq_outbound_media_dropped',
|
|
59
|
+
type: seg.type,
|
|
60
|
+
reason: 'missing_media_ref',
|
|
61
|
+
}));
|
|
62
|
+
return null;
|
|
70
63
|
}
|
|
71
|
-
|
|
72
|
-
:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
64
|
+
if (media.kind === 'url') {
|
|
65
|
+
return { type: seg.type, data: { url: media.value } };
|
|
66
|
+
}
|
|
67
|
+
const file = resolveMediaFile(seg.data ?? {});
|
|
68
|
+
if (!file) {
|
|
69
|
+
logger.warn(formatCompact({
|
|
70
|
+
op: 'qq_outbound_media_dropped',
|
|
71
|
+
type: seg.type,
|
|
72
|
+
reason: 'missing_source',
|
|
73
|
+
}));
|
|
74
|
+
return null;
|
|
77
75
|
}
|
|
78
|
-
|
|
79
|
-
logger.warn(formatCompact({
|
|
80
|
-
op: 'qq_outbound_media_dropped',
|
|
81
|
-
type: seg.type,
|
|
82
|
-
reason: 'missing_source',
|
|
83
|
-
}));
|
|
84
|
-
return null;
|
|
76
|
+
return { type: seg.type, data: { file } };
|
|
85
77
|
}
|
|
86
78
|
|
|
87
79
|
function normalizeMarkdownSegment(seg: QqWireSegment): QqOutboundElem | null {
|
package/src/protocol.ts
CHANGED
|
@@ -76,9 +76,13 @@ export interface QqInboundMessage {
|
|
|
76
76
|
readonly timestamp: number;
|
|
77
77
|
readonly guildId?: string;
|
|
78
78
|
readonly rawMessage?: string;
|
|
79
|
+
/** canonical 入站段(text/mention/face/image/audio/video/file/reply),与 content 同源 */
|
|
80
|
+
readonly segments?: readonly import('@zhin.js/core').Segment[];
|
|
79
81
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
+
* 入站是否 @ 当前机器人:统一按 mentions 判定。群场景只认 is_you(@ 另一个
|
|
83
|
+
* 机器人时 bot:true 但无 is_you,不误判);bot 回退仅限频道 AT 事件。
|
|
84
|
+
* 群 GROUP_MESSAGE_CREATE 的非 @ 消息不置位。新 Runtime 纯文本 content 需经
|
|
85
|
+
* metadata 传递。
|
|
82
86
|
*/
|
|
83
87
|
readonly mentioned?: boolean;
|
|
84
88
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `qq
|
|
2
|
+
* `qq.endpoint` 命令的业务逻辑(与命令定义文件分离,便于测试)。
|
|
3
3
|
* 通用部分(权限 / list / remove / 配置写回 / .env 持久化)已迁移到
|
|
4
4
|
* @zhin.js/adapter 的 createEndpointCommands 套件;本文件保留 QQ 特化的
|
|
5
5
|
* 扫码绑定 add 流程(经套件 bindFlow 钩子接入)与 cancel,并导出
|
|
@@ -41,12 +41,12 @@ export function extractQqCommandReply(input: unknown): QqCommandReply {
|
|
|
41
41
|
return extractEndpointCommandReply(input);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
/** `qq
|
|
44
|
+
/** `qq.endpoint list`:运行中的 endpoints(本 generation adapter create 注册)+ 配置里的 endpoints */
|
|
45
45
|
export function runQqEndpointList(state: QqRuntimeState, projectRoot?: string): string {
|
|
46
46
|
return formatEndpointList(qqEndpointListSpec, {
|
|
47
47
|
running: state.endpoints.values(),
|
|
48
48
|
configured: listQqEndpointEntries(projectRoot),
|
|
49
|
-
footer: state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq
|
|
49
|
+
footer: state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq.endpoint cancel 取消' : undefined,
|
|
50
50
|
});
|
|
51
51
|
}
|
|
52
52
|
|
|
@@ -57,7 +57,7 @@ const qqEndpointListSpec = {
|
|
|
57
57
|
} as const;
|
|
58
58
|
|
|
59
59
|
/**
|
|
60
|
-
* `qq
|
|
60
|
+
* `qq.endpoint add [name]`:启动扫码绑定流程。
|
|
61
61
|
* 返回的 Promise 在二维码链接就绪(或前置失败)时 resolve 为首条回复;
|
|
62
62
|
* 后续状态(已扫码 / 成功 / 失败 / 过期刷新)通过 reply 推回当前会话。
|
|
63
63
|
*/
|
|
@@ -68,7 +68,7 @@ export function runQqEndpointAdd(
|
|
|
68
68
|
projectRoot?: string,
|
|
69
69
|
): Promise<string> {
|
|
70
70
|
if (state.bindFlow) {
|
|
71
|
-
return Promise.resolve('已有进行中的 QQ 机器人绑定,请先发送 qq
|
|
71
|
+
return Promise.resolve('已有进行中的 QQ 机器人绑定,请先发送 qq.endpoint cancel 取消后再试');
|
|
72
72
|
}
|
|
73
73
|
const endpointName = name?.trim() || undefined;
|
|
74
74
|
return new Promise<string>((resolve) => {
|
|
@@ -121,7 +121,7 @@ export function runQqEndpointAdd(
|
|
|
121
121
|
});
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
/** `qq
|
|
124
|
+
/** `qq.endpoint cancel`:中止进行中的绑定流程 */
|
|
125
125
|
export function runQqEndpointCancel(state: QqRuntimeState): string {
|
|
126
126
|
if (!state.bindFlow) {
|
|
127
127
|
return '当前没有进行中的 QQ 绑定流程';
|
|
@@ -131,7 +131,7 @@ export function runQqEndpointCancel(state: QqRuntimeState): string {
|
|
|
131
131
|
return '已取消进行中的 QQ 绑定流程';
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
/** `qq
|
|
134
|
+
/** `qq.endpoint remove <name>`:从 zhin.config.yml 移除对应 endpoints 项 */
|
|
135
135
|
export function runQqEndpointRemove(
|
|
136
136
|
_state: QqRuntimeState,
|
|
137
137
|
name: string,
|
|
@@ -153,7 +153,7 @@ export const qqEndpointCommands = createEndpointCommands({
|
|
|
153
153
|
running: (use) => use(qqRuntimeStateToken).endpoints.values(),
|
|
154
154
|
listFooter: (use) => {
|
|
155
155
|
const state = use(qqRuntimeStateToken);
|
|
156
|
-
return state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq
|
|
156
|
+
return state.bindFlow ? '⚠️ 有进行中的扫码绑定,可用 qq.endpoint cancel 取消' : undefined;
|
|
157
157
|
},
|
|
158
158
|
addDescription: '手机 QQ 扫码绑定机器人,凭据写入 .env 并追加到 zhin.config.yml(重启生效)',
|
|
159
159
|
bindFlow: ({ name, reply, use }) => runQqEndpointAdd(use(qqRuntimeStateToken), name, reply),
|
package/src/ws.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { Bot, ReceiverMode, type Sendable } from 'qq-official-bot';
|
|
6
6
|
import { getLevel, toLog4jsLevel } from '@zhin.js/logger';
|
|
7
|
+
import type { MediaRef, Segment } from '@zhin.js/core';
|
|
7
8
|
import type { QqOutboundMessage } from './outbound.js';
|
|
8
9
|
import type { QqChannelKind, QqInboundMessage, ResolvedQqWebsocketConfig } from './protocol.js';
|
|
9
10
|
|
|
@@ -94,8 +95,8 @@ export function normalizeQqMessage(raw: unknown): QqInboundMessage | null {
|
|
|
94
95
|
sender?: { user_id?: string | number; user_name?: string; roles?: (string | number)[] };
|
|
95
96
|
member?: { roles?: (string | number)[] };
|
|
96
97
|
author?: { member_openid?: string; user_openid?: string; id?: string; username?: string };
|
|
97
|
-
/**
|
|
98
|
-
mentions?: Array<{ id?: string | number; bot?: boolean }>;
|
|
98
|
+
/** AT 事件载荷:被 @ 的用户列表;is_you 标识当前机器人,bot 标识机器人账号 */
|
|
99
|
+
mentions?: Array<{ id?: string | number; bot?: boolean; is_you?: boolean; member_openid?: string; member_role?: string }>;
|
|
99
100
|
};
|
|
100
101
|
if (msg.message_id == null) return null;
|
|
101
102
|
|
|
@@ -130,11 +131,15 @@ export function normalizeQqMessage(raw: unknown): QqInboundMessage | null {
|
|
|
130
131
|
?? '',
|
|
131
132
|
);
|
|
132
133
|
const content = extractTextContent(msg.message) || msg.raw_message || '';
|
|
134
|
+
const segments = extractCanonicalSegments(msg.message);
|
|
133
135
|
const rawRoles = msg.member?.roles ?? msg.sender?.roles;
|
|
134
136
|
const authorRoles = Array.isArray(rawRoles) ? rawRoles.map(String) : undefined;
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
137
|
+
// 统一按 mentions 判定 @:is_you 精确标识当前机器人(群载荷实测字段)。
|
|
138
|
+
// 群里 @ 另一个机器人时 mentions 带 bot:true 但无 is_you,不能误判为 @ 当前机器人,
|
|
139
|
+
// 故群场景只认 is_you;bot 回退仅限频道 AT_MESSAGE_CREATE(该事件仅 @ 当前
|
|
140
|
+
// 机器人时下发,其 mentions 无 is_you)。
|
|
141
|
+
const mentioned = Array.isArray(msg.mentions)
|
|
142
|
+
&& msg.mentions.some((m) => m?.is_you === true || (m?.bot === true && channelKind !== 'group'));
|
|
138
143
|
|
|
139
144
|
return {
|
|
140
145
|
id: String(msg.message_id),
|
|
@@ -147,10 +152,84 @@ export function normalizeQqMessage(raw: unknown): QqInboundMessage | null {
|
|
|
147
152
|
timestamp: Date.now(),
|
|
148
153
|
guildId: msg.guild_id != null ? String(msg.guild_id) : undefined,
|
|
149
154
|
rawMessage: msg.raw_message,
|
|
155
|
+
...(segments.length > 0 ? { segments } : {}),
|
|
150
156
|
...(mentioned ? { mentioned: true } : {}),
|
|
151
157
|
};
|
|
152
158
|
}
|
|
153
159
|
|
|
160
|
+
/**
|
|
161
|
+
* SDK 解析后的消息段(text/at/face/image/video/audio/file/reply)→ canonical Segment[]。
|
|
162
|
+
* 附件段的 url 已由 SDK 归一(补 https:// 前缀);mime 无法从载荷得知时按类型给默认。
|
|
163
|
+
*/
|
|
164
|
+
function extractCanonicalSegments(message: unknown): Segment[] {
|
|
165
|
+
if (!Array.isArray(message)) return [];
|
|
166
|
+
const out: Segment[] = [];
|
|
167
|
+
for (const seg of message) {
|
|
168
|
+
if (!seg || typeof seg !== 'object') continue;
|
|
169
|
+
const item = seg as { type?: string; data?: Record<string, unknown> };
|
|
170
|
+
const type = item.type ?? '';
|
|
171
|
+
const data = item.data ?? {};
|
|
172
|
+
switch (type) {
|
|
173
|
+
case 'text': {
|
|
174
|
+
const text = String(data.text ?? '');
|
|
175
|
+
if (text) out.push({ type: 'text', data: { text } });
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
case 'at': {
|
|
179
|
+
const target = String(data.user_id ?? data.id ?? '');
|
|
180
|
+
if (!target) break;
|
|
181
|
+
const name = typeof data.username === 'string' && data.username
|
|
182
|
+
? data.username
|
|
183
|
+
: undefined;
|
|
184
|
+
out.push({ type: 'mention', data: name ? { target, name } : { target } });
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
case 'face': {
|
|
188
|
+
const id = data.id;
|
|
189
|
+
if (id == null) break;
|
|
190
|
+
out.push({ type: 'face', data: { id: id as string | number } });
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
case 'image':
|
|
194
|
+
case 'video':
|
|
195
|
+
case 'audio':
|
|
196
|
+
case 'file': {
|
|
197
|
+
const url = typeof data.url === 'string' ? data.url : '';
|
|
198
|
+
if (!url) break;
|
|
199
|
+
const media: MediaRef = {
|
|
200
|
+
kind: 'url',
|
|
201
|
+
value: url,
|
|
202
|
+
mime_type: defaultMimeForSegment(type),
|
|
203
|
+
...(typeof data.name === 'string' && data.name ? { file_name: data.name } : {}),
|
|
204
|
+
...(typeof data.size === 'number' ? { size: data.size } : {}),
|
|
205
|
+
};
|
|
206
|
+
if (type === 'image') out.push({ type: 'image', data: { media } });
|
|
207
|
+
else if (type === 'video') out.push({ type: 'video', data: { media } });
|
|
208
|
+
else if (type === 'audio') out.push({ type: 'audio', data: { media } });
|
|
209
|
+
else out.push({ type: 'file', data: { media, ...(media.file_name ? { name: media.file_name } : {}) } });
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case 'reply': {
|
|
213
|
+
const messageId = String(data.id ?? data.message_id ?? '');
|
|
214
|
+
if (messageId) out.push({ type: 'reply', data: { message_id: messageId } });
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
default:
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function defaultMimeForSegment(type: string): string | undefined {
|
|
225
|
+
switch (type) {
|
|
226
|
+
case 'image': return 'image/jpeg';
|
|
227
|
+
case 'video': return 'video/mp4';
|
|
228
|
+
case 'audio': return 'audio/mpeg';
|
|
229
|
+
default: return undefined;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
154
233
|
function extractTextContent(message: unknown): string {
|
|
155
234
|
if (typeof message === 'string') return message;
|
|
156
235
|
if (!Array.isArray(message)) return '';
|