cc-wechat 0.1.0

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 (48) hide show
  1. package/.claude-plugin/plugin.json +6 -0
  2. package/.mcp.json +8 -0
  3. package/.pace/stop-block-count +1 -0
  4. package/LICENSE +21 -0
  5. package/README.md +83 -0
  6. package/dist/auth.d.ts +18 -0
  7. package/dist/auth.js +351 -0
  8. package/dist/auth.js.map +1 -0
  9. package/dist/cdn.d.ts +39 -0
  10. package/dist/cdn.js +228 -0
  11. package/dist/cdn.js.map +1 -0
  12. package/dist/cli.d.ts +5 -0
  13. package/dist/cli.js +127 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/ilink-api.d.ts +33 -0
  16. package/dist/ilink-api.js +206 -0
  17. package/dist/ilink-api.js.map +1 -0
  18. package/dist/patch.d.ts +7 -0
  19. package/dist/patch.js +165 -0
  20. package/dist/patch.js.map +1 -0
  21. package/dist/server.d.ts +6 -0
  22. package/dist/server.js +406 -0
  23. package/dist/server.js.map +1 -0
  24. package/dist/store.d.ts +24 -0
  25. package/dist/store.js +57 -0
  26. package/dist/store.js.map +1 -0
  27. package/dist/text-utils.d.ts +7 -0
  28. package/dist/text-utils.js +56 -0
  29. package/dist/text-utils.js.map +1 -0
  30. package/dist/types.d.ts +98 -0
  31. package/dist/types.js +13 -0
  32. package/dist/types.js.map +1 -0
  33. package/package.json +24 -0
  34. package/packages/cc-channel-patch/README.md +36 -0
  35. package/packages/cc-channel-patch/index.mjs +228 -0
  36. package/packages/cc-channel-patch/package.json +11 -0
  37. package/skills/configure/SKILL.md +32 -0
  38. package/src/auth.ts +400 -0
  39. package/src/cdn.ts +261 -0
  40. package/src/cli.ts +121 -0
  41. package/src/ilink-api.ts +279 -0
  42. package/src/patch.ts +182 -0
  43. package/src/qrcode-terminal.d.ts +10 -0
  44. package/src/server.ts +445 -0
  45. package/src/store.ts +62 -0
  46. package/src/text-utils.ts +56 -0
  47. package/src/types.ts +94 -0
  48. package/tsconfig.json +17 -0
