@chanlerdev/scorel 0.0.1 → 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 +409 -69
- package/dist/index.js +4593 -1751
- package/dist/index.js.map +4 -4
- package/docs/CHANGELOG.md +115 -0
- package/docs/ROADMAP.md +112 -9
- package/docs/SHIP.md +9 -3
- package/docs/spec/channels.md +107 -100
- package/docs/spec/client.md +11 -5
- package/docs/spec/extensions.md +115 -43
- package/docs/spec/ship/S0062-npm-package-and-release-workflow.md +3 -0
- package/docs/spec/ship/S0063-ai-release-notes.md +129 -0
- package/docs/spec/ship/S0064-gui-product-intent-and-boundary.md +79 -0
- package/docs/spec/ship/S0065-gui-electron-shell-and-embedded-host.md +73 -0
- package/docs/spec/ship/S0066-gui-local-project-workspace.md +79 -0
- package/docs/spec/ship/S0067-gui-relay-device-and-remote-project-selection.md +97 -0
- package/docs/spec/ship/S0068-gui-codex-app-polish-and-e2e.md +102 -0
- package/docs/spec/ship/S0068-gui-e2e-verification.md +50 -0
- package/docs/spec/ship/S0069-gui-codex-ui-refactor.md +371 -0
- package/docs/spec/ship/S0070-gui-streaming-and-tool-blocks.md +202 -0
- package/docs/spec/ship/S0071-gui-visual-fidelity-and-settings-shell.md +360 -0
- package/docs/spec/ship/S0072-gui-glass-sidebar-and-picker-anchoring.md +116 -0
- package/docs/spec/ship/S0073-provider-model-profile-contract.md +241 -0
- package/docs/spec/ship/S0074-gui-model-provider-settings-split.md +113 -0
- package/docs/spec/ship/S0075-provider-catalog-model-cards.md +93 -0
- package/docs/spec/ship/S0076-provider-modal-search-and-direct-key.md +70 -0
- package/docs/spec/ship/S0077-auxiliary-session-title-generation.md +95 -0
- package/docs/spec/ship/S0078-gui-provider-settings-forward-config-and-simplification.md +150 -0
- package/docs/spec/ship/S0079-gui-sidebar-layout-controls.md +49 -0
- package/docs/spec/ship/S0080-session-title-hook-and-gui-markdown-dark-code.md +58 -0
- package/docs/spec/ship/S0081-automatic-memory.md +117 -0
- package/docs/spec/ship/S0082-memory-journal-tool-and-idle-dream.md +107 -0
- package/docs/spec/ship/S0083-extension-manifest-and-im-channel-runtime.md +338 -0
- package/docs/spec/ship/S0084-built-in-telegram-im-extension.md +188 -0
- package/docs/spec/ship/S0085-gui-im-extension-settings.md +47 -0
- package/docs/spec/ship/S0086-auto-compact-and-session-memory.md +124 -0
- 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/adapter.js +13 -0
- package/extensions/builtin/loopback/scorel.extension.json +7 -0
- package/extensions/builtin/loopback/skills/loopback/SKILL.md +9 -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 +43 -0
- package/extensions/builtin/telegram/adapter.js +259 -0
- package/extensions/builtin/telegram/scorel.extension.json +7 -0
- package/extensions/builtin/telegram/skills/telegram/SKILL.md +11 -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 +6 -2
|
@@ -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.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Reply to the current loopback IM conversation with SendChannelMessage.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Loopback IM
|
|
6
|
+
|
|
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.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type TelegramAdapterOptions = {
|
|
2
|
+
token: string;
|
|
3
|
+
apiBaseUrl: string;
|
|
4
|
+
pollIntervalMs?: number;
|
|
5
|
+
allowedChatIds?: string[];
|
|
6
|
+
botUsername?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type TelegramIncomingMessage = {
|
|
10
|
+
externalConversationId: string;
|
|
11
|
+
text: string;
|
|
12
|
+
conversationType?: string;
|
|
13
|
+
senderDisplayName?: string;
|
|
14
|
+
mentionedBot?: boolean;
|
|
15
|
+
target?: TelegramTarget;
|
|
16
|
+
data?: Record<string, unknown>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type TelegramTarget = {
|
|
20
|
+
externalConversationId: string;
|
|
21
|
+
data?: Record<string, unknown>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type TelegramAdapter = {
|
|
25
|
+
start(ctx: {
|
|
26
|
+
onMessage(message: TelegramIncomingMessage): Promise<void>;
|
|
27
|
+
logger: {
|
|
28
|
+
info(message: string, data?: Record<string, unknown>): void;
|
|
29
|
+
error(message: string, data?: Record<string, unknown>): void;
|
|
30
|
+
};
|
|
31
|
+
}): Promise<void>;
|
|
32
|
+
stop(): Promise<void>;
|
|
33
|
+
sendMessage(target: TelegramTarget, message: { text?: string; attachments?: Array<Record<string, unknown>> }): Promise<void>;
|
|
34
|
+
setTyping?(target: TelegramTarget, typing: boolean): Promise<void>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function createAdapter(options?: { config?: Record<string, string | number | boolean> }): TelegramAdapter;
|
|
38
|
+
export function createTelegramAdapter(options: TelegramAdapterOptions): TelegramAdapter;
|
|
39
|
+
export function normalizeTelegramUpdate(update: unknown, options?: { botUsername?: string; allowedChatIds?: string[] }): unknown;
|
|
40
|
+
export function isBotMentioned(message: unknown, botUsername?: string): boolean;
|
|
41
|
+
export function splitTelegramText(text: string): string[];
|
|
42
|
+
export function parseAllowedChatIds(value: unknown): string[];
|
|
43
|
+
export function redactToken(value: string): string;
|