@chanlerdev/scorel 0.0.2 → 0.0.3
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/README.md +55 -2
- package/dist/index.js +395 -31
- package/dist/index.js.map +3 -3
- package/docs/CHANGELOG.md +53 -0
- package/docs/ROADMAP.md +19 -0
- package/docs/spec/channels.md +15 -5
- package/docs/spec/ship/S0087-gui-ui-polish-sweep.md +153 -0
- package/docs/spec/ship/S0088-gui-streaming-thinking-contract.md +35 -0
- package/docs/spec/ship/S0089-memory-reliability-and-dream-trigger.md +84 -0
- package/docs/spec/ship/S0090-gui-provider-delete-and-dark-code-theme.md +77 -0
- package/docs/spec/ship/S0091-built-in-qq-and-wechat-im-extensions.md +125 -0
- package/docs/spec/ship/S0092-im-message-media-and-human-cadence.md +83 -0
- package/docs/spec/ship/S0093-gui-im-settings-platform-layout.md +66 -0
- package/docs/spec/ship/S0094-im-inbound-runtime.md +67 -0
- package/docs/spec/ship/S0095-gui-im-session-list-refresh.md +36 -0
- package/extensions/builtin/loopback/skills/loopback/SKILL.md +2 -0
- package/extensions/builtin/qq/adapter.d.ts +27 -0
- package/extensions/builtin/qq/adapter.js +384 -0
- package/extensions/builtin/qq/scorel.extension.json +7 -0
- package/extensions/builtin/qq/skills/qq/SKILL.md +9 -0
- package/extensions/builtin/telegram/adapter.d.ts +1 -1
- package/extensions/builtin/telegram/adapter.js +7 -0
- package/extensions/builtin/telegram/skills/telegram/SKILL.md +2 -0
- package/extensions/builtin/wechat/adapter.d.ts +24 -0
- package/extensions/builtin/wechat/adapter.js +226 -0
- package/extensions/builtin/wechat/scorel.extension.json +7 -0
- package/extensions/builtin/wechat/skills/wechat/SKILL.md +9 -0
- package/package.json +1 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# S0092: IM Message Media And Human Cadence
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Make IM conversations feel alive and trustworthy by improving outgoing message capability and IM-specific response cadence.
|
|
6
|
+
|
|
7
|
+
The business value is user confidence. In IM, silence for minutes reads as failure; Scorel needs visible progress and short human-style replies without compromising the existing agent loop.
|
|
8
|
+
|
|
9
|
+
## Scope
|
|
10
|
+
|
|
11
|
+
### SendChannelMessage Payload
|
|
12
|
+
|
|
13
|
+
Extend `SendChannelMessage` from text-only to a structured outgoing message:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
type SendChannelMessageInput = {
|
|
17
|
+
text?: string;
|
|
18
|
+
attachments?: Array<{
|
|
19
|
+
type: "image" | "file";
|
|
20
|
+
path?: string;
|
|
21
|
+
url?: string;
|
|
22
|
+
mimeType?: string;
|
|
23
|
+
caption?: string;
|
|
24
|
+
}>;
|
|
25
|
+
channel?: string;
|
|
26
|
+
target?: "current";
|
|
27
|
+
};
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Rules:
|
|
31
|
+
|
|
32
|
+
- At least one of `text` or `attachments` is required.
|
|
33
|
+
- Adapters may downgrade unsupported attachments to a clear tool error.
|
|
34
|
+
- Local file paths must be explicit and must not be guessed from raw platform ids.
|
|
35
|
+
- Tool result details report per-attachment status.
|
|
36
|
+
|
|
37
|
+
### Adapter Capability Contract
|
|
38
|
+
|
|
39
|
+
- Add optional adapter capabilities for outgoing attachment support.
|
|
40
|
+
- Telegram/QQ/WeChat can initially support text and explicitly reject unsupported media.
|
|
41
|
+
- Loopback should support structured capture of text and attachment metadata for tests.
|
|
42
|
+
|
|
43
|
+
### IM System Prompt / Harness Guidance
|
|
44
|
+
|
|
45
|
+
Add IM-specific guidance to channel context:
|
|
46
|
+
|
|
47
|
+
- acknowledge quickly when work will take time;
|
|
48
|
+
- send brief progress updates through `SendChannelMessage` during long tasks;
|
|
49
|
+
- prefer concise, conversational wording;
|
|
50
|
+
- do not wait until every tool finishes before sending any reply;
|
|
51
|
+
- avoid exposing internal tool names unless useful to the user;
|
|
52
|
+
- keep business-critical facts and file references precise.
|
|
53
|
+
|
|
54
|
+
This guidance must enter through the existing channel harness item / skill path, not a second provider-level system prompt.
|
|
55
|
+
|
|
56
|
+
## Not In Scope
|
|
57
|
+
|
|
58
|
+
- Full media upload implementation for every platform.
|
|
59
|
+
- Voice, stickers, albums, interactive buttons, or payments.
|
|
60
|
+
- Cross-conversation proactive messages.
|
|
61
|
+
- A new IM runtime loop or queue.
|
|
62
|
+
- Fake progress timers outside the agent loop.
|
|
63
|
+
|
|
64
|
+
## Acceptance Criteria
|
|
65
|
+
|
|
66
|
+
- `SendChannelMessage` accepts text, image, and file attachment metadata.
|
|
67
|
+
- Text-only calls remain backward compatible.
|
|
68
|
+
- Unsupported attachment sends fail as tool errors, not silent no-ops.
|
|
69
|
+
- IM channel reminders tell the agent to respond early and keep long-running users informed.
|
|
70
|
+
- Built-in IM skills include platform-specific response cadence guidance.
|
|
71
|
+
- Tests prove the model-facing tool schema rejects empty sends and preserves attachment metadata.
|
|
72
|
+
|
|
73
|
+
## Testing Requirements
|
|
74
|
+
|
|
75
|
+
- Core channel tool tests for structured payload parsing.
|
|
76
|
+
- Loopback adapter tests for captured attachment metadata.
|
|
77
|
+
- Telegram/QQ/WeChat tests for unsupported media errors or implemented send mapping.
|
|
78
|
+
- Instruction/channel context tests for IM cadence guidance.
|
|
79
|
+
- Full `pnpm typecheck && pnpm test`.
|
|
80
|
+
|
|
81
|
+
## Status
|
|
82
|
+
|
|
83
|
+
Done.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# S0093: GUI IM Settings Platform Layout
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Redesign GUI IM Settings so multiple platforms fit without crowding the page.
|
|
6
|
+
|
|
7
|
+
The business value is scale. Telegram was one provider; Telegram + QQ + WeChat needs a repeatable platform row pattern where details expand only when the user asks.
|
|
8
|
+
|
|
9
|
+
## Scope
|
|
10
|
+
|
|
11
|
+
- Replace the current Telegram-only large form with a platform list.
|
|
12
|
+
- Each platform row shows:
|
|
13
|
+
- platform name;
|
|
14
|
+
- enabled/disabled toggle;
|
|
15
|
+
- active/inactive status;
|
|
16
|
+
- concise credential/config summary;
|
|
17
|
+
- expand/collapse affordance.
|
|
18
|
+
- Clicking a row expands its detailed config below that row.
|
|
19
|
+
- Clicking the expanded row again collapses it.
|
|
20
|
+
- The page defaults to all platforms collapsed unless a previous expanded platform is stored locally.
|
|
21
|
+
- The last expanded platform is remembered locally; collapsing clears that remembered state.
|
|
22
|
+
- Support Telegram, QQ, and WeChat using one reusable component shape.
|
|
23
|
+
- Preserve existing Telegram config fields and persistence behavior.
|
|
24
|
+
- Add QQ and WeChat config fields that match S0091/S0094.
|
|
25
|
+
- For QQ and WeChat, expose the current quick setup fields only: QQ `App ID` / `App Secret`, WeChat `Outbound Webhook` plus inbound callback `Callback Token` / `Callback Host` / `Callback Port`.
|
|
26
|
+
- Keep settings stored through the existing `getExtensionSettings` / `upsertExtensionSettings` IPC path.
|
|
27
|
+
- Absorb immediate GUI review fixes found before push:
|
|
28
|
+
- expanded platform details must have a visible ownership boundary;
|
|
29
|
+
- the composer must not submit while an IME composition is active.
|
|
30
|
+
|
|
31
|
+
## Not In Scope
|
|
32
|
+
|
|
33
|
+
- Remote Relay IM settings.
|
|
34
|
+
- Diagnostics timeline.
|
|
35
|
+
- Live credential validation beyond existing adapter refresh behavior.
|
|
36
|
+
- Account OAuth or QR login.
|
|
37
|
+
|
|
38
|
+
## Acceptance Criteria
|
|
39
|
+
|
|
40
|
+
- The IM Settings page renders three compact platform rows.
|
|
41
|
+
- The first render does not expand Telegram by default.
|
|
42
|
+
- No single disabled platform consumes a full settings page height.
|
|
43
|
+
- Expanding Telegram reveals the existing credential/poll/allow-list fields.
|
|
44
|
+
- Expanding QQ or WeChat reveals their S0091 config fields.
|
|
45
|
+
- QQ and WeChat do not expose env-var-first credential fields in the default Settings flow.
|
|
46
|
+
- Re-clicking the expanded platform collapses details.
|
|
47
|
+
- Re-opening IM Settings restores the previously expanded platform when one was stored.
|
|
48
|
+
- Toggling a platform writes the correct extension config and refreshes local Host IM extensions.
|
|
49
|
+
- Direct secret fields are password inputs and are never displayed in summaries.
|
|
50
|
+
- Layout remains readable in narrow GUI widths.
|
|
51
|
+
- Expanded fields are visually grouped under the active platform, not blended into sibling platform rows.
|
|
52
|
+
- Pressing Enter while Chinese/Japanese/Korean IME composition is active does not submit or block candidate selection.
|
|
53
|
+
- Plain Enter still submits when enabled; Shift+Enter remains available for newline input.
|
|
54
|
+
|
|
55
|
+
## Testing Requirements
|
|
56
|
+
|
|
57
|
+
- GUI render tests for compact rows and expansion.
|
|
58
|
+
- GUI interaction tests for default-collapsed, toggle-to-collapse, and stored expansion restore.
|
|
59
|
+
- GUI interaction tests for toggling and field blur persistence.
|
|
60
|
+
- Existing Telegram settings behavior remains covered.
|
|
61
|
+
- GUI composer tests cover IME composition Enter, plain Enter, and Shift+Enter.
|
|
62
|
+
- Full `pnpm typecheck && pnpm test`.
|
|
63
|
+
|
|
64
|
+
## Status
|
|
65
|
+
|
|
66
|
+
Done.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# S0094: IM Inbound Runtime
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Make built-in QQ and WeChat IM integrations actually receive user messages at runtime and route them through the shared Scorel IM session bridge.
|
|
6
|
+
|
|
7
|
+
## Scope
|
|
8
|
+
|
|
9
|
+
- Implement QQ Bot inbound receive through the official WebSocket Gateway.
|
|
10
|
+
- Implement WeChat inbound receive through an official HTTP callback surface for official-account style plaintext text messages.
|
|
11
|
+
- Keep WeCom group robot webhook as an outbound-only sender because that official webhook surface does not deliver user messages back to Scorel.
|
|
12
|
+
- Keep adapters platform-IO only. They call `ctx.onMessage(...)`; daemon session creation stays in the existing IM bridge.
|
|
13
|
+
- Update GUI settings and docs so outbound webhook configuration is not presented as if it enables inbound WeChat chat.
|
|
14
|
+
|
|
15
|
+
## Not In Scope
|
|
16
|
+
|
|
17
|
+
- Consumer personal WeChat reverse engineering, browser automation, or Web WeChat scraping.
|
|
18
|
+
- Hosted public ingress, TLS certificates, relay tunneling, or automatic public URL provisioning.
|
|
19
|
+
- Full WeChat encrypted callback decrypt/encrypt support. Plaintext callback is the S0094 receive baseline.
|
|
20
|
+
- QQ sharding beyond one local gateway connection.
|
|
21
|
+
|
|
22
|
+
## Acceptance Criteria
|
|
23
|
+
|
|
24
|
+
- QQ adapter `start(ctx)` fetches an access token, fetches `/gateway`, opens a WebSocket, identifies with `QQBot <access_token>`, sends heartbeats, and calls `ctx.onMessage(...)` for text dispatch events normalized by `normalizeQQEvent`.
|
|
25
|
+
- QQ adapter `stop()` closes the WebSocket and heartbeat timer.
|
|
26
|
+
- WeChat adapter starts a local HTTP callback server when callback config is present, responds to GET URL verification, accepts text POST callbacks, normalizes them, and calls `ctx.onMessage(...)`.
|
|
27
|
+
- WeChat adapter can still send outbound text to a configured WeCom group robot webhook; if only webhook URL is configured, inbound is explicitly not started.
|
|
28
|
+
- Tests cover QQ gateway identify/heartbeat/dispatch/stop and WeChat callback verification/message routing.
|
|
29
|
+
|
|
30
|
+
## Test Requirements
|
|
31
|
+
|
|
32
|
+
- Add focused adapter tests before implementation.
|
|
33
|
+
- Run:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pnpm --filter @scorel/daemon test -- qq-adapter.test.ts wechat-adapter.test.ts
|
|
37
|
+
pnpm typecheck
|
|
38
|
+
pnpm test
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Impacted Files
|
|
42
|
+
|
|
43
|
+
- `extensions/builtin/qq/adapter.js`
|
|
44
|
+
- `extensions/builtin/qq/adapter.d.ts`
|
|
45
|
+
- `extensions/builtin/wechat/adapter.js`
|
|
46
|
+
- `extensions/builtin/wechat/adapter.d.ts`
|
|
47
|
+
- `packages/daemon/src/qq-adapter.test.ts`
|
|
48
|
+
- `packages/daemon/src/wechat-adapter.test.ts`
|
|
49
|
+
- `apps/gui/src/renderer/settings/sections/ImSection.tsx`
|
|
50
|
+
- `docs/spec/ship/S0091-built-in-qq-and-wechat-im-extensions.md`
|
|
51
|
+
|
|
52
|
+
## Risks And Boundaries
|
|
53
|
+
|
|
54
|
+
- QQ event delivery also depends on the bot's platform-side event subscriptions and permissions. Scorel can connect to the gateway, but QQ may still omit events that are not enabled for the bot.
|
|
55
|
+
- WeChat callback receive requires a URL Tencent can reach. Local-only callback ports are useful for tunnels and tests, but are not publicly reachable by themselves.
|
|
56
|
+
- WeCom group robot webhook remains outbound-only by official product design; Scorel must not imply that it can receive user chat through that webhook.
|
|
57
|
+
|
|
58
|
+
## References
|
|
59
|
+
|
|
60
|
+
- QQ Bot event delivery and WebSocket gateway: <https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html>
|
|
61
|
+
- QQ Bot gateway URL API: <https://bot.q.qq.com/wiki/develop/api-v2/openapi/wss/url_get.html>
|
|
62
|
+
- WeCom group robot outbound webhook: <https://developer.work.weixin.qq.com/document/path/91770>
|
|
63
|
+
- WeChat official account callback verification and normal messages: <https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html>, <https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Receiving_standard_messages.html>
|
|
64
|
+
|
|
65
|
+
## Status
|
|
66
|
+
|
|
67
|
+
Done.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# S0095: GUI IM Session List Refresh
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Make GUI sidebars show IM sessions that are created in the background by Telegram, QQ, or WeChat inbound messages.
|
|
6
|
+
|
|
7
|
+
## Scope
|
|
8
|
+
|
|
9
|
+
- Notify GUI when the local Host creates a session, including IM sessions created in the background.
|
|
10
|
+
- Refresh only the affected Project session list in response to that notification.
|
|
11
|
+
- Keep IM session storage and Project binding unchanged.
|
|
12
|
+
- Cover the selected/default IM workspace Project so background QQ/Telegram sessions appear without app restart.
|
|
13
|
+
|
|
14
|
+
## Not In Scope
|
|
15
|
+
|
|
16
|
+
- Changing where session JSONL files are stored.
|
|
17
|
+
- Creating per-platform Projects.
|
|
18
|
+
- Relay or hosted notification push for remote devices.
|
|
19
|
+
|
|
20
|
+
## Acceptance Criteria
|
|
21
|
+
|
|
22
|
+
- A GUI Project refreshes when the local Host reports a new session for that Project.
|
|
23
|
+
- The refresh does not clear the active transcript.
|
|
24
|
+
- Renderer test covers a background session appearing after initial load.
|
|
25
|
+
|
|
26
|
+
## Test Requirements
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pnpm --filter @scorel/app-gui test -- src/renderer/app-session-preload.test.tsx
|
|
30
|
+
pnpm typecheck
|
|
31
|
+
pnpm test
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Status
|
|
35
|
+
|
|
36
|
+
Done.
|
|
@@ -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, "\\$&");
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# QQ Bot
|
|
2
|
+
|
|
3
|
+
Use this skill when replying through the QQ Bot IM channel.
|
|
4
|
+
|
|
5
|
+
- Treat QQ messages as short conversational turns.
|
|
6
|
+
- In groups, assume the user intentionally mentioned the bot when the channel context says `mentioned_bot: true`.
|
|
7
|
+
- Reply with `SendChannelMessage` to the current conversation instead of exposing raw QQ ids.
|
|
8
|
+
- If work will take more than a brief moment, send a short acknowledgement first, then follow with progress or the final result.
|
|
9
|
+
- Keep replies concise and avoid dumping internal tool logs unless the user asked for technical detail.
|