@chanlerdev/scorel 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +55 -2
  2. package/dist/index.js +1162 -192
  3. package/dist/index.js.map +3 -3
  4. package/docs/CHANGELOG.md +106 -0
  5. package/docs/ROADMAP.md +34 -2
  6. package/docs/SHIP.md +1 -1
  7. package/docs/spec/channels.md +15 -5
  8. package/docs/spec/extensions.md +6 -5
  9. package/docs/spec/ship/S0060-relay-hosted-webui-e2e-validation.verification.md +1 -1
  10. package/docs/spec/ship/S0073-provider-model-profile-contract.md +6 -6
  11. package/docs/spec/ship/S0074-gui-model-provider-settings-split.md +1 -1
  12. package/docs/spec/ship/S0076-provider-modal-search-and-direct-key.md +1 -1
  13. package/docs/spec/ship/S0086-auto-compact-and-session-memory.md +1 -1
  14. package/docs/spec/ship/S0087-gui-ui-polish-sweep.md +153 -0
  15. package/docs/spec/ship/S0088-gui-streaming-thinking-contract.md +35 -0
  16. package/docs/spec/ship/S0089-memory-reliability-and-dream-trigger.md +84 -0
  17. package/docs/spec/ship/S0090-gui-provider-delete-and-dark-code-theme.md +77 -0
  18. package/docs/spec/ship/S0091-built-in-qq-and-wechat-im-extensions.md +125 -0
  19. package/docs/spec/ship/S0092-im-message-media-and-human-cadence.md +83 -0
  20. package/docs/spec/ship/S0093-gui-im-settings-platform-layout.md +66 -0
  21. package/docs/spec/ship/S0094-im-inbound-runtime.md +67 -0
  22. package/docs/spec/ship/S0095-gui-im-session-list-refresh.md +36 -0
  23. package/docs/spec/ship/S0096-glob-stable-order.md +32 -0
  24. package/docs/spec/ship/S0097-rtk-token-saving-settings.md +61 -0
  25. package/docs/spec/ship/S0098-local-daemon-singleton-unified-state.md +96 -0
  26. package/docs/spec/ship/S0099-gui-connection-device-settings.md +85 -0
  27. package/docs/spec/ship/S0100-gui-provider-danger-zone.md +57 -0
  28. package/docs/spec/ship/S0101-gui-device-settings-polish.md +66 -0
  29. package/docs/spec/ship/S0102-device-only-config.md +58 -0
  30. package/extensions/builtin/loopback/skills/loopback/SKILL.md +2 -0
  31. package/extensions/builtin/qq/adapter.d.ts +27 -0
  32. package/extensions/builtin/qq/adapter.js +384 -0
  33. package/extensions/builtin/qq/scorel.extension.json +7 -0
  34. package/extensions/builtin/qq/skills/qq/SKILL.md +9 -0
  35. package/extensions/builtin/telegram/adapter.d.ts +1 -1
  36. package/extensions/builtin/telegram/adapter.js +7 -0
  37. package/extensions/builtin/telegram/skills/telegram/SKILL.md +2 -0
  38. package/extensions/builtin/wechat/adapter.d.ts +24 -0
  39. package/extensions/builtin/wechat/adapter.js +226 -0
  40. package/extensions/builtin/wechat/scorel.extension.json +7 -0
  41. package/extensions/builtin/wechat/skills/wechat/SKILL.md +9 -0
  42. package/package.json +4 -2
