@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.
Files changed (28) hide show
  1. package/README.md +55 -2
  2. package/dist/index.js +395 -31
  3. package/dist/index.js.map +3 -3
  4. package/docs/CHANGELOG.md +53 -0
  5. package/docs/ROADMAP.md +19 -0
  6. package/docs/spec/channels.md +15 -5
  7. package/docs/spec/ship/S0087-gui-ui-polish-sweep.md +153 -0
  8. package/docs/spec/ship/S0088-gui-streaming-thinking-contract.md +35 -0
  9. package/docs/spec/ship/S0089-memory-reliability-and-dream-trigger.md +84 -0
  10. package/docs/spec/ship/S0090-gui-provider-delete-and-dark-code-theme.md +77 -0
  11. package/docs/spec/ship/S0091-built-in-qq-and-wechat-im-extensions.md +125 -0
  12. package/docs/spec/ship/S0092-im-message-media-and-human-cadence.md +83 -0
  13. package/docs/spec/ship/S0093-gui-im-settings-platform-layout.md +66 -0
  14. package/docs/spec/ship/S0094-im-inbound-runtime.md +67 -0
  15. package/docs/spec/ship/S0095-gui-im-session-list-refresh.md +36 -0
  16. package/extensions/builtin/loopback/skills/loopback/SKILL.md +2 -0
  17. package/extensions/builtin/qq/adapter.d.ts +27 -0
  18. package/extensions/builtin/qq/adapter.js +384 -0
  19. package/extensions/builtin/qq/scorel.extension.json +7 -0
  20. package/extensions/builtin/qq/skills/qq/SKILL.md +9 -0
  21. package/extensions/builtin/telegram/adapter.d.ts +1 -1
  22. package/extensions/builtin/telegram/adapter.js +7 -0
  23. package/extensions/builtin/telegram/skills/telegram/SKILL.md +2 -0
  24. package/extensions/builtin/wechat/adapter.d.ts +24 -0
  25. package/extensions/builtin/wechat/adapter.js +226 -0
  26. package/extensions/builtin/wechat/scorel.extension.json +7 -0
  27. package/extensions/builtin/wechat/skills/wechat/SKILL.md +9 -0
  28. package/package.json +1 -1
@@ -30,7 +30,7 @@ export type TelegramAdapter = {
30
30
  };
31
31
  }): Promise<void>;
32
32
  stop(): Promise<void>;
33
- sendMessage(target: TelegramTarget, message: { text: string }): Promise<void>;
33
+ sendMessage(target: TelegramTarget, message: { text?: string; attachments?: Array<Record<string, unknown>> }): Promise<void>;
34
34
  setTyping?(target: TelegramTarget, typing: boolean): Promise<void>;
35
35
  };
36
36
 
@@ -87,6 +87,7 @@ export const createTelegramAdapter = (options) => {
87
87
  }
88
88
  },
89
89
  async sendMessage(target, message) {
90
+ rejectUnsupportedAttachments("Telegram", message);
90
91
  const chatId = target?.data?.chatId;
91
92
  if (chatId === undefined || chatId === null) {
92
93
  throw new Error("telegram target is missing chatId");
@@ -249,4 +250,10 @@ const safeErrorMessage = (cause) => cause instanceof Error ? redactToken(cause.m
249
250
 
250
251
  export const redactToken = (value) => value.replace(/bot[0-9]+:[A-Za-z0-9_-]+/g, "bot[REDACTED]");
251
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
+
252
259
  const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
@@ -7,3 +7,5 @@ description: Reply to the current Telegram conversation through SendChannelMessa
7
7
  When a message comes from Telegram, use `SendChannelMessage` to reply to the current chat when a response is needed.
8
8
 
9
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,7 @@
1
+ {
2
+ "id": "wechat",
3
+ "kind": "im",
4
+ "displayName": "WeChat",
5
+ "adapter": "./adapter.js",
6
+ "skills": ["./skills"]
7
+ }
@@ -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.2",
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",