@openclaw/qqbot 2026.5.1-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api.ts +56 -0
- package/channel-plugin-api.ts +1 -0
- package/dist/.boundary-tsc.stamp +1 -0
- package/dist/.boundary-tsc.tsbuildinfo +1 -0
- package/index.ts +29 -0
- package/openclaw.plugin.json +814 -0
- package/package.json +66 -0
- package/runtime-api.ts +9 -0
- package/setup-entry.ts +9 -0
- package/setup-plugin-api.ts +3 -0
- package/skills/qqbot-channel/SKILL.md +262 -0
- package/skills/qqbot-channel/references/api_references.md +521 -0
- package/skills/qqbot-media/SKILL.md +37 -0
- package/skills/qqbot-remind/SKILL.md +153 -0
- package/src/bridge/approval/capability.ts +237 -0
- package/src/bridge/approval/handler-runtime.ts +204 -0
- package/src/bridge/bootstrap.ts +135 -0
- package/src/bridge/channel-entry.ts +18 -0
- package/src/bridge/commands/framework-context-adapter.ts +60 -0
- package/src/bridge/commands/framework-registration.ts +47 -0
- package/src/bridge/commands/from-parser.test.ts +86 -0
- package/src/bridge/commands/from-parser.ts +60 -0
- package/src/bridge/commands/result-dispatcher.ts +76 -0
- package/src/bridge/config-shared.ts +132 -0
- package/src/bridge/config.ts +111 -0
- package/src/bridge/gateway.ts +174 -0
- package/src/bridge/logger.ts +31 -0
- package/src/bridge/narrowing.ts +31 -0
- package/src/bridge/plugin-version.test.ts +146 -0
- package/src/bridge/plugin-version.ts +102 -0
- package/src/bridge/runtime.ts +25 -0
- package/src/bridge/sdk-adapter.ts +131 -0
- package/src/bridge/setup/finalize.ts +144 -0
- package/src/bridge/setup/surface.ts +34 -0
- package/src/bridge/tools/channel.ts +58 -0
- package/src/bridge/tools/index.ts +15 -0
- package/src/bridge/tools/remind.test.ts +124 -0
- package/src/bridge/tools/remind.ts +91 -0
- package/src/channel.setup.ts +33 -0
- package/src/channel.ts +288 -0
- package/src/command-auth.test.ts +62 -0
- package/src/config-schema.ts +84 -0
- package/src/config.test.ts +364 -0
- package/src/engine/access/access-control.test.ts +198 -0
- package/src/engine/access/access-control.ts +226 -0
- package/src/engine/access/index.ts +16 -0
- package/src/engine/access/resolve-policy.test.ts +59 -0
- package/src/engine/access/resolve-policy.ts +57 -0
- package/src/engine/access/sender-match.test.ts +60 -0
- package/src/engine/access/sender-match.ts +55 -0
- package/src/engine/access/types.ts +53 -0
- package/src/engine/adapter/audio.port.ts +27 -0
- package/src/engine/adapter/commands.port.ts +22 -0
- package/src/engine/adapter/history.port.ts +52 -0
- package/src/engine/adapter/index.ts +139 -0
- package/src/engine/adapter/mention-gate.port.ts +50 -0
- package/src/engine/adapter/types.ts +38 -0
- package/src/engine/api/api-client.ts +212 -0
- package/src/engine/api/media-chunked.test.ts +336 -0
- package/src/engine/api/media-chunked.ts +622 -0
- package/src/engine/api/media.ts +218 -0
- package/src/engine/api/messages.ts +293 -0
- package/src/engine/api/retry.ts +217 -0
- package/src/engine/api/routes.ts +95 -0
- package/src/engine/api/token.ts +271 -0
- package/src/engine/approval/index.test.ts +22 -0
- package/src/engine/approval/index.ts +224 -0
- package/src/engine/commands/builtin/log-helpers.ts +319 -0
- package/src/engine/commands/builtin/register-all.ts +17 -0
- package/src/engine/commands/builtin/register-approve.ts +201 -0
- package/src/engine/commands/builtin/register-basic.ts +95 -0
- package/src/engine/commands/builtin/register-clear-storage.ts +187 -0
- package/src/engine/commands/builtin/register-logs.ts +20 -0
- package/src/engine/commands/builtin/register-streaming.ts +137 -0
- package/src/engine/commands/builtin/state.ts +31 -0
- package/src/engine/commands/slash-command-auth.ts +48 -0
- package/src/engine/commands/slash-command-handler.ts +146 -0
- package/src/engine/commands/slash-commands-impl.test.ts +8 -0
- package/src/engine/commands/slash-commands-impl.ts +61 -0
- package/src/engine/commands/slash-commands.ts +199 -0
- package/src/engine/config/credential-backup.test.ts +88 -0
- package/src/engine/config/credential-backup.ts +107 -0
- package/src/engine/config/credentials.ts +76 -0
- package/src/engine/config/group.test.ts +234 -0
- package/src/engine/config/group.ts +299 -0
- package/src/engine/config/resolve.test.ts +152 -0
- package/src/engine/config/resolve.ts +283 -0
- package/src/engine/config/setup-logic.ts +84 -0
- package/src/engine/engine-import-boundary.test.ts +73 -0
- package/src/engine/gateway/codec.ts +47 -0
- package/src/engine/gateway/constants.ts +117 -0
- package/src/engine/gateway/event-dispatcher.ts +177 -0
- package/src/engine/gateway/gateway-connection.ts +371 -0
- package/src/engine/gateway/gateway.ts +291 -0
- package/src/engine/gateway/inbound-attachments.test.ts +126 -0
- package/src/engine/gateway/inbound-attachments.ts +360 -0
- package/src/engine/gateway/inbound-context.ts +195 -0
- package/src/engine/gateway/inbound-pipeline.self-echo.test.ts +218 -0
- package/src/engine/gateway/inbound-pipeline.ts +235 -0
- package/src/engine/gateway/interaction-handler.ts +220 -0
- package/src/engine/gateway/message-queue.test.ts +282 -0
- package/src/engine/gateway/message-queue.ts +499 -0
- package/src/engine/gateway/outbound-dispatch.test.ts +231 -0
- package/src/engine/gateway/outbound-dispatch.ts +575 -0
- package/src/engine/gateway/reconnect.ts +199 -0
- package/src/engine/gateway/stages/access-stage.ts +132 -0
- package/src/engine/gateway/stages/assembly-stage.ts +156 -0
- package/src/engine/gateway/stages/content-stage.test.ts +77 -0
- package/src/engine/gateway/stages/content-stage.ts +77 -0
- package/src/engine/gateway/stages/envelope-stage.test.ts +152 -0
- package/src/engine/gateway/stages/envelope-stage.ts +144 -0
- package/src/engine/gateway/stages/group-gate-stage.ts +292 -0
- package/src/engine/gateway/stages/index.ts +18 -0
- package/src/engine/gateway/stages/quote-stage.ts +113 -0
- package/src/engine/gateway/stages/refidx-stage.ts +62 -0
- package/src/engine/gateway/stages/stub-contexts.ts +116 -0
- package/src/engine/gateway/types.ts +264 -0
- package/src/engine/gateway/typing-keepalive.ts +79 -0
- package/src/engine/group/activation.test.ts +114 -0
- package/src/engine/group/activation.ts +147 -0
- package/src/engine/group/history.test.ts +314 -0
- package/src/engine/group/history.ts +321 -0
- package/src/engine/group/mention.test.ts +141 -0
- package/src/engine/group/mention.ts +197 -0
- package/src/engine/group/message-gating.test.ts +188 -0
- package/src/engine/group/message-gating.ts +216 -0
- package/src/engine/messaging/decode-media-path.ts +82 -0
- package/src/engine/messaging/media-source.ts +215 -0
- package/src/engine/messaging/media-type-detect.ts +37 -0
- package/src/engine/messaging/outbound-audio-port.ts +38 -0
- package/src/engine/messaging/outbound-deliver.ts +810 -0
- package/src/engine/messaging/outbound-media-send.ts +702 -0
- package/src/engine/messaging/outbound-reply.ts +27 -0
- package/src/engine/messaging/outbound-result-helpers.ts +54 -0
- package/src/engine/messaging/outbound-types.ts +45 -0
- package/src/engine/messaging/outbound.ts +485 -0
- package/src/engine/messaging/reply-dispatcher.ts +597 -0
- package/src/engine/messaging/reply-limiter.ts +164 -0
- package/src/engine/messaging/sender.ts +729 -0
- package/src/engine/messaging/streaming-c2c.ts +1192 -0
- package/src/engine/messaging/streaming-media-send.ts +544 -0
- package/src/engine/messaging/target-parser.ts +104 -0
- package/src/engine/ref/format-message-ref.ts +142 -0
- package/src/engine/ref/format-ref-entry.test.ts +60 -0
- package/src/engine/ref/format-ref-entry.ts +27 -0
- package/src/engine/ref/store.ts +224 -0
- package/src/engine/ref/types.ts +27 -0
- package/src/engine/session/known-users.ts +254 -0
- package/src/engine/session/session-store.ts +284 -0
- package/src/engine/tools/channel-api.ts +244 -0
- package/src/engine/tools/remind-logic.test.ts +280 -0
- package/src/engine/tools/remind-logic.ts +377 -0
- package/src/engine/types.ts +313 -0
- package/src/engine/utils/attachment-tags.test.ts +186 -0
- package/src/engine/utils/attachment-tags.ts +174 -0
- package/src/engine/utils/audio.test.ts +250 -0
- package/src/engine/utils/audio.ts +585 -0
- package/src/engine/utils/data-paths.ts +38 -0
- package/src/engine/utils/diagnostics.ts +109 -0
- package/src/engine/utils/file-utils.test.ts +72 -0
- package/src/engine/utils/file-utils.ts +225 -0
- package/src/engine/utils/format.test.ts +68 -0
- package/src/engine/utils/format.ts +70 -0
- package/src/engine/utils/image-size.test.ts +158 -0
- package/src/engine/utils/image-size.ts +249 -0
- package/src/engine/utils/log.test.ts +28 -0
- package/src/engine/utils/log.ts +61 -0
- package/src/engine/utils/media-tags.test.ts +32 -0
- package/src/engine/utils/media-tags.ts +177 -0
- package/src/engine/utils/payload.test.ts +68 -0
- package/src/engine/utils/payload.ts +145 -0
- package/src/engine/utils/platform-storage-laziness.test.ts +65 -0
- package/src/engine/utils/platform.test.ts +148 -0
- package/src/engine/utils/platform.ts +343 -0
- package/src/engine/utils/request-context.ts +60 -0
- package/src/engine/utils/string-normalize.ts +91 -0
- package/src/engine/utils/stt.test.ts +104 -0
- package/src/engine/utils/stt.ts +100 -0
- package/src/engine/utils/text-parsing.test.ts +29 -0
- package/src/engine/utils/text-parsing.ts +155 -0
- package/src/engine/utils/upload-cache.ts +96 -0
- package/src/engine/utils/voice-text.ts +15 -0
- package/src/exec-approvals.ts +218 -0
- package/src/manifest-schema.test.ts +56 -0
- package/src/qqbot-test-support.ts +29 -0
- package/src/types.ts +210 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveGroupActivation, type SessionStoreReader } from "./activation.js";
|
|
3
|
+
|
|
4
|
+
describe("engine/group/activation", () => {
|
|
5
|
+
describe("resolveGroupActivation — no reader", () => {
|
|
6
|
+
it("maps configRequireMention=true → mention", () => {
|
|
7
|
+
expect(
|
|
8
|
+
resolveGroupActivation({
|
|
9
|
+
cfg: {},
|
|
10
|
+
agentId: "main",
|
|
11
|
+
sessionKey: "s",
|
|
12
|
+
configRequireMention: true,
|
|
13
|
+
}),
|
|
14
|
+
).toBe("mention");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("maps configRequireMention=false → always", () => {
|
|
18
|
+
expect(
|
|
19
|
+
resolveGroupActivation({
|
|
20
|
+
cfg: {},
|
|
21
|
+
agentId: "main",
|
|
22
|
+
sessionKey: "s",
|
|
23
|
+
configRequireMention: false,
|
|
24
|
+
}),
|
|
25
|
+
).toBe("always");
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("resolveGroupActivation — with reader", () => {
|
|
30
|
+
const makeReader = (
|
|
31
|
+
store: Record<string, { groupActivation?: string }> | null,
|
|
32
|
+
): SessionStoreReader => ({
|
|
33
|
+
read: () => store,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("honours explicit session-store override (mention)", () => {
|
|
37
|
+
const reader = makeReader({ k1: { groupActivation: "mention" } });
|
|
38
|
+
expect(
|
|
39
|
+
resolveGroupActivation({
|
|
40
|
+
cfg: {},
|
|
41
|
+
agentId: "main",
|
|
42
|
+
sessionKey: "k1",
|
|
43
|
+
configRequireMention: false,
|
|
44
|
+
sessionStoreReader: reader,
|
|
45
|
+
}),
|
|
46
|
+
).toBe("mention");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("honours explicit session-store override (always)", () => {
|
|
50
|
+
const reader = makeReader({ k1: { groupActivation: "always" } });
|
|
51
|
+
expect(
|
|
52
|
+
resolveGroupActivation({
|
|
53
|
+
cfg: {},
|
|
54
|
+
agentId: "main",
|
|
55
|
+
sessionKey: "k1",
|
|
56
|
+
configRequireMention: true,
|
|
57
|
+
sessionStoreReader: reader,
|
|
58
|
+
}),
|
|
59
|
+
).toBe("always");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("ignores override when the key is absent", () => {
|
|
63
|
+
const reader = makeReader({});
|
|
64
|
+
expect(
|
|
65
|
+
resolveGroupActivation({
|
|
66
|
+
cfg: {},
|
|
67
|
+
agentId: "main",
|
|
68
|
+
sessionKey: "MISSING",
|
|
69
|
+
configRequireMention: true,
|
|
70
|
+
sessionStoreReader: reader,
|
|
71
|
+
}),
|
|
72
|
+
).toBe("mention");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("ignores reader errors (null) and falls back", () => {
|
|
76
|
+
const reader = makeReader(null);
|
|
77
|
+
expect(
|
|
78
|
+
resolveGroupActivation({
|
|
79
|
+
cfg: {},
|
|
80
|
+
agentId: "main",
|
|
81
|
+
sessionKey: "k1",
|
|
82
|
+
configRequireMention: false,
|
|
83
|
+
sessionStoreReader: reader,
|
|
84
|
+
}),
|
|
85
|
+
).toBe("always");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("ignores invalid activation values", () => {
|
|
89
|
+
const reader = makeReader({ k1: { groupActivation: "weird-mode" } });
|
|
90
|
+
expect(
|
|
91
|
+
resolveGroupActivation({
|
|
92
|
+
cfg: {},
|
|
93
|
+
agentId: "main",
|
|
94
|
+
sessionKey: "k1",
|
|
95
|
+
configRequireMention: true,
|
|
96
|
+
sessionStoreReader: reader,
|
|
97
|
+
}),
|
|
98
|
+
).toBe("mention");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("normalizes whitespace / case", () => {
|
|
102
|
+
const reader = makeReader({ k1: { groupActivation: " Always " } });
|
|
103
|
+
expect(
|
|
104
|
+
resolveGroupActivation({
|
|
105
|
+
cfg: {},
|
|
106
|
+
agentId: "main",
|
|
107
|
+
sessionKey: "k1",
|
|
108
|
+
configRequireMention: true,
|
|
109
|
+
sessionStoreReader: reader,
|
|
110
|
+
}),
|
|
111
|
+
).toBe("always");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group activation mode — how the bot decides whether to respond in a group.
|
|
3
|
+
*
|
|
4
|
+
* Resolution chain:
|
|
5
|
+
* 1. session store override (`/activation` command writes per-session
|
|
6
|
+
* `groupActivation` value) — highest priority
|
|
7
|
+
* 2. per-group `requireMention` config
|
|
8
|
+
* 3. `"mention"` default (require @-bot to respond)
|
|
9
|
+
*
|
|
10
|
+
* File I/O is isolated in the default node-based reader so the gating
|
|
11
|
+
* logic itself stays a pure function, testable without touching disk.
|
|
12
|
+
*
|
|
13
|
+
* Note: the implicit-mention predicate (quoting a bot message counts as
|
|
14
|
+
* @-ing the bot) lives in `./mention.ts` alongside the other mention
|
|
15
|
+
* helpers — see `resolveImplicitMention` there.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
|
|
21
|
+
// ────────────────────────── Types ──────────────────────────
|
|
22
|
+
|
|
23
|
+
/** High-level activation outcome. */
|
|
24
|
+
export type GroupActivationMode = "mention" | "always";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Pluggable reader that returns parsed session-store contents.
|
|
28
|
+
*
|
|
29
|
+
* A return value of `null` means "no override available" (file missing,
|
|
30
|
+
* parse error, or reader disabled). Implementations must **not** throw —
|
|
31
|
+
* the gating pipeline treats any failure as "fall back to the config
|
|
32
|
+
* default".
|
|
33
|
+
*/
|
|
34
|
+
export interface SessionStoreReader {
|
|
35
|
+
read(params: {
|
|
36
|
+
cfg: Record<string, unknown>;
|
|
37
|
+
agentId: string;
|
|
38
|
+
}): Record<string, { groupActivation?: string }> | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ────────────────────────── groupActivation ──────────────────────────
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the effective activation mode for one inbound message.
|
|
45
|
+
*
|
|
46
|
+
* Order of precedence:
|
|
47
|
+
* 1. `store[sessionKey].groupActivation` (read via the injected reader)
|
|
48
|
+
* 2. config-level `requireMention` (maps to `"mention"` / `"always"`)
|
|
49
|
+
* 3. `"mention"` (safe default)
|
|
50
|
+
*/
|
|
51
|
+
export function resolveGroupActivation(params: {
|
|
52
|
+
cfg: Record<string, unknown>;
|
|
53
|
+
agentId: string;
|
|
54
|
+
sessionKey: string;
|
|
55
|
+
configRequireMention: boolean;
|
|
56
|
+
/** Pluggable reader; omit to disable the session-store override. */
|
|
57
|
+
sessionStoreReader?: SessionStoreReader;
|
|
58
|
+
}): GroupActivationMode {
|
|
59
|
+
const fallback: GroupActivationMode = params.configRequireMention ? "mention" : "always";
|
|
60
|
+
|
|
61
|
+
const store = params.sessionStoreReader?.read({
|
|
62
|
+
cfg: params.cfg,
|
|
63
|
+
agentId: params.agentId,
|
|
64
|
+
});
|
|
65
|
+
if (!store) {
|
|
66
|
+
return fallback;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const entry = store[params.sessionKey];
|
|
70
|
+
if (!entry?.groupActivation) {
|
|
71
|
+
return fallback;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const normalized = entry.groupActivation.trim().toLowerCase();
|
|
75
|
+
if (normalized === "mention" || normalized === "always") {
|
|
76
|
+
return normalized;
|
|
77
|
+
}
|
|
78
|
+
return fallback;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ────────────────────────── Default node reader ──────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the on-disk path to the agent-sessions file.
|
|
85
|
+
*
|
|
86
|
+
* Priority:
|
|
87
|
+
* 1. `cfg.session.store` (supports `{agentId}` placeholder and `~` expansion)
|
|
88
|
+
* 2. `$OPENCLAW_STATE_DIR` / `$CLAWDBOT_STATE_DIR`
|
|
89
|
+
* 3. `~/.openclaw/agents/{agentId}/sessions/sessions.json`
|
|
90
|
+
*/
|
|
91
|
+
function resolveSessionStorePath(
|
|
92
|
+
cfg: Record<string, unknown>,
|
|
93
|
+
agentId: string | undefined,
|
|
94
|
+
): string {
|
|
95
|
+
const resolvedAgentId = agentId || "default";
|
|
96
|
+
|
|
97
|
+
const session =
|
|
98
|
+
typeof cfg.session === "object" && cfg.session !== null
|
|
99
|
+
? (cfg.session as { store?: unknown })
|
|
100
|
+
: undefined;
|
|
101
|
+
const rawStore = typeof session?.store === "string" ? session.store : undefined;
|
|
102
|
+
|
|
103
|
+
if (rawStore) {
|
|
104
|
+
let expanded = rawStore;
|
|
105
|
+
if (expanded.includes("{agentId}")) {
|
|
106
|
+
expanded = expanded.replaceAll("{agentId}", resolvedAgentId);
|
|
107
|
+
}
|
|
108
|
+
if (expanded.startsWith("~")) {
|
|
109
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
110
|
+
expanded = expanded.replace(/^~/, home);
|
|
111
|
+
}
|
|
112
|
+
return path.resolve(expanded);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const stateDir =
|
|
116
|
+
process.env.OPENCLAW_STATE_DIR?.trim() ||
|
|
117
|
+
process.env.CLAWDBOT_STATE_DIR?.trim() ||
|
|
118
|
+
path.join(process.env.HOME || process.env.USERPROFILE || "", ".openclaw");
|
|
119
|
+
return path.join(stateDir, "agents", resolvedAgentId, "sessions", "sessions.json");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Create the default, production-ready session-store reader.
|
|
124
|
+
*
|
|
125
|
+
* Reads the file synchronously on every call. The overhead is acceptable
|
|
126
|
+
* because activation mode is only resolved once per group message and
|
|
127
|
+
* the sessions file is typically a handful of kilobytes.
|
|
128
|
+
*
|
|
129
|
+
* Any I/O or JSON error is swallowed and returned as `null` so the
|
|
130
|
+
* gating pipeline falls back to the config default.
|
|
131
|
+
*/
|
|
132
|
+
export function createNodeSessionStoreReader(): SessionStoreReader {
|
|
133
|
+
return {
|
|
134
|
+
read: ({ cfg, agentId }) => {
|
|
135
|
+
try {
|
|
136
|
+
const storePath = resolveSessionStorePath(cfg, agentId);
|
|
137
|
+
if (!fs.existsSync(storePath)) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const raw = fs.readFileSync(storePath, "utf-8");
|
|
141
|
+
return JSON.parse(raw) as Record<string, { groupActivation?: string }>;
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildMergedMessageContext,
|
|
4
|
+
buildPendingHistoryContext,
|
|
5
|
+
clearPendingHistory,
|
|
6
|
+
formatAttachmentTags,
|
|
7
|
+
formatMessageContent,
|
|
8
|
+
inferAttachmentType,
|
|
9
|
+
recordPendingHistoryEntry,
|
|
10
|
+
toAttachmentSummaries,
|
|
11
|
+
type HistoryEntry,
|
|
12
|
+
} from "./history.js";
|
|
13
|
+
|
|
14
|
+
function makeMap(): Map<string, HistoryEntry[]> {
|
|
15
|
+
return new Map();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function entry(sender: string, body: string, extras: Partial<HistoryEntry> = {}): HistoryEntry {
|
|
19
|
+
return { sender, body, ...extras };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("engine/group/history", () => {
|
|
23
|
+
describe("inferAttachmentType", () => {
|
|
24
|
+
it("maps image/* → image", () => {
|
|
25
|
+
expect(inferAttachmentType("image/png")).toBe("image");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("maps voice / audio / silk / amr → voice", () => {
|
|
29
|
+
expect(inferAttachmentType("voice")).toBe("voice");
|
|
30
|
+
expect(inferAttachmentType("audio/mpeg")).toBe("voice");
|
|
31
|
+
expect(inferAttachmentType("application/silk")).toBe("voice");
|
|
32
|
+
expect(inferAttachmentType("audio/amr")).toBe("voice");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("maps video / application / text → their category", () => {
|
|
36
|
+
expect(inferAttachmentType("video/mp4")).toBe("video");
|
|
37
|
+
expect(inferAttachmentType("application/pdf")).toBe("file");
|
|
38
|
+
expect(inferAttachmentType("text/plain")).toBe("file");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("unknown content types fall back to unknown", () => {
|
|
42
|
+
expect(inferAttachmentType()).toBe("unknown");
|
|
43
|
+
expect(inferAttachmentType("weird/thing")).toBe("unknown");
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("toAttachmentSummaries", () => {
|
|
48
|
+
it("returns undefined for empty input", () => {
|
|
49
|
+
expect(toAttachmentSummaries()).toBeUndefined();
|
|
50
|
+
expect(toAttachmentSummaries([])).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("normalizes raw fields", () => {
|
|
54
|
+
const result = toAttachmentSummaries([
|
|
55
|
+
{
|
|
56
|
+
content_type: "image/png",
|
|
57
|
+
filename: "a.png",
|
|
58
|
+
url: "https://x/a.png",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
content_type: "voice",
|
|
62
|
+
asr_refer_text: "hello",
|
|
63
|
+
},
|
|
64
|
+
]);
|
|
65
|
+
expect(result).toEqual([
|
|
66
|
+
{ type: "image", filename: "a.png", transcript: undefined, url: "https://x/a.png" },
|
|
67
|
+
{ type: "voice", filename: undefined, transcript: "hello", url: undefined },
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("formatAttachmentTags", () => {
|
|
73
|
+
it("returns empty string for empty input", () => {
|
|
74
|
+
expect(formatAttachmentTags()).toBe("");
|
|
75
|
+
expect(formatAttachmentTags([])).toBe("");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("renders MEDIA:path for entries with a source", () => {
|
|
79
|
+
expect(formatAttachmentTags([{ type: "image", localPath: "/tmp/a.png" }])).toBe(
|
|
80
|
+
"MEDIA:/tmp/a.png",
|
|
81
|
+
);
|
|
82
|
+
expect(formatAttachmentTags([{ type: "image", url: "https://x/b.png" }])).toBe(
|
|
83
|
+
"MEDIA:https://x/b.png",
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("inlines transcript for voice w/ source", () => {
|
|
88
|
+
expect(
|
|
89
|
+
formatAttachmentTags([{ type: "voice", localPath: "/tmp/v.wav", transcript: "hi" }]),
|
|
90
|
+
).toBe('MEDIA:/tmp/v.wav (transcript: "hi")');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("uses descriptive tags when no source is available", () => {
|
|
94
|
+
expect(formatAttachmentTags([{ type: "image" }])).toBe("[image]");
|
|
95
|
+
expect(formatAttachmentTags([{ type: "image", filename: "a.png" }])).toBe("[image: a.png]");
|
|
96
|
+
expect(formatAttachmentTags([{ type: "voice" }])).toBe("[voice]");
|
|
97
|
+
expect(formatAttachmentTags([{ type: "voice", transcript: "t" }])).toBe(
|
|
98
|
+
'[voice (transcript: "t")]',
|
|
99
|
+
);
|
|
100
|
+
expect(formatAttachmentTags([{ type: "video" }])).toBe("[video]");
|
|
101
|
+
expect(formatAttachmentTags([{ type: "file", filename: "b.pdf" }])).toBe("[file: b.pdf]");
|
|
102
|
+
expect(formatAttachmentTags([{ type: "unknown" }])).toBe("[attachment]");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("joins multiple entries with newline", () => {
|
|
106
|
+
expect(
|
|
107
|
+
formatAttachmentTags([
|
|
108
|
+
{ type: "image", localPath: "/tmp/a.png" },
|
|
109
|
+
{ type: "voice", transcript: "hi" },
|
|
110
|
+
]),
|
|
111
|
+
).toBe('MEDIA:/tmp/a.png\n[voice (transcript: "hi")]');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("formatMessageContent", () => {
|
|
116
|
+
it("passes content through parseFaceTags (no-op for plain text)", () => {
|
|
117
|
+
// parseFaceTags only rewrites the `<faceType=...>` tag form; plain
|
|
118
|
+
// text must round-trip unchanged so regressions in the pipeline
|
|
119
|
+
// don't silently mangle user input.
|
|
120
|
+
expect(formatMessageContent({ content: "hello world" })).toBe("hello world");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("strips mentions only for group chat", () => {
|
|
124
|
+
expect(
|
|
125
|
+
formatMessageContent({
|
|
126
|
+
content: "<@X>hi",
|
|
127
|
+
chatType: "group",
|
|
128
|
+
mentions: [{ member_openid: "X", is_you: true }],
|
|
129
|
+
}),
|
|
130
|
+
).toBe("hi");
|
|
131
|
+
// Non-group: strip is NOT applied.
|
|
132
|
+
expect(
|
|
133
|
+
formatMessageContent({
|
|
134
|
+
content: "<@X>hi",
|
|
135
|
+
chatType: "c2c",
|
|
136
|
+
mentions: [{ member_openid: "X", is_you: true }],
|
|
137
|
+
}),
|
|
138
|
+
).toBe("<@X>hi");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("appends attachment tags", () => {
|
|
142
|
+
expect(
|
|
143
|
+
formatMessageContent({
|
|
144
|
+
content: "see",
|
|
145
|
+
attachments: [{ content_type: "image/png", url: "https://x/a.png" }],
|
|
146
|
+
}),
|
|
147
|
+
).toBe("see MEDIA:https://x/a.png");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("recordPendingHistoryEntry / buildPendingHistoryContext", () => {
|
|
152
|
+
it("no-ops when limit is 0", () => {
|
|
153
|
+
const map = makeMap();
|
|
154
|
+
const entries = recordPendingHistoryEntry({
|
|
155
|
+
historyMap: map,
|
|
156
|
+
historyKey: "G",
|
|
157
|
+
entry: entry("A", "hi"),
|
|
158
|
+
limit: 0,
|
|
159
|
+
});
|
|
160
|
+
expect(entries).toEqual([]);
|
|
161
|
+
expect(map.size).toBe(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("no-ops when entry is null", () => {
|
|
165
|
+
const map = makeMap();
|
|
166
|
+
recordPendingHistoryEntry({
|
|
167
|
+
historyMap: map,
|
|
168
|
+
historyKey: "G",
|
|
169
|
+
entry: null,
|
|
170
|
+
limit: 10,
|
|
171
|
+
});
|
|
172
|
+
expect(map.size).toBe(0);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("appends and caps at the limit", () => {
|
|
176
|
+
const map = makeMap();
|
|
177
|
+
for (let i = 0; i < 5; i++) {
|
|
178
|
+
recordPendingHistoryEntry({
|
|
179
|
+
historyMap: map,
|
|
180
|
+
historyKey: "G",
|
|
181
|
+
entry: entry("A", `m${i}`),
|
|
182
|
+
limit: 3,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const entries = map.get("G")!;
|
|
186
|
+
expect(entries).toHaveLength(3);
|
|
187
|
+
expect(entries.map((e) => e.body)).toEqual(["m2", "m3", "m4"]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("builds a history-wrapped message when entries exist", () => {
|
|
191
|
+
const map = makeMap();
|
|
192
|
+
recordPendingHistoryEntry({
|
|
193
|
+
historyMap: map,
|
|
194
|
+
historyKey: "G",
|
|
195
|
+
entry: entry("Alice", "hello"),
|
|
196
|
+
limit: 10,
|
|
197
|
+
});
|
|
198
|
+
const out = buildPendingHistoryContext({
|
|
199
|
+
historyMap: map,
|
|
200
|
+
historyKey: "G",
|
|
201
|
+
limit: 10,
|
|
202
|
+
currentMessage: "[Bob] @bot",
|
|
203
|
+
formatEntry: (e) => `${e.sender}: ${e.body}`,
|
|
204
|
+
});
|
|
205
|
+
expect(out).toContain("[Chat messages since your last reply — CONTEXT ONLY]");
|
|
206
|
+
expect(out).toContain("Alice: hello");
|
|
207
|
+
expect(out).toContain("[CURRENT MESSAGE — reply to this]");
|
|
208
|
+
expect(out.endsWith("[Bob] @bot")).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("returns current message unchanged when buffer is empty or disabled", () => {
|
|
212
|
+
const map = makeMap();
|
|
213
|
+
expect(
|
|
214
|
+
buildPendingHistoryContext({
|
|
215
|
+
historyMap: map,
|
|
216
|
+
historyKey: "G",
|
|
217
|
+
limit: 10,
|
|
218
|
+
currentMessage: "hi",
|
|
219
|
+
formatEntry: () => "x",
|
|
220
|
+
}),
|
|
221
|
+
).toBe("hi");
|
|
222
|
+
expect(
|
|
223
|
+
buildPendingHistoryContext({
|
|
224
|
+
historyMap: map,
|
|
225
|
+
historyKey: "G",
|
|
226
|
+
limit: 0,
|
|
227
|
+
currentMessage: "hi",
|
|
228
|
+
formatEntry: () => "x",
|
|
229
|
+
}),
|
|
230
|
+
).toBe("hi");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("LRU eviction across groups", () => {
|
|
235
|
+
it("evicts oldest keys past the implicit cap (smoke check)", () => {
|
|
236
|
+
const map = makeMap();
|
|
237
|
+
// Just ensure the cache doesn't explode. The hard cap (1000) is an
|
|
238
|
+
// implementation detail; here we confirm the data structure keeps
|
|
239
|
+
// re-inserting without error at modest volume.
|
|
240
|
+
for (let i = 0; i < 100; i++) {
|
|
241
|
+
recordPendingHistoryEntry({
|
|
242
|
+
historyMap: map,
|
|
243
|
+
historyKey: `G${i}`,
|
|
244
|
+
entry: entry("A", `m${i}`),
|
|
245
|
+
limit: 1,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
expect(map.size).toBe(100);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("refreshes LRU ordering on subsequent writes to the same key", () => {
|
|
252
|
+
const map = makeMap();
|
|
253
|
+
recordPendingHistoryEntry({
|
|
254
|
+
historyMap: map,
|
|
255
|
+
historyKey: "OLD",
|
|
256
|
+
entry: entry("A", "1"),
|
|
257
|
+
limit: 5,
|
|
258
|
+
});
|
|
259
|
+
recordPendingHistoryEntry({
|
|
260
|
+
historyMap: map,
|
|
261
|
+
historyKey: "NEW",
|
|
262
|
+
entry: entry("A", "2"),
|
|
263
|
+
limit: 5,
|
|
264
|
+
});
|
|
265
|
+
recordPendingHistoryEntry({
|
|
266
|
+
historyMap: map,
|
|
267
|
+
historyKey: "OLD",
|
|
268
|
+
entry: entry("A", "3"),
|
|
269
|
+
limit: 5,
|
|
270
|
+
});
|
|
271
|
+
// After re-writing OLD, its iteration order should come last.
|
|
272
|
+
const keys = [...map.keys()];
|
|
273
|
+
expect(keys).toEqual(["NEW", "OLD"]);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("buildMergedMessageContext", () => {
|
|
278
|
+
it("returns current message unchanged when no preceding parts", () => {
|
|
279
|
+
expect(buildMergedMessageContext({ precedingParts: [], currentMessage: "hi" })).toBe("hi");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("wraps preceding parts with tags", () => {
|
|
283
|
+
const out = buildMergedMessageContext({
|
|
284
|
+
precedingParts: ["a", "b"],
|
|
285
|
+
currentMessage: "c",
|
|
286
|
+
});
|
|
287
|
+
expect(out).toContain("[Merged earlier messages — CONTEXT ONLY]");
|
|
288
|
+
expect(out).toContain("a\nb");
|
|
289
|
+
expect(out).toContain("[CURRENT MESSAGE — reply using the context above]");
|
|
290
|
+
expect(out.endsWith("c")).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("clearPendingHistory", () => {
|
|
295
|
+
it("resets the buffer to empty", () => {
|
|
296
|
+
const map = makeMap();
|
|
297
|
+
recordPendingHistoryEntry({
|
|
298
|
+
historyMap: map,
|
|
299
|
+
historyKey: "G",
|
|
300
|
+
entry: entry("A", "m"),
|
|
301
|
+
limit: 5,
|
|
302
|
+
});
|
|
303
|
+
clearPendingHistory({ historyMap: map, historyKey: "G", limit: 5 });
|
|
304
|
+
expect(map.get("G")).toEqual([]);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("no-ops when disabled", () => {
|
|
308
|
+
const map = makeMap();
|
|
309
|
+
map.set("G", [entry("A", "m")]);
|
|
310
|
+
clearPendingHistory({ historyMap: map, historyKey: "G", limit: 0 });
|
|
311
|
+
expect(map.get("G")).toHaveLength(1);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
});
|