@@ -0,0 +1,85 @@
1
+ # S0099: GUI Connection And Device Settings
2
+
3
+ ## Goal
4
+
5
+ Make GUI connection setup match the hosted Relay product path, so users can pair devices, rename paired devices, and inspect connection details.
6
+
7
+ S0101 supersedes the original settings-scope part of this spec: settings configuration is device-scoped, not Project-scoped.
8
+
9
+ ## Scope
10
+
11
+ - GUI Relay pairing:
12
+ - default to the official Relay without showing an editable URL field;
13
+ - expose Relay URL editing only behind an explicit edit affordance;
14
+ - rename the pairing action from `Pair` to `Get Pair Code`.
15
+ - GUI paired devices:
16
+ - allow users to rename a paired Relay Device locally in GUI state;
17
+ - preserve the Relay-reported label as fallback when no local name exists;
18
+ - show device details from the generic device view: status, Device ID, IP when available, and Relay URL.
19
+ - GUI Settings scope:
20
+ - superseded by S0101;
21
+ - the correct product model is device-scoped configuration.
22
+ - Tests and docs:
23
+ - cover local device rename persistence;
24
+ - cover the connection section rendering contract;
25
+ - keep ROADMAP in sync.
26
+
27
+ ## Not In Scope
28
+
29
+ - Relay protocol changes or IP discovery in Relay V1. The UI may expose an optional IP field, but Relay currently does not guarantee one.
30
+ - SSH Remote Device, remote installation, or SSH stdio proxy.
31
+ - Direct WS + token GUI setup.
32
+ - Account/OAuth identity.
33
+ - Importing every remote Host Project automatically into the GUI Project list.
34
+ - Moving IM extension settings to remote scope; IM settings remain local GUI/Host extension settings in this spec.
35
+
36
+ ## Acceptance Criteria
37
+
38
+ - Opening GUI Settings -> Connections shows the official Relay as the default endpoint and does not show a Relay URL input until the user chooses edit.
39
+ - The pair action reads `Get Pair Code`.
40
+ - A pair code is still created with the default official Relay when the URL field has not been edited.
41
+ - Paired devices can be renamed from the Connections page, and the local name persists in `~/.scorel/gui-store.json`.
42
+ - Paired device details expose status, Device ID, Relay URL, and an IP row that is populated only when the device view has IP information.
43
+ - Settings nav behavior is governed by S0101: it shows devices, not Projects.
44
+
45
+ ## Test Requirements
46
+
47
+ ```bash
48
+ pnpm --filter @scorel/app-gui test -- src/main/gui-store.test.ts src/renderer/gui-shell.test.tsx
49
+ pnpm typecheck
50
+ pnpm test
51
+ ```
52
+
53
+ Manual:
54
+
55
+ - Open GUI Settings -> Connections.
56
+ - Confirm the Relay URL input is hidden by default and `Get Pair Code` returns a pair code against the official Relay.
57
+ - Click edit, change Relay URL, and confirm pair/refresh use the edited URL.
58
+ - Pair or seed a Relay Device, rename it, refresh, and confirm the local name remains.
59
+ - Open Settings and confirm the settings selector follows S0101 device-scoped behavior.
60
+
61
+ ## Impacted Files
62
+
63
+ - `apps/gui/src/main/gui-store.ts`
64
+ - `apps/gui/src/main.ts`
65
+ - `apps/gui/src/preload.ts`
66
+ - `apps/gui/src/shared/ipc.ts`
67
+ - `apps/gui/src/renderer/App.tsx`
68
+ - `apps/gui/src/renderer/settings/SettingsShell.tsx`
69
+ - `apps/gui/src/renderer/settings/SettingsNav.tsx`
70
+ - `apps/gui/src/renderer/settings/sections/ConfigSection.tsx`
71
+ - `apps/gui/src/renderer/styles.css`
72
+ - `apps/gui/src/main/gui-store.test.ts`
73
+ - `apps/gui/src/renderer/gui-shell.test.tsx`
74
+ - `docs/CHANGELOG.md`
75
+ - `docs/ROADMAP.md`
76
+
77
+ ## Risks And Boundaries
78
+
79
+ - Device rename is GUI-local metadata, not a Relay identity mutation. Refresh must not overwrite a user's local name with a Relay label.
80
+ - Settings scope is device-based as of S0101. Projects are workspace/session objects, not configuration owners.
81
+ - IP is optional because the current Relay protocol does not report it; the UI contract must tolerate absence without inventing a fake value.
82
+
83
+ ## Status
84
+
85
+ Done.
@@ -0,0 +1,57 @@
1
+ # S0100: GUI Provider Danger Zone Placement
2
+
3
+ ## Goal
4
+
5
+ Move destructive Provider management actions out of the primary Provider edit form, so normal configuration fields remain the first visual focus and deletion is clearly presented as a secondary dangerous action.
6
+
7
+ S0101 supersedes the final placement: `删除提供商` now lives in the Provider configuration block's lower-right action area instead of a bottom danger row.
8
+
9
+ ## Scope
10
+
11
+ - In GUI Settings -> Provider:
12
+ - this spec recorded the first placement pass;
13
+ - S0101 defines the current placement in the Provider configuration block;
14
+ - keep the existing `removeModelProvider` behavior unchanged.
15
+ - Add rendering coverage that verifies the destructive action is below normal model-management controls.
16
+
17
+ ## Not In Scope
18
+
19
+ - Changing Provider deletion semantics, confirmation behavior, or daemon/client APIs.
20
+ - Redesigning the Provider page layout beyond the destructive-action placement.
21
+ - Changing model catalog, model selection, or provider form fields.
22
+
23
+ ## Acceptance Criteria
24
+
25
+ - Current acceptance is governed by S0101: `删除提供商` appears in the Provider configuration block.
26
+ - Existing Provider add/edit/model actions remain unchanged.
27
+ - GUI rendering tests cover the current placement.
28
+
29
+ ## Test Requirements
30
+
31
+ ```bash
32
+ pnpm --filter @scorel/app-gui test -- src/renderer/gui-shell.test.tsx
33
+ pnpm --filter @scorel/app-gui typecheck
34
+ ```
35
+
36
+ Manual:
37
+
38
+ - Start the GUI with a Project that has a configured Provider.
39
+ - Open Settings -> Provider.
40
+ - Confirm S0101 current behavior: the delete button appears in the Provider configuration block's lower-right action area.
41
+
42
+ ## Impacted Files
43
+
44
+ - `apps/gui/src/renderer/settings/sections/ProviderSection.tsx`
45
+ - `apps/gui/src/renderer/styles.css`
46
+ - `apps/gui/src/renderer/gui-shell.test.tsx`
47
+ - `docs/CHANGELOG.md`
48
+ - `docs/ROADMAP.md`
49
+
50
+ ## Risks And Boundaries
51
+
52
+ - The delete action remains destructive and immediate, matching existing behavior. This spec only changes placement.
53
+ - The danger row should not introduce another card nested inside the Provider card; it stays as an inline separated row.
54
+
55
+ ## Status
56
+
57
+ Done.
@@ -0,0 +1,66 @@
1
+ # S0101 GUI Device Settings Polish
2
+
3
+ ## Goal
4
+
5
+ Fix GUI settings so configuration is device-scoped, not Project-scoped, and polish the settings interactions surfaced by the latest Provider / Token / Connection review.
6
+
7
+ One device has one configuration. The Settings scope selector chooses a device:
8
+
9
+ - `此电脑` configures the local device.
10
+ - A Relay device configures that remote device.
11
+ - Projects remain workspace/session objects and must not appear as the settings configuration scope.
12
+
13
+ ## Scope
14
+
15
+ - Settings left scope selector becomes device-based.
16
+ - GUI settings IPC for model profile, Provider catalog/deletion, memory settings, and runtime settings targets only a device.
17
+ - Daemon/client config requests used by GUI are device-level and write the device user config at `~/.scorel/config.toml` for that daemon.
18
+ - Provider deletion moves into the top Provider configuration form area, aligned to the lower-right of the Provider parameter block.
19
+ - Runtime token statistics use understandable Chinese labels and expose both output token total and saved token estimate.
20
+ - Relay device rows have an explicit expand affordance.
21
+ - Relay device rename is inline: a small edit icon next to the device name turns the name into an input.
22
+
23
+ ## Not In Scope
24
+
25
+ - Changing session/project ownership: sessions still belong to Projects.
26
+ - Redesigning Project registry or remote Project selection.
27
+ - Creating per-Project settings overrides.
28
+ - Reworking IM extension settings beyond existing user-config behavior.
29
+ - Changing RTK savings math.
30
+
31
+ ## Acceptance Criteria
32
+
33
+ - Settings selector labels are device names only, for example `此电脑` and `Remote Device`; it does not render `此电脑 / ProjectName` or `Device / ProjectName`.
34
+ - Settings Provider/Model/Memory/Runtime mutations do not accept a Project in GUI IPC.
35
+ - Daemon writes GUI settings requests to device-level user config.
36
+ - Provider delete is visually close to Provider credentials/configuration, not in a separate bottom danger row.
37
+ - Runtime token stats are Chinese and self-explanatory.
38
+ - Relay device rows visibly indicate expand/collapse and rename through a name-adjacent edit icon.
39
+
40
+ ## Test Requirements
41
+
42
+ - Update renderer tests for device settings scope, Provider delete placement, Runtime labels, and inline Relay device rename affordance.
43
+ - Add or update daemon/local-host tests proving projectless settings write `config.toml` under device user config.
44
+ - Run targeted GUI/protocol/daemon tests covering changed paths.
45
+ - Run `pnpm typecheck && pnpm test` before shipping.
46
+
47
+ ## Impacted Files
48
+
49
+ - `packages/protocol/src/events.ts`
50
+ - `packages/protocol/src/wire.ts`
51
+ - `packages/client/src/index.ts`
52
+ - `packages/daemon/src/index.ts`
53
+ - `apps/gui/src/main.ts`
54
+ - `apps/gui/src/main/local-host.ts`
55
+ - `apps/gui/src/main/relay-service.ts`
56
+ - `apps/gui/src/preload.ts`
57
+ - `apps/gui/src/shared/ipc.ts`
58
+ - `apps/gui/src/renderer/App.tsx`
59
+ - `apps/gui/src/renderer/settings/*`
60
+ - `apps/gui/src/renderer/styles.css`
61
+ - `docs/ROADMAP.md`
62
+ - `docs/CHANGELOG.md`
63
+
64
+ ## Risks And Boundaries
65
+
66
+ - Memory status is a Project activity/status concept; this spec keeps Settings focused on Memory configuration, not Project activity status.
@@ -0,0 +1,58 @@
1
+ # S0102 Device Only Config
2
+
3
+ ## Goal
4
+
5
+ Remove project-level config as a product concept. A Scorel device has exactly one editable config, stored at that device's Scorel home `config.toml`.
6
+
7
+ Projects are workspace/session objects. They do not own Provider, Model, Memory, Runtime, or Extension settings.
8
+
9
+ ## Scope
10
+
11
+ - Core config loading reads only the device/user config file.
12
+ - Remove `.scorel/config.toml` from the public config schema and runtime loading contract.
13
+ - Settings writes always target the device config, even if an older request still includes a `projectId`.
14
+ - CLI daemon, CLI chat, GUI local host, and daemon fallback config reads pass the device `scorelHomeDir` explicitly so custom device roots do not read the process user's real `~/.scorel/config.toml`.
15
+ - Update current config docs/specs that describe project-scoped config.
16
+
17
+ ## Not In Scope
18
+
19
+ - Changing Project/session ownership.
20
+ - Removing Project registry or remote Project selection.
21
+ - Adding migration for old project `.scorel/config.toml` files.
22
+ - Changing project-scoped Memory status or runtime stats, which are activity/status data, not config ownership.
23
+
24
+ ## Acceptance Criteria
25
+
26
+ - `loadScorelConfig` and `loadScorelConfigProfile` ignore project `.scorel/config.toml`.
27
+ - If device config is missing, config loading reports the missing device config path.
28
+ - Provider/model/memory/runtime/extension Settings writes go to device `config.toml`.
29
+ - Requests with `projectId` do not create or mutate project `.scorel/config.toml`.
30
+ - Runtime creation for a Project still uses that device's single config.
31
+ - Docs describe config as device-only.
32
+
33
+ ## Test Requirements
34
+
35
+ - Core config tests load from device/user config and prove project `.scorel/config.toml` is ignored.
36
+ - Daemon embedded tests prove Settings writes with `projectId` still write device config only.
37
+ - CLI daemon idle test continues to prove custom device state roots do not inherit active IM from the real user config.
38
+ - Run `pnpm typecheck && pnpm test`.
39
+
40
+ ## Impacted Files
41
+
42
+ - `packages/core/src/config/index.ts`
43
+ - `packages/core/src/config/config.test.ts`
44
+ - `packages/daemon/src/index.ts`
45
+ - `packages/daemon/src/embedded/embedded.test.ts`
46
+ - `apps/cli/src/index.ts`
47
+ - `apps/cli/src/daemon-cli.ts`
48
+ - `apps/gui/src/main/local-host.ts`
49
+ - `docs/spec/extensions.md`
50
+ - `docs/spec/ship/S0097-rtk-token-saving-settings.md`
51
+ - `docs/spec/ship/S0086-auto-compact-and-session-memory.md`
52
+ - `docs/ROADMAP.md`
53
+ - `docs/CHANGELOG.md`
54
+
55
+ ## Risks And Boundaries
56
+
57
+ - Pre-1.0 development rules allow removing this stale config surface rather than preserving compatibility.
58
+ - Old project `.scorel/config.toml` files may remain on disk, but runtime no longer treats them as config.
@@ -5,3 +5,5 @@ description: Reply to the current loopback IM conversation with SendChannelMessa
5
5
  # Loopback IM
