@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,259 @@
|
|
|
1
|
+
const DEFAULT_POLL_INTERVAL_MS = 1000;
|
|
2
|
+
const TELEGRAM_MESSAGE_LIMIT = 4096;
|
|
3
|
+
|
|
4
|
+
export const createAdapter = ({ config = {} } = {}) => {
|
|
5
|
+
const directToken = optionalStringConfig(config.apiKey ?? config.botToken, "Telegram direct API key");
|
|
6
|
+
const tokenEnv = stringConfig(config.botTokenEnv, "SCOREL_TELEGRAM_BOT_TOKEN");
|
|
7
|
+
const token = directToken ?? process.env[tokenEnv];
|
|
8
|
+
if (!token) {
|
|
9
|
+
throw new Error(`${tokenEnv} is not set`);
|
|
10
|
+
}
|
|
11
|
+
return createTelegramAdapter({
|
|
12
|
+
token,
|
|
13
|
+
apiBaseUrl: stringConfig(config.apiBaseUrl, "https://api.telegram.org"),
|
|
14
|
+
pollIntervalMs: numberConfig(config.pollIntervalMs, DEFAULT_POLL_INTERVAL_MS),
|
|
15
|
+
allowedChatIds: parseAllowedChatIds(config.allowedChatIds),
|
|
16
|
+
botUsername: typeof config.botUsername === "string" ? config.botUsername : undefined,
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const createTelegramAdapter = (options) => {
|
|
21
|
+
const state = {
|
|
22
|
+
running: false,
|
|
23
|
+
offset: undefined,
|
|
24
|
+
timer: undefined,
|
|
25
|
+
ctx: undefined,
|
|
26
|
+
botUsername: options.botUsername,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const request = async (method, body) => telegramRequest(options.apiBaseUrl, options.token, method, body);
|
|
30
|
+
|
|
31
|
+
const pollOnce = async () => {
|
|
32
|
+
if (!state.running || !state.ctx) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
if (!state.botUsername) {
|
|
37
|
+
const me = await request("getMe", {});
|
|
38
|
+
state.botUsername = typeof me?.username === "string" ? me.username : undefined;
|
|
39
|
+
}
|
|
40
|
+
const updates = await request("getUpdates", {
|
|
41
|
+
timeout: 0,
|
|
42
|
+
...(state.offset !== undefined ? { offset: state.offset } : {}),
|
|
43
|
+
});
|
|
44
|
+
if (!Array.isArray(updates)) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
for (const update of updates) {
|
|
48
|
+
if (typeof update?.update_id === "number") {
|
|
49
|
+
state.offset = update.update_id + 1;
|
|
50
|
+
}
|
|
51
|
+
const incoming = normalizeTelegramUpdate(update, {
|
|
52
|
+
botUsername: state.botUsername,
|
|
53
|
+
allowedChatIds: options.allowedChatIds ?? [],
|
|
54
|
+
});
|
|
55
|
+
if (!incoming) {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
await state.ctx.onMessage(incoming);
|
|
59
|
+
}
|
|
60
|
+
} catch (cause) {
|
|
61
|
+
state.ctx.logger.error("telegram_poll_failed", { message: safeErrorMessage(cause) });
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const scheduleNextPoll = () => {
|
|
66
|
+
if (!state.running) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
state.timer = setTimeout(() => {
|
|
70
|
+
void pollOnce().finally(scheduleNextPoll);
|
|
71
|
+
}, options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS);
|
|
72
|
+
state.timer.unref?.();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
async start(ctx) {
|
|
77
|
+
state.ctx = ctx;
|
|
78
|
+
state.running = true;
|
|
79
|
+
await pollOnce();
|
|
80
|
+
scheduleNextPoll();
|
|
81
|
+
},
|
|
82
|
+
async stop() {
|
|
83
|
+
state.running = false;
|
|
84
|
+
if (state.timer) {
|
|
85
|
+
clearTimeout(state.timer);
|
|
86
|
+
state.timer = undefined;
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
async sendMessage(target, message) {
|
|
90
|
+
rejectUnsupportedAttachments("Telegram", message);
|
|
91
|
+
const chatId = target?.data?.chatId;
|
|
92
|
+
if (chatId === undefined || chatId === null) {
|
|
93
|
+
throw new Error("telegram target is missing chatId");
|
|
94
|
+
}
|
|
95
|
+
for (const text of splitTelegramText(message.text)) {
|
|
96
|
+
await request("sendMessage", { chat_id: chatId, text });
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
async setTyping(target, typing) {
|
|
100
|
+
if (!typing) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const chatId = target?.data?.chatId;
|
|
104
|
+
if (chatId === undefined || chatId === null) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
await request("sendChatAction", { chat_id: chatId, action: "typing" });
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const normalizeTelegramUpdate = (update, options = {}) => {
|
|
113
|
+
const message = update?.message;
|
|
114
|
+
if (!message || typeof message !== "object") {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
if (typeof message.text !== "string" || message.text.trim().length === 0) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
const chat = message.chat;
|
|
121
|
+
if (!chat || typeof chat.id !== "number") {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
const allowedChatIds = options.allowedChatIds ?? [];
|
|
125
|
+
if (allowedChatIds.length > 0 && !allowedChatIds.includes(String(chat.id))) {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
const conversationType = telegramConversationType(chat.type);
|
|
129
|
+
const mentionedBot = isBotMentioned(message, options.botUsername);
|
|
130
|
+
if ((conversationType === "group" || conversationType === "supergroup") && !mentionedBot) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
externalConversationId: `telegram:${conversationType}:${chat.id}`,
|
|
135
|
+
text: stripBotMention(message.text, options.botUsername),
|
|
136
|
+
conversationType,
|
|
137
|
+
senderDisplayName: senderDisplayName(message.from),
|
|
138
|
+
mentionedBot,
|
|
139
|
+
target: {
|
|
140
|
+
externalConversationId: `telegram:${conversationType}:${chat.id}`,
|
|
141
|
+
data: { chatId: chat.id },
|
|
142
|
+
},
|
|
143
|
+
data: {
|
|
144
|
+
messageId: message.message_id,
|
|
145
|
+
chatType: chat.type,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export const isBotMentioned = (message, botUsername) => {
|
|
151
|
+
const text = typeof message?.text === "string" ? message.text : "";
|
|
152
|
+
if (botUsername && new RegExp(`(^|\\s)@${escapeRegExp(botUsername)}\\b`, "i").test(text)) {
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
const replyUsername = message?.reply_to_message?.from?.username;
|
|
156
|
+
return Boolean(botUsername && typeof replyUsername === "string" && replyUsername.toLowerCase() === botUsername.toLowerCase());
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
export const splitTelegramText = (text) => {
|
|
160
|
+
const normalized = String(text).trim();
|
|
161
|
+
if (normalized.length <= TELEGRAM_MESSAGE_LIMIT) {
|
|
162
|
+
return [normalized];
|
|
163
|
+
}
|
|
164
|
+
const chunks = [];
|
|
165
|
+
for (let index = 0; index < normalized.length; index += TELEGRAM_MESSAGE_LIMIT) {
|
|
166
|
+
chunks.push(normalized.slice(index, index + TELEGRAM_MESSAGE_LIMIT));
|
|
167
|
+
}
|
|
168
|
+
return chunks;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const parseAllowedChatIds = (value) => {
|
|
172
|
+
if (value === undefined || value === "") {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
if (typeof value === "number") {
|
|
176
|
+
return [String(value)];
|
|
177
|
+
}
|
|
178
|
+
if (typeof value !== "string") {
|
|
179
|
+
throw new Error("allowedChatIds must be a comma-separated string");
|
|
180
|
+
}
|
|
181
|
+
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const telegramRequest = async (apiBaseUrl, token, method, body) => {
|
|
185
|
+
const response = await fetch(`${apiBaseUrl.replace(/\/+$/, "")}/bot${token}/${method}`, {
|
|
186
|
+
method: "POST",
|
|
187
|
+
headers: { "content-type": "application/json" },
|
|
188
|
+
body: JSON.stringify(body),
|
|
189
|
+
});
|
|
190
|
+
const payload = await response.json().catch(() => undefined);
|
|
191
|
+
if (!response.ok || payload?.ok !== true) {
|
|
192
|
+
throw new Error(`telegram ${method} failed: ${payload?.description ?? response.status}`);
|
|
193
|
+
}
|
|
194
|
+
return payload.result;
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const telegramConversationType = (type) => {
|
|
198
|
+
if (type === "group" || type === "supergroup" || type === "private") {
|
|
199
|
+
return type;
|
|
200
|
+
}
|
|
201
|
+
return "group";
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const senderDisplayName = (from) => {
|
|
205
|
+
if (!from || typeof from !== "object") {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
return [from.first_name, from.last_name].filter((part) => typeof part === "string" && part.trim()).join(" ") ||
|
|
209
|
+
(typeof from.username === "string" ? from.username : undefined);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const stripBotMention = (text, botUsername) => {
|
|
213
|
+
if (!botUsername) {
|
|
214
|
+
return text.trim();
|
|
215
|
+
}
|
|
216
|
+
return text.replace(new RegExp(`(^|\\s)@${escapeRegExp(botUsername)}\\b`, "i"), " ").trim();
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const stringConfig = (value, fallback) => {
|
|
220
|
+
if (value === undefined || value === "") {
|
|
221
|
+
return fallback;
|
|
222
|
+
}
|
|
223
|
+
if (typeof value !== "string") {
|
|
224
|
+
throw new Error("Telegram config value must be a string");
|
|
225
|
+
}
|
|
226
|
+
return value;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const optionalStringConfig = (value, name) => {
|
|
230
|
+
if (value === undefined || value === "") {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
if (typeof value !== "string") {
|
|
234
|
+
throw new Error(`${name} must be a string`);
|
|
235
|
+
}
|
|
236
|
+
return value;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const numberConfig = (value, fallback) => {
|
|
240
|
+
if (value === undefined) {
|
|
241
|
+
return fallback;
|
|
242
|
+
}
|
|
243
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
244
|
+
throw new Error("Telegram numeric config value must be non-negative");
|
|
245
|
+
}
|
|
246
|
+
return value;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const safeErrorMessage = (cause) => cause instanceof Error ? redactToken(cause.message) : redactToken(String(cause));
|
|
250
|
+
|
|
251
|
+
export const redactToken = (value) => value.replace(/bot[0-9]+:[A-Za-z0-9_-]+/g, "bot[REDACTED]");
|
|
252
|
+
|
|
253
|
+
const rejectUnsupportedAttachments = (platform, message) => {
|
|
254
|
+
if (Array.isArray(message.attachments) && message.attachments.length > 0) {
|
|
255
|
+
throw new Error(`${platform} attachment sending is not supported yet`);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Reply to the current Telegram conversation through SendChannelMessage.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Telegram
|
|
6
|
+
|
|
7
|
+
When a message comes from Telegram, use `SendChannelMessage` to reply to the current chat when a response is needed.
|
|
8
|
+
|
|
9
|
+
In groups, assume the user mentioned or replied to the bot before the message reached Scorel. Keep replies concise and avoid exposing raw chat ids, user ids, bot tokens, or internal routing details.
|
|
10
|
+
|
|
11
|
+
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,24 @@
|
|
|
1
|
+
export type WeChatAdapterOptions = {
|
|
2
|
+
webhookUrl?: string;
|
|
3
|
+
callbackHost?: string;
|
|
4
|
+
callbackPort?: number;
|
|
5
|
+
callbackPath?: string;
|
|
6
|
+
callbackToken?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type WeChatTarget = {
|
|
10
|
+
externalConversationId: string;
|
|
11
|
+
data?: Record<string, unknown>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type WeChatAdapter = {
|
|
15
|
+
start(ctx: unknown): Promise<void>;
|
|
16
|
+
stop(): Promise<void>;
|
|
17
|
+
callbackUrl?(): string | undefined;
|
|
18
|
+
sendMessage(target: WeChatTarget, message: { text?: string; attachments?: Array<Record<string, unknown>> }): Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function createAdapter(options?: { config?: Record<string, string | number | boolean> }): WeChatAdapter;
|
|
22
|
+
export function createWeChatAdapter(options: WeChatAdapterOptions): WeChatAdapter;
|
|
23
|
+
export function normalizeWeChatEvent(event: unknown): unknown;
|
|
24
|
+
export function redactWeChatSecret(value: string): string;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
|
|
4
|
+
export const createAdapter = ({ config = {} } = {}) => {
|
|
5
|
+
return createWeChatAdapter({
|
|
6
|
+
webhookUrl: optionalStringConfig(config.webhookUrl, "WeChat webhook URL"),
|
|
7
|
+
callbackHost: stringConfig(config.callbackHost, "127.0.0.1"),
|
|
8
|
+
callbackPort: numberConfig(config.callbackPort, undefined),
|
|
9
|
+
callbackPath: stringConfig(config.callbackPath, "/wechat/callback"),
|
|
10
|
+
callbackToken: optionalStringConfig(config.callbackToken, "WeChat callback token"),
|
|
11
|
+
});
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const createWeChatAdapter = (options) => {
|
|
15
|
+
if (!options.webhookUrl && !options.callbackToken) {
|
|
16
|
+
throw new Error("WeChat webhook URL or callback token is required");
|
|
17
|
+
}
|
|
18
|
+
let server;
|
|
19
|
+
let ctx;
|
|
20
|
+
let callbackPort;
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
async start(startCtx) {
|
|
24
|
+
ctx = startCtx;
|
|
25
|
+
if (!options.callbackToken) {
|
|
26
|
+
ctx?.logger?.info("wechat_callback_not_configured", {});
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
server = createServer((request, response) => {
|
|
30
|
+
void handleCallbackRequest(options, request, response, ctx).catch((cause) => {
|
|
31
|
+
ctx?.logger?.error("wechat_callback_failed", { message: redactWeChatSecret(safeErrorMessage(cause)) });
|
|
32
|
+
if (!response.headersSent) {
|
|
33
|
+
response.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
34
|
+
}
|
|
35
|
+
response.end("error");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
await new Promise((resolve, reject) => {
|
|
39
|
+
server.once("error", reject);
|
|
40
|
+
server.once("listening", () => {
|
|
41
|
+
server.off("error", reject);
|
|
42
|
+
resolve();
|
|
43
|
+
});
|
|
44
|
+
server.listen(options.callbackPort ?? 0, options.callbackHost ?? "127.0.0.1");
|
|
45
|
+
});
|
|
46
|
+
const address = server.address();
|
|
47
|
+
if (!address || typeof address === "string") {
|
|
48
|
+
throw new Error("WeChat callback server did not expose a TCP address");
|
|
49
|
+
}
|
|
50
|
+
callbackPort = address.port;
|
|
51
|
+
ctx?.logger?.info("wechat_callback_started", { url: callbackUrl(options, callbackPort) });
|
|
52
|
+
},
|
|
53
|
+
async stop() {
|
|
54
|
+
const closing = server;
|
|
55
|
+
server = undefined;
|
|
56
|
+
callbackPort = undefined;
|
|
57
|
+
if (closing) {
|
|
58
|
+
await new Promise((resolve, reject) => closing.close((error) => error ? reject(error) : resolve()));
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
callbackUrl() {
|
|
62
|
+
return callbackPort ? callbackUrl(options, callbackPort) : undefined;
|
|
63
|
+
},
|
|
64
|
+
async sendMessage(_target, message) {
|
|
65
|
+
rejectUnsupportedAttachments("WeChat", message);
|
|
66
|
+
if (!options.webhookUrl) {
|
|
67
|
+
throw new Error("WeChat outbound webhook URL is not configured");
|
|
68
|
+
}
|
|
69
|
+
const response = await fetch(options.webhookUrl, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { "content-type": "application/json" },
|
|
72
|
+
body: JSON.stringify({
|
|
73
|
+
msgtype: "text",
|
|
74
|
+
text: { content: String(message.text).trim() },
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
const payload = await response.json().catch(() => undefined);
|
|
78
|
+
if (!response.ok || (payload?.errcode !== undefined && payload.errcode !== 0)) {
|
|
79
|
+
throw new Error(redactWeChatSecret(`wechat send failed: ${payload?.errmsg ?? response.status}`));
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const normalizeWeChatEvent = (event) => {
|
|
86
|
+
const text = typeof event?.Content === "string" ? event.Content.trim() : "";
|
|
87
|
+
if (!text || event?.MsgType !== "text") {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
const openId = optionalEventString(event.FromUserName);
|
|
91
|
+
if (!openId) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
const messageId = optionalEventString(event.MsgId);
|
|
95
|
+
const externalConversationId = `wechat:official:${openId}`;
|
|
96
|
+
return {
|
|
97
|
+
externalConversationId,
|
|
98
|
+
text,
|
|
99
|
+
conversationType: "official",
|
|
100
|
+
mentionedBot: true,
|
|
101
|
+
target: {
|
|
102
|
+
externalConversationId,
|
|
103
|
+
data: { kind: "official", id: openId, ...(messageId ? { messageId } : {}) },
|
|
104
|
+
},
|
|
105
|
+
data: {
|
|
106
|
+
...(messageId ? { messageId } : {}),
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const redactWeChatSecret = (value) =>
|
|
112
|
+
String(value)
|
|
113
|
+
.replace(/([?&]key=)[^&\s]+/g, "$1[REDACTED]")
|
|
114
|
+
.replace(/(callbackToken"\s*:\s*")[^"]+/g, "$1[REDACTED]");
|
|
115
|
+
|
|
116
|
+
const handleCallbackRequest = async (options, request, response, ctx) => {
|
|
117
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
118
|
+
if (url.pathname !== (options.callbackPath ?? "/wechat/callback")) {
|
|
119
|
+
response.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
120
|
+
response.end("not found");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const timestamp = url.searchParams.get("timestamp") ?? "";
|
|
124
|
+
const nonce = url.searchParams.get("nonce") ?? "";
|
|
125
|
+
const signature = url.searchParams.get("signature") ?? "";
|
|
126
|
+
if (!verifySignature(options.callbackToken, timestamp, nonce, signature)) {
|
|
127
|
+
response.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
|
|
128
|
+
response.end("invalid signature");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (request.method === "GET") {
|
|
132
|
+
response.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
|
133
|
+
response.end(url.searchParams.get("echostr") ?? "");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (request.method !== "POST") {
|
|
137
|
+
response.writeHead(405, { "content-type": "text/plain; charset=utf-8" });
|
|
138
|
+
response.end("method not allowed");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const event = parseWeChatXml(await readText(request));
|
|
142
|
+
const incoming = normalizeWeChatEvent(event);
|
|
143
|
+
if (incoming) {
|
|
144
|
+
await ctx?.onMessage(incoming);
|
|
145
|
+
}
|
|
146
|
+
response.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
|
147
|
+
response.end("success");
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const optionalEventString = (value) => typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
151
|
+
|
|
152
|
+
const requiredStringConfig = (value, name) => {
|
|
153
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
154
|
+
throw new Error(`${name} is required`);
|
|
155
|
+
}
|
|
156
|
+
return value.trim();
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const optionalStringConfig = (value, name) => {
|
|
160
|
+
if (value === undefined || value === "") {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
if (typeof value !== "string") {
|
|
164
|
+
throw new Error(`${name} must be a string`);
|
|
165
|
+
}
|
|
166
|
+
return value.trim();
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const stringConfig = (value, fallback) => {
|
|
170
|
+
if (value === undefined || value === "") {
|
|
171
|
+
return fallback;
|
|
172
|
+
}
|
|
173
|
+
if (typeof value !== "string") {
|
|
174
|
+
throw new Error("WeChat config value must be a string");
|
|
175
|
+
}
|
|
176
|
+
return value.trim();
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const numberConfig = (value, fallback) => {
|
|
180
|
+
if (value === undefined || value === "") {
|
|
181
|
+
return fallback;
|
|
182
|
+
}
|
|
183
|
+
const parsed = Number(value);
|
|
184
|
+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
|
|
185
|
+
throw new Error("WeChat callback port must be a valid TCP port");
|
|
186
|
+
}
|
|
187
|
+
return parsed;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const callbackUrl = (options, port) =>
|
|
191
|
+
`http://${options.callbackHost ?? "127.0.0.1"}:${port}${options.callbackPath ?? "/wechat/callback"}`;
|
|
192
|
+
|
|
193
|
+
const verifySignature = (token, timestamp, nonce, signature) => {
|
|
194
|
+
if (!token || !timestamp || !nonce || !signature) {
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
const expected = createHash("sha1").update([token, timestamp, nonce].sort().join("")).digest("hex");
|
|
198
|
+
return expected === signature;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const readText = async (request) => {
|
|
202
|
+
const chunks = [];
|
|
203
|
+
for await (const chunk of request) {
|
|
204
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
205
|
+
}
|
|
206
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const parseWeChatXml = (xml) => {
|
|
210
|
+
const result = {};
|
|
211
|
+
for (const key of ["ToUserName", "FromUserName", "CreateTime", "MsgType", "Content", "MsgId"]) {
|
|
212
|
+
const match = new RegExp(`<${key}>(?:<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>|([\\s\\S]*?))<\\/${key}>`).exec(xml);
|
|
213
|
+
if (match) {
|
|
214
|
+
result[key] = (match[1] ?? match[2] ?? "").trim();
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return result;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const safeErrorMessage = (cause) => cause instanceof Error ? cause.message : String(cause);
|
|
221
|
+
|
|
222
|
+
const rejectUnsupportedAttachments = (platform, message) => {
|
|
223
|
+
if (Array.isArray(message.attachments) && message.attachments.length > 0) {
|
|
224
|
+
throw new Error(`${platform} attachment sending is not supported yet`);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# WeChat
|
|
2
|
+
|
|
3
|
+
Use this skill when replying through the WeChat IM channel.
|
|
4
|
+
|
|
5
|
+
- Treat WeChat replies as human chat, not as a delayed report.
|
|
6
|
+
- Use `SendChannelMessage` for the current conversation; do not expose OpenID, webhook key, or raw platform routing ids.
|
|
7
|
+
- Send a short acknowledgement before long work, then send concise progress or final results.
|
|
8
|
+
- Prefer short paragraphs and concrete next actions.
|
|
9
|
+
- Do not imply personal WeChat account automation; Scorel uses official WeChat or WeCom-style bot surfaces.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chanlerdev/scorel",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Replayable, recoverable, remotely controllable AI Agent workspace.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.1.2",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
12
|
+
"extensions",
|
|
12
13
|
"README.md",
|
|
13
14
|
"docs/SHIP.md",
|
|
14
15
|
"docs/ROADMAP.md",
|
|
@@ -31,11 +32,14 @@
|
|
|
31
32
|
"check": "pnpm typecheck && pnpm test",
|
|
32
33
|
"pack:smoke": "node scripts/pack-smoke.mjs",
|
|
33
34
|
"release": "node scripts/release.mjs",
|
|
35
|
+
"release-notes": "node scripts/release-notes.mjs",
|
|
34
36
|
"typecheck": "pnpm -r typecheck",
|
|
35
37
|
"test": "pnpm -r test",
|
|
36
38
|
"verify:m8-relay": "node --import tsx scripts/verify-m8-relay-e2e.ts",
|
|
39
|
+
"verify:m9-gui": "node --import tsx scripts/verify-m9-gui-e2e.ts",
|
|
37
40
|
"scorel": "node --import tsx apps/cli/src/index.ts",
|
|
38
|
-
"dev": "node --import tsx apps/cli/src/index.ts up"
|
|
41
|
+
"dev": "node --import tsx apps/cli/src/index.ts up",
|
|
42
|
+
"gui": "pnpm --filter @scorel/app-gui dev"
|
|
39
43
|
},
|
|
40
44
|
"devDependencies": {
|
|
41
45
|
"@types/node": "^24.10.1",
|