@aster110/cc2wechat 1.0.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.
package/src/cli.ts ADDED
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execSync } from 'node:child_process';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { loginWithQRWeb } from './auth.js';
7
+ import { saveAccount, getActiveAccount } from './store.js';
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ const serverPath = path.join(__dirname, 'server.js');
11
+
12
+ const command = process.argv[2];
13
+
14
+ function printUsage(): void {
15
+ console.log(`
16
+ đŸĻž wechat-claude — WeChat channel for Claude Code
17
+
18
+ Usage:
19
+ npx @aster110/wechat-claude install Setup: register MCP + scan QR login
20
+ npx @aster110/wechat-claude login Re-login (scan QR code)
21
+ npx @aster110/wechat-claude status Check connection status
22
+ npx @aster110/wechat-claude help Show this help
23
+ `);
24
+ }
25
+
26
+ async function install(): Promise<void> {
27
+ console.log('\n đŸĻž wechat-claude installer\n');
28
+
29
+ // Step 1: Register MCP server
30
+ console.log(' [1/3] Registering MCP server...');
31
+ try {
32
+ execSync(
33
+ `claude mcp add -s user wechat-channel node ${serverPath}`,
34
+ { stdio: 'pipe' },
35
+ );
36
+ console.log(' ✅ MCP server registered (user-level)\n');
37
+ } catch {
38
+ // May already exist, try remove + add
39
+ try {
40
+ execSync(`claude mcp remove -s user wechat-channel`, { stdio: 'pipe' });
41
+ execSync(
42
+ `claude mcp add -s user wechat-channel node ${serverPath}`,
43
+ { stdio: 'pipe' },
44
+ );
45
+ console.log(' ✅ MCP server updated (user-level)\n');
46
+ } catch (err) {
47
+ console.error(' âš ī¸ Failed to register MCP server. You may need to add it manually:');
48
+ console.log(` claude mcp add -s user wechat-channel node ${serverPath}\n`);
49
+ }
50
+ }
51
+
52
+ // Step 2: QR Login
53
+ console.log(' [2/3] WeChat QR login...');
54
+ const existing = getActiveAccount();
55
+ if (existing) {
56
+ console.log(` â„šī¸ Found existing account: ${existing.accountId}`);
57
+ console.log(' Skipping login. Run "npx @aster110/wechat-claude login" to re-login.\n');
58
+ } else {
59
+ try {
60
+ const result = await loginWithQRWeb();
61
+ saveAccount({
62
+ accountId: result.accountId.replace(/@/g, '-').replace(/\./g, '-'),
63
+ token: result.token,
64
+ baseUrl: result.baseUrl,
65
+ savedAt: new Date().toISOString(),
66
+ });
67
+ console.log(` ✅ Login successful! Account: ${result.accountId}\n`);
68
+ } catch (err) {
69
+ console.error(` ❌ Login failed: ${err}`);
70
+ console.log(' Run "npx @aster110/wechat-claude login" to retry.\n');
71
+ }
72
+ }
73
+
74
+ // Step 3: Print next steps
75
+ console.log(' [3/3] Setup complete!\n');
76
+ console.log(' Next steps:');
77
+ console.log(' 1. Start Claude Code with WeChat channel:');
78
+ console.log(' claude --dangerously-load-development-channels server:wechat-channel\n');
79
+ console.log(' 2. Send a message to your WeChat — Claude Code will auto-reply!\n');
80
+ }
81
+
82
+ async function login(): Promise<void> {
83
+ console.log('\n đŸĻž WeChat QR Login\n');
84
+ try {
85
+ const result = await loginWithQRWeb();
86
+ saveAccount({
87
+ accountId: result.accountId.replace(/@/g, '-').replace(/\./g, '-'),
88
+ token: result.token,
89
+ baseUrl: result.baseUrl,
90
+ savedAt: new Date().toISOString(),
91
+ });
92
+ console.log(`\n ✅ Login successful! Account: ${result.accountId}\n`);
93
+ } catch (err) {
94
+ console.error(`\n ❌ Login failed: ${err}\n`);
95
+ process.exit(1);
96
+ }
97
+ }
98
+
99
+ function status(): void {
100
+ const account = getActiveAccount();
101
+ if (account) {
102
+ console.log(`\n đŸĻž WeChat Channel Status\n`);
103
+ console.log(` Account: ${account.accountId}`);
104
+ console.log(` Token: ${account.token.slice(0, 10)}...`);
105
+ console.log(` Base URL: ${account.baseUrl || 'https://ilinkai.weixin.qq.com'}`);
106
+ console.log(` Saved: ${account.savedAt}\n`);
107
+ } else {
108
+ console.log('\n âš ī¸ Not logged in. Run: npx @aster110/wechat-claude install\n');
109
+ }
110
+ }
111
+
112
+ switch (command) {
113
+ case 'install':
114
+ case 'setup':
115
+ install().catch(console.error);
116
+ break;
117
+ case 'login':
118
+ login().catch(console.error);
119
+ break;
120
+ case 'status':
121
+ status();
122
+ break;
123
+ case 'help':
124
+ case '--help':
125
+ case '-h':
126
+ case undefined:
127
+ printUsage();
128
+ break;
129
+ default:
130
+ console.error(` Unknown command: ${command}`);
131
+ printUsage();
132
+ process.exit(1);
133
+ }
@@ -0,0 +1,9 @@
1
+ declare module 'qrcode-terminal' {
2
+ interface Options {
3
+ small?: boolean;
4
+ }
5
+ const qrcode: {
6
+ generate(text: string, opts?: Options, cb?: (qr: string) => void): void;
7
+ };
8
+ export default qrcode;
9
+ }
package/src/server.ts ADDED
@@ -0,0 +1,479 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import {
6
+ ListToolsRequestSchema,
7
+ CallToolRequestSchema,
8
+ } from '@modelcontextprotocol/sdk/types.js';
9
+
10
+ import { loginWithQRWeb } from './auth.js';
11
+ import { getActiveAccount, saveAccount, loadSyncBuf, saveSyncBuf } from './store.js';
12
+ import {
13
+ getUpdates,
14
+ sendMessage,
15
+ sendTyping,
16
+ getConfig,
17
+ uploadAndSendMedia,
18
+ } from './wechat-api.js';
19
+ import fs from 'node:fs';
20
+ import type { WeixinMessage } from './types.js';
21
+ import { MessageItemType } from './types.js';
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // State
25
+ // ---------------------------------------------------------------------------
26
+
27
+ let pollingActive = false;
28
+ let pollingAbort: AbortController | null = null;
29
+
30
+ /** Cache: userId -> typing_ticket */
31
+ const typingTicketCache = new Map<string, string>();
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Message text extraction
35
+ // ---------------------------------------------------------------------------
36
+
37
+ function extractText(msg: WeixinMessage): string {
38
+ const parts: string[] = [];
39
+ for (const item of msg.item_list ?? []) {
40
+ if (item.type === MessageItemType.TEXT && item.text_item?.text) {
41
+ parts.push(item.text_item.text);
42
+ } else if (item.type === MessageItemType.IMAGE) {
43
+ parts.push('[Image]');
44
+ } else if (item.type === MessageItemType.VOICE && item.voice_item?.text) {
45
+ parts.push(`[Voice] ${item.voice_item.text}`);
46
+ } else if (item.type === MessageItemType.FILE && item.file_item?.file_name) {
47
+ parts.push(`[File: ${item.file_item.file_name}]`);
48
+ } else if (item.type === MessageItemType.VIDEO) {
49
+ parts.push('[Video]');
50
+ }
51
+ }
52
+ return parts.join('\n') || '[Empty message]';
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Text chunking for WeChat 4000-char limit
57
+ // ---------------------------------------------------------------------------
58
+
59
+ const MAX_CHUNK_LENGTH = 3900;
60
+
61
+ function chunkText(text: string): string[] {
62
+ if (text.length <= MAX_CHUNK_LENGTH) return [text];
63
+ const chunks: string[] = [];
64
+ let remaining = text;
65
+ while (remaining.length > 0) {
66
+ if (remaining.length <= MAX_CHUNK_LENGTH) {
67
+ chunks.push(remaining);
68
+ break;
69
+ }
70
+ // Try to break at newline
71
+ let breakAt = remaining.lastIndexOf('\n', MAX_CHUNK_LENGTH);
72
+ if (breakAt < MAX_CHUNK_LENGTH * 0.5) {
73
+ // No good newline break, try space
74
+ breakAt = remaining.lastIndexOf(' ', MAX_CHUNK_LENGTH);
75
+ }
76
+ if (breakAt < MAX_CHUNK_LENGTH * 0.3) {
77
+ breakAt = MAX_CHUNK_LENGTH;
78
+ }
79
+ chunks.push(remaining.slice(0, breakAt));
80
+ remaining = remaining.slice(breakAt).trimStart();
81
+ }
82
+ return chunks;
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Strip markdown for WeChat plain text
87
+ // ---------------------------------------------------------------------------
88
+
89
+ function stripMarkdown(text: string): string {
90
+ let result = text;
91
+ // Code blocks: strip fences, keep content
92
+ result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, code: string) => code.trim());
93
+ // Images: remove
94
+ result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, '');
95
+ // Links: keep display text
96
+ result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1');
97
+ // Bold/italic
98
+ result = result.replace(/\*\*(.+?)\*\*/g, '$1');
99
+ result = result.replace(/\*(.+?)\*/g, '$1');
100
+ result = result.replace(/__(.+?)__/g, '$1');
101
+ result = result.replace(/_(.+?)_/g, '$1');
102
+ // Headings
103
+ result = result.replace(/^#{1,6}\s+/gm, '');
104
+ // Horizontal rules
105
+ result = result.replace(/^[-*_]{3,}$/gm, '');
106
+ // Blockquotes
107
+ result = result.replace(/^>\s?/gm, '');
108
+ return result.trim();
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // MCP Server
113
+ // ---------------------------------------------------------------------------
114
+
115
+ const server = new Server(
116
+ { name: 'wechat-channel', version: '1.0.0' },
117
+ {
118
+ capabilities: {
119
+ experimental: { 'claude/channel': {} },
120
+ tools: {},
121
+ },
122
+ },
123
+ );
124
+
125
+ // -- Tools --
126
+
127
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
128
+ tools: [
129
+ {
130
+ name: 'reply',
131
+ description:
132
+ 'Reply to a WeChat message. Supports text and image/file. Set media to a local file path to send an image or file.',
133
+ inputSchema: {
134
+ type: 'object' as const,
135
+ properties: {
136
+ user_id: {
137
+ type: 'string',
138
+ description: 'The WeChat user ID to reply to (from_user_id from the incoming message)',
139
+ },
140
+ context_token: {
141
+ type: 'string',
142
+ description: 'The context_token from the incoming message (required for reply association)',
143
+ },
144
+ content: {
145
+ type: 'string',
146
+ description: 'Text content',
147
+ },
148
+ media: {
149
+ type: 'string',
150
+ description: 'Optional: absolute path to a local file (image/video/file) to send',
151
+ },
152
+ },
153
+ required: ['user_id', 'context_token', 'content'],
154
+ },
155
+ },
156
+ {
157
+ name: 'login',
158
+ description:
159
+ 'Login to WeChat by scanning a QR code. Run this first if not already logged in.',
160
+ inputSchema: {
161
+ type: 'object' as const,
162
+ properties: {},
163
+ },
164
+ },
165
+ ],
166
+ }));
167
+
168
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
169
+ const { name, arguments: args } = request.params;
170
+
171
+ if (name === 'login') {
172
+ try {
173
+ const result = await loginWithQRWeb();
174
+ saveAccount({
175
+ accountId: result.accountId,
176
+ token: result.token,
177
+ baseUrl: result.baseUrl,
178
+ savedAt: new Date().toISOString(),
179
+ });
180
+ // Start polling after login
181
+ startPolling();
182
+ return {
183
+ content: [
184
+ {
185
+ type: 'text' as const,
186
+ text: `WeChat login successful! Account: ${result.accountId}. Polling started.`,
187
+ },
188
+ ],
189
+ };
190
+ } catch (err) {
191
+ return {
192
+ content: [
193
+ { type: 'text' as const, text: `Login failed: ${String(err)}` },
194
+ ],
195
+ isError: true,
196
+ };
197
+ }
198
+ }
199
+
200
+ if (name === 'reply') {
201
+ const userId = (args as Record<string, string>).user_id;
202
+ const contextToken = (args as Record<string, string>).context_token;
203
+ const content = (args as Record<string, string>).content;
204
+ const media = (args as Record<string, string>).media;
205
+
206
+ if (!userId || !contextToken || !content) {
207
+ return {
208
+ content: [
209
+ { type: 'text' as const, text: 'Missing required fields: user_id, context_token, content' },
210
+ ],
211
+ isError: true,
212
+ };
213
+ }
214
+
215
+ // Validate media path if provided
216
+ if (media && !fs.existsSync(media)) {
217
+ return {
218
+ content: [
219
+ { type: 'text' as const, text: `Media file not found: ${media}` },
220
+ ],
221
+ isError: true,
222
+ };
223
+ }
224
+
225
+ const account = getActiveAccount();
226
+ if (!account) {
227
+ return {
228
+ content: [
229
+ { type: 'text' as const, text: 'Not logged in. Use the login tool first.' },
230
+ ],
231
+ isError: true,
232
+ };
233
+ }
234
+
235
+ try {
236
+ // Send typing indicator
237
+ const ticket = typingTicketCache.get(userId);
238
+ if (ticket) {
239
+ await sendTyping(account.token, userId, ticket, 1, account.baseUrl).catch(() => {});
240
+ }
241
+
242
+ // Strip markdown and chunk
243
+ const plainText = stripMarkdown(content);
244
+ const chunks = chunkText(plainText);
245
+
246
+ for (const chunk of chunks) {
247
+ await sendMessage(account.token, userId, chunk, contextToken, account.baseUrl);
248
+ }
249
+
250
+ // Send media if provided
251
+ let mediaSent = false;
252
+ if (media) {
253
+ await uploadAndSendMedia({
254
+ token: account.token,
255
+ toUser: userId,
256
+ contextToken,
257
+ filePath: media,
258
+ baseUrl: account.baseUrl,
259
+ });
260
+ mediaSent = true;
261
+ }
262
+
263
+ // Cancel typing
264
+ if (ticket) {
265
+ await sendTyping(account.token, userId, ticket, 2, account.baseUrl).catch(() => {});
266
+ }
267
+
268
+ const parts = [`Reply sent to ${userId} (${chunks.length} chunk${chunks.length > 1 ? 's' : ''})`];
269
+ if (mediaSent) parts.push(`Media sent: ${media}`);
270
+
271
+ return {
272
+ content: [
273
+ {
274
+ type: 'text' as const,
275
+ text: parts.join('. '),
276
+ },
277
+ ],
278
+ };
279
+ } catch (err) {
280
+ return {
281
+ content: [
282
+ { type: 'text' as const, text: `Failed to send reply: ${String(err)}` },
283
+ ],
284
+ isError: true,
285
+ };
286
+ }
287
+ }
288
+
289
+ return {
290
+ content: [{ type: 'text' as const, text: `Unknown tool: ${name}` }],
291
+ isError: true,
292
+ };
293
+ });
294
+
295
+ // ---------------------------------------------------------------------------
296
+ // Long-polling loop
297
+ // ---------------------------------------------------------------------------
298
+
299
+ const SESSION_EXPIRED_ERRCODE = -14;
300
+ const MAX_CONSECUTIVE_FAILURES = 3;
301
+ const BACKOFF_DELAY_MS = 30_000;
302
+ const RETRY_DELAY_MS = 2_000;
303
+ const SESSION_PAUSE_MS = 5 * 60_000;
304
+
305
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
306
+ return new Promise((resolve, reject) => {
307
+ const t = setTimeout(resolve, ms);
308
+ signal?.addEventListener(
309
+ 'abort',
310
+ () => {
311
+ clearTimeout(t);
312
+ reject(new Error('aborted'));
313
+ },
314
+ { once: true },
315
+ );
316
+ });
317
+ }
318
+
319
+ async function pollLoop(account: { token: string; accountId: string; baseUrl?: string }): Promise<void> {
320
+ let buf = loadSyncBuf(account.accountId);
321
+ let consecutiveFailures = 0;
322
+ let nextTimeoutMs = 35_000;
323
+
324
+ process.stderr.write(`[wechat-channel] Polling started for account ${account.accountId}\n`);
325
+
326
+ while (pollingActive && !pollingAbort?.signal.aborted) {
327
+ try {
328
+ const resp = await getUpdates(account.token, buf, account.baseUrl, nextTimeoutMs);
329
+
330
+ // Update timeout if server suggests one
331
+ if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) {
332
+ nextTimeoutMs = resp.longpolling_timeout_ms;
333
+ }
334
+
335
+ // Check for API errors
336
+ const isApiError =
337
+ (resp.ret !== undefined && resp.ret !== 0) ||
338
+ (resp.errcode !== undefined && resp.errcode !== 0);
339
+
340
+ if (isApiError) {
341
+ const isSessionExpired =
342
+ resp.errcode === SESSION_EXPIRED_ERRCODE || resp.ret === SESSION_EXPIRED_ERRCODE;
343
+
344
+ if (isSessionExpired) {
345
+ process.stderr.write(
346
+ `[wechat-channel] Session expired (errcode ${SESSION_EXPIRED_ERRCODE}), pausing ${Math.ceil(SESSION_PAUSE_MS / 60_000)} min\n`,
347
+ );
348
+ consecutiveFailures = 0;
349
+ await sleep(SESSION_PAUSE_MS, pollingAbort?.signal);
350
+ continue;
351
+ }
352
+
353
+ consecutiveFailures++;
354
+ process.stderr.write(
355
+ `[wechat-channel] getUpdates error: ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg ?? ''} (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})\n`,
356
+ );
357
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
358
+ consecutiveFailures = 0;
359
+ await sleep(BACKOFF_DELAY_MS, pollingAbort?.signal);
360
+ } else {
361
+ await sleep(RETRY_DELAY_MS, pollingAbort?.signal);
362
+ }
363
+ continue;
364
+ }
365
+
366
+ consecutiveFailures = 0;
367
+
368
+ // Save sync buf
369
+ if (resp.get_updates_buf != null && resp.get_updates_buf !== '') {
370
+ saveSyncBuf(account.accountId, resp.get_updates_buf);
371
+ buf = resp.get_updates_buf;
372
+ }
373
+
374
+ // Process messages
375
+ const msgs = resp.msgs ?? [];
376
+ for (const msg of msgs) {
377
+ // Only process user messages (message_type === 1)
378
+ if (msg.message_type !== 1) continue;
379
+
380
+ const text = extractText(msg);
381
+ const fromUser = msg.from_user_id ?? 'unknown';
382
+ const contextToken = msg.context_token ?? '';
383
+
384
+ process.stderr.write(`[wechat-channel] Message from ${fromUser}: ${text.slice(0, 100)}\n`);
385
+
386
+ // Cache typing ticket for this user
387
+ try {
388
+ const cfg = await getConfig(account.token, fromUser, contextToken, account.baseUrl);
389
+ if (cfg.typing_ticket) {
390
+ typingTicketCache.set(fromUser, cfg.typing_ticket);
391
+ }
392
+ } catch {
393
+ // non-critical
394
+ }
395
+
396
+ // Send typing indicator
397
+ const ticket = typingTicketCache.get(fromUser);
398
+ if (ticket) {
399
+ await sendTyping(account.token, fromUser, ticket, 1, account.baseUrl).catch(() => {});
400
+ }
401
+
402
+ // Push to Claude Code via channel notification
403
+ server.notification({
404
+ method: 'notifications/claude/channel',
405
+ params: {
406
+ content: `${text}\n\n[System: Reply via the "reply" tool. user_id and context_token are in the message metadata above.]`,
407
+ meta: {
408
+ source: 'wechat',
409
+ sender: fromUser,
410
+ user_id: fromUser,
411
+ context_token: contextToken,
412
+ message_id: String(msg.message_id ?? ''),
413
+ session_id: msg.session_id ?? '',
414
+ },
415
+ },
416
+ });
417
+ }
418
+ } catch (err) {
419
+ if (pollingAbort?.signal.aborted) return;
420
+ consecutiveFailures++;
421
+ process.stderr.write(
422
+ `[wechat-channel] Poll error (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}): ${String(err)}\n`,
423
+ );
424
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
425
+ consecutiveFailures = 0;
426
+ await sleep(BACKOFF_DELAY_MS, pollingAbort?.signal);
427
+ } else {
428
+ await sleep(RETRY_DELAY_MS, pollingAbort?.signal);
429
+ }
430
+ }
431
+ }
432
+ }
433
+
434
+ function startPolling(): void {
435
+ const account = getActiveAccount();
436
+ if (!account) {
437
+ process.stderr.write('[wechat-channel] No account found, skipping polling\n');
438
+ return;
439
+ }
440
+ if (pollingActive) {
441
+ process.stderr.write('[wechat-channel] Polling already active\n');
442
+ return;
443
+ }
444
+
445
+ pollingActive = true;
446
+ pollingAbort = new AbortController();
447
+
448
+ // Run poll loop in background (don't await)
449
+ pollLoop(account).catch((err) => {
450
+ if (!pollingAbort?.signal.aborted) {
451
+ process.stderr.write(`[wechat-channel] Poll loop crashed: ${String(err)}\n`);
452
+ }
453
+ pollingActive = false;
454
+ });
455
+ }
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // Main
459
+ // ---------------------------------------------------------------------------
460
+
461
+ async function main(): Promise<void> {
462
+ const transport = new StdioServerTransport();
463
+ await server.connect(transport);
464
+ process.stderr.write('[wechat-channel] MCP server started\n');
465
+
466
+ // Auto-start polling if we have saved credentials
467
+ const account = getActiveAccount();
468
+ if (account) {
469
+ process.stderr.write(`[wechat-channel] Found saved account: ${account.accountId}\n`);
470
+ startPolling();
471
+ } else {
472
+ process.stderr.write('[wechat-channel] No saved account. Use the login tool to connect.\n');
473
+ }
474
+ }
475
+
476
+ main().catch((err) => {
477
+ process.stderr.write(`[wechat-channel] Fatal: ${String(err)}\n`);
478
+ process.exit(1);
479
+ });
package/src/store.ts ADDED
@@ -0,0 +1,79 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+
5
+ const CHANNEL_DIR = path.join(os.homedir(), '.claude', 'channels', 'wechat-channel');
6
+
7
+ function ensureDir(dir: string): void {
8
+ fs.mkdirSync(dir, { recursive: true });
9
+ }
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Account credentials
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export interface AccountData {
16
+ accountId: string;
17
+ token: string;
18
+ baseUrl?: string;
19
+ savedAt: string;
20
+ }
21
+
22
+ function accountsFilePath(): string {
23
+ return path.join(CHANNEL_DIR, 'accounts.json');
24
+ }
25
+
26
+ export function loadAccounts(): AccountData[] {
27
+ try {
28
+ const raw = fs.readFileSync(accountsFilePath(), 'utf-8');
29
+ const parsed = JSON.parse(raw);
30
+ return Array.isArray(parsed) ? parsed : [];
31
+ } catch {
32
+ return [];
33
+ }
34
+ }
35
+
36
+ export function saveAccount(account: AccountData): void {
37
+ ensureDir(CHANNEL_DIR);
38
+ const accounts = loadAccounts().filter((a) => a.accountId !== account.accountId);
39
+ accounts.push(account);
40
+ const filePath = accountsFilePath();
41
+ fs.writeFileSync(filePath, JSON.stringify(accounts, null, 2), 'utf-8');
42
+ try {
43
+ fs.chmodSync(filePath, 0o600);
44
+ } catch {
45
+ // best-effort
46
+ }
47
+ }
48
+
49
+ export function getActiveAccount(): AccountData | null {
50
+ const accounts = loadAccounts();
51
+ return accounts.length > 0 ? accounts[accounts.length - 1]! : null;
52
+ }
53
+
54
+ export function removeAccount(accountId: string): void {
55
+ const accounts = loadAccounts().filter((a) => a.accountId !== accountId);
56
+ ensureDir(CHANNEL_DIR);
57
+ fs.writeFileSync(accountsFilePath(), JSON.stringify(accounts, null, 2), 'utf-8');
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Sync buf (long-poll cursor)
62
+ // ---------------------------------------------------------------------------
63
+
64
+ function syncBufFilePath(accountId: string): string {
65
+ return path.join(CHANNEL_DIR, `sync-buf-${accountId}.txt`);
66
+ }
67
+
68
+ export function loadSyncBuf(accountId: string): string {
69
+ try {
70
+ return fs.readFileSync(syncBufFilePath(accountId), 'utf-8');
71
+ } catch {
72
+ return '';
73
+ }
74
+ }
75
+
76
+ export function saveSyncBuf(accountId: string, buf: string): void {
77
+ ensureDir(CHANNEL_DIR);
78
+ fs.writeFileSync(syncBufFilePath(accountId), buf, 'utf-8');
79
+ }