6
6
 
7
7
  When a message comes from the loopback IM channel, use `SendChannelMessage` to reply to the current conversation. Do not ask for raw channel ids.
8
+
9
+ If work will take more than a brief moment, send a short acknowledgement first, then follow with concise progress or the final result.
@@ -0,0 +1,27 @@
1
+ export type QQAdapterOptions = {
2
+ appId: string;
3
+ appSecret: string;
4
+ apiBaseUrl: string;
5
+ accessTokenUrl?: string;
6
+ gatewayUrl?: string;
7
+ botId?: string;
8
+ intents?: number;
9
+ heartbeatIntervalMs?: number;
10
+ dedupeTtlMs?: number;
11
+ };
12
+
13
+ export type QQTarget = {
14
+ externalConversationId: string;
15
+ data?: Record<string, unknown>;
16
+ };
17
+
18
+ export type QQAdapter = {
19
+ start(ctx: unknown): Promise<void>;
20
+ stop(): Promise<void>;
21
+ sendMessage(target: QQTarget, message: { text?: string; attachments?: Array<Record<string, unknown>> }): Promise<void>;
22
+ };
23
+
24
+ export function createAdapter(options?: { config?: Record<string, string | number | boolean> }): QQAdapter;
25
+ export function createQQAdapter(options: QQAdapterOptions): QQAdapter;
26
+ export function normalizeQQEvent(event: unknown, options?: { botId?: string }): unknown;
27
+ export function redactQQSecret(value: string): string;
@@ -0,0 +1,384 @@
1
+ import WebSocket from "ws";
2
+
3
+ const DEFAULT_QQ_API_BASE_URL = "https://api.sgroup.qq.com";
4
+ const DEFAULT_QQ_ACCESS_TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
5
+ const DEFAULT_QQ_INTENTS = (1 << 9) | (1 << 25) | (1 << 30);
6
+ const DEFAULT_DEDUPE_TTL_MS = 5 * 60_000;
7
+
8
+ export const createAdapter = ({ config = {} } = {}) => {
9
+ return createQQAdapter({
10
+ appId: requiredStringConfig(config.appId, "QQ App ID"),
11
+ appSecret: requiredStringConfig(config.appSecret, "QQ App Secret"),
12
+ apiBaseUrl: stringConfig(config.apiBaseUrl, DEFAULT_QQ_API_BASE_URL),
13
+ accessTokenUrl: stringConfig(config.accessTokenUrl, DEFAULT_QQ_ACCESS_TOKEN_URL),
14
+ gatewayUrl: stringConfig(config.gatewayUrl, ""),
15
+ botId: optionalStringConfig(config.botId, "QQ botId"),
16
+ });
17
+ };
18
+
19
+ export const createQQAdapter = (options) => {
20
+ let accessToken;
21
+ let accessTokenExpiresAt = 0;
22
+ let ctx;
23
+ let running = false;
24
+ let socket;
25
+ let heartbeatTimer;
26
+ let lastSequence = null;
27
+ let sessionId;
28
+ const recentMessageIds = new Map();
29
+
30
+ const getAccessToken = async () => {
31
+ const refreshAt = accessTokenExpiresAt - 60_000;
32
+ if (accessToken && Date.now() < refreshAt) {
33
+ return accessToken;
34
+ }
35
+ const response = await fetch(options.accessTokenUrl ?? DEFAULT_QQ_ACCESS_TOKEN_URL, {
36
+ method: "POST",
37
+ headers: { "content-type": "application/json" },
38
+ body: JSON.stringify({
39
+ appId: options.appId,
40
+ clientSecret: options.appSecret,
41
+ }),
42
+ });
43
+ const payload = await response.json().catch(() => undefined);
44
+ if (!response.ok || typeof payload?.access_token !== "string" || !payload.access_token.trim()) {
45
+ throw new Error(redactQQSecret(`qq access token failed: ${payload?.message ?? payload?.errmsg ?? response.status}`));
46
+ }
47
+ const expiresIn = Number(payload.expires_in);
48
+ accessToken = payload.access_token.trim();
49
+ accessTokenExpiresAt = Date.now() + (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn * 1000 : 7200_000);
50
+ return accessToken;
51
+ };
52
+
53
+ const fetchGatewayUrl = async (token) => {
54
+ const configured = typeof options.gatewayUrl === "string" && options.gatewayUrl.trim() ? options.gatewayUrl.trim() : undefined;
55
+ const response = await fetch(configured ?? `${options.apiBaseUrl.replace(/\/+$/, "")}/gateway`, {
56
+ method: "GET",
57
+ headers: { authorization: `QQBot ${token}` },
58
+ });
59
+ const payload = await response.json().catch(() => undefined);
60
+ if (!response.ok || typeof payload?.url !== "string" || !payload.url.trim()) {
61
+ throw new Error(redactQQSecret(`qq gateway failed: ${payload?.message ?? response.status}`));
62
+ }
63
+ return payload.url.trim();
64
+ };
65
+
66
+ const sendSocket = (payload) => {
67
+ if (socket?.readyState === WebSocket.OPEN) {
68
+ socket.send(JSON.stringify(payload));
69
+ }
70
+ };
71
+
72
+ const sendHeartbeat = () => {
73
+ sendSocket({ op: 1, d: lastSequence });
74
+ };
75
+
76
+ const clearHeartbeat = () => {
77
+ if (heartbeatTimer) {
78
+ clearInterval(heartbeatTimer);
79
+ heartbeatTimer = undefined;
80
+ }
81
+ };
82
+
83
+ const identify = async () => {
84
+ sendSocket({
85
+ op: 2,
86
+ d: {
87
+ token: `QQBot ${await getAccessToken()}`,
88
+ intents: numberConfig(options.intents, DEFAULT_QQ_INTENTS),
89
+ shard: Array.isArray(options.shard) ? options.shard : [0, 1],
90
+ properties: {
91
+ "$os": process.platform,
92
+ "$browser": "scorel",
93
+ "$device": "scorel",
94
+ },
95
+ },
96
+ });
97
+ };
98
+
99
+ const handleGatewayPayload = async (payload) => {
100
+ if (typeof payload?.s === "number") {
101
+ lastSequence = payload.s;
102
+ }
103
+ if (payload?.op === 10) {
104
+ const interval = numberConfig(options.heartbeatIntervalMs, Number(payload?.d?.heartbeat_interval) || 45_000);
105
+ clearHeartbeat();
106
+ sendHeartbeat();
107
+ heartbeatTimer = setInterval(sendHeartbeat, interval);
108
+ heartbeatTimer.unref?.();
109
+ await identify();
110
+ return;
111
+ }
112
+ if (payload?.op === 1) {
113
+ sendHeartbeat();
114
+ return;
115
+ }
116
+ if (payload?.op === 0) {
117
+ if (payload.t === "READY" && typeof payload.d?.session_id === "string") {
118
+ sessionId = payload.d.session_id;
119
+ return;
120
+ }
121
+ const incoming = normalizeQQEvent(payload.d, { botId: options.botId });
122
+ if (!incoming || isDuplicateMessage(incoming, recentMessageIds, numberConfig(options.dedupeTtlMs, DEFAULT_DEDUPE_TTL_MS))) {
123
+ return;
124
+ }
125
+ await ctx?.onMessage(incoming);
126
+ return;
127
+ }
128
+ if (payload?.op === 7 || payload?.op === 9) {
129
+ ctx?.logger?.error("qq_gateway_reconnect_required", { op: payload.op, sessionId });
130
+ }
131
+ };
132
+
133
+ return {
134
+ async start(startCtx) {
135
+ ctx = startCtx;
136
+ running = true;
137
+ const token = await getAccessToken();
138
+ const url = await fetchGatewayUrl(token);
139
+ await new Promise((resolve, reject) => {
140
+ const ws = new WebSocket(url);
141
+ socket = ws;
142
+ let settled = false;
143
+ const settle = (error) => {
144
+ if (settled) return;
145
+ settled = true;
146
+ error ? reject(error) : resolve();
147
+ };
148
+ ws.once("open", () => settle());
149
+ ws.once("error", (error) => {
150
+ ctx?.logger?.error("qq_gateway_error", { message: safeErrorMessage(error) });
151
+ settle(error);
152
+ });
153
+ ws.on("message", (data) => {
154
+ void handleGatewayPayload(parseGatewayMessage(data)).catch((cause) => {
155
+ ctx?.logger?.error("qq_gateway_message_failed", { message: redactQQSecret(safeErrorMessage(cause)) });
156
+ });
157
+ });
158
+ ws.on("close", () => {
159
+ clearHeartbeat();
160
+ if (running) {
161
+ ctx?.logger?.error("qq_gateway_closed", {});
162
+ }
163
+ });
164
+ });
165
+ },
166
+ async stop() {
167
+ running = false;
168
+ clearHeartbeat();
169
+ const closing = socket;
170
+ socket = undefined;
171
+ if (closing && closing.readyState !== WebSocket.CLOSED) {
172
+ await new Promise((resolve) => {
173
+ closing.once("close", () => resolve());
174
+ closing.close();
175
+ setTimeout(resolve, 250).unref?.();
176
+ });
177
+ }
178
+ },
179
+ async sendMessage(target, message) {
180
+ rejectUnsupportedAttachments("QQ", message);
181
+ const route = qqSendRoute(target);
182
+ await qqRequest(options, route, await getAccessToken(), {
183
+ msg_type: 0,
184
+ content: String(message.text).trim(),
185
+ ...(target?.data?.messageId ? { msg_id: target.data.messageId } : {}),
186
+ msg_seq: 1,
187
+ });
188
+ },
189
+ };
190
+ };
191
+
192
+ const parseGatewayMessage = (data) => {
193
+ const text = Buffer.isBuffer(data) ? data.toString("utf8") : String(data);
194
+ return JSON.parse(text);
195
+ };
196
+
197
+ export const normalizeQQEvent = (event, options = {}) => {
198
+ const text = typeof event?.content === "string" ? event.content.trim() : "";
199
+ if (!text) {
200
+ return undefined;
201
+ }
202
+ const groupOpenId = optionalEventString(event.group_openid);
203
+ const userOpenId = optionalEventString(event.user_openid ?? event.author?.user_openid ?? event.author?.id);
204
+ const channelId = optionalEventString(event.channel_id);
205
+ const guildId = optionalEventString(event.guild_id);
206
+ const messageId = optionalEventString(event.id);
207
+ const mentionedBot = isQQBotMentioned(text, options.botId) || Boolean(groupOpenId || guildId);
208
+ if (groupOpenId) {
209
+ return qqIncoming({
210
+ kind: "group",
211
+ id: groupOpenId,
212
+ text: stripQQMention(text, options.botId),
213
+ senderDisplayName: senderDisplayName(event.author ?? event.member),
214
+ mentionedBot,
215
+ messageId,
216
+ });
217
+ }
218
+ if (userOpenId) {
219
+ return qqIncoming({
220
+ kind: "private",
221
+ id: userOpenId,
222
+ text,
223
+ senderDisplayName: senderDisplayName(event.author),
224
+ mentionedBot: false,
225
+ messageId,
226
+ });
227
+ }
228
+ if (channelId) {
229
+ return qqIncoming({
230
+ kind: "channel",
231
+ id: channelId,
232
+ text: stripQQMention(text, options.botId),
233
+ senderDisplayName: senderDisplayName(event.author ?? event.member),
234
+ mentionedBot,
235
+ messageId,
236
+ extraData: guildId ? { guildId } : {},
237
+ });
238
+ }
239
+ return undefined;
240
+ };
241
+
242
+ export const redactQQSecret = (value) =>
243
+ String(value)
244
+ .replace(/(clientSecret"\s*:\s*")[^"]+/g, "$1[REDACTED]")
245
+ .replace(/QQBot\s+[A-Za-z0-9._-]+/g, "QQBot [REDACTED]");
246
+
247
+ const qqIncoming = ({ kind, id, text, senderDisplayName, mentionedBot, messageId, extraData = {} }) => {
248
+ const conversationType = kind === "private" ? "private" : kind;
249
+ const externalConversationId = `qq:${conversationType}:${id}`;
250
+ return {
251
+ externalConversationId,
252
+ text,
253
+ conversationType,
254
+ senderDisplayName,
255
+ mentionedBot,
256
+ target: {
257
+ externalConversationId,
258
+ data: { kind, id, ...(messageId ? { messageId } : {}), ...extraData },
259
+ },
260
+ data: {
261
+ ...(messageId ? { messageId } : {}),
262
+ ...extraData,
263
+ },
264
+ };
265
+ };
266
+
267
+ const qqSendRoute = (target) => {
268
+ const kind = target?.data?.kind;
269
+ const id = target?.data?.id;
270
+ if (typeof id !== "string" || !id) {
271
+ throw new Error("QQ target is missing id");
272
+ }
273
+ if (kind === "group") {
274
+ return `/v2/groups/${encodeURIComponent(id)}/messages`;
275
+ }
276
+ if (kind === "private") {
277
+ return `/v2/users/${encodeURIComponent(id)}/messages`;
278
+ }
279
+ if (kind === "channel") {
280
+ return `/channels/${encodeURIComponent(id)}/messages`;
281
+ }
282
+ throw new Error("QQ target kind must be group, private, or channel");
283
+ };
284
+
285
+ const qqRequest = async (options, route, accessToken, body) => {
286
+ const response = await fetch(`${options.apiBaseUrl.replace(/\/+$/, "")}${route}`, {
287
+ method: "POST",
288
+ headers: {
289
+ "content-type": "application/json",
290
+ authorization: `QQBot ${accessToken}`,
291
+ },
292
+ body: JSON.stringify(body),
293
+ });
294
+ const payload = await response.json().catch(() => undefined);
295
+ if (!response.ok) {
296
+ throw new Error(redactQQSecret(`qq send failed: ${payload?.message ?? response.status}`));
297
+ }
298
+ return payload;
299
+ };
300
+
301
+ const isQQBotMentioned = (text, botId) =>
302
+ Boolean(botId && new RegExp(`<@!?${escapeRegExp(botId)}>`, "i").test(text));
303
+
304
+ const stripQQMention = (text, botId) => {
305
+ if (!botId) {
306
+ return text.trim();
307
+ }
308
+ return text.replace(new RegExp(`<@!?${escapeRegExp(botId)}>`, "gi"), " ").trim();
309
+ };
310
+
311
+ const senderDisplayName = (value) => {
312
+ if (!value || typeof value !== "object") {
313
+ return undefined;
314
+ }
315
+ return optionalEventString(value.username ?? value.nick ?? value.name);
316
+ };
317
+
318
+ const optionalEventString = (value) => typeof value === "string" && value.trim() ? value.trim() : undefined;
319
+
320
+ const requiredStringConfig = (value, name) => {
321
+ if (typeof value !== "string" || !value.trim()) {
322
+ throw new Error(`${name} is required`);
323
+ }
324
+ return value.trim();
325
+ };
326
+
327
+ const stringConfig = (value, fallback) => {
328
+ if (value === undefined || value === "") {
329
+ return fallback;
330
+ }
331
+ if (typeof value !== "string") {
332
+ throw new Error("QQ config value must be a string");
333
+ }
334
+ return value;
335
+ };
336
+
337
+ const optionalStringConfig = (value, name) => {
338
+ if (value === undefined || value === "") {
339
+ return undefined;
340
+ }
341
+ if (typeof value !== "string") {
342
+ throw new Error(`${name} must be a string`);
343
+ }
344
+ return value;
345
+ };
346
+
347
+ const numberConfig = (value, fallback) => {
348
+ if (value === undefined || value === "") {
349
+ return fallback;
350
+ }
351
+ const parsed = Number(value);
352
+ if (!Number.isFinite(parsed) || parsed <= 0) {
353
+ throw new Error("QQ config value must be a positive number");
354
+ }
355
+ return parsed;
356
+ };
357
+
358
+ const isDuplicateMessage = (incoming, recentMessageIds, ttlMs) => {
359
+ const messageId = optionalEventString(incoming?.data?.messageId ?? incoming?.target?.data?.messageId);
360
+ if (!messageId) {
361
+ return false;
362
+ }
363
+ const now = Date.now();
364
+ for (const [id, expiresAt] of recentMessageIds) {
365
+ if (expiresAt <= now) {
366
+ recentMessageIds.delete(id);
367
+ }
368
+ }
369
+ if (recentMessageIds.has(messageId)) {
370
+ return true;
371
+ }
372
+ recentMessageIds.set(messageId, now + ttlMs);
373
+ return false;
374
+ };
375
+
376
+ const safeErrorMessage = (cause) => cause instanceof Error ? cause.message : String(cause);
377
+
378
+ const rejectUnsupportedAttachments = (platform, message) => {
379
+ if (Array.isArray(message.attachments) && message.attachments.length > 0) {
380
+ throw new Error(`${platform} attachment sending is not supported yet`);
381
+ }
382
+ };
383
+
384
+ const escapeRegExp = (value) => String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");