package/src/server.ts ADDED
@@ -0,0 +1,445 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cc-wechat MCP Server 主入口
4
+ * Claude Code Channel 插件 — 微信消息桥接
5
+ */
6
+
7
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
8
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
+ import { ListToolsRequestSchema, CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js';
10
+ import fs from 'node:fs';
11
+
12
+ import { getActiveAccount, saveAccount, loadSyncBuf, saveSyncBuf } from './store.js';
13
+ import { getUpdates, sendMessage, sendTyping, getConfig } from './ilink-api.js';
14
+ import { loginBrowser } from './auth.js';
15
+ import { uploadMedia, downloadMedia } from './cdn.js';
16
+ import { stripMarkdown, chunkText } from './text-utils.js';
17
+ import type { WeixinMessage, AccountData } from './types.js';
18
+ import { MessageItemType } from './types.js';
19
+
20
+ // ─── 状态变量 ─────────────────────────────────────────
21
+
22
+ let pollingActive = false;
23
+ let pollingAbort: AbortController | null = null;
24
+ const typingTicketCache = new Map<string, string>();
25
+
26
+ // ─── Session 过期处理常量 ─────────────────────────────
27
+
28
+ const SESSION_EXPIRED_ERRCODE = -14;
29
+ const MAX_SESSION_RETRIES = 3;
30
+ const MAX_CONSECUTIVE_FAILURES = 5;
31
+ const INITIAL_RETRY_DELAY_MS = 2_000;
32
+ const MAX_RETRY_DELAY_MS = 30_000;
33
+ const SESSION_PAUSE_MS = 5 * 60_000;
34
+
35
+ // ─── 辅助函数 ─────────────────────────────────────────
36
+
37
+ /** 可中断的 sleep */
38
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
39
+ return new Promise((resolve, reject) => {
40
+ const t = setTimeout(resolve, ms);
41
+ signal?.addEventListener('abort', () => { clearTimeout(t); reject(new Error('aborted')); }, { once: true });
42
+ });
43
+ }
44
+
45
+ /** 从消息提取可读文本(异步,支持媒体下载) */
46
+ async function extractText(msg: WeixinMessage): Promise<string> {
47
+ const parts: string[] = [];
48
+ for (const item of msg.item_list ?? []) {
49
+ const t = item.type ?? 0;
50
+
51
+ if (t === MessageItemType.TEXT) {
52
+ if (item.text_item?.text) parts.push(item.text_item.text);
53
+ } else if (t === MessageItemType.IMAGE) {
54
+ let desc = '[图片]';
55
+ if (item.image_item?.media?.encrypt_query_param && item.image_item?.media?.aes_key) {
56
+ try {
57
+ const filePath = await downloadMedia({
58
+ encryptQueryParam: item.image_item.media.encrypt_query_param,
59
+ aesKeyBase64: item.image_item.media.aes_key,
60
+ });
61
+ desc += `\n[附件: ${filePath}]`;
62
+ } catch {
63
+ // 下载失败不阻塞消息处理
64
+ }
65
+ }
66
+ parts.push(desc);
67
+ } else if (t === MessageItemType.VOICE) {
68
+ parts.push(`[语音] ${item.voice_item?.text ?? ''}`);
69
+ } else if (t === MessageItemType.FILE) {
70
+ let desc = `[文件: ${item.file_item?.file_name ?? 'unknown'}]`;
71
+ if (item.file_item?.media?.encrypt_query_param && item.file_item?.media?.aes_key) {
72
+ try {
73
+ const filePath = await downloadMedia({
74
+ encryptQueryParam: item.file_item.media.encrypt_query_param,
75
+ aesKeyBase64: item.file_item.media.aes_key,
76
+ fileName: item.file_item.file_name,
77
+ });
78
+ desc += `\n[附件: ${filePath}]`;
79
+ } catch {
80
+ // 下载失败不阻塞消息处理
81
+ }
82
+ }
83
+ parts.push(desc);
84
+ } else if (t === MessageItemType.VIDEO) {
85
+ parts.push('[视频]');
86
+ }
87
+ }
88
+ return parts.join('\n') || '[空消息]';
89
+ }
90
+
91
+ // ─── MCP Server 创建 ─────────────────────────────────
92
+
93
+ const server = new Server(
94
+ { name: 'wechat-channel', version: '0.1.0' },
95
+ {
96
+ capabilities: {
97
+ experimental: { 'claude/channel': {} },
98
+ tools: {},
99
+ },
100
+ instructions: `Messages arrive as <channel source="wechat-channel" user_id="..." context_token="...">.
101
+ Reply using the reply tool. Pass user_id and context_token from the channel tag.
102
+ For media: set media to an absolute local file path to send image/video/file.
103
+ IMPORTANT: Always use the reply tool to respond to WeChat messages. Do not just output text.`,
104
+ },
105
+ );
106
+
107
+ // ─── Tools — ListToolsRequestSchema ──────────────────
108
+
109
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
110
+ tools: [
111
+ {
112
+ name: 'login',
113
+ description: '扫码登录微信。首次使用或 session 过期后运行。',
114
+ inputSchema: {
115
+ type: 'object' as const,
116
+ properties: {},
117
+ },
118
+ },
119
+ {
120
+ name: 'reply',
121
+ description: '回复微信消息',
122
+ inputSchema: {
123
+ type: 'object' as const,
124
+ properties: {
125
+ user_id: { type: 'string', description: '微信用户 ID(来自消息 meta 的 user_id)' },
126
+ context_token: { type: 'string', description: '会话上下文令牌(来自消息 meta 的 context_token)' },
127
+ content: { type: 'string', description: '回复文本内容' },
128
+ media: { type: 'string', description: '可选:本地文件绝对路径,发送图片/视频/文件' },
129
+ },
130
+ required: ['user_id', 'context_token', 'content'],
131
+ },
132
+ },
133
+ ],
134
+ }));
135
+
136
+ // ─── Tools — CallToolRequestSchema ───────────────────
137
+
138
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
139
+ const { name, arguments: args } = request.params;
140
+
141
+ // ── login tool ──
142
+ if (name === 'login') {
143
+ try {
144
+ const result = await loginBrowser();
145
+ saveAccount({
146
+ token: result.token,
147
+ baseUrl: result.baseUrl ?? '',
148
+ botId: result.accountId,
149
+ savedAt: new Date().toISOString(),
150
+ });
151
+ startPolling();
152
+ return {
153
+ content: [{ type: 'text' as const, text: `登录成功!账号 ID: ${result.accountId}` }],
154
+ };
155
+ } catch (err) {
156
+ return {
157
+ content: [{ type: 'text' as const, text: `登录失败: ${String(err)}` }],
158
+ isError: true,
159
+ };
160
+ }
161
+ }
162
+
163
+ // ── reply tool ──
164
+ if (name === 'reply') {
165
+ const userId = args?.user_id as string | undefined;
166
+ const contextToken = args?.context_token as string | undefined;
167
+ const content = args?.content as string | undefined;
168
+ const media = args?.media as string | undefined;
169
+
170
+ // 验证必填参数
171
+ if (!userId || !contextToken || !content) {
172
+ return {
173
+ content: [{ type: 'text' as const, text: '缺少必填参数: user_id, context_token, content' }],
174
+ isError: true,
175
+ };
176
+ }
177
+
178
+ // 验证账号存在
179
+ const account = getActiveAccount();
180
+ if (!account) {
181
+ return {
182
+ content: [{ type: 'text' as const, text: '未登录,请先使用 login 工具扫码登录' }],
183
+ isError: true,
184
+ };
185
+ }
186
+
187
+ // 检查媒体文件是否存在
188
+ if (media && !fs.existsSync(media)) {
189
+ return {
190
+ content: [{ type: 'text' as const, text: `媒体文件不存在: ${media}` }],
191
+ isError: true,
192
+ };
193
+ }
194
+
195
+ try {
196
+ // 发送 typing 状态(best-effort)
197
+ try {
198
+ let ticket = typingTicketCache.get(userId);
199
+ if (!ticket) {
200
+ const config = await getConfig(account.token, userId, contextToken, account.baseUrl);
201
+ ticket = config.typing_ticket ?? '';
202
+ if (ticket) typingTicketCache.set(userId, ticket);
203
+ }
204
+ if (ticket) {
205
+ await sendTyping(account.token, userId, ticket, 1, account.baseUrl);
206
+ }
207
+ } catch {
208
+ // typing 失败不阻塞
209
+ }
210
+
211
+ // 清理 Markdown 并分段发送
212
+ const plainText = stripMarkdown(content);
213
+ const chunks = chunkText(plainText, 3900);
214
+ for (const chunk of chunks) {
215
+ await sendMessage(account.token, userId, chunk, contextToken, account.baseUrl);
216
+ }
217
+
218
+ // 发送媒体文件(如有)
219
+ let mediaError = '';
220
+ if (media) {
221
+ try {
222
+ await uploadMedia({
223
+ token: account.token,
224
+ toUser: userId,
225
+ contextToken,
226
+ filePath: media,
227
+ baseUrl: account.baseUrl,
228
+ });
229
+ } catch (err) {
230
+ mediaError = `(媒体发送失败: ${String(err)})`;
231
+ }
232
+ }
233
+
234
+ // 停止 typing 状态(best-effort)
235
+ try {
236
+ const ticket = typingTicketCache.get(userId);
237
+ if (ticket) {
238
+ await sendTyping(account.token, userId, ticket, 2, account.baseUrl);
239
+ }
240
+ } catch {
241
+ // typing 失败不阻塞
242
+ }
243
+
244
+ return {
245
+ content: [{
246
+ type: 'text' as const,
247
+ text: `已发送 ${chunks.length} 段文本${media ? ' + 1 个媒体文件' : ''}${mediaError}`,
248
+ }],
249
+ };
250
+ } catch (err) {
251
+ return {
252
+ content: [{ type: 'text' as const, text: `发送失败: ${String(err)}` }],
253
+ isError: true,
254
+ };
255
+ }
256
+ }
257
+
258
+ return {
259
+ content: [{ type: 'text' as const, text: `未知工具: ${name}` }],
260
+ isError: true,
261
+ };
262
+ });
263
+
264
+ // ─── 轮询循环 ─────────────────────────────────────────
265
+
266
+ /** 消息长轮询循环 */
267
+ async function pollLoop(account: AccountData): Promise<void> {
268
+ let buf = loadSyncBuf();
269
+ let consecutiveFailures = 0;
270
+ let sessionRetries = 0;
271
+ let retryDelay = INITIAL_RETRY_DELAY_MS;
272
+ let nextTimeoutMs: number | undefined;
273
+
274
+ while (pollingActive && !pollingAbort?.signal.aborted) {
275
+ try {
276
+ const resp = await getUpdates(account.token, buf, account.baseUrl, nextTimeoutMs);
277
+
278
+ // 更新长轮询超时
279
+ if (resp.longpolling_timeout_ms) {
280
+ nextTimeoutMs = resp.longpolling_timeout_ms;
281
+ }
282
+
283
+ // 检查 API 错误
284
+ if ((resp.ret !== undefined && resp.ret !== 0) || (resp.errcode !== undefined && resp.errcode !== 0)) {
285
+ const errcode = resp.errcode ?? resp.ret ?? 0;
286
+
287
+ if (errcode === SESSION_EXPIRED_ERRCODE) {
288
+ sessionRetries++;
289
+ process.stderr.write(`[wechat-channel] Session 过期 (${sessionRetries}/${MAX_SESSION_RETRIES})\n`);
290
+
291
+ if (sessionRetries >= MAX_SESSION_RETRIES) {
292
+ pollingActive = false;
293
+ // 通知 Claude session 过期
294
+ server.notification({
295
+ method: 'notifications/message',
296
+ params: {
297
+ level: 'error',
298
+ data: 'WeChat session expired, please use login tool to reconnect',
299
+ },
300
+ });
301
+ return;
302
+ }
303
+
304
+ await sleep(SESSION_PAUSE_MS, pollingAbort?.signal);
305
+ continue;
306
+ }
307
+
308
+ // 其他错误
309
+ consecutiveFailures++;
310
+ process.stderr.write(
311
+ `[wechat-channel] API 错误 errcode=${errcode} errmsg=${resp.errmsg ?? ''} ` +
312
+ `(${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})\n`,
313
+ );
314
+
315
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
316
+ process.stderr.write(`[wechat-channel] 连续失败过多,暂停 ${SESSION_PAUSE_MS / 1000}s\n`);
317
+ await sleep(SESSION_PAUSE_MS, pollingAbort?.signal);
318
+ consecutiveFailures = 0;
319
+ } else {
320
+ await sleep(retryDelay, pollingAbort?.signal);
321
+ retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY_MS);
322
+ }
323
+ continue;
324
+ }
325
+
326
+ // 成功 → 重置计数器
327
+ consecutiveFailures = 0;
328
+ retryDelay = INITIAL_RETRY_DELAY_MS;
329
+
330
+ // 保存 sync buf
331
+ if (resp.get_updates_buf) {
332
+ buf = resp.get_updates_buf;
333
+ saveSyncBuf(buf);
334
+ }
335
+
336
+ // 处理消息(仅用户消息 message_type === 1)
337
+ for (const msg of resp.msgs ?? []) {
338
+ if (msg.message_type !== 1) continue;
339
+
340
+ const fromUser = msg.from_user_id ?? '';
341
+ const contextToken = msg.context_token ?? '';
342
+
343
+ // 提取文本
344
+ const text = await extractText(msg);
345
+
346
+ // 缓存 typing ticket(best-effort)
347
+ try {
348
+ const config = await getConfig(account.token, fromUser, contextToken, account.baseUrl);
349
+ if (config.typing_ticket) {
350
+ typingTicketCache.set(fromUser, config.typing_ticket);
351
+ }
352
+ } catch {
353
+ // 忽略
354
+ }
355
+
356
+ // 发送 typing 状态(best-effort)
357
+ try {
358
+ const ticket = typingTicketCache.get(fromUser);
359
+ if (ticket) {
360
+ await sendTyping(account.token, fromUser, ticket, 1, account.baseUrl);
361
+ }
362
+ } catch {
363
+ // 忽略
364
+ }
365
+
366
+ // 通知 Claude 有新消息
367
+ server.notification({
368
+ method: 'notifications/claude/channel',
369
+ params: {
370
+ content: text,
371
+ meta: {
372
+ source: 'wechat',
373
+ user_id: fromUser,
374
+ context_token: contextToken,
375
+ message_id: String(msg.message_id ?? ''),
376
+ session_id: msg.session_id ?? '',
377
+ },
378
+ },
379
+ });
380
+ }
381
+ } catch (err) {
382
+ if (pollingAbort?.signal.aborted) return;
383
+
384
+ // 网络错误 → 指数退避
385
+ consecutiveFailures++;
386
+ process.stderr.write(
387
+ `[wechat-channel] 网络错误: ${String(err)} ` +
388
+ `(${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})\n`,
389
+ );
390
+
391
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
392
+ process.stderr.write(`[wechat-channel] 连续失败过多,暂停 ${SESSION_PAUSE_MS / 1000}s\n`);
393
+ await sleep(SESSION_PAUSE_MS, pollingAbort?.signal);
394
+ consecutiveFailures = 0;
395
+ } else {
396
+ await sleep(retryDelay, pollingAbort?.signal);
397
+ retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY_MS);
398
+ }
399
+ }
400
+ }
401
+ }
402
+
403
+ // ─── 轮询控制 ─────────────────────────────────────────
404
+
405
+ /** 启动消息轮询 */
406
+ function startPolling(): void {
407
+ const account = getActiveAccount();
408
+ if (!account || pollingActive) return;
409
+ pollingActive = true;
410
+ pollingAbort = new AbortController();
411
+ pollLoop(account).catch((err) => {
412
+ if (!pollingAbort?.signal.aborted) {
413
+ process.stderr.write(`[wechat-channel] Poll loop crashed: ${String(err)}\n`);
414
+ }
415
+ pollingActive = false;
416
+ });
417
+ }
418
+
419
+ /** 停止消息轮询 */
420
+ function stopPolling(): void {
421
+ pollingActive = false;
422
+ pollingAbort?.abort();
423
+ pollingAbort = null;
424
+ }
425
+
426
+ // ─── 主入口 ───────────────────────────────────────────
427
+
428
+ async function main(): Promise<void> {
429
+ const transport = new StdioServerTransport();
430
+ await server.connect(transport);
431
+ process.stderr.write('[wechat-channel] MCP server started\n');
432
+
433
+ const account = getActiveAccount();
434
+ if (account) {
435
+ process.stderr.write(`[wechat-channel] Found saved account: ${account.botId}\n`);
436
+ startPolling();
437
+ } else {
438
+ process.stderr.write('[wechat-channel] No saved account. Use the login tool to connect.\n');
439
+ }
440
+ }
441
+
442
+ main().catch((err) => {
443
+ process.stderr.write(`[wechat-channel] Fatal: ${String(err)}\n`);
444
+ process.exit(1);
445
+ });
package/src/store.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * cc-wechat 凭证持久化 — account.json 原子写入 + sync buf
3
+ */
4
+
5
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import type { AccountData } from './types.js';
9
+
10
+ const ACCOUNT_FILE = 'account.json';
11
+ const SYNC_BUF_FILE = 'sync-buf.txt';
12
+
13
+ /**
14
+ * 获取状态目录路径,不存在则自动创建
15
+ */
16
+ export function getStateDir(): string {
17
+ const dir = join(homedir(), '.claude', 'channels', 'wechat');
18
+ mkdirSync(dir, { recursive: true });
19
+ return dir;
20
+ }
21
+
22
+ /**
23
+ * 原子写入账号数据(先写 tmp 再 rename)
24
+ */
25
+ export function saveAccount(data: AccountData): void {
26
+ const dir = getStateDir();
27
+ const tmpPath = join(dir, `${ACCOUNT_FILE}.tmp`);
28
+ const finalPath = join(dir, ACCOUNT_FILE);
29
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf-8');
30
+ renameSync(tmpPath, finalPath);
31
+ }
32
+
33
+ /**
34
+ * 读取当前活跃账号,文件不存在返回 null
35
+ */
36
+ export function getActiveAccount(): AccountData | null {
37
+ try {
38
+ const filePath = join(getStateDir(), ACCOUNT_FILE);
39
+ const raw = readFileSync(filePath, 'utf-8');
40
+ return JSON.parse(raw) as AccountData;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * 读取 sync buf,不存在返回空字符串
48
+ */
49
+ export function loadSyncBuf(): string {
50
+ try {
51
+ return readFileSync(join(getStateDir(), SYNC_BUF_FILE), 'utf-8');
52
+ } catch {
53
+ return '';
54
+ }
55
+ }
56
+
57
+ /**
58
+ * 写入 sync buf
59
+ */
60
+ export function saveSyncBuf(buf: string): void {
61
+ writeFileSync(join(getStateDir(), SYNC_BUF_FILE), buf, 'utf-8');
62
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * 文本处理工具 — Markdown 清理 + 分段
3
+ */
4
+
5
+ /** 去除 Markdown 格式,转为微信纯文本 */
6
+ export function stripMarkdown(text: string): string {
7
+ let result = text;
8
+ // 代码围栏:去除 ``` 保留内容
9
+ result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, code: string) => code.trim());
10
+ // 图片链接:移除
11
+ result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, '');
12
+ // 链接:保留显示文字
13
+ result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1');
14
+ // 粗体
15
+ result = result.replace(/\*\*(.+?)\*\*/g, '$1');
16
+ result = result.replace(/__(.+?)__/g, '$1');
17
+ // 斜体
18
+ result = result.replace(/\*(.+?)\*/g, '$1');
19
+ result = result.replace(/_(.+?)_/g, '$1');
20
+ // 标题
21
+ result = result.replace(/^#{1,6}\s+/gm, '');
22
+ // 水平线
23
+ result = result.replace(/^[-*_]{3,}$/gm, '');
24
+ // 引用
25
+ result = result.replace(/^>\s?/gm, '');
26
+ return result.trim();
27
+ }
28
+
29
+ /** 将长文本分段(微信限制约 4000 字符) */
30
+ export function chunkText(text: string, maxLen = 3900): string[] {
31
+ if (text.length <= maxLen) return [text];
32
+ const chunks: string[] = [];
33
+ let remaining = text;
34
+ while (remaining.length > 0) {
35
+ if (remaining.length <= maxLen) {
36
+ chunks.push(remaining);
37
+ break;
38
+ }
39
+ // 优先在双换行处分割
40
+ let breakAt = remaining.lastIndexOf('\n\n', maxLen);
41
+ if (breakAt < maxLen * 0.3) {
42
+ // 其次单换行
43
+ breakAt = remaining.lastIndexOf('\n', maxLen);
44
+ }
45
+ if (breakAt < maxLen * 0.3) {
46
+ // 其次空格(避免截断 URL)
47
+ breakAt = remaining.lastIndexOf(' ', maxLen);
48
+ }
49
+ if (breakAt < maxLen * 0.3) {
50
+ breakAt = maxLen;
51
+ }
52
+ chunks.push(remaining.slice(0, breakAt));
53
+ remaining = remaining.slice(breakAt).trimStart();
54
+ }
55
+ return chunks;
56
+ }
package/src/types.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * cc-wechat 类型定义 — iLink Bot API 请求/响应/消息结构
3
+ */
4
+
5
+ // 消息内容类型枚举
6
+ export const MessageItemType = {
7
+ NONE: 0,
8
+ TEXT: 1,
9
+ IMAGE: 2,
10
+ VOICE: 3,
11
+ FILE: 4,
12
+ VIDEO: 5,
13
+ } as const;
14
+
15
+ // CDN 媒体引用
16
+ export interface CDNMedia {
17
+ encrypt_query_param?: string;
18
+ aes_key?: string;
19
+ encrypt_type?: number;
20
+ }
21
+
22
+ // 消息内容项
23
+ export interface MessageItem {
24
+ type?: number;
25
+ text_item?: { text?: string };
26
+ image_item?: { media?: CDNMedia; url?: string; mid_size?: number };
27
+ voice_item?: { media?: CDNMedia; text?: string; playtime?: number };
28
+ file_item?: { media?: CDNMedia; file_name?: string; len?: string; md5?: string };
29
+ video_item?: { media?: CDNMedia; video_size?: number };
30
+ ref_msg?: { title?: string; message_item?: MessageItem };
31
+ msg_id?: string;
32
+ }
33
+
34
+ // 微信消息
35
+ export interface WeixinMessage {
36
+ seq?: number;
37
+ message_id?: number;
38
+ from_user_id?: string;
39
+ to_user_id?: string;
40
+ client_id?: string;
41
+ create_time_ms?: number;
42
+ session_id?: string;
43
+ message_type?: number; // 1=USER, 2=BOT
44
+ message_state?: number; // 0=NEW, 1=GENERATING, 2=FINISH
45
+ item_list?: MessageItem[];
46
+ context_token?: string;
47
+ }
48
+
49
+ // API 响应
50
+ export interface GetUpdatesResp {
51
+ ret?: number;
52
+ errcode?: number;
53
+ errmsg?: string;
54
+ msgs?: WeixinMessage[];
55
+ get_updates_buf?: string;
56
+ longpolling_timeout_ms?: number;
57
+ }
58
+
59
+ export interface QRCodeResponse {
60
+ qrcode: string;
61
+ qrcode_img_content: string;
62
+ }
63
+
64
+ export interface QRStatusResponse {
65
+ status: 'wait' | 'scaned' | 'confirmed' | 'expired';
66
+ bot_token?: string;
67
+ ilink_bot_id?: string;
68
+ baseurl?: string;
69
+ ilink_user_id?: string;
70
+ }
71
+
72
+ export interface GetConfigResp {
73
+ ret?: number;
74
+ typing_ticket?: string;
75
+ }
76
+
77
+ export interface GetUploadUrlResp {
78
+ upload_param?: string;
79
+ thumb_upload_param?: string;
80
+ filekey?: string;
81
+ }
82
+
83
+ // 本地存储
84
+ export interface AccountData {
85
+ token: string;
86
+ baseUrl: string;
87
+ botId: string;
88
+ userId?: string;
89
+ savedAt: string;
90
+ }
91
+
92
+ export interface BaseInfo {
93
+ channel_version?: string;
94
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2024",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "noImplicitAny": true,
10
+ "declaration": true,
11
+ "sourceMap": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true
14
+ },
15
+ "include": ["src"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }