cortico 0.1.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/LICENSE +21 -0
- package/README.md +47 -0
- package/package.json +43 -0
- package/src/boot.ts +46 -0
- package/src/bot.ts +1342 -0
- package/src/config-file.ts +41 -0
- package/src/core/README.md +92 -0
- package/src/core/billing.ts +26 -0
- package/src/core/blobs.ts +106 -0
- package/src/core/bus.ts +311 -0
- package/src/core/config-schema.ts +201 -0
- package/src/core/config.ts +262 -0
- package/src/core/core.ts +699 -0
- package/src/core/cost.ts +244 -0
- package/src/core/event-store.ts +304 -0
- package/src/core/fork.ts +113 -0
- package/src/core/generation.ts +118 -0
- package/src/core/instance-lock.ts +215 -0
- package/src/core/ipc-logger.ts +105 -0
- package/src/core/language.ts +62 -0
- package/src/core/log-context.ts +38 -0
- package/src/core/loop.ts +1858 -0
- package/src/core/markers.ts +48 -0
- package/src/core/prefix.ts +121 -0
- package/src/core/run.ts +95 -0
- package/src/core/secrets.ts +18 -0
- package/src/core/session.ts +56 -0
- package/src/core/sessions.ts +261 -0
- package/src/core/state.ts +104 -0
- package/src/core/template.ts +72 -0
- package/src/core/timers.ts +129 -0
- package/src/core/tool-log.ts +135 -0
- package/src/core/transcript.ts +92 -0
- package/src/core/truncate.ts +121 -0
- package/src/core/types.ts +1140 -0
- package/src/core/usage-log.ts +79 -0
- package/src/core/util.ts +386 -0
- package/src/deploy-listing.ts +160 -0
- package/src/deploy.ts +162 -0
- package/src/extensions/README.md +72 -0
- package/src/extensions/dry-mount.ts +390 -0
- package/src/extensions/manifest.ts +153 -0
- package/src/extensions/runtime.ts +42 -0
- package/src/extensions.ts +519 -0
- package/src/launcher.ts +285 -0
- package/src/paths.ts +155 -0
- package/src/protocol/open-responses/LICENSE +201 -0
- package/src/protocol/open-responses/README.md +14 -0
- package/src/protocol/open-responses/context-helpers.ts +27 -0
- package/src/protocol/open-responses/context-log.ts +59 -0
- package/src/protocol/open-responses/context.ts +89 -0
- package/src/protocol/open-responses/generated.ts +115 -0
- package/src/protocol/open-responses/index.ts +33 -0
- package/src/protocol/open-responses/openapi.json +4230 -0
- package/src/protocol/open-responses/stream.ts +146 -0
- package/src/protocol/open-responses/tokens.ts +31 -0
- package/src/providers/README.md +86 -0
- package/src/providers/base.ts +111 -0
- package/src/providers/configuration.ts +97 -0
- package/src/providers/console/config.ts +21 -0
- package/src/providers/console/settings.ts +443 -0
- package/src/providers/console/strings.ts +74 -0
- package/src/providers/console/types.ts +11 -0
- package/src/providers/llamacpp/archive.ts +148 -0
- package/src/providers/llamacpp/catalog.ts +247 -0
- package/src/providers/llamacpp/config.ts +36 -0
- package/src/providers/llamacpp/console/client.ts +6 -0
- package/src/providers/llamacpp/console/models-panel.ts +120 -0
- package/src/providers/llamacpp/console/runtime-panel.ts +173 -0
- package/src/providers/llamacpp/console/server.ts +153 -0
- package/src/providers/llamacpp/index.ts +95 -0
- package/src/providers/llamacpp/native.ts +51 -0
- package/src/providers/llamacpp/options.ts +117 -0
- package/src/providers/llamacpp/runtime-store.ts +139 -0
- package/src/providers/llamacpp/runtime.ts +214 -0
- package/src/providers/llamacpp/server.ts +286 -0
- package/src/providers/llamacpp/strings.ts +230 -0
- package/src/providers/openai-responses-compat/config.ts +20 -0
- package/src/providers/openai-responses-compat/index.ts +89 -0
- package/src/providers/openai-responses-compat/native.ts +127 -0
- package/src/providers/openai-responses-compat/strings.ts +19 -0
- package/src/providers/pricebook.ts +75 -0
- package/src/providers/registry.ts +137 -0
- package/src/providers/strings.ts +71 -0
- package/src/providers/transport/chat.ts +156 -0
- package/src/providers/transport/errors.ts +48 -0
- package/src/providers/transport/history.ts +68 -0
- package/src/providers/transport/native-input.ts +83 -0
- package/src/providers/transport/native-types.ts +13 -0
- package/src/providers/transport/response-assembly.ts +191 -0
- package/src/providers/transport/response-http.ts +196 -0
- package/src/providers/transport/response-meters.ts +27 -0
- package/src/providers/transport/responses-input.ts +56 -0
- package/src/web/README.md +84 -0
- package/src/web/client/console-pages/builtins/llm-settings/panel.ts +625 -0
- package/src/web/client/console-pages/builtins/llm-settings/pricing-panel.ts +168 -0
- package/src/web/client/console-pages/builtins/llm-settings/strings.ts +213 -0
- package/src/web/client/console-pages/builtins.ts +10 -0
- package/src/web/client/console-pages/context.ts +135 -0
- package/src/web/client/console-pages/host.ts +633 -0
- package/src/web/client/console-pages/loader.ts +132 -0
- package/src/web/client/console-pages/strings.ts +90 -0
- package/src/web/client/console-pages/tools/strings.ts +49 -0
- package/src/web/client/console-pages/tools/view.ts +215 -0
- package/src/web/client/core/api.ts +200 -0
- package/src/web/client/core/language.ts +56 -0
- package/src/web/client/core/lifecycle.ts +132 -0
- package/src/web/client/core/router.ts +199 -0
- package/src/web/client/core/stream.ts +157 -0
- package/src/web/client/core/websocket.ts +82 -0
- package/src/web/client/features/appearance/index.ts +333 -0
- package/src/web/client/features/appearance/strings.ts +87 -0
- package/src/web/client/features/config/index.ts +11 -0
- package/src/web/client/features/config/strings.ts +41 -0
- package/src/web/client/features/config/view.ts +428 -0
- package/src/web/client/features/core/events.ts +197 -0
- package/src/web/client/features/core/index.ts +254 -0
- package/src/web/client/features/core/run.ts +56 -0
- package/src/web/client/features/core/runlog.ts +214 -0
- package/src/web/client/features/core/sessions.ts +53 -0
- package/src/web/client/features/core/strings.ts +157 -0
- package/src/web/client/features/extensions/index.ts +422 -0
- package/src/web/client/features/extensions/strings.ts +164 -0
- package/src/web/client/features/feature.ts +93 -0
- package/src/web/client/features/live/context.ts +280 -0
- package/src/web/client/features/live/fork.ts +156 -0
- package/src/web/client/features/live/index.ts +392 -0
- package/src/web/client/features/live/onboarding.ts +113 -0
- package/src/web/client/features/live/protocol.ts +122 -0
- package/src/web/client/features/live/sessions.ts +71 -0
- package/src/web/client/features/live/status.ts +68 -0
- package/src/web/client/features/live/strings.ts +252 -0
- package/src/web/client/features/live/timeline.ts +590 -0
- package/src/web/client/features/prompts/editor.ts +292 -0
- package/src/web/client/features/prompts/index.ts +346 -0
- package/src/web/client/features/prompts/strings.ts +136 -0
- package/src/web/client/features/prompts/view.ts +256 -0
- package/src/web/client/features/providers/index.ts +100 -0
- package/src/web/client/features/providers/strings.ts +23 -0
- package/src/web/client/features/settings/general.ts +28 -0
- package/src/web/client/features/settings/index.ts +82 -0
- package/src/web/client/features/settings/strings.ts +33 -0
- package/src/web/client/features/storage/index.ts +1 -0
- package/src/web/client/features/storage/strings.ts +45 -0
- package/src/web/client/features/storage/view.ts +154 -0
- package/src/web/client/features/usage/chart.ts +486 -0
- package/src/web/client/features/usage/index.ts +407 -0
- package/src/web/client/features/usage/labels.ts +46 -0
- package/src/web/client/features/usage/range.ts +36 -0
- package/src/web/client/features/usage/state.ts +37 -0
- package/src/web/client/features/usage/strings.ts +191 -0
- package/src/web/client/features/usage/tooltip.ts +86 -0
- package/src/web/client/features/usage/types.ts +96 -0
- package/src/web/client/features/worlds/index.ts +424 -0
- package/src/web/client/features/worlds/strings.ts +121 -0
- package/src/web/client/main.ts +273 -0
- package/src/web/client/shell/avatar.ts +233 -0
- package/src/web/client/shell/index.ts +549 -0
- package/src/web/client/shell/strings.ts +89 -0
- package/src/web/client/theme/handoff.ts +43 -0
- package/src/web/client/theme/palette.ts +109 -0
- package/src/web/client/theme/registry.ts +130 -0
- package/src/web/client/theme/storage.ts +72 -0
- package/src/web/client/theme/strings.ts +123 -0
- package/src/web/client/theme/studio.ts +366 -0
- package/src/web/client/ui/actions.ts +109 -0
- package/src/web/client/ui/data.ts +185 -0
- package/src/web/client/ui/dom.ts +23 -0
- package/src/web/client/ui/fields.ts +191 -0
- package/src/web/client/ui/format.ts +75 -0
- package/src/web/client/ui/icons.ts +183 -0
- package/src/web/client/ui/images.ts +91 -0
- package/src/web/client/ui/index.ts +89 -0
- package/src/web/client/ui/lamp.ts +125 -0
- package/src/web/client/ui/log.ts +98 -0
- package/src/web/client/ui/overlay.ts +184 -0
- package/src/web/client/ui/page.ts +8 -0
- package/src/web/client/ui/prompt-input.tsx +311 -0
- package/src/web/client/ui/sheet.ts +131 -0
- package/src/web/client/ui/strings.ts +71 -0
- package/src/web/console-pages.ts +367 -0
- package/src/web/files.ts +107 -0
- package/src/web/path-picker.ts +296 -0
- package/src/web/public/index.html +17 -0
- package/src/web/public/styles.css +1715 -0
- package/src/web/server.ts +1883 -0
- package/src/web/shared/client-panel.ts +603 -0
- package/src/web/shared/console-protocol.ts +560 -0
- package/src/web/shared/css.d.ts +1 -0
- package/src/web/shared/path-picker.ts +23 -0
- package/src/web/shared/theme.ts +256 -0
- package/src/web/theme-store.ts +37 -0
- package/src/world.ts +449 -0
- package/src/worlds/bilibili/ENV_PROMPT.md +5 -0
- package/src/worlds/bilibili/README.md +176 -0
- package/src/worlds/bilibili/audience-admission.ts +995 -0
- package/src/worlds/bilibili/client.ts +528 -0
- package/src/worlds/bilibili/coalescing-buffer.ts +125 -0
- package/src/worlds/bilibili/config.ts +185 -0
- package/src/worlds/bilibili/console/client.ts +202 -0
- package/src/worlds/bilibili/definition.ts +35 -0
- package/src/worlds/bilibili/gift-frame.ts +131 -0
- package/src/worlds/bilibili/normalize.ts +452 -0
- package/src/worlds/bilibili/overlay/announcement.ts +76 -0
- package/src/worlds/bilibili/overlay/assets.ts +82 -0
- package/src/worlds/bilibili/overlay/model.ts +494 -0
- package/src/worlds/bilibili/overlay/project.ts +202 -0
- package/src/worlds/bilibili/overlay/server.ts +286 -0
- package/src/worlds/bilibili/overlay/types.ts +217 -0
- package/src/worlds/bilibili/overlay/web/app.js +690 -0
- package/src/worlds/bilibili/overlay/web/editor.css +297 -0
- package/src/worlds/bilibili/overlay/web/editor.html +102 -0
- package/src/worlds/bilibili/overlay/web/editor.js +2116 -0
- package/src/worlds/bilibili/overlay/web/overlay.html +13 -0
- package/src/worlds/bilibili/overlay/web/styles.css +258 -0
- package/src/worlds/bilibili/protobuf.ts +123 -0
- package/src/worlds/bilibili/wire.ts +94 -0
- package/src/worlds/bilibili/world.ts +1389 -0
- package/src/worlds/console-fixture/ENV_PROMPT.md +1 -0
- package/src/worlds/console-fixture/console/client.ts +72 -0
- package/src/worlds/console-fixture/world.ts +73 -0
- package/src/worlds/index.ts +15 -0
- package/src/worlds/minecraft/ENV_PROMPT.md +32 -0
- package/src/worlds/minecraft/ENV_PROMPT_CAMERA.md +2 -0
- package/src/worlds/minecraft/LICENSE-mineflayer-pathfinder.txt +21 -0
- package/src/worlds/minecraft/README.md +917 -0
- package/src/worlds/minecraft/blueprint-plan.ts +915 -0
- package/src/worlds/minecraft/blueprint-registry.ts +566 -0
- package/src/worlds/minecraft/blueprint-repair.ts +236 -0
- package/src/worlds/minecraft/blueprint-resource.ts +272 -0
- package/src/worlds/minecraft/blueprint.ts +492 -0
- package/src/worlds/minecraft/body-lease.ts +340 -0
- package/src/worlds/minecraft/bridge.ts +749 -0
- package/src/worlds/minecraft/check.ts +651 -0
- package/src/worlds/minecraft/chests.ts +292 -0
- package/src/worlds/minecraft/client-launch.ts +272 -0
- package/src/worlds/minecraft/client-options.ts +85 -0
- package/src/worlds/minecraft/client-skins.ts +164 -0
- package/src/worlds/minecraft/client-window.ps1 +91 -0
- package/src/worlds/minecraft/client.ts +423 -0
- package/src/worlds/minecraft/combat-context.ts +109 -0
- package/src/worlds/minecraft/combat.ts +1552 -0
- package/src/worlds/minecraft/config.ts +499 -0
- package/src/worlds/minecraft/console/access.ts +218 -0
- package/src/worlds/minecraft/console/client.ts +244 -0
- package/src/worlds/minecraft/console/log.ts +110 -0
- package/src/worlds/minecraft/console/mount.ts +420 -0
- package/src/worlds/minecraft/console/skin.ts +257 -0
- package/src/worlds/minecraft/console/style.css +154 -0
- package/src/worlds/minecraft/console/world.ts +449 -0
- package/src/worlds/minecraft/deaths.ts +135 -0
- package/src/worlds/minecraft/definition.ts +18 -0
- package/src/worlds/minecraft/engine-child.ts +274 -0
- package/src/worlds/minecraft/engine-ipc.ts +98 -0
- package/src/worlds/minecraft/entity-facts.ts +132 -0
- package/src/worlds/minecraft/escape.ts +407 -0
- package/src/worlds/minecraft/executor.ts +14494 -0
- package/src/worlds/minecraft/explored.ts +156 -0
- package/src/worlds/minecraft/geometry.ts +205 -0
- package/src/worlds/minecraft/goal-plan.ts +647 -0
- package/src/worlds/minecraft/item-break.ts +126 -0
- package/src/worlds/minecraft/item-facts.ts +146 -0
- package/src/worlds/minecraft/item-pick.ts +67 -0
- package/src/worlds/minecraft/level-dat.ts +176 -0
- package/src/worlds/minecraft/log.ts +132 -0
- package/src/worlds/minecraft/mineflayer-fixes.ts +1180 -0
- package/src/worlds/minecraft/names.ts +377 -0
- package/src/worlds/minecraft/pathfinder-lib.d.ts +13 -0
- package/src/worlds/minecraft/pathfinder-perf.ts +796 -0
- package/src/worlds/minecraft/piglin.ts +49 -0
- package/src/worlds/minecraft/policy.ts +547 -0
- package/src/worlds/minecraft/precheck.ts +655 -0
- package/src/worlds/minecraft/proxy.ts +445 -0
- package/src/worlds/minecraft/ranged.ts +635 -0
- package/src/worlds/minecraft/readouts.ts +102 -0
- package/src/worlds/minecraft/round.ts +26 -0
- package/src/worlds/minecraft/search-observation.ts +92 -0
- package/src/worlds/minecraft/server-config.ts +467 -0
- package/src/worlds/minecraft/server.ts +658 -0
- package/src/worlds/minecraft/show.ts +84 -0
- package/src/worlds/minecraft/skills.ts +1763 -0
- package/src/worlds/minecraft/terrain.ts +1735 -0
- package/src/worlds/minecraft/window.ts +96 -0
- package/src/worlds/minecraft/works.ts +179 -0
- package/src/worlds/minecraft/world.ts +6438 -0
- package/src/worlds/qq/ENV_PROMPT.md +5 -0
- package/src/worlds/qq/config.ts +108 -0
- package/src/worlds/qq/console/client.ts +126 -0
- package/src/worlds/qq/console/events.ts +155 -0
- package/src/worlds/qq/console/gate.ts +232 -0
- package/src/worlds/qq/console/roster.ts +203 -0
- package/src/worlds/qq/console/style.css +65 -0
- package/src/worlds/qq/conversation.ts +28 -0
- package/src/worlds/qq/definition.ts +62 -0
- package/src/worlds/qq/driver.ts +375 -0
- package/src/worlds/qq/history-tools.ts +209 -0
- package/src/worlds/qq/normalize.ts +323 -0
- package/src/worlds/qq/qface-map.ts +282 -0
- package/src/worlds/qq/vision-prompt.ts +27 -0
- package/src/worlds/qq/vision.ts +642 -0
- package/src/worlds/qq/vlm.ts +142 -0
- package/src/worlds/qq/world.ts +1503 -0
- package/src/worlds/terminal/ENV_PROMPT.md +11 -0
- package/src/worlds/terminal/config.ts +9 -0
- package/src/worlds/terminal/definition.ts +14 -0
- package/src/worlds/terminal/world.ts +700 -0
- package/src/worlds/websearch/ENV_PROMPT.md +1 -0
- package/src/worlds/websearch/brave-client.ts +261 -0
- package/src/worlds/websearch/config.ts +75 -0
- package/src/worlds/websearch/definition.ts +20 -0
- package/src/worlds/websearch/world.ts +171 -0
|
@@ -0,0 +1,1503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ World。
|
|
3
|
+
*
|
|
4
|
+
* 仅监听配置名单中的群与私聊,名单可通过 Web 热更新。其他会话与请求不落盘。
|
|
5
|
+
* 每条消息渲染带会话标签(`[群「X」HH:MM]` / `[私聊 HH:MM]`),事件 meta.conv
|
|
6
|
+
* 记录归属,发送/回复据此路由。
|
|
7
|
+
*
|
|
8
|
+
* 发送使用起草-确认门:draft 暂存单槽草稿并形成一次推理屏障。起草期间已经
|
|
9
|
+
* 到达的会话消息会作为新的 user 事件紧随其后投递;下一次推理可确认或取消。
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import type {
|
|
13
|
+
ConfigGroup,
|
|
14
|
+
EventEnvelope,
|
|
15
|
+
WorldConsoleDecl,
|
|
16
|
+
World,
|
|
17
|
+
WorldHost,
|
|
18
|
+
Logger,
|
|
19
|
+
StoragePart,
|
|
20
|
+
ToolDef,
|
|
21
|
+
BlobInput,
|
|
22
|
+
} from '../../core/types.ts';
|
|
23
|
+
import { readFileSync } from 'node:fs';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { nowIso, shortTime, nullLogger } from '../../core/util.ts';
|
|
26
|
+
import {
|
|
27
|
+
eventInConversation,
|
|
28
|
+
parseConversationAddress,
|
|
29
|
+
sameConversation,
|
|
30
|
+
type Conv,
|
|
31
|
+
} from './conversation.ts';
|
|
32
|
+
import { OneBotDriver, type OneBotEvent } from './driver.ts';
|
|
33
|
+
import { createHistoryTools } from './history-tools.ts';
|
|
34
|
+
import {
|
|
35
|
+
buildOutgoing,
|
|
36
|
+
makeImagePolicy,
|
|
37
|
+
parseJsonCard,
|
|
38
|
+
renderIncoming,
|
|
39
|
+
renderSegmentsPlain,
|
|
40
|
+
type ImageRenderPolicy,
|
|
41
|
+
type JsonCardRenderPolicy,
|
|
42
|
+
type OneBotGroupMessage,
|
|
43
|
+
type Segment,
|
|
44
|
+
} from './normalize.ts';
|
|
45
|
+
import type { VisionService } from './vision.ts';
|
|
46
|
+
import { QQ_DEFAULTS, QQ_SECRETS, QQ_CONFIG_GROUP, type QQRosterEntry } from './config.ts';
|
|
47
|
+
|
|
48
|
+
export type { Conv } from './conversation.ts';
|
|
49
|
+
const ENV_PROMPT_FILE = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url));
|
|
50
|
+
|
|
51
|
+
/** QQ在查不到账号资料时返回的昵称占位,等于「没有名字」。 */
|
|
52
|
+
const PLACEHOLDER_NICKNAME = 'QQ用户';
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
export function enabledRosterIds(entries: QQRosterEntry[]): number[] {
|
|
57
|
+
return entries.filter((e) => e.enabled).map((e) => e.id);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
interface QQWorldConfig {
|
|
63
|
+
wsUrl: string;
|
|
64
|
+
/** 监听的群号集合 */
|
|
65
|
+
groups: number[];
|
|
66
|
+
/** 监听的私聊QQ号集合 */
|
|
67
|
+
privates: number[];
|
|
68
|
+
token: string;
|
|
69
|
+
/** Event rendering timezone. */
|
|
70
|
+
timezone?: string;
|
|
71
|
+
/** 重连退避参数透传(测试用短退避) */
|
|
72
|
+
reconnectBaseMs?: number;
|
|
73
|
+
reconnectMaxMs?: number;
|
|
74
|
+
apiTimeoutMs?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 「QQ 接入」面板的装配数据面。装配层管理 roster、enabled、持久化与重启;
|
|
79
|
+
* World 管理面板语义和校验。缺少该依赖时 gate/roster 面板不可用。
|
|
80
|
+
*/
|
|
81
|
+
interface QQGatePanelDeps {
|
|
82
|
+
/** config.worlds.qq.enabled(当前进程装配态;改它要重启才生效) */
|
|
83
|
+
enabled(): boolean;
|
|
84
|
+
wsUrl(): string;
|
|
85
|
+
/** 是否已配置 access token(只报有无,不回明文) */
|
|
86
|
+
tokenSet(): boolean;
|
|
87
|
+
/** 完整监听名单,包含未进入 World 监听集合的 disabled 条目。 */
|
|
88
|
+
roster(): { groups: QQRosterEntry[]; privates: QQRosterEntry[] };
|
|
89
|
+
/** 整体替换名单并写回 config.json;返回一句结果描述 */
|
|
90
|
+
setRoster(groups: QQRosterEntry[], privates: QQRosterEntry[]): string;
|
|
91
|
+
/** 持久化 worlds.qq.enabled;本身不重启,重启由调用方另行触发 */
|
|
92
|
+
setEnabled(enabled: boolean): void;
|
|
93
|
+
/** 持久化 NapCat 连接(wsUrl + token);token 空串=保持原值不动 */
|
|
94
|
+
setConnection(wsUrl: string, token: string): void;
|
|
95
|
+
/** 落重启标志并让进程退出(启动器重新拉起,回来是暂停态) */
|
|
96
|
+
restart(): void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface QQWorldDeps {
|
|
100
|
+
vision?: VisionService;
|
|
101
|
+
gate?: QQGatePanelDeps;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 暂存的草稿(单槽,一进一出;不确认到本轮末即作废) */
|
|
105
|
+
interface PendingDraft {
|
|
106
|
+
target: Conv;
|
|
107
|
+
text: string;
|
|
108
|
+
/** 引用回复的平台message_id(已从游标解析好) */
|
|
109
|
+
replyMessageId?: number | string;
|
|
110
|
+
/** 要发送的图片的句柄(`log:` 她看见过的 / `mem:` 她收藏的);无=纯文本 */
|
|
111
|
+
imageRef?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface ForwardNode {
|
|
115
|
+
line: string;
|
|
116
|
+
/** 正文(不带发言人前缀),身份塌掉时重新渲染用 */
|
|
117
|
+
body: string;
|
|
118
|
+
/** 协议给了可用的发言人名字(不是空、也不是「QQ用户」占位) */
|
|
119
|
+
named: boolean;
|
|
120
|
+
time?: number;
|
|
121
|
+
userId?: string;
|
|
122
|
+
messageType?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export class QQWorld implements World {
|
|
126
|
+
readonly id = 'qq';
|
|
127
|
+
|
|
128
|
+
private readonly cfg: QQWorldConfig;
|
|
129
|
+
private readonly timezone: string;
|
|
130
|
+
private host?: WorldHost;
|
|
131
|
+
private driver?: OneBotDriver;
|
|
132
|
+
private log: Logger = nullLogger();
|
|
133
|
+
private imagePolicy: ImageRenderPolicy = () => '[图片]';
|
|
134
|
+
|
|
135
|
+
/** 外挂视觉(注入=auxVLM生效);null=无,一切保持[图片]占位现状 */
|
|
136
|
+
private readonly vision?: VisionService;
|
|
137
|
+
|
|
138
|
+
/** 监听集合(可web端热改) */
|
|
139
|
+
private watchedGroups: Set<number>;
|
|
140
|
+
private watchedPrivates: Set<number>;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 已记录的 QQ 消息:平台 message_id → 所在会话与时间(内存索引,启动时从store重建)。
|
|
144
|
+
* QQ 消息身份即平台自身的 message_id,不使用 core 事件游标。
|
|
145
|
+
*/
|
|
146
|
+
private knownMessages = new Map<string, { conv: Conv; ts: string }>();
|
|
147
|
+
/** QQ号 → 最近见到的显示名(撤回/入退群/私聊寻址渲染用;同样从store重建) */
|
|
148
|
+
private nameByUserId = new Map<string, string>();
|
|
149
|
+
|
|
150
|
+
/** 单槽草稿(起草-确认门) */
|
|
151
|
+
private pendingDraft: PendingDraft | null = null;
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
/** 最近一次成功组装的动态账号/会话事实(断线期间沿用;不缓存可编辑固定文本) */
|
|
155
|
+
private cachedDynamicContext?: string;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 消息处理串行链:所有消息严格按到达顺序处理。图片去重预判(precheckDup)
|
|
159
|
+
* 是每条消息内部的异步等待点,不排队会导致连续到达的消息乱序落库。
|
|
160
|
+
*/
|
|
161
|
+
private msgChain: Promise<void> = Promise.resolve();
|
|
162
|
+
|
|
163
|
+
constructor(cfg: QQWorldConfig, deps?: QQWorldDeps) {
|
|
164
|
+
this.cfg = cfg;
|
|
165
|
+
this.timezone = cfg.timezone ?? 'Asia/Shanghai';
|
|
166
|
+
this.vision = deps?.vision;
|
|
167
|
+
this.gate = deps?.gate;
|
|
168
|
+
this.watchedGroups = new Set(cfg.groups);
|
|
169
|
+
this.watchedPrivates = new Set(cfg.privates);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private readonly gate?: QQGatePanelDeps;
|
|
173
|
+
|
|
174
|
+
/** 被动识图在视觉服务可用时启用;识图描述写入事件库作为可检索记忆。 */
|
|
175
|
+
private passiveEnabled(): boolean {
|
|
176
|
+
return !!this.vision;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 去重预判在消息渲染前就有结果时,直接内联进这条消息(不走占位+异步事件)。
|
|
181
|
+
* meta 来自 VisionService.precheckDup:dup_of 表示命中去重,ok=false 表示下载失败。
|
|
182
|
+
*/
|
|
183
|
+
private inlineVisionText(
|
|
184
|
+
id: string,
|
|
185
|
+
gifMark: string,
|
|
186
|
+
meta: Record<string, unknown>,
|
|
187
|
+
label = '图片',
|
|
188
|
+
): string {
|
|
189
|
+
if (meta.dup_of) {
|
|
190
|
+
return `[${label} ${id}${gifMark},与 ${meta.dup_of} 是同一张图,已识别过(沿用其描述)]`;
|
|
191
|
+
}
|
|
192
|
+
return `[${label} ${id}${gifMark} 识别失败]`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
/** 当前监听集合(web面板读取用) */
|
|
197
|
+
getWatched(): { groups: number[]; privates: number[] } {
|
|
198
|
+
return { groups: [...this.watchedGroups], privates: [...this.watchedPrivates] };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 连接与身份快照(web「QQ 接入」控制台的连接状态卡数据源)。
|
|
203
|
+
* 断线期间 connected=false,但 selfId/群名沿用上次身份;私聊名字随消息填充。
|
|
204
|
+
*/
|
|
205
|
+
getConnection(): {
|
|
206
|
+
connected: boolean;
|
|
207
|
+
selfId: number | null;
|
|
208
|
+
nickname: string;
|
|
209
|
+
groups: Array<{ id: number; name: string; card: string }>;
|
|
210
|
+
privates: Array<{ id: number; name: string }>;
|
|
211
|
+
} {
|
|
212
|
+
const idn = this.driver?.identity;
|
|
213
|
+
return {
|
|
214
|
+
connected: this.driver?.connected ?? false,
|
|
215
|
+
selfId: idn?.selfId ?? null,
|
|
216
|
+
nickname: idn?.nickname ?? '',
|
|
217
|
+
groups: [...this.watchedGroups].map((id) => {
|
|
218
|
+
const g = idn?.groups.get(id);
|
|
219
|
+
return { id, name: g?.groupName ?? String(id), card: g?.card ?? '' };
|
|
220
|
+
}),
|
|
221
|
+
privates: [...this.watchedPrivates].map((id) => ({
|
|
222
|
+
id,
|
|
223
|
+
name: this.nameByUserId.get(String(id)) ?? '',
|
|
224
|
+
})),
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* 热改监听集合(roster 里 enabled 的子集)。新群的身份在下次重连(或已连接时
|
|
230
|
+
* 的后台刷新)时补齐;私聊名字随后续消息自然填充。进入/离开监听的每个 id
|
|
231
|
+
* 各推一条 qq.watch 事件——监听名单变化属于环境事件,不静默修改。
|
|
232
|
+
*/
|
|
233
|
+
setWatched(groups: number[], privates: number[]): void {
|
|
234
|
+
const oldGroups = this.watchedGroups;
|
|
235
|
+
const oldPrivates = this.watchedPrivates;
|
|
236
|
+
const newGroups = new Set(groups.filter((g) => Number.isFinite(g)));
|
|
237
|
+
const newPrivates = new Set(privates.filter((p) => Number.isFinite(p)));
|
|
238
|
+
|
|
239
|
+
this.watchedGroups = newGroups;
|
|
240
|
+
this.watchedPrivates = newPrivates;
|
|
241
|
+
this.cachedDynamicContext = undefined;
|
|
242
|
+
this.driver?.setGroups([...newGroups]);
|
|
243
|
+
this.log.info('监听配置已更新', {
|
|
244
|
+
groups: [...newGroups],
|
|
245
|
+
privates: [...newPrivates],
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
this.announceWatchDiff('group', oldGroups, newGroups);
|
|
249
|
+
this.announceWatchDiff('private', oldPrivates, newPrivates);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 推送监听集合的差集通知:每个进入/离开监听的id各一条qq.watch事件 */
|
|
253
|
+
private announceWatchDiff(kind: 'group' | 'private', before: Set<number>, after: Set<number>): void {
|
|
254
|
+
if (!this.host) return;
|
|
255
|
+
for (const id of after) if (!before.has(id)) this.pushWatchEvent(kind, id, true);
|
|
256
|
+
for (const id of before) if (!after.has(id)) this.pushWatchEvent(kind, id, false);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private pushWatchEvent(kind: 'group' | 'private', id: number, joined: boolean): void {
|
|
260
|
+
const host = this.host!;
|
|
261
|
+
const label = kind === 'group' ? `群(群号${id})` : `私聊(QQ号${id})`;
|
|
262
|
+
host.pushEvent({
|
|
263
|
+
type: 'qq.watch',
|
|
264
|
+
ts: nowIso(this.timezone),
|
|
265
|
+
source: this.id,
|
|
266
|
+
text: `[系统] ${label} ${joined ? '已加入监听' : '已从监听移除'}`,
|
|
267
|
+
senderKey: String(id),
|
|
268
|
+
meta: { conv: { kind, id } },
|
|
269
|
+
}).catch((e) => host.log.warn('事件投递失败', { err: String(e) }));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 会话清单与自身账号两个洞。措辞(含"下面是你正在参与的会话:"那句和两条降级
|
|
275
|
+
* 文案)全在 ENV_PROMPT.md 里,这里只算值。
|
|
276
|
+
*
|
|
277
|
+
* 三态,中间那态是有意的:**掉线时沿用断线前的群名快照**。若把群名换成"未知",
|
|
278
|
+
* 整段前缀内容就变了,前缀缓存全部失效、下一次调用要重新 prefill 一整份 system。
|
|
279
|
+
* 为省这个钱才留着旧快照——别当成可以顺手简化掉的分支。
|
|
280
|
+
*/
|
|
281
|
+
envPromptVars(): Record<string, string> {
|
|
282
|
+
const idn = this.driver?.identity;
|
|
283
|
+
if (!idn && this.cachedDynamicContext !== undefined) {
|
|
284
|
+
return { 'qq.conversations': this.cachedDynamicContext, 'qq.identity': '' };
|
|
285
|
+
}
|
|
286
|
+
const lines: string[] = [];
|
|
287
|
+
for (const gid of this.watchedGroups) {
|
|
288
|
+
const g = idn?.groups.get(gid);
|
|
289
|
+
lines.push(
|
|
290
|
+
g
|
|
291
|
+
? `- 群「${g.groupName}」(群号${gid});你在这个群的昵称是「${g.card}」`
|
|
292
|
+
: `- 群(群号${gid});群信息尚未就绪`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
if (this.watchedPrivates.size > 0) {
|
|
296
|
+
const names = [...this.watchedPrivates].map((uid) => {
|
|
297
|
+
const n = this.nameByUserId.get(String(uid));
|
|
298
|
+
return n ? `${n}(${uid})` : String(uid);
|
|
299
|
+
});
|
|
300
|
+
lines.push(`- 私聊:${names.join('、')}`);
|
|
301
|
+
}
|
|
302
|
+
const conversations = lines.join('\n');
|
|
303
|
+
if (idn) this.cachedDynamicContext = conversations;
|
|
304
|
+
return {
|
|
305
|
+
'qq.conversations': conversations,
|
|
306
|
+
'qq.identity': idn ? `你的QQ号是${idn.selfId}。` : '',
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 控制台里露出什么:连接状态徽标 + 本 World 的三个专用面板 + 视觉旋钮。
|
|
312
|
+
* 控制台不认识 'qq' 这个 id,它只照这份声明渲染。
|
|
313
|
+
*/
|
|
314
|
+
console(): WorldConsoleDecl {
|
|
315
|
+
const conn = this.getConnection();
|
|
316
|
+
return {
|
|
317
|
+
// 两条链路各一颗:协议端连着没有、监听名单空不空。
|
|
318
|
+
//
|
|
319
|
+
// 接入门关着 = 灰(拨下来的);开着却连不上协议端 = 红:这个渠道存在的全部意义
|
|
320
|
+
// 就是连着那个协议端,连不上就是她在 QQ 上失联了。名单空着不算故障,但那时候
|
|
321
|
+
// 连着也收不到任何消息——所以它值一颗自己的灯。
|
|
322
|
+
lamps: [
|
|
323
|
+
{
|
|
324
|
+
label: '协议端',
|
|
325
|
+
...(this.gate?.enabled() === false
|
|
326
|
+
? { state: 'offline' as const, hint: '接入门关闭' }
|
|
327
|
+
: conn.connected
|
|
328
|
+
? { state: 'online' as const, hint: conn.nickname || String(conn.selfId ?? '') }
|
|
329
|
+
: { state: 'error' as const, hint: '未连接' }),
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
label: '监听',
|
|
333
|
+
...(conn.groups.length + conn.privates.length > 0
|
|
334
|
+
? {
|
|
335
|
+
state: 'online' as const,
|
|
336
|
+
hint: `群 ${conn.groups.length} · 私聊 ${conn.privates.length}`,
|
|
337
|
+
}
|
|
338
|
+
: { state: 'offline' as const, hint: '名单为空' }),
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
badges: [
|
|
342
|
+
{
|
|
343
|
+
label: '协议端',
|
|
344
|
+
value: conn.connected ? `已连接 ${conn.nickname || conn.selfId || ''}`.trim() : '未连接',
|
|
345
|
+
tone: conn.connected ? 'on' : 'off',
|
|
346
|
+
},
|
|
347
|
+
{ label: '监听', value: `群 ${conn.groups.length} · 私聊 ${conn.privates.length}` },
|
|
348
|
+
],
|
|
349
|
+
// 局部 id + 真标题(控制台把 title 画成面板切换页签);渲染在
|
|
350
|
+
// src/worlds/qq/console/ 的自有浏览器扩展里,中央前端不认识这三个名字。
|
|
351
|
+
panels: [
|
|
352
|
+
{ id: 'gate', title: '接入门', description: '接入开关、连接状态与 NapCat 地址。' },
|
|
353
|
+
{ id: 'roster', title: '监听名单', description: '监听哪些群、哪些私聊;改动热生效。' },
|
|
354
|
+
{ id: 'events', title: '事件', description: '事件库里 source=qq 的历史,按会话翻。' },
|
|
355
|
+
],
|
|
356
|
+
invoke: (panel, method, args) => this.invokePanel(panel, method, args),
|
|
357
|
+
promptDocs: [
|
|
358
|
+
{
|
|
359
|
+
key: 'worlds.qq.envPrompt',
|
|
360
|
+
title: 'QQ · 环境提示词',
|
|
361
|
+
description: 'QQ 渠道的常驻事实(消息形态、起草确认门、图片)。',
|
|
362
|
+
path: ENV_PROMPT_FILE,
|
|
363
|
+
role: 'envPrompt',
|
|
364
|
+
vars: [
|
|
365
|
+
{
|
|
366
|
+
name: 'qq.conversations',
|
|
367
|
+
description: '当前监听的群与私聊清单,每行一条:群名、群号、你在该群的昵称。',
|
|
368
|
+
multiline: true,
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
name: 'qq.identity',
|
|
372
|
+
description: '你自己的 QQ 号;未连接协议端时为空(此时用缺省文案)。',
|
|
373
|
+
},
|
|
374
|
+
],
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
config: [QQ_CONFIG_GROUP],
|
|
378
|
+
...(this.vision ? { storage: [this.visionStorage(this.vision)] } : {}),
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** 辅助视觉的缓存(图片字节 + 被动描述 + 追问 session)作为可清除存储上报。 */
|
|
383
|
+
private visionStorage(vision: VisionService): StoragePart {
|
|
384
|
+
return {
|
|
385
|
+
key: 'vision',
|
|
386
|
+
label: '外挂视觉缓存(图片+描述+追问session)',
|
|
387
|
+
kind: 'disk',
|
|
388
|
+
location: 'data/vision/',
|
|
389
|
+
note: '图片字节/被动描述/追问轮次全部抹除,IMG计数器归零;历史消息里旧IMG-N此后查不到',
|
|
390
|
+
stat: () => `${vision.imageCount()}张图`,
|
|
391
|
+
clear: () => `已清除${vision.clear()}张图的视觉缓存`,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* 面板调用按 (panel, method, args) 透传;panel 为 console() 声明中的局部 id。
|
|
397
|
+
*/
|
|
398
|
+
private async invokePanel(panel: string, method: string, args: unknown[]): Promise<unknown> {
|
|
399
|
+
if (panel === 'gate') return this.invokeGate(method, args);
|
|
400
|
+
if (panel === 'roster') return this.invokeRoster(method, args);
|
|
401
|
+
if (panel === 'events') return this.invokeEvents(method, args);
|
|
402
|
+
throw new Error(`未知面板: ${panel}`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* 会话名字快照(群名/我的群昵称/私聊称呼)。roster 与 events 两个面板都要拿它
|
|
407
|
+
* 把号码渲染成人看得懂的名字,而 `ctx.invoke` 只能打到**本面板**——所以两边
|
|
408
|
+
* 各开一个 `names`,而不是让它们去借 gate 的 `state`。
|
|
409
|
+
*/
|
|
410
|
+
private convNames(): {
|
|
411
|
+
groups: Array<{ id: number; name: string; card: string }>;
|
|
412
|
+
privates: Array<{ id: number; name: string }>;
|
|
413
|
+
} {
|
|
414
|
+
const conn = this.getConnection();
|
|
415
|
+
return { groups: conn.groups, privates: conn.privates };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private async invokeGate(method: string, args: unknown[]): Promise<unknown> {
|
|
419
|
+
const gate = this.gate;
|
|
420
|
+
if (!gate) throw new Error('QQ 接入控制不可用(装配层没提供 gate 数据面)');
|
|
421
|
+
if (method === 'state') {
|
|
422
|
+
const conn = this.getConnection();
|
|
423
|
+
const roster = gate.roster();
|
|
424
|
+
return {
|
|
425
|
+
enabled: gate.enabled(),
|
|
426
|
+
wsUrl: gate.wsUrl(),
|
|
427
|
+
tokenSet: gate.tokenSet(),
|
|
428
|
+
connected: conn.connected,
|
|
429
|
+
selfId: conn.selfId,
|
|
430
|
+
nickname: conn.nickname,
|
|
431
|
+
groups: conn.connected || conn.groups.length > 0
|
|
432
|
+
? conn.groups
|
|
433
|
+
: enabledRosterIds(roster.groups).map((id) => ({ id, name: String(id), card: '' })),
|
|
434
|
+
privates: conn.connected || conn.privates.length > 0
|
|
435
|
+
? conn.privates
|
|
436
|
+
: enabledRosterIds(roster.privates).map((id) => ({ id, name: '' })),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
if (method === 'setEnabled') {
|
|
440
|
+
const enabled = args[0];
|
|
441
|
+
if (typeof enabled !== 'boolean') throw new Error('enabled 必须是布尔');
|
|
442
|
+
gate.setEnabled(enabled);
|
|
443
|
+
this.log.warn('QQ 接入开关已改,即将重启', { enabled });
|
|
444
|
+
this.scheduleGateRestart(gate);
|
|
445
|
+
return { ok: true, restarting: true, enabled };
|
|
446
|
+
}
|
|
447
|
+
if (method === 'setConnection') {
|
|
448
|
+
const wsUrl = typeof args[0] === 'string' ? args[0].trim() : '';
|
|
449
|
+
const token = typeof args[1] === 'string' ? args[1] : '';
|
|
450
|
+
if (!/^wss?:\/\/.+/i.test(wsUrl)) throw new Error('wsUrl 必须是 ws:// 或 wss:// 地址');
|
|
451
|
+
gate.setConnection(wsUrl, token);
|
|
452
|
+
this.log.warn('NapCat 连接配置已改,即将重启', { wsUrl, tokenChanged: token !== '' });
|
|
453
|
+
this.scheduleGateRestart(gate);
|
|
454
|
+
return { ok: true, restarting: true };
|
|
455
|
+
}
|
|
456
|
+
throw new Error(`未知面板方法: gate.${method}`);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** HTTP 响应冲刷后再写重启标志并退出进程。 */
|
|
460
|
+
private scheduleGateRestart(gate: QQGatePanelDeps): void {
|
|
461
|
+
setTimeout(() => {
|
|
462
|
+
try { gate.restart(); } catch (err) { this.log.error('触发重启失败', { error: String(err) }); }
|
|
463
|
+
}, 300);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private async invokeRoster(method: string, args: unknown[]): Promise<unknown> {
|
|
467
|
+
// 名字快照不依赖装配层的 gate 数据面:名单读不出来的时候,至少还能显示名字
|
|
468
|
+
if (method === 'names') return this.convNames();
|
|
469
|
+
const gate = this.gate;
|
|
470
|
+
if (!gate) throw new Error('QQ 监听配置不可用(装配层没提供 gate 数据面)');
|
|
471
|
+
if (method === 'get') return gate.roster();
|
|
472
|
+
if (method === 'set') {
|
|
473
|
+
const coerce = (raw: unknown, field: string): QQRosterEntry[] => {
|
|
474
|
+
if (raw === undefined || raw === null) return [];
|
|
475
|
+
if (!Array.isArray(raw)) throw new Error(`${field}必须是数组`);
|
|
476
|
+
const out: QQRosterEntry[] = [];
|
|
477
|
+
const seen = new Set<number>();
|
|
478
|
+
for (const item of raw) {
|
|
479
|
+
if (!item || typeof item !== 'object') throw new Error(`${field}的每一项必须是{id,enabled}对象`);
|
|
480
|
+
const id = Number((item as Record<string, unknown>).id);
|
|
481
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
482
|
+
throw new Error(`${field}的每一项id必须是>0的整数,收到: ${String((item as Record<string, unknown>).id)}`);
|
|
483
|
+
}
|
|
484
|
+
if (seen.has(id)) throw new Error(`${field}里号码${id}重复`);
|
|
485
|
+
seen.add(id);
|
|
486
|
+
out.push({ id, enabled: (item as Record<string, unknown>).enabled !== false });
|
|
487
|
+
}
|
|
488
|
+
return out;
|
|
489
|
+
};
|
|
490
|
+
const groups = coerce(args[0], 'groups');
|
|
491
|
+
const privates = coerce(args[1], 'privates');
|
|
492
|
+
const result = gate.setRoster(groups, privates);
|
|
493
|
+
this.log.warn('QQ监听配置已修改', { groups, privates });
|
|
494
|
+
return { ok: true, result, config: gate.roster() };
|
|
495
|
+
}
|
|
496
|
+
throw new Error(`未知面板方法: roster.${method}`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** 事件历史按会话分组(读事件库 source=qq;World 未启动时不可用) */
|
|
500
|
+
private async invokeEvents(method: string, args: unknown[]): Promise<unknown> {
|
|
501
|
+
if (method === 'names') return this.convNames();
|
|
502
|
+
if (method !== 'list') throw new Error(`未知面板方法: events.${method}`);
|
|
503
|
+
const store = this.host?.store;
|
|
504
|
+
if (!store) throw new Error('QQ World 未启动,事件历史不可用');
|
|
505
|
+
const opts = (args[0] ?? {}) as { conv?: unknown; limit?: unknown };
|
|
506
|
+
const all = store.range({ source: this.id });
|
|
507
|
+
const convMap = new Map<string, { kind: string; id: number; count: number; lastTs: string }>();
|
|
508
|
+
for (const e of all) {
|
|
509
|
+
const c = e.meta?.conv as { kind?: string; id?: number } | undefined;
|
|
510
|
+
if (!c || (c.kind !== 'group' && c.kind !== 'private') || c.id === undefined) continue;
|
|
511
|
+
const key = `${c.kind}:${Number(c.id)}`;
|
|
512
|
+
const cur = convMap.get(key);
|
|
513
|
+
if (cur) { cur.count++; cur.lastTs = e.ts; }
|
|
514
|
+
else convMap.set(key, { kind: c.kind, id: Number(c.id), count: 1, lastTs: e.ts });
|
|
515
|
+
}
|
|
516
|
+
const conversations = [...convMap.values()].sort((a, b) => (a.lastTs < b.lastTs ? 1 : -1));
|
|
517
|
+
|
|
518
|
+
const convRaw = typeof opts.conv === 'string' ? opts.conv.trim() : '';
|
|
519
|
+
let events = all;
|
|
520
|
+
const m = convRaw ? /^(group|private):(\d+)$/i.exec(convRaw) : null;
|
|
521
|
+
if (convRaw && !m) {
|
|
522
|
+
events = [];
|
|
523
|
+
} else if (m) {
|
|
524
|
+
const kind = m[1].toLowerCase();
|
|
525
|
+
const id = Number(m[2]);
|
|
526
|
+
events = all.filter((e) => {
|
|
527
|
+
const c = e.meta?.conv as { kind?: string; id?: number } | undefined;
|
|
528
|
+
return !!c && c.kind === kind && Number(c.id) === id;
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
const limit = Math.max(1, Math.min(2000, Number(opts.limit) || 300));
|
|
532
|
+
if (events.length > limit) events = events.slice(events.length - limit);
|
|
533
|
+
const lean = events.map((e) => ({
|
|
534
|
+
cursor: e.cursor,
|
|
535
|
+
type: e.type,
|
|
536
|
+
ts: e.ts,
|
|
537
|
+
text: e.text,
|
|
538
|
+
senderKey: e.senderKey,
|
|
539
|
+
senderName: (e.meta?.sender_name as string | undefined) ?? '',
|
|
540
|
+
conv: e.meta?.conv ?? null,
|
|
541
|
+
}));
|
|
542
|
+
return { conversations, events: lean, total: all.length };
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
async start(host: WorldHost): Promise<void> {
|
|
547
|
+
this.host = host;
|
|
548
|
+
this.log = host.log;
|
|
549
|
+
// 主模型支持 image/png 或 World 配置了视觉模型时启用取图。
|
|
550
|
+
this.imagePolicy = makeImagePolicy(!!this.vision || host.modelFacts.accepts('image/png'));
|
|
551
|
+
// 外挂视觉的用量自愿上报进 core 的成本账(不报就在成本页看不到)
|
|
552
|
+
this.vision?.setUsageSink((usage, model) =>
|
|
553
|
+
host.reportUsage(usage, { model, label: 'QQWorld·辅助视觉' }),
|
|
554
|
+
);
|
|
555
|
+
this.rebuildMaps();
|
|
556
|
+
|
|
557
|
+
const driver = new OneBotDriver({
|
|
558
|
+
wsUrl: this.cfg.wsUrl,
|
|
559
|
+
groups: [...this.watchedGroups],
|
|
560
|
+
token: this.cfg.token,
|
|
561
|
+
log: this.log.child('driver'),
|
|
562
|
+
reconnectBaseMs: this.cfg.reconnectBaseMs,
|
|
563
|
+
reconnectMaxMs: this.cfg.reconnectMaxMs,
|
|
564
|
+
apiTimeoutMs: this.cfg.apiTimeoutMs,
|
|
565
|
+
});
|
|
566
|
+
this.driver = driver;
|
|
567
|
+
driver.onEvent((ev) => this.handleEvent(ev));
|
|
568
|
+
driver.onReady(() => {
|
|
569
|
+
this.envPromptVars(); // 为副作用调用:趁 identity 在,把群名快照存进缓存
|
|
570
|
+
this.log.info('身份就绪', { context: this.cachedDynamicContext });
|
|
571
|
+
});
|
|
572
|
+
await driver.start();
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async stop(): Promise<void> {
|
|
576
|
+
await this.driver?.stop();
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** 测试/联调辅助:等驱动连接并完成身份初始化 */
|
|
580
|
+
waitReady(timeoutMs = 10000): Promise<void> {
|
|
581
|
+
if (!this.driver) return Promise.reject(new Error('World 未启动'));
|
|
582
|
+
return this.driver.waitReady(timeoutMs);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** 每轮起点调用:上一轮未确认的草稿作废(起草-确认门的"默认擦除") */
|
|
586
|
+
/** 一轮自然收束:未确认的草稿只在本轮内有效。draft 屏障后紧接的 user 事件仍属同一轮,不在这里作废。 */
|
|
587
|
+
onTurnEnded(): void {
|
|
588
|
+
this.discardPendingDraft();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
discardPendingDraft(): void {
|
|
592
|
+
if (this.pendingDraft) {
|
|
593
|
+
this.log.debug('未确认的草稿已作废');
|
|
594
|
+
this.pendingDraft = null;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
private handleEvent(ev: OneBotEvent): void {
|
|
600
|
+
const host = this.host;
|
|
601
|
+
if (!host) return;
|
|
602
|
+
|
|
603
|
+
if (ev.post_type === 'message') {
|
|
604
|
+
if (ev.message_type === 'group' && ev.group_id !== undefined) {
|
|
605
|
+
if (this.watchedGroups.has(Number(ev.group_id))) {
|
|
606
|
+
this.enqueueMessage(ev as unknown as OneBotGroupMessage, {
|
|
607
|
+
kind: 'group',
|
|
608
|
+
id: Number(ev.group_id),
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
if (ev.message_type === 'private' && ev.user_id !== undefined) {
|
|
614
|
+
if (this.watchedPrivates.has(Number(ev.user_id))) {
|
|
615
|
+
this.enqueueMessage(ev as unknown as OneBotGroupMessage, {
|
|
616
|
+
kind: 'private',
|
|
617
|
+
id: Number(ev.user_id),
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
return; // 其他消息类型丢弃
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (ev.post_type === 'notice') {
|
|
626
|
+
// 群通知仅处理监听中的群;私聊/名单外一律丢弃
|
|
627
|
+
if (ev.group_id === undefined || !this.watchedGroups.has(Number(ev.group_id))) return;
|
|
628
|
+
const gid = Number(ev.group_id);
|
|
629
|
+
switch (ev.notice_type) {
|
|
630
|
+
case 'group_recall':
|
|
631
|
+
this.handleRecall(ev, gid);
|
|
632
|
+
return;
|
|
633
|
+
case 'group_increase':
|
|
634
|
+
this.handleMemberChange(ev, gid, 'join');
|
|
635
|
+
return;
|
|
636
|
+
case 'group_decrease':
|
|
637
|
+
this.handleMemberChange(ev, gid, 'leave');
|
|
638
|
+
return;
|
|
639
|
+
case 'group_msg_emoji_like':
|
|
640
|
+
this.handleEmojiLike(ev, gid);
|
|
641
|
+
return;
|
|
642
|
+
case 'notify':
|
|
643
|
+
if (ev.sub_type === 'poke') this.handlePoke(ev, gid);
|
|
644
|
+
return;
|
|
645
|
+
default:
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
// request(好友/加群请求)/meta_event(心跳)全部丢弃
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* 入队一条消息:严格按到达顺序处理(见msgChain字段注释)。单条消息内部
|
|
654
|
+
* 出错不冲垮后续消息——记录日志,链继续往下走。
|
|
655
|
+
*/
|
|
656
|
+
private enqueueMessage(msg: OneBotGroupMessage, conv: Conv): void {
|
|
657
|
+
this.msgChain = this.msgChain.then(() => this.handleMessage(msg, conv)).catch((e) => {
|
|
658
|
+
this.log.error('消息处理异常', { err: String(e) });
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
private async handleMessage(msg: OneBotGroupMessage, conv: Conv): Promise<void> {
|
|
663
|
+
const host = this.host!;
|
|
664
|
+
const idn = this.driver?.identity;
|
|
665
|
+
const selfId = idn?.selfId ?? -1;
|
|
666
|
+
|
|
667
|
+
// 自己账号的回声消息丢弃(自己说的话由 confirm(send) 回录 qq.self)
|
|
668
|
+
if (Number(msg.user_id) === selfId) return;
|
|
669
|
+
|
|
670
|
+
const senderKey = String(msg.user_id);
|
|
671
|
+
const displayName = msg.sender?.card || msg.sender?.nickname || senderKey;
|
|
672
|
+
this.nameByUserId.set(senderKey, displayName);
|
|
673
|
+
|
|
674
|
+
// 图片渲染策略(外挂视觉,群/私聊同处理)。被动开启时,每张图先做一次限时的
|
|
675
|
+
// 内容去重预判(下载+算hash+查重复,不跑VLM);时限内出结果(命中或下载失败)
|
|
676
|
+
// 则直接内联进消息,跳过占位与异步qq.vision事件,否则显示占位符。
|
|
677
|
+
const collected: Array<{ id: string; url: string }> = [];
|
|
678
|
+
const earlyResolved: Array<{ text: string; meta: Record<string, unknown> } | null> = [];
|
|
679
|
+
let renderImage: ImageRenderPolicy;
|
|
680
|
+
let renderJsonCard: JsonCardRenderPolicy;
|
|
681
|
+
|
|
682
|
+
if (this.vision) {
|
|
683
|
+
const vision = this.vision;
|
|
684
|
+
const passive = this.passiveEnabled();
|
|
685
|
+
const items = (msg.message ?? [])
|
|
686
|
+
.filter((seg) => seg.type === 'image')
|
|
687
|
+
.map((seg) => {
|
|
688
|
+
const data = seg.data ?? {};
|
|
689
|
+
const url = extractImageUrl(data);
|
|
690
|
+
const gif = looksLikeGif(data);
|
|
691
|
+
return { url, gif, id: vision.registerImage(url, { gif }) };
|
|
692
|
+
});
|
|
693
|
+
const precheck =
|
|
694
|
+
passive && items.length
|
|
695
|
+
? await Promise.all(items.map((it) => vision.precheckDup(it.id)))
|
|
696
|
+
: items.map(() => null);
|
|
697
|
+
|
|
698
|
+
let idx = 0;
|
|
699
|
+
renderImage = () => {
|
|
700
|
+
const it = items[idx];
|
|
701
|
+
const early = precheck[idx];
|
|
702
|
+
idx++;
|
|
703
|
+
collected.push({ id: it.id, url: it.url });
|
|
704
|
+
earlyResolved.push(early);
|
|
705
|
+
const gifMark = it.gif ? '(GIF·仅首帧)' : '';
|
|
706
|
+
if (early) return this.inlineVisionText(it.id, gifMark, early.meta);
|
|
707
|
+
return passive
|
|
708
|
+
? `[图片 ${it.id}${gifMark} 正在载入VLM理解中...]`
|
|
709
|
+
: `[图片 ${it.id}${gifMark}]`;
|
|
710
|
+
};
|
|
711
|
+
|
|
712
|
+
// JSON 卡片封面复用图片的去重预判与被动识图路径;下载失败由识别失败分支呈现。
|
|
713
|
+
const cardItems = (msg.message ?? [])
|
|
714
|
+
.filter((seg) => seg.type === 'json')
|
|
715
|
+
.map((seg) => {
|
|
716
|
+
const info = parseJsonCard(seg.data ?? {});
|
|
717
|
+
return {
|
|
718
|
+
...info,
|
|
719
|
+
id: info.previewUrl ? vision.registerImage(info.previewUrl, { gif: false }) : undefined,
|
|
720
|
+
};
|
|
721
|
+
});
|
|
722
|
+
const cardPrecheck =
|
|
723
|
+
passive && cardItems.some((it) => it.id)
|
|
724
|
+
? await Promise.all(cardItems.map((it) => (it.id ? vision.precheckDup(it.id) : null)))
|
|
725
|
+
: cardItems.map(() => null);
|
|
726
|
+
|
|
727
|
+
let cIdx = 0;
|
|
728
|
+
renderJsonCard = () => {
|
|
729
|
+
const it = cardItems[cIdx];
|
|
730
|
+
const early = cardPrecheck[cIdx];
|
|
731
|
+
cIdx++;
|
|
732
|
+
const promptText = it.prompt ? `[分享:${it.prompt}]` : '[json]';
|
|
733
|
+
if (!it.id) return promptText;
|
|
734
|
+
collected.push({ id: it.id, url: it.previewUrl! });
|
|
735
|
+
earlyResolved.push(early);
|
|
736
|
+
if (early) return `${promptText} ${this.inlineVisionText(it.id, '', early.meta, '封面')}`;
|
|
737
|
+
return passive
|
|
738
|
+
? `${promptText} [封面 ${it.id} 正在载入VLM理解中...]`
|
|
739
|
+
: `${promptText} [封面 ${it.id}]`;
|
|
740
|
+
};
|
|
741
|
+
} else {
|
|
742
|
+
renderImage = this.imagePolicy;
|
|
743
|
+
renderJsonCard = (info) =>
|
|
744
|
+
!info.prompt
|
|
745
|
+
? '[json]'
|
|
746
|
+
: info.previewUrl
|
|
747
|
+
? `[分享:${info.prompt}] ${this.imagePolicy({ url: info.previewUrl })}`
|
|
748
|
+
: `[分享:${info.prompt}]`;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const selfName =
|
|
752
|
+
conv.kind === 'group'
|
|
753
|
+
? idn?.groups.get(conv.id)?.card || idn?.nickname || '你'
|
|
754
|
+
: idn?.nickname || '你';
|
|
755
|
+
|
|
756
|
+
const { text, mentionedSelf } = renderIncoming(msg, {
|
|
757
|
+
selfId,
|
|
758
|
+
selfName,
|
|
759
|
+
timezone: this.timezone,
|
|
760
|
+
convLabel: this.convLabel(conv),
|
|
761
|
+
knowsMessage: (mid) => this.knownMessages.has(String(mid)),
|
|
762
|
+
nameOf: (qq) => this.nameByUserId.get(qq),
|
|
763
|
+
renderImage,
|
|
764
|
+
renderJsonCard,
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
const when =
|
|
768
|
+
typeof msg.time === 'number' ? new Date(msg.time * 1000) : new Date();
|
|
769
|
+
// 去重预判阶段已经拿到字节的图随这条消息落库(她因此有句柄可以存、可以转发);
|
|
770
|
+
// 还在下载/识别中的图随稍后的 qq.vision 事件落库。
|
|
771
|
+
const blobs: BlobInput[] = [];
|
|
772
|
+
if (this.vision) {
|
|
773
|
+
for (let i = 0; i < collected.length; i++) {
|
|
774
|
+
if (!earlyResolved[i]) continue;
|
|
775
|
+
const got = await this.vision.getImageBytes(collected[i].id).catch(() => null);
|
|
776
|
+
if (got) blobs.push({ bytes: got.buffer, mime: got.mime, name: collected[i].id, fallbackText: `图片 ${collected[i].id}` });
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
// 私聊照常合批;群里被@立即投递
|
|
780
|
+
const trigger = conv.kind === 'group' && mentionedSelf ? 'flush' as const : 'debounce' as const;
|
|
781
|
+
const env = await host.pushEvent(
|
|
782
|
+
{
|
|
783
|
+
type: 'qq.message',
|
|
784
|
+
ts: nowIso(this.timezone, when),
|
|
785
|
+
source: this.id,
|
|
786
|
+
text,
|
|
787
|
+
senderKey,
|
|
788
|
+
meta: {
|
|
789
|
+
message_id: msg.message_id,
|
|
790
|
+
user_id: msg.user_id,
|
|
791
|
+
sender_name: displayName,
|
|
792
|
+
conv,
|
|
793
|
+
},
|
|
794
|
+
...(blobs.length ? { blobs } : {}),
|
|
795
|
+
},
|
|
796
|
+
{ trigger },
|
|
797
|
+
);
|
|
798
|
+
this.knownMessages.set(String(msg.message_id), { conv, ts: env.ts });
|
|
799
|
+
|
|
800
|
+
// 图片:回填所属消息号,被动识图(理解闭合后作为qq.vision事件延迟到达)。
|
|
801
|
+
// 已经被上面的去重预判内联进消息本身的图,不用再走这条异步路径。
|
|
802
|
+
if (this.vision && collected.length) {
|
|
803
|
+
collected.forEach((img, i) => {
|
|
804
|
+
this.vision!.attachMessage(img.id, msg.message_id);
|
|
805
|
+
if (this.passiveEnabled() && !earlyResolved[i]) {
|
|
806
|
+
this.vision!.startPassive(img.id, (visionText, visionMeta) => {
|
|
807
|
+
void this.vision!.getImageBytes(img.id).catch(() => null).then((got) => host.pushEvent({
|
|
808
|
+
type: 'qq.vision',
|
|
809
|
+
ts: nowIso(this.timezone),
|
|
810
|
+
source: this.id,
|
|
811
|
+
text: visionText,
|
|
812
|
+
meta: { ...visionMeta, conv },
|
|
813
|
+
...(got ? { blobs: [{ bytes: got.buffer, mime: got.mime, name: img.id, fallbackText: `图片 ${img.id}` }] } : {}),
|
|
814
|
+
})).catch((e) => host.log.warn('事件投递失败', { err: String(e) }));
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// 引用回复指向的消息不在索引里(未被记录):异步取原文,不阻塞当前投递
|
|
821
|
+
const replySeg = msg.message.find((s) => s.type === 'reply');
|
|
822
|
+
if (replySeg) {
|
|
823
|
+
const rid = String(replySeg.data?.id ?? '');
|
|
824
|
+
if (rid && !this.knownMessages.has(rid)) {
|
|
825
|
+
this.lookupUncapturedReply(rid, conv, msg.message_id);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// 转发的聊天记录:异步展开内容,不阻塞当前投递(同一套延迟到达风格)
|
|
830
|
+
const forwardSeg = msg.message.find((s) => s.type === 'forward');
|
|
831
|
+
if (forwardSeg) {
|
|
832
|
+
const resId = String(forwardSeg.data?.id ?? '');
|
|
833
|
+
if (resId) this.lookupForward(resId, conv, msg.message_id);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* 引用回复的原消息不在 knownMessages 里(可能发生在开始关注这个会话
|
|
839
|
+
* 之前,或跨重启丢失):尽力用标准动作 get_msg 取回原文。取到与否都追加一条
|
|
840
|
+
* 系统事件说明情况;不在当前消息处理里同步等待,避免打乱事件的到达顺序
|
|
841
|
+
* (与被动识图的延迟到达是同一套非阻塞风格)。
|
|
842
|
+
*/
|
|
843
|
+
private lookupUncapturedReply(rid: string, conv: Conv, ofMessageId: number | string): void {
|
|
844
|
+
const host = this.host;
|
|
845
|
+
const driver = this.driver;
|
|
846
|
+
if (!host || !driver) return;
|
|
847
|
+
const midNum = Number(rid);
|
|
848
|
+
void driver
|
|
849
|
+
.callApi('get_msg', { message_id: Number.isFinite(midNum) ? midNum : rid })
|
|
850
|
+
.then(
|
|
851
|
+
(data) => {
|
|
852
|
+
const d = data as {
|
|
853
|
+
sender?: { nickname?: string; card?: string; user_id?: number };
|
|
854
|
+
message?: Segment[] | string;
|
|
855
|
+
};
|
|
856
|
+
const uid = d?.sender?.user_id;
|
|
857
|
+
const name = d?.sender?.card || d?.sender?.nickname || (uid !== undefined ? String(uid) : rid);
|
|
858
|
+
const body = typeof d?.message === 'string' ? d.message : renderSegmentsPlain(d?.message ?? []);
|
|
859
|
+
host.pushEvent({
|
|
860
|
+
type: 'qq.reply.uncaptured',
|
|
861
|
+
ts: nowIso(this.timezone),
|
|
862
|
+
source: this.id,
|
|
863
|
+
text: `[系统] #${ofMessageId} 引用的消息不在你的记录里(未被捕获);查到的原文是 ${name}${uid !== undefined ? `(${uid})` : ''}: ${body}`,
|
|
864
|
+
meta: { conv, message_id: rid },
|
|
865
|
+
}).catch((e) => host.log.warn('事件投递失败', { err: String(e) }));
|
|
866
|
+
},
|
|
867
|
+
(err) => {
|
|
868
|
+
host.pushEvent({
|
|
869
|
+
type: 'qq.reply.uncaptured',
|
|
870
|
+
ts: nowIso(this.timezone),
|
|
871
|
+
source: this.id,
|
|
872
|
+
text: `[系统] #${ofMessageId} 引用的消息不在你的记录里(未被捕获),原文也没能取到:${err instanceof Error ? err.message : String(err)}`,
|
|
873
|
+
meta: { conv, message_id: rid },
|
|
874
|
+
}).catch((e) => host.log.warn('事件投递失败', { err: String(e) }));
|
|
875
|
+
},
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** 转发消息的单个节点(发言人+内容),兼容NapCat记录与OneBot标准node段。 */
|
|
880
|
+
private renderForwardNode(value: unknown): ForwardNode {
|
|
881
|
+
const outer =
|
|
882
|
+
value !== null && typeof value === 'object'
|
|
883
|
+
? (value as Record<string, unknown>)
|
|
884
|
+
: {};
|
|
885
|
+
const data = outer.data;
|
|
886
|
+
const n =
|
|
887
|
+
outer.type === 'node' && data !== null && typeof data === 'object'
|
|
888
|
+
? (data as Record<string, unknown>)
|
|
889
|
+
: outer;
|
|
890
|
+
const sender =
|
|
891
|
+
n.sender !== null && typeof n.sender === 'object'
|
|
892
|
+
? (n.sender as Record<string, unknown>)
|
|
893
|
+
: {};
|
|
894
|
+
const uidValue = sender.user_id ?? n.user_id;
|
|
895
|
+
const uid =
|
|
896
|
+
typeof uidValue === 'string' || typeof uidValue === 'number'
|
|
897
|
+
? uidValue
|
|
898
|
+
: undefined;
|
|
899
|
+
const nicknameValue = sender.nickname ?? sender.card ?? n.nickname;
|
|
900
|
+
const nickname =
|
|
901
|
+
typeof nicknameValue === 'string' && nicknameValue && nicknameValue !== PLACEHOLDER_NICKNAME
|
|
902
|
+
? nicknameValue
|
|
903
|
+
: undefined;
|
|
904
|
+
const isSelf =
|
|
905
|
+
uid !== undefined && String(uid) === String(this.driver?.identity?.selfId);
|
|
906
|
+
const name = isSelf ? '你' : (nickname ?? (uid !== undefined ? String(uid) : '?'));
|
|
907
|
+
const raw = n.content ?? n.message ?? n.raw_message;
|
|
908
|
+
const body =
|
|
909
|
+
typeof raw === 'string'
|
|
910
|
+
? raw
|
|
911
|
+
: Array.isArray(raw)
|
|
912
|
+
? renderSegmentsPlain(raw as Segment[])
|
|
913
|
+
: '';
|
|
914
|
+
return {
|
|
915
|
+
line: `${name}${uid !== undefined ? `(${uid})` : ''}: ${body}`,
|
|
916
|
+
body,
|
|
917
|
+
named: isSelf || nickname !== undefined,
|
|
918
|
+
time: typeof n.time === 'number' ? n.time : undefined,
|
|
919
|
+
userId: uid !== undefined ? String(uid) : undefined,
|
|
920
|
+
messageType: typeof n.message_type === 'string' ? n.message_type : undefined,
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* NapCat 对「私聊合并转发被转发进群」只提供协议兼容路径:该路径下所有节点的
|
|
926
|
+
* 发言人一律填成转发者本人、昵称落回「QQ用户」占位,身份信息实质丢失。命中时
|
|
927
|
+
* 调用方去掉发言人前缀只留正文,并在标题注明身份未取到,避免把多人对话误读
|
|
928
|
+
* 成一个人的独白。
|
|
929
|
+
*/
|
|
930
|
+
private forwardIdentityCollapsed(nodes: ForwardNode[]): boolean {
|
|
931
|
+
if (nodes.length < 2 || nodes.some((n) => n.named)) return false;
|
|
932
|
+
const ids = new Set(nodes.map((n) => n.userId));
|
|
933
|
+
return ids.size === 1 && !ids.has(undefined);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
private forwardMessages(data: unknown): unknown[] {
|
|
937
|
+
if (data === null || typeof data !== 'object') return [];
|
|
938
|
+
const response = data as Record<string, unknown>;
|
|
939
|
+
if (Array.isArray(response.messages)) return response.messages;
|
|
940
|
+
return Array.isArray(response.message) ? response.message : [];
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* NapCat目前会把私聊合并转发中bot自己的节点全部漏掉。事件库已经捕获了这些
|
|
945
|
+
* qq.self消息;只在协议结果完全没有自身节点时,按同一私聊和紧邻时间补回。
|
|
946
|
+
*/
|
|
947
|
+
private recoverForwardSelfNodes(nodes: ForwardNode[]): ForwardNode[] {
|
|
948
|
+
const host = this.host;
|
|
949
|
+
const selfId = this.driver?.identity?.selfId;
|
|
950
|
+
if (!host || selfId === undefined || nodes.some((n) => n.userId === String(selfId))) {
|
|
951
|
+
return [];
|
|
952
|
+
}
|
|
953
|
+
const timed = nodes.filter(
|
|
954
|
+
(n): n is ForwardNode & { time: number } =>
|
|
955
|
+
n.messageType === 'private' && n.time !== undefined,
|
|
956
|
+
);
|
|
957
|
+
if (timed.length !== nodes.length || timed.length === 0) return [];
|
|
958
|
+
const peerIds = new Set(
|
|
959
|
+
timed.map((n) => n.userId).filter((id): id is string => Boolean(id)),
|
|
960
|
+
);
|
|
961
|
+
peerIds.delete(String(selfId));
|
|
962
|
+
if (peerIds.size !== 1) return [];
|
|
963
|
+
const peerId = Number([...peerIds][0]);
|
|
964
|
+
if (!Number.isSafeInteger(peerId)) return [];
|
|
965
|
+
|
|
966
|
+
const recoveryWindowSeconds = 120;
|
|
967
|
+
const from = Math.min(...timed.map((n) => n.time));
|
|
968
|
+
const to = Math.max(...timed.map((n) => n.time)) + recoveryWindowSeconds;
|
|
969
|
+
return host.store
|
|
970
|
+
.range({
|
|
971
|
+
source: this.id,
|
|
972
|
+
senderKey: String(selfId),
|
|
973
|
+
})
|
|
974
|
+
.filter(
|
|
975
|
+
(event) =>
|
|
976
|
+
event.type === 'qq.self' &&
|
|
977
|
+
eventInConversation(event, { kind: 'private', id: peerId }) &&
|
|
978
|
+
Date.parse(event.ts) / 1000 >= from &&
|
|
979
|
+
Date.parse(event.ts) / 1000 <= to,
|
|
980
|
+
)
|
|
981
|
+
.filter((event) => {
|
|
982
|
+
const eventTime = Date.parse(event.ts) / 1000;
|
|
983
|
+
return timed.some(
|
|
984
|
+
(node) =>
|
|
985
|
+
eventTime >= node.time &&
|
|
986
|
+
eventTime <= node.time + recoveryWindowSeconds,
|
|
987
|
+
);
|
|
988
|
+
})
|
|
989
|
+
.map((event) => {
|
|
990
|
+
const marker = '] 你: ';
|
|
991
|
+
const markerAt = event.text.indexOf(marker);
|
|
992
|
+
const body = markerAt >= 0 ? event.text.slice(markerAt + marker.length) : event.text;
|
|
993
|
+
return {
|
|
994
|
+
line: `你(${selfId}): ${body} [本地记录补回]`,
|
|
995
|
+
body: `${body} [本地记录补回]`,
|
|
996
|
+
named: true,
|
|
997
|
+
time: Date.parse(event.ts) / 1000,
|
|
998
|
+
userId: String(selfId),
|
|
999
|
+
messageType: 'private',
|
|
1000
|
+
};
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* 转发(合并转发)消息:异步用扩展动作 get_forward_msg 取具体内容,取到与否都
|
|
1006
|
+
* 追加一条系统事件;不阻塞当前消息投递(同上,非阻塞延迟到达风格)。
|
|
1007
|
+
*/
|
|
1008
|
+
private lookupForward(resId: string, conv: Conv, ofMessageId: number | string): void {
|
|
1009
|
+
const host = this.host;
|
|
1010
|
+
const driver = this.driver;
|
|
1011
|
+
if (!host || !driver) return;
|
|
1012
|
+
void driver.callApi('get_forward_msg', { message_id: resId }).then(
|
|
1013
|
+
(data) => {
|
|
1014
|
+
const nodes = this.forwardMessages(data).map((m) => this.renderForwardNode(m));
|
|
1015
|
+
const collapsed = this.forwardIdentityCollapsed(nodes);
|
|
1016
|
+
const recovered = this.recoverForwardSelfNodes(nodes);
|
|
1017
|
+
const lines = [...nodes, ...recovered]
|
|
1018
|
+
.map((node, order) => ({ node, order }))
|
|
1019
|
+
.sort((a, b) =>
|
|
1020
|
+
a.node.time !== undefined && b.node.time !== undefined
|
|
1021
|
+
? a.node.time - b.node.time || a.order - b.order
|
|
1022
|
+
: a.order - b.order,
|
|
1023
|
+
)
|
|
1024
|
+
.map(({ node }) => (collapsed && !node.named ? node.body : node.line));
|
|
1025
|
+
const body = lines.length ? lines.join('\n') : '(转发记录是空的)';
|
|
1026
|
+
const header = collapsed
|
|
1027
|
+
? `[系统] #${ofMessageId} 引用的转发消息展开如下(里面的发言人身份没能取到,可能是多个人在说话):`
|
|
1028
|
+
: `[系统] #${ofMessageId} 引用的转发消息展开如下:`;
|
|
1029
|
+
host.pushEvent({
|
|
1030
|
+
type: 'qq.forward',
|
|
1031
|
+
ts: nowIso(this.timezone),
|
|
1032
|
+
source: this.id,
|
|
1033
|
+
text: `${header}\n${body}`,
|
|
1034
|
+
meta: { conv, forward_id: resId },
|
|
1035
|
+
}).catch((e) => host.log.warn('事件投递失败', { err: String(e) }));
|
|
1036
|
+
},
|
|
1037
|
+
(err) => {
|
|
1038
|
+
host.pushEvent({
|
|
1039
|
+
type: 'qq.forward',
|
|
1040
|
+
ts: nowIso(this.timezone),
|
|
1041
|
+
source: this.id,
|
|
1042
|
+
text: `[系统] #${ofMessageId} 转发的消息没能展开:${err instanceof Error ? err.message : String(err)}`,
|
|
1043
|
+
meta: { conv, forward_id: resId },
|
|
1044
|
+
}).catch((e) => host.log.warn('事件投递失败', { err: String(e) }));
|
|
1045
|
+
},
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
private handleRecall(ev: OneBotEvent, gid: number): void {
|
|
1050
|
+
const host = this.host!;
|
|
1051
|
+
const conv: Conv = { kind: 'group', id: gid };
|
|
1052
|
+
const userId = String(ev.user_id ?? '');
|
|
1053
|
+
const name = this.nameByUserId.get(userId) ?? userId;
|
|
1054
|
+
const mid = String(ev.message_id ?? '');
|
|
1055
|
+
const t = shortTime(this.timezone);
|
|
1056
|
+
const tail = this.knownMessages.has(mid) ? `一条消息(#${mid})` : '一条消息';
|
|
1057
|
+
host.pushEvent({
|
|
1058
|
+
type: 'qq.recall',
|
|
1059
|
+
ts: nowIso(this.timezone),
|
|
1060
|
+
source: this.id,
|
|
1061
|
+
text: `[${this.convLabel(conv)} ${t}] ${name}(${userId}) 撤回了${tail}`,
|
|
1062
|
+
senderKey: userId,
|
|
1063
|
+
meta: { message_id: ev.message_id, conv },
|
|
1064
|
+
}).catch((e) => host.log.warn('事件投递失败', { err: String(e) }));
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
private handleMemberChange(ev: OneBotEvent, gid: number, kind: 'join' | 'leave'): void {
|
|
1068
|
+
const host = this.host!;
|
|
1069
|
+
const conv: Conv = { kind: 'group', id: gid };
|
|
1070
|
+
const userId = String(ev.user_id ?? '');
|
|
1071
|
+
const name = this.nameByUserId.get(userId) ?? userId;
|
|
1072
|
+
const verb = kind === 'join' ? '加入了群' : '退出了群';
|
|
1073
|
+
host.pushEvent({
|
|
1074
|
+
type: 'qq.member',
|
|
1075
|
+
ts: nowIso(this.timezone),
|
|
1076
|
+
source: this.id,
|
|
1077
|
+
text: `[${this.convLabel(conv)} 系统] ${name}(${userId})${verb}`,
|
|
1078
|
+
senderKey: userId,
|
|
1079
|
+
meta: { user_id: ev.user_id, conv },
|
|
1080
|
+
}).catch((e) => host.log.warn('事件投递失败', { err: String(e) }));
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
private handleEmojiLike(ev: OneBotEvent, gid: number): void {
|
|
1084
|
+
const host = this.host!;
|
|
1085
|
+
const conv: Conv = { kind: 'group', id: gid };
|
|
1086
|
+
const userId = String(ev.user_id ?? '');
|
|
1087
|
+
const name = this.nameByUserId.get(userId) ?? userId;
|
|
1088
|
+
const mid = String(ev.message_id ?? '');
|
|
1089
|
+
const t = shortTime(this.timezone);
|
|
1090
|
+
const tail = this.knownMessages.has(mid) ? `给#${mid}贴了个表情` : '给一条消息贴了个表情';
|
|
1091
|
+
host.pushEvent({
|
|
1092
|
+
type: 'qq.emoji',
|
|
1093
|
+
ts: nowIso(this.timezone),
|
|
1094
|
+
source: this.id,
|
|
1095
|
+
text: `[${this.convLabel(conv)} ${t}] ${name}(${userId}) ${tail}`,
|
|
1096
|
+
senderKey: userId,
|
|
1097
|
+
meta: { message_id: ev.message_id, conv },
|
|
1098
|
+
}).catch((e) => host.log.warn('事件投递失败', { err: String(e) }));
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/**
|
|
1102
|
+
* 尽力从NapCat戳一戳notify的raw_info里拼出动作文案(如"戳了戳"/"拍了拍");
|
|
1103
|
+
* 不同协议端/版本这个字段形状不保证,取不到就退回通用"戳了戳"(防御性解析)。
|
|
1104
|
+
*/
|
|
1105
|
+
private extractPokeAction(rawInfo: unknown): string {
|
|
1106
|
+
if (Array.isArray(rawInfo)) {
|
|
1107
|
+
const texts = rawInfo
|
|
1108
|
+
.map((seg) => (seg && typeof seg === 'object' ? (seg as Record<string, unknown>).txt : undefined))
|
|
1109
|
+
.filter((t): t is string => typeof t === 'string' && t.trim().length > 0);
|
|
1110
|
+
if (texts.length > 0) return texts.join('').trim();
|
|
1111
|
+
}
|
|
1112
|
+
return '戳了戳';
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
private handlePoke(ev: OneBotEvent, gid: number): void {
|
|
1116
|
+
const host = this.host!;
|
|
1117
|
+
const conv: Conv = { kind: 'group', id: gid };
|
|
1118
|
+
const idn = this.driver?.identity;
|
|
1119
|
+
const selfId = idn?.selfId ?? -1;
|
|
1120
|
+
const userId = String(ev.user_id ?? '');
|
|
1121
|
+
const targetId = String(ev.target_id ?? '');
|
|
1122
|
+
const name = this.nameByUserId.get(userId) ?? userId;
|
|
1123
|
+
const targetLabel =
|
|
1124
|
+
Number(targetId) === selfId
|
|
1125
|
+
? `${idn?.nickname ?? '你'}(你)`
|
|
1126
|
+
: `${this.nameByUserId.get(targetId) ?? targetId}(${targetId})`;
|
|
1127
|
+
const action = this.extractPokeAction(ev.raw_info);
|
|
1128
|
+
const t = shortTime(this.timezone);
|
|
1129
|
+
host.pushEvent({
|
|
1130
|
+
type: 'qq.poke',
|
|
1131
|
+
ts: nowIso(this.timezone),
|
|
1132
|
+
source: this.id,
|
|
1133
|
+
text: `[${this.convLabel(conv)} ${t}] ${name}(${userId}) ${action} ${targetLabel}`,
|
|
1134
|
+
senderKey: userId,
|
|
1135
|
+
meta: { user_id: ev.user_id, conv },
|
|
1136
|
+
}).catch((e) => host.log.warn('事件投递失败', { err: String(e) }));
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/** 启动时从事件库meta重建 已记录消息索引 与 QQ号→称呼 映射 */
|
|
1140
|
+
private rebuildMaps(): void {
|
|
1141
|
+
const host = this.host!;
|
|
1142
|
+
const events = host.store.range({ source: this.id });
|
|
1143
|
+
for (const e of events) {
|
|
1144
|
+
const mid = e.meta?.message_id;
|
|
1145
|
+
const conv = e.meta?.conv as Conv | undefined;
|
|
1146
|
+
if (mid !== undefined && conv && (conv.kind === 'group' || conv.kind === 'private')) {
|
|
1147
|
+
this.knownMessages.set(String(mid), {
|
|
1148
|
+
conv: { kind: conv.kind, id: Number(conv.id) },
|
|
1149
|
+
ts: e.ts,
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
const uid = e.meta?.user_id;
|
|
1153
|
+
const name = e.meta?.sender_name;
|
|
1154
|
+
if (uid !== undefined && typeof name === 'string') {
|
|
1155
|
+
this.nameByUserId.set(String(uid), name);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
this.log.debug('映射重建完成', {
|
|
1159
|
+
messages: this.knownMessages.size,
|
|
1160
|
+
names: this.nameByUserId.size,
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
private groupName(gid: number): string {
|
|
1166
|
+
return this.driver?.identity?.groups.get(gid)?.groupName ?? String(gid);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
private convLabel(conv: Conv): string {
|
|
1170
|
+
return conv.kind === 'group' ? `群「${this.groupName(conv.id)}」` : '私聊';
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
private targetDesc(t: Conv): string {
|
|
1174
|
+
if (t.kind === 'group') return `群「${this.groupName(t.id)}」`;
|
|
1175
|
+
const n = this.nameByUserId.get(String(t.id));
|
|
1176
|
+
return n ? `私聊 ${n}` : `私聊 ${t.id}`;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* 解析发送目标:显式 to("group:群号"/"private:QQ号",数字寻址,不支持按群名/好友
|
|
1181
|
+
* 名匹配——昵称/群名可被随意改,QQ号才是稳定标识),或 to 省略时从 reply_to 引用
|
|
1182
|
+
* 的消息推断其所在会话(查 World 自己的消息索引,不经事件库)。
|
|
1183
|
+
*/
|
|
1184
|
+
private resolveTarget(
|
|
1185
|
+
toArg: string | undefined,
|
|
1186
|
+
replyToMessageId: string | undefined,
|
|
1187
|
+
): Conv | { error: string } {
|
|
1188
|
+
if (!toArg && replyToMessageId !== undefined) {
|
|
1189
|
+
const known = this.knownMessages.get(replyToMessageId);
|
|
1190
|
+
if (known) {
|
|
1191
|
+
const t = known.conv;
|
|
1192
|
+
// 会话可能已被移出监听名单,不能再往里发
|
|
1193
|
+
const watched =
|
|
1194
|
+
t.kind === 'group' ? this.watchedGroups.has(t.id) : this.watchedPrivates.has(t.id);
|
|
1195
|
+
if (!watched) {
|
|
1196
|
+
return { error: `the conversation of #${replyToMessageId} is no longer on the watch list` };
|
|
1197
|
+
}
|
|
1198
|
+
return t;
|
|
1199
|
+
}
|
|
1200
|
+
return { error: `no conversation found for #${replyToMessageId}; specify a recipient with to` };
|
|
1201
|
+
}
|
|
1202
|
+
if (!toArg) {
|
|
1203
|
+
return { error: 'specify a recipient: to="group:<id>" or "private:<id>", or use reply_to' };
|
|
1204
|
+
}
|
|
1205
|
+
const s = toArg.trim();
|
|
1206
|
+
const target = parseConversationAddress(s);
|
|
1207
|
+
if (target?.kind === 'group') {
|
|
1208
|
+
if (!this.watchedGroups.has(target.id)) {
|
|
1209
|
+
return { error: `group ${target.id} is not on the watch list` };
|
|
1210
|
+
}
|
|
1211
|
+
return target;
|
|
1212
|
+
}
|
|
1213
|
+
if (target?.kind === 'private') {
|
|
1214
|
+
if (!this.watchedPrivates.has(target.id)) {
|
|
1215
|
+
return { error: `QQ ${target.id} is not on the private watch list` };
|
|
1216
|
+
}
|
|
1217
|
+
return target;
|
|
1218
|
+
}
|
|
1219
|
+
return { error: `"${s}" is not a valid target; use to="group:<id>" or "private:<id>" (numeric QQ group/user id, not a name)` };
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* 校验 reply_to 的平台 message_id 已被记录;是则原样用于引用回复。
|
|
1224
|
+
* 消息行首的 `#<message_id>` 直接表示该平台标识。
|
|
1225
|
+
*/
|
|
1226
|
+
private resolveReplyMessageId(replyTo: unknown): { mid?: string } | { error: string } {
|
|
1227
|
+
if (replyTo === undefined || replyTo === null || replyTo === '') return {};
|
|
1228
|
+
const mid = String(replyTo).trim().replace(/^#/, '');
|
|
1229
|
+
if (!this.knownMessages.has(mid)) {
|
|
1230
|
+
return { error: `no recorded QQ message with id #${mid}; cannot quote-reply` };
|
|
1231
|
+
}
|
|
1232
|
+
return { mid };
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** 实际发送到目标会话,回录 qq.self,返回回执文本 */
|
|
1236
|
+
private async sendToTarget(
|
|
1237
|
+
target: Conv,
|
|
1238
|
+
opts: { text: string; replyMessageId?: number | string; imageRef?: string; imageBytes?: Uint8Array },
|
|
1239
|
+
): Promise<string> {
|
|
1240
|
+
const host = this.host!;
|
|
1241
|
+
const driver = this.driver!;
|
|
1242
|
+
let imageBase64: string | undefined;
|
|
1243
|
+
if (opts.imageBytes) {
|
|
1244
|
+
imageBase64 = Buffer.from(opts.imageBytes).toString('base64');
|
|
1245
|
+
}
|
|
1246
|
+
const message = buildOutgoing({
|
|
1247
|
+
text: opts.text,
|
|
1248
|
+
reply_to_message_id: opts.replyMessageId,
|
|
1249
|
+
image_base64: imageBase64,
|
|
1250
|
+
});
|
|
1251
|
+
const action = target.kind === 'group' ? 'send_group_msg' : 'send_private_msg';
|
|
1252
|
+
const params =
|
|
1253
|
+
target.kind === 'group'
|
|
1254
|
+
? { group_id: target.id, message }
|
|
1255
|
+
: { user_id: target.id, message };
|
|
1256
|
+
const data = (await driver.callApi(action, params)) as { message_id?: number };
|
|
1257
|
+
const messageId = data?.message_id;
|
|
1258
|
+
|
|
1259
|
+
// 回录正文:文字 + (如有)图片标记,保证自己发的图在历史里也可见。
|
|
1260
|
+
// 行首保留平台消息号,使历史查询结果可引用自身消息。
|
|
1261
|
+
const imgMark = opts.imageRef ? `[图片: ${opts.imageRef}]` : '';
|
|
1262
|
+
const selfBody = [opts.text, imgMark].filter(Boolean).join(' ');
|
|
1263
|
+
const idTag = messageId !== undefined ? `#${messageId} ` : '';
|
|
1264
|
+
const env = await host.pushEvent(
|
|
1265
|
+
{
|
|
1266
|
+
type: 'qq.self',
|
|
1267
|
+
ts: nowIso(this.timezone),
|
|
1268
|
+
source: this.id,
|
|
1269
|
+
text: `${idTag}[${this.convLabel(target)} ${shortTime(this.timezone)}] 你: ${selfBody}`,
|
|
1270
|
+
// 发出去的图按句柄附在自己这条记录上:时间线与吃图的模型都看得到发了什么
|
|
1271
|
+
...(opts.imageRef ? { blobs: [{ handle: opts.imageRef, fallbackText: '你发出的图片' }] } : {}),
|
|
1272
|
+
senderKey: String(driver.identity?.selfId ?? 'self'),
|
|
1273
|
+
meta: {
|
|
1274
|
+
message_id: messageId,
|
|
1275
|
+
user_id: driver.identity?.selfId,
|
|
1276
|
+
sender_name: driver.identity?.nickname,
|
|
1277
|
+
conv: target,
|
|
1278
|
+
},
|
|
1279
|
+
},
|
|
1280
|
+
{ deliver: false },
|
|
1281
|
+
);
|
|
1282
|
+
// 自己发出去的也进索引:可以引用回复自己刚说过的话
|
|
1283
|
+
if (messageId !== undefined) {
|
|
1284
|
+
this.knownMessages.set(String(messageId), { conv: target, ts: env.ts });
|
|
1285
|
+
}
|
|
1286
|
+
return `[sent${messageId !== undefined ? ` #${messageId}` : ''} → ${this.targetDesc(target)}]`;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
tools(): ToolDef[] {
|
|
1291
|
+
const list = [
|
|
1292
|
+
this.draftTool(),
|
|
1293
|
+
this.confirmTool(),
|
|
1294
|
+
...createHistoryTools({ source: this.id, host: () => this.host }),
|
|
1295
|
+
];
|
|
1296
|
+
// 看图追问依赖 IMG-N(外挂视觉),随视觉出现/消失
|
|
1297
|
+
if (this.vision) list.push(this.viewImageTool());
|
|
1298
|
+
return list;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
private draftTool(): ToolDef {
|
|
1302
|
+
return {
|
|
1303
|
+
name: 'qq_draft',
|
|
1304
|
+
description:
|
|
1305
|
+
'Stage a QQ message without sending. This ends the current tool sequence so you can review the draft and any newly arrived user events once before calling confirm (send or cancel). An unconfirmed draft is discarded at end of turn.',
|
|
1306
|
+
tags: ['speak'],
|
|
1307
|
+
barrierAfter: true,
|
|
1308
|
+
parameters: {
|
|
1309
|
+
type: 'object',
|
|
1310
|
+
properties: {
|
|
1311
|
+
to: {
|
|
1312
|
+
type: 'string',
|
|
1313
|
+
description:
|
|
1314
|
+
'Target conversation: "group:<id>" or "private:<id>" (numeric QQ group/user id, not a name). Optional when reply_to is given (taken from the quoted message).',
|
|
1315
|
+
},
|
|
1316
|
+
text: { type: 'string', description: 'Message text. Optional if image is given.' },
|
|
1317
|
+
image: {
|
|
1318
|
+
type: 'string',
|
|
1319
|
+
description:
|
|
1320
|
+
'Optional. An image to send, given by handle: a log: handle from a [blob ...] line you saw, '
|
|
1321
|
+
+ 'or a mem: handle of one kept in your workspace.',
|
|
1322
|
+
},
|
|
1323
|
+
reply_to: {
|
|
1324
|
+
type: 'string',
|
|
1325
|
+
description:
|
|
1326
|
+
'Optional. The QQ message id to quote-reply — the `#<id>` shown at the start of that message line.',
|
|
1327
|
+
},
|
|
1328
|
+
},
|
|
1329
|
+
required: [],
|
|
1330
|
+
},
|
|
1331
|
+
handler: async (args, ctx) => {
|
|
1332
|
+
const host = this.host;
|
|
1333
|
+
const driver = this.driver;
|
|
1334
|
+
if (!host || !driver) return '[tool failed] QQ module not started';
|
|
1335
|
+
const rawText = typeof args.text === 'string' ? args.text : '';
|
|
1336
|
+
const imageArg =
|
|
1337
|
+
typeof args.image === 'string' && args.image.trim() ? args.image.trim() : undefined;
|
|
1338
|
+
if (!rawText.trim() && !imageArg) return '[bad input] provide at least one of text or image';
|
|
1339
|
+
// 纯空白正文视为不带字(避免只发图时混进一个空白 text 段)
|
|
1340
|
+
const text = rawText.trim() ? rawText : '';
|
|
1341
|
+
|
|
1342
|
+
const toArg = typeof args.to === 'string' && args.to.trim() ? args.to.trim() : undefined;
|
|
1343
|
+
const replyToId =
|
|
1344
|
+
args.reply_to !== undefined && args.reply_to !== null && String(args.reply_to) !== ''
|
|
1345
|
+
? String(args.reply_to).trim().replace(/^#/, '')
|
|
1346
|
+
: undefined;
|
|
1347
|
+
|
|
1348
|
+
const target = this.resolveTarget(toArg, replyToId);
|
|
1349
|
+
if ('error' in target) return `[send failed] ${target.error}`;
|
|
1350
|
+
|
|
1351
|
+
const rep = this.resolveReplyMessageId(args.reply_to);
|
|
1352
|
+
if ('error' in rep) return `[send failed] ${rep.error}`;
|
|
1353
|
+
|
|
1354
|
+
// 引用回复必须与发送目标同会话(否则 reply 段带的是别会话的 message_id)
|
|
1355
|
+
if (replyToId !== undefined) {
|
|
1356
|
+
const replyConv = this.knownMessages.get(replyToId)?.conv;
|
|
1357
|
+
if (replyConv && !sameConversation(replyConv, target)) {
|
|
1358
|
+
return `[send failed] #${replyToId} is not in ${this.targetDesc(target)}; cannot quote-reply across conversations`;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// 图片:按句柄确认取得到,并按字节内容弹出已有的识别描述作为preview(省一次qq_view_image)
|
|
1363
|
+
let imageRef: string | undefined;
|
|
1364
|
+
let previewLine = '';
|
|
1365
|
+
if (imageArg) {
|
|
1366
|
+
const got = host.blob(imageArg);
|
|
1367
|
+
if (!got) return `[send failed] no blob for ${imageArg}; use a log: handle from a [blob ...] line or a mem: handle from your workspace`;
|
|
1368
|
+
if (!got.mime.startsWith('image/')) return `[send failed] ${imageArg} is ${got.mime}, not an image`;
|
|
1369
|
+
imageRef = imageArg;
|
|
1370
|
+
const desc = this.vision?.descriptionByHash(createHash('sha256').update(got.bytes).digest('hex'));
|
|
1371
|
+
previewLine = desc
|
|
1372
|
+
? `[will send image: ${imageArg} — cached description: ${desc}]`
|
|
1373
|
+
: `[will send image: ${imageArg}]`;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
this.pendingDraft = { target, text, replyMessageId: rep.mid, imageRef };
|
|
1377
|
+
|
|
1378
|
+
// 排出当下已积、尚未投递的QQ会话事件(consume-once)。正文不能伪装成
|
|
1379
|
+
// draft 工具结果:交回 loop 按常规投递,本轮下一次推理前它们就在眼前,
|
|
1380
|
+
// 要不要据此改口由 agent 决定。
|
|
1381
|
+
const drained = await host.drainPendingEvents((e) => e.source === this.id);
|
|
1382
|
+
ctx.queueExternalEvents?.(drained);
|
|
1383
|
+
|
|
1384
|
+
const lines = [`[draft staged → ${this.targetDesc(target)}]`];
|
|
1385
|
+
lines.push(text.trim() ? `Text: ${text}` : '(image only, no text)');
|
|
1386
|
+
if (previewLine) lines.push(previewLine);
|
|
1387
|
+
lines.push(
|
|
1388
|
+
'',
|
|
1389
|
+
drained.length > 0
|
|
1390
|
+
? `${drained.length} message(s) arrived while staging; they are queued for delivery.`
|
|
1391
|
+
: 'No messages arrived while staging.',
|
|
1392
|
+
'Review once, then call confirm(decision="send") to send or confirm(decision="cancel") to discard. An unconfirmed draft is dropped at end of turn.',
|
|
1393
|
+
);
|
|
1394
|
+
return lines.join('\n');
|
|
1395
|
+
},
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
private confirmTool(): ToolDef {
|
|
1400
|
+
return {
|
|
1401
|
+
name: 'qq_confirm',
|
|
1402
|
+
description:
|
|
1403
|
+
'Act on the last draft: decision="send" sends it, decision="cancel" discards it. Only the most recent draft can be confirmed.',
|
|
1404
|
+
tags: ['speak'],
|
|
1405
|
+
parameters: {
|
|
1406
|
+
type: 'object',
|
|
1407
|
+
properties: {
|
|
1408
|
+
decision: {
|
|
1409
|
+
type: 'string',
|
|
1410
|
+
enum: ['send', 'cancel'],
|
|
1411
|
+
description: '"send" or "cancel".',
|
|
1412
|
+
},
|
|
1413
|
+
},
|
|
1414
|
+
required: ['decision'],
|
|
1415
|
+
},
|
|
1416
|
+
handler: async (args) => {
|
|
1417
|
+
const host = this.host;
|
|
1418
|
+
const driver = this.driver;
|
|
1419
|
+
if (!host || !driver) return '[tool failed] QQ module not started';
|
|
1420
|
+
const draft = this.pendingDraft;
|
|
1421
|
+
if (!draft) return '[no draft to confirm; draft one first]';
|
|
1422
|
+
|
|
1423
|
+
if (args.decision === 'cancel') {
|
|
1424
|
+
this.pendingDraft = null;
|
|
1425
|
+
return `[draft discarded (${this.targetDesc(draft.target)})]`;
|
|
1426
|
+
}
|
|
1427
|
+
if (args.decision === 'send') {
|
|
1428
|
+
try {
|
|
1429
|
+
// 发送时才把字节取回来(草稿只记引用;取消的草稿一次都不读)
|
|
1430
|
+
let imageBytes: Uint8Array | undefined;
|
|
1431
|
+
if (draft.imageRef) {
|
|
1432
|
+
const got = host.blob(draft.imageRef);
|
|
1433
|
+
if (!got) return `[send failed] blob ${draft.imageRef} is gone (draft kept)`;
|
|
1434
|
+
imageBytes = got.bytes;
|
|
1435
|
+
}
|
|
1436
|
+
const receipt = await this.sendToTarget(draft.target, {
|
|
1437
|
+
text: draft.text,
|
|
1438
|
+
replyMessageId: draft.replyMessageId,
|
|
1439
|
+
imageRef: draft.imageRef,
|
|
1440
|
+
imageBytes,
|
|
1441
|
+
});
|
|
1442
|
+
this.pendingDraft = null; // 成功才清;失败保留草稿,可再 confirm 重试
|
|
1443
|
+
return receipt;
|
|
1444
|
+
} catch (e) {
|
|
1445
|
+
return `[send failed] ${e instanceof Error ? e.message : String(e)} (draft kept; confirm again to retry or cancel)`;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
return '[bad input] decision must be send or cancel';
|
|
1449
|
+
},
|
|
1450
|
+
};
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
private viewImageTool(): ToolDef {
|
|
1454
|
+
return {
|
|
1455
|
+
name: 'qq_view_image',
|
|
1456
|
+
description: 'Ask the auxiliary vision model a specific question about an image.',
|
|
1457
|
+
tags: ['read'],
|
|
1458
|
+
parameters: {
|
|
1459
|
+
type: 'object',
|
|
1460
|
+
properties: {
|
|
1461
|
+
image_id: { type: 'string', description: 'Image id, e.g. IMG-3.' },
|
|
1462
|
+
prompt: { type: 'string', description: 'Your question about the image.' },
|
|
1463
|
+
},
|
|
1464
|
+
required: ['image_id', 'prompt'],
|
|
1465
|
+
},
|
|
1466
|
+
handler: async (args) => {
|
|
1467
|
+
const vision = this.vision;
|
|
1468
|
+
if (!vision) return '[tool failed] auxiliary vision not configured';
|
|
1469
|
+
const id = normalizeImageId(String(args.image_id ?? ''));
|
|
1470
|
+
const prompt = typeof args.prompt === 'string' ? args.prompt.trim() : '';
|
|
1471
|
+
if (!prompt) return '[bad input] prompt must not be empty';
|
|
1472
|
+
if (!vision.hasImage(id)) {
|
|
1473
|
+
return `[bad input] image ${id} not found (may be from an earlier run or cleared)`;
|
|
1474
|
+
}
|
|
1475
|
+
try {
|
|
1476
|
+
return await vision.ask(id, prompt);
|
|
1477
|
+
} catch (e) {
|
|
1478
|
+
return `[tool failed] ${e instanceof Error ? e.message : String(e)}`;
|
|
1479
|
+
}
|
|
1480
|
+
},
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
function extractImageUrl(data: Record<string, unknown>): string {
|
|
1486
|
+
if (typeof data.url === 'string' && data.url) return data.url;
|
|
1487
|
+
if (typeof data.file === 'string' && data.file) return data.file;
|
|
1488
|
+
return '';
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
/** 渲染阶段的 GIF 提示仅依据 url/file 的 .gif 后缀。 */
|
|
1492
|
+
function looksLikeGif(data: Record<string, unknown>): boolean {
|
|
1493
|
+
const gifExt = /\.gif(\?|#|$)/i;
|
|
1494
|
+
const url = typeof data.url === 'string' ? data.url : '';
|
|
1495
|
+
const file = typeof data.file === 'string' ? data.file : '';
|
|
1496
|
+
return gifExt.test(url) || gifExt.test(file);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
/** 归一化主agent写的图片ID:"IMG-3"/"img3"/"3" → "IMG-3" */
|
|
1500
|
+
function normalizeImageId(raw: string): string {
|
|
1501
|
+
const m = raw.match(/(\d+)/);
|
|
1502
|
+
return m ? `IMG-${m[1]}` : raw.trim();
|
|
1503
|
+
}
|