metame-cli 1.3.5 → 1.3.7

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 CHANGED
@@ -182,6 +182,27 @@ Each chat gets a persistent session via `claude -p --resume <session-id>`. This
182
182
 
183
183
  **Parallel request handling:** The daemon uses async spawning, so multiple users or overlapping requests don't block each other. Each Claude call runs in a non-blocking subprocess.
184
184
 
185
+ **Streaming status (v1.3.7):** See Claude's work progress in real-time on your phone:
186
+
187
+ ```
188
+ 📖 Read: 「config.yaml」
189
+ ✏️ Edit: 「daemon.js」
190
+ 💻 Bash: 「git status」
191
+ ```
192
+
193
+ **File sending (v1.3.7):** Ask Claude to send any file to your phone:
194
+
195
+ ```
196
+ You: 把 report.md 发过来
197
+ Claude: 请查收~!
198
+ [📎 report.md] ← tap to download
199
+ ```
200
+
201
+ Works for documents, audio, images, etc. Click button to download. Links valid for 30 minutes.
202
+
203
+ - **Telegram:** Works out of the box
204
+ - **Feishu:** Requires `im:resource` permission in app settings
205
+
185
206
  **Other commands:**
186
207
 
187
208
  | Command | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metame-cli",
3
- "version": "1.3.5",
3
+ "version": "1.3.7",
4
4
  "description": "The Cognitive Profile Layer for Claude Code. Knows how you think, not just what you said.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -47,3 +47,10 @@ budget:
47
47
  daemon:
48
48
  log_max_size: 1048576
49
49
  heartbeat_check_interval: 60
50
+ # Pre-authorize tools for mobile sessions (no permission prompts)
51
+ # Examples: "Bash(git:*)" "Bash(npm:*)" "Edit" "Write" "WebFetch" "WebSearch"
52
+ session_allowed_tools:
53
+ - "WebFetch"
54
+ - "WebSearch"
55
+ - "Bash(git:*)"
56
+ - "Bash(npm:*)"
package/scripts/daemon.js CHANGED
@@ -655,6 +655,36 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
655
655
  return;
656
656
  }
657
657
 
658
+ // /file <shortId> — send cached file (from button callback)
659
+ if (text.startsWith('/file ')) {
660
+ const shortId = text.slice(6).trim();
661
+ const filePath = getCachedFile(shortId);
662
+ if (!filePath) {
663
+ await bot.sendMessage(chatId, '⏰ 文件链接已过期,请重新生成');
664
+ return;
665
+ }
666
+ if (!fs.existsSync(filePath)) {
667
+ await bot.sendMessage(chatId, '❌ 文件不存在');
668
+ return;
669
+ }
670
+ if (bot.sendFile) {
671
+ try {
672
+ // Insert zero-width space before extension to prevent link parsing
673
+ const basename = path.basename(filePath);
674
+ const dotIdx = basename.lastIndexOf('.');
675
+ const safeBasename = dotIdx > 0 ? basename.slice(0, dotIdx) + '\u200B' + basename.slice(dotIdx) : basename;
676
+ await bot.sendMessage(chatId, `⏳ 正在发送「${safeBasename}」...`);
677
+ await bot.sendFile(chatId, filePath);
678
+ } catch (e) {
679
+ log('ERROR', `File send failed: ${e.message}`);
680
+ await bot.sendMessage(chatId, `❌ 发送失败: ${e.message.slice(0, 100)}`);
681
+ }
682
+ } else {
683
+ await bot.sendMessage(chatId, '❌ 当前平台不支持文件发送');
684
+ }
685
+ return;
686
+ }
687
+
658
688
  // /last — smart resume: prefer current cwd, then most recent globally
659
689
  if (text === '/last') {
660
690
  const curSession = getSession(chatId);
@@ -904,17 +934,22 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
904
934
 
905
935
  if (text.startsWith('/')) {
906
936
  await bot.sendMessage(chatId, [
907
- 'Commands:',
908
- '/last — ⚡ 一键继续最近的 session',
909
- '/new [path] [name] — new session',
910
- '/resume [name] 选择/搜索 session',
911
- '/continue — resume last in current dir',
912
- '/name <name> — name current session',
913
- '/cd <path|last> — change workdir (last=最近目录)',
914
- '/session current session info',
915
- '/status /tasks /budget /reload',
937
+ '📱 手机端 Claude Code',
938
+ '',
939
+ ' 快速同步电脑工作:',
940
+ '/last继续电脑上最近的对话',
941
+ '/cd last 切到电脑最近的项目目录',
942
+ '',
943
+ '📂 Session 管理:',
944
+ '/new [path] [name] 新建会话',
945
+ '/resume [name] 选择/恢复会话',
946
+ '/name <name> — 命名当前会话',
947
+ '/cd <path> — 切换工作目录',
948
+ '/session — 查看当前会话',
916
949
  '',
917
- 'Or just type naturally.',
950
+ '⚙️ /status /tasks /budget /reload',
951
+ '',
952
+ '直接打字即可对话 💬',
918
953
  ].join('\n'));
919
954
  return;
920
955
  }
@@ -947,17 +982,64 @@ function listRecentSessions(limit, cwd) {
947
982
  try {
948
983
  if (!fs.existsSync(CLAUDE_PROJECTS_DIR)) return [];
949
984
  const projects = fs.readdirSync(CLAUDE_PROJECTS_DIR);
950
- let all = [];
985
+
986
+ // Build a map: sessionId -> entry (for deduplication)
987
+ const sessionMap = new Map();
988
+ // Cache: projDirName -> real projectPath (from index)
989
+ const projPathCache = new Map();
990
+
951
991
  for (const proj of projects) {
952
- const indexFile = path.join(CLAUDE_PROJECTS_DIR, proj, 'sessions-index.json');
992
+ const projDir = path.join(CLAUDE_PROJECTS_DIR, proj);
993
+
994
+ // 1. Read from sessions-index.json (Claude's native index)
995
+ const indexFile = path.join(projDir, 'sessions-index.json');
996
+ try {
997
+ if (fs.existsSync(indexFile)) {
998
+ const data = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
999
+ if (data.entries && data.entries.length > 0) {
1000
+ // Cache the real projectPath from any indexed session
1001
+ const realPath = data.entries[0].projectPath;
1002
+ if (realPath) projPathCache.set(proj, realPath);
1003
+
1004
+ for (const entry of data.entries) {
1005
+ if (entry.messageCount >= 1) {
1006
+ sessionMap.set(entry.sessionId, entry);
1007
+ }
1008
+ }
1009
+ }
1010
+ }
1011
+ } catch { /* skip */ }
1012
+
1013
+ // 2. Direct scan of .jsonl files (hot reload: catches sessions not yet indexed)
953
1014
  try {
954
- if (!fs.existsSync(indexFile)) continue;
955
- const data = JSON.parse(fs.readFileSync(indexFile, 'utf8'));
956
- if (data.entries) all = all.concat(data.entries);
1015
+ const files = fs.readdirSync(projDir).filter(f => f.endsWith('.jsonl'));
1016
+ for (const file of files) {
1017
+ const sessionId = file.replace('.jsonl', '');
1018
+ const filePath = path.join(projDir, file);
1019
+ const stat = fs.statSync(filePath);
1020
+ const fileMtime = stat.mtimeMs;
1021
+
1022
+ // Only add if not already in map, or if file is newer
1023
+ const existing = sessionMap.get(sessionId);
1024
+ if (!existing || fileMtime > (existing.fileMtime || 0)) {
1025
+ // Use cached real projectPath, or fall back to lossy decode
1026
+ const projectPath = projPathCache.get(proj) || proj.slice(1).replace(/-/g, '/');
1027
+ sessionMap.set(sessionId, {
1028
+ sessionId,
1029
+ projectPath,
1030
+ fileMtime,
1031
+ modified: new Date(fileMtime).toISOString(),
1032
+ messageCount: 1, // Assume at least 1 if file exists
1033
+ ...(existing || {}), // Preserve existing metadata like customTitle
1034
+ fileMtime, // Override with real mtime
1035
+ });
1036
+ }
1037
+ }
957
1038
  } catch { /* skip */ }
958
1039
  }
959
- // Filter: must have at least 1 message
960
- all = all.filter(s => s.messageCount >= 1);
1040
+
1041
+ let all = Array.from(sessionMap.values());
1042
+
961
1043
  // Filter by cwd if provided
962
1044
  if (cwd) {
963
1045
  const matched = all.filter(s => s.projectPath === cwd);
@@ -1218,6 +1300,205 @@ function spawnClaudeAsync(args, input, cwd, timeoutMs = 300000) {
1218
1300
  });
1219
1301
  }
1220
1302
 
1303
+ /**
1304
+ * Tool name to emoji mapping for status display
1305
+ */
1306
+ const TOOL_EMOJI = {
1307
+ Read: '📖',
1308
+ Edit: '✏️',
1309
+ Write: '📝',
1310
+ Bash: '💻',
1311
+ Glob: '🔍',
1312
+ Grep: '🔎',
1313
+ WebFetch: '🌐',
1314
+ WebSearch: '🔍',
1315
+ Task: '🤖',
1316
+ default: '🔧',
1317
+ };
1318
+
1319
+ // Content file extensions (user-facing files, not code/config)
1320
+ const CONTENT_EXTENSIONS = new Set([
1321
+ '.md', '.txt', '.rtf', // Text
1322
+ '.doc', '.docx', '.pdf', '.odt', // Documents
1323
+ '.wav', '.mp3', '.m4a', '.ogg', '.flac', // Audio
1324
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', // Images
1325
+ '.mp4', '.mov', '.avi', '.webm', // Video
1326
+ '.csv', '.xlsx', '.xls', // Data
1327
+ '.html', '.htm', // Web content
1328
+ ]);
1329
+
1330
+ // File cache for button callbacks (shortId -> fullPath)
1331
+ const fileCache = new Map();
1332
+ const FILE_CACHE_TTL = 1800000; // 30 minutes
1333
+
1334
+ function cacheFile(filePath) {
1335
+ const shortId = Math.random().toString(36).slice(2, 10);
1336
+ fileCache.set(shortId, { path: filePath, expires: Date.now() + FILE_CACHE_TTL });
1337
+ return shortId;
1338
+ }
1339
+
1340
+ function getCachedFile(shortId) {
1341
+ const entry = fileCache.get(shortId);
1342
+ if (!entry) return null;
1343
+ if (Date.now() > entry.expires) {
1344
+ fileCache.delete(shortId);
1345
+ return null;
1346
+ }
1347
+ return entry.path;
1348
+ }
1349
+
1350
+ function isContentFile(filePath) {
1351
+ const ext = path.extname(filePath).toLowerCase();
1352
+ return CONTENT_EXTENSIONS.has(ext);
1353
+ }
1354
+
1355
+ /**
1356
+ * Spawn claude with streaming output (stream-json mode).
1357
+ * Calls onStatus callback when tool usage is detected.
1358
+ * Returns { output, error } after process exits.
1359
+ */
1360
+ function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000) {
1361
+ return new Promise((resolve) => {
1362
+ // Add stream-json output format (requires --verbose)
1363
+ const streamArgs = [...args, '--output-format', 'stream-json', '--verbose'];
1364
+
1365
+ const child = spawn('claude', streamArgs, {
1366
+ cwd,
1367
+ stdio: ['pipe', 'pipe', 'pipe'],
1368
+ env: { ...process.env },
1369
+ });
1370
+
1371
+ let buffer = '';
1372
+ let stderr = '';
1373
+ let killed = false;
1374
+ let finalResult = '';
1375
+ let lastStatusTime = 0;
1376
+ const STATUS_THROTTLE = 3000; // Min 3s between status updates
1377
+ const writtenFiles = []; // Track files created/modified by Write tool
1378
+
1379
+ const timer = setTimeout(() => {
1380
+ killed = true;
1381
+ child.kill('SIGTERM');
1382
+ }, timeoutMs);
1383
+
1384
+ child.stdout.on('data', (data) => {
1385
+ buffer += data.toString();
1386
+
1387
+ // Process complete JSON lines
1388
+ const lines = buffer.split('\n');
1389
+ buffer = lines.pop() || ''; // Keep incomplete line in buffer
1390
+
1391
+ for (const line of lines) {
1392
+ if (!line.trim()) continue;
1393
+ try {
1394
+ const event = JSON.parse(line);
1395
+
1396
+ // Extract final result text
1397
+ if (event.type === 'assistant' && event.message?.content) {
1398
+ const textBlocks = event.message.content.filter(b => b.type === 'text');
1399
+ if (textBlocks.length > 0) {
1400
+ finalResult = textBlocks.map(b => b.text).join('\n');
1401
+ }
1402
+ }
1403
+
1404
+ // Detect tool usage and send status
1405
+ if (event.type === 'assistant' && event.message?.content) {
1406
+ for (const block of event.message.content) {
1407
+ if (block.type === 'tool_use') {
1408
+ const toolName = block.name || 'Tool';
1409
+
1410
+ // Track files written by Write tool
1411
+ if (toolName === 'Write' && block.input?.file_path) {
1412
+ const filePath = block.input.file_path;
1413
+ if (!writtenFiles.includes(filePath)) {
1414
+ writtenFiles.push(filePath);
1415
+ }
1416
+ }
1417
+
1418
+ const now = Date.now();
1419
+ if (now - lastStatusTime >= STATUS_THROTTLE) {
1420
+ lastStatusTime = now;
1421
+ const emoji = TOOL_EMOJI[toolName] || TOOL_EMOJI.default;
1422
+
1423
+ // Extract brief context from tool input
1424
+ let context = '';
1425
+ if (block.input) {
1426
+ if (block.input.file_path) {
1427
+ // Insert zero-width space before extension to prevent link parsing
1428
+ const basename = path.basename(block.input.file_path);
1429
+ const dotIdx = basename.lastIndexOf('.');
1430
+ context = dotIdx > 0 ? basename.slice(0, dotIdx) + '\u200B' + basename.slice(dotIdx) : basename;
1431
+ } else if (block.input.command) {
1432
+ context = block.input.command.slice(0, 30);
1433
+ if (block.input.command.length > 30) context += '...';
1434
+ } else if (block.input.pattern) {
1435
+ context = block.input.pattern.slice(0, 20);
1436
+ } else if (block.input.query) {
1437
+ context = block.input.query.slice(0, 25);
1438
+ } else if (block.input.url) {
1439
+ try {
1440
+ context = new URL(block.input.url).hostname;
1441
+ } catch { context = 'web'; }
1442
+ }
1443
+ }
1444
+
1445
+ const status = context
1446
+ ? `${emoji} ${toolName}: 「${context}」`
1447
+ : `${emoji} ${toolName}...`;
1448
+
1449
+ if (onStatus) {
1450
+ onStatus(status).catch(() => {});
1451
+ }
1452
+ }
1453
+ }
1454
+ }
1455
+ }
1456
+
1457
+ // Also check for result message type
1458
+ if (event.type === 'result' && event.result) {
1459
+ finalResult = event.result;
1460
+ }
1461
+ } catch {
1462
+ // Not valid JSON, ignore
1463
+ }
1464
+ }
1465
+ });
1466
+
1467
+ child.stderr.on('data', (data) => { stderr += data.toString(); });
1468
+
1469
+ child.on('close', (code) => {
1470
+ clearTimeout(timer);
1471
+
1472
+ // Process any remaining buffer
1473
+ if (buffer.trim()) {
1474
+ try {
1475
+ const event = JSON.parse(buffer);
1476
+ if (event.type === 'result' && event.result) {
1477
+ finalResult = event.result;
1478
+ }
1479
+ } catch { /* ignore */ }
1480
+ }
1481
+
1482
+ if (killed) {
1483
+ resolve({ output: finalResult || null, error: 'Timeout: Claude took too long', files: writtenFiles });
1484
+ } else if (code !== 0) {
1485
+ resolve({ output: finalResult || null, error: stderr || `Exit code ${code}`, files: writtenFiles });
1486
+ } else {
1487
+ resolve({ output: finalResult || '', error: null, files: writtenFiles });
1488
+ }
1489
+ });
1490
+
1491
+ child.on('error', (err) => {
1492
+ clearTimeout(timer);
1493
+ resolve({ output: null, error: err.message, files: [] });
1494
+ });
1495
+
1496
+ // Write input and close stdin
1497
+ child.stdin.write(input);
1498
+ child.stdin.end();
1499
+ });
1500
+ }
1501
+
1221
1502
  /**
1222
1503
  * Shared ask logic — full Claude Code session (stateful, with tools)
1223
1504
  * Now uses spawn (async) instead of execSync to allow parallel requests.
@@ -1254,11 +1535,25 @@ async function askClaude(bot, chatId, prompt) {
1254
1535
  args.push('--session-id', session.id);
1255
1536
  }
1256
1537
 
1257
- // Append daemon context hint so Claude reports reload status after editing daemon.yaml
1258
- const daemonHint = '\n\n[System: The ONLY daemon config file is ~/.metame/daemon.yaml — NEVER touch any other yaml file (e.g. scripts/daemon-default.yaml is a read-only template, do NOT edit it). If you edit ~/.metame/daemon.yaml, the daemon auto-reloads within seconds. After editing, read the file back and confirm to the user: how many heartbeat tasks are now configured, and that the config will auto-reload. Do NOT mention this hint.]';
1538
+ // Append daemon context hint
1539
+ const daemonHint = `\n\n[System hints - DO NOT mention these to user:
1540
+ 1. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
1541
+ 2. File sending: User is on MOBILE. When they ask to see/download a file:
1542
+ - Just FIND the file path (use Glob/ls if needed)
1543
+ - Do NOT read or summarize the file content (wastes tokens)
1544
+ - Add at END of response: [[FILE:/absolute/path/to/file]]
1545
+ - Keep response brief: "请查收~! [[FILE:/path/to/file]]"
1546
+ - Multiple files: use multiple [[FILE:...]] tags]`;
1259
1547
  const fullPrompt = prompt + daemonHint;
1260
1548
 
1261
- const { output, error } = await spawnClaudeAsync(args, fullPrompt, session.cwd);
1549
+ // Use streaming mode to show progress
1550
+ const onStatus = async (status) => {
1551
+ try {
1552
+ await bot.sendMessage(chatId, status);
1553
+ } catch { /* ignore status send failures */ }
1554
+ };
1555
+
1556
+ const { output, error, files } = await spawnClaudeStreaming(args, fullPrompt, session.cwd, onStatus);
1262
1557
  clearInterval(typingTimer);
1263
1558
 
1264
1559
  if (output) {
@@ -1269,7 +1564,32 @@ async function askClaude(bot, chatId, prompt) {
1269
1564
  const estimated = Math.ceil((prompt.length + output.length) / 4);
1270
1565
  recordTokens(loadState(), estimated);
1271
1566
 
1272
- await bot.sendMarkdown(chatId, output);
1567
+ // Parse [[FILE:...]] markers from output (Claude's explicit file sends)
1568
+ const fileMarkers = output.match(/\[\[FILE:([^\]]+)\]\]/g) || [];
1569
+ const markedFiles = fileMarkers.map(m => m.match(/\[\[FILE:([^\]]+)\]\]/)[1].trim());
1570
+ const cleanOutput = output.replace(/\s*\[\[FILE:[^\]]+\]\]/g, '').trim();
1571
+
1572
+ await bot.sendMarkdown(chatId, cleanOutput);
1573
+
1574
+ // Combine: marked files + auto-detected content files from Write operations
1575
+ const allFiles = new Set(markedFiles);
1576
+ if (files && files.length > 0) {
1577
+ for (const f of files) {
1578
+ if (isContentFile(f)) allFiles.add(f);
1579
+ }
1580
+ }
1581
+
1582
+ // Send file buttons
1583
+ if (allFiles.size > 0 && bot.sendButtons) {
1584
+ const validFiles = [...allFiles].filter(f => fs.existsSync(f));
1585
+ if (validFiles.length > 0) {
1586
+ const buttons = validFiles.map(filePath => {
1587
+ const shortId = cacheFile(filePath);
1588
+ return [{ text: `📎 ${path.basename(filePath)}`, callback_data: `/file ${shortId}` }];
1589
+ });
1590
+ await bot.sendButtons(chatId, '📂 文件:', buttons);
1591
+ }
1592
+ }
1273
1593
 
1274
1594
  // Auto-name: if this was the first message and session has no name, generate one
1275
1595
  if (wasNew && !getSessionName(session.id)) {
@@ -1287,10 +1607,31 @@ async function askClaude(bot, chatId, prompt) {
1287
1607
  const retryArgs = ['-p', '--session-id', session.id];
1288
1608
  for (const tool of sessionAllowed) retryArgs.push('--allowedTools', tool);
1289
1609
 
1290
- const retry = await spawnClaudeAsync(retryArgs, prompt, session.cwd);
1610
+ const retry = await spawnClaudeStreaming(retryArgs, prompt, session.cwd, onStatus);
1291
1611
  if (retry.output) {
1292
1612
  markSessionStarted(chatId);
1293
- await bot.sendMarkdown(chatId, retry.output);
1613
+ // Parse [[FILE:...]] markers
1614
+ const retryFileMarkers = retry.output.match(/\[\[FILE:([^\]]+)\]\]/g) || [];
1615
+ const retryMarkedFiles = retryFileMarkers.map(m => m.match(/\[\[FILE:([^\]]+)\]\]/)[1].trim());
1616
+ const retryCleanOutput = retry.output.replace(/\s*\[\[FILE:[^\]]+\]\]/g, '').trim();
1617
+ await bot.sendMarkdown(chatId, retryCleanOutput);
1618
+ // Combine marked + auto-detected content files
1619
+ const retryAllFiles = new Set(retryMarkedFiles);
1620
+ if (retry.files && retry.files.length > 0) {
1621
+ for (const f of retry.files) {
1622
+ if (isContentFile(f)) retryAllFiles.add(f);
1623
+ }
1624
+ }
1625
+ if (retryAllFiles.size > 0 && bot.sendButtons) {
1626
+ const validFiles = [...retryAllFiles].filter(f => fs.existsSync(f));
1627
+ if (validFiles.length > 0) {
1628
+ const buttons = validFiles.map(filePath => {
1629
+ const shortId = cacheFile(filePath);
1630
+ return [{ text: `📎 ${path.basename(filePath)}`, callback_data: `/file ${shortId}` }];
1631
+ });
1632
+ await bot.sendButtons(chatId, '📂 文件:', buttons);
1633
+ }
1634
+ }
1294
1635
  } else {
1295
1636
  log('ERROR', `askClaude retry failed: ${(retry.error || '').slice(0, 200)}`);
1296
1637
  try { await bot.sendMessage(chatId, `Error: ${(retry.error || '').slice(0, 200)}`); } catch { /* */ }
@@ -174,7 +174,7 @@ Do NOT repeat existing unchanged values. Only output NEW or CHANGED fields.`;
174
174
  let result;
175
175
  try {
176
176
  result = execSync(
177
- `claude -p --model haiku`,
177
+ `claude -p --model haiku --no-session-persistence`,
178
178
  {
179
179
  input: distillPrompt,
180
180
  encoding: 'utf8',
@@ -673,7 +673,7 @@ If no clear patterns found: respond with exactly NO_PATTERNS`;
673
673
 
674
674
  try {
675
675
  const result = execSync(
676
- `claude -p --model haiku`,
676
+ `claude -p --model haiku --no-session-persistence`,
677
677
  {
678
678
  input: patternPrompt,
679
679
  encoding: 'utf8',
@@ -6,6 +6,9 @@
6
6
 
7
7
  'use strict';
8
8
 
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
9
12
  let Lark;
10
13
  try {
11
14
  Lark = require('@larksuiteoapi/node-sdk');
@@ -108,6 +111,82 @@ function createBot(config) {
108
111
  return { app_id, app_name: 'MetaMe' };
109
112
  },
110
113
 
114
+ /**
115
+ * Send a file/document
116
+ * @param {string} chatId
117
+ * @param {string} filePath - Local file path
118
+ * @param {string} [caption] - Optional caption (sent as separate message)
119
+ */
120
+ async sendFile(chatId, filePath, caption) {
121
+ if (!fs.existsSync(filePath)) {
122
+ throw new Error(`File not found: ${filePath}`);
123
+ }
124
+ const fileName = path.basename(filePath);
125
+ const fileSize = fs.statSync(filePath).size;
126
+
127
+ // For text files under 4KB, just send as text
128
+ const ext = path.extname(filePath).toLowerCase();
129
+ const isText = ['.md', '.txt', '.json', '.yaml', '.yml', '.csv'].includes(ext);
130
+ if (isText && fileSize < 4096) {
131
+ const content = fs.readFileSync(filePath, 'utf8');
132
+ await this.sendMessage(chatId, `📄 ${fileName}:\n\`\`\`\n${content}\n\`\`\``);
133
+ return;
134
+ }
135
+
136
+ // For larger/binary files, try file upload
137
+ try {
138
+ // Use ReadStream as per Feishu SDK docs
139
+ const fileStream = fs.createReadStream(filePath);
140
+
141
+ // 1. Upload file to Feishu
142
+ const uploadRes = await client.im.file.create({
143
+ data: {
144
+ file_type: 'stream',
145
+ file_name: fileName,
146
+ file: fileStream,
147
+ },
148
+ });
149
+
150
+ console.log('[Feishu] Upload response:', JSON.stringify(uploadRes));
151
+
152
+ // Response is { code, msg, data: { file_key } }
153
+ const fileKey = uploadRes?.data?.file_key || uploadRes?.file_key;
154
+ if (!fileKey) {
155
+ throw new Error(`No file_key in response: ${JSON.stringify(uploadRes)}`);
156
+ }
157
+
158
+ // 2. Send file message
159
+ await client.im.message.create({
160
+ params: { receive_id_type: 'chat_id' },
161
+ data: {
162
+ receive_id: chatId,
163
+ msg_type: 'file',
164
+ content: JSON.stringify({ file_key: fileKey }),
165
+ },
166
+ });
167
+ } catch (uploadErr) {
168
+ // Log detailed error
169
+ const errDetail = uploadErr.response?.data || uploadErr.message || uploadErr;
170
+ console.error('[Feishu] File upload error:', JSON.stringify(errDetail));
171
+
172
+ // Fallback: for text files, send content truncated
173
+ if (isText) {
174
+ const content = fs.readFileSync(filePath, 'utf8');
175
+ const truncated = content.length > 3000 ? content.slice(0, 3000) + '\n...(truncated)' : content;
176
+ await this.sendMessage(chatId, `📄 ${fileName}:\n\`\`\`\n${truncated}\n\`\`\``);
177
+ } else {
178
+ // For binary files, give more helpful error
179
+ const errMsg = errDetail?.msg || errDetail?.message || '上传失败';
180
+ throw new Error(`${errMsg} (请检查飞书应用权限: im:resource)`);
181
+ }
182
+ }
183
+
184
+ // 3. Send caption as separate message if provided
185
+ if (caption) {
186
+ await this.sendMessage(chatId, caption);
187
+ }
188
+ },
189
+
111
190
  /**
112
191
  * Start WebSocket long connection to receive messages
113
192
  * @param {function} onMessage - callback(chatId, text, event)
@@ -7,6 +7,8 @@
7
7
  'use strict';
8
8
 
9
9
  const https = require('https');
10
+ const fs = require('fs');
11
+ const path = require('path');
10
12
 
11
13
  const API_BASE = 'https://api.telegram.org';
12
14
 
@@ -169,6 +171,71 @@ function createBot(token) {
169
171
  });
170
172
  },
171
173
 
174
+ /**
175
+ * Send a file/document
176
+ * @param {number|string} chatId - Target chat ID
177
+ * @param {string} filePath - Local file path
178
+ * @param {string} [caption] - Optional caption
179
+ */
180
+ async sendFile(chatId, filePath, caption) {
181
+ if (!fs.existsSync(filePath)) {
182
+ throw new Error(`File not found: ${filePath}`);
183
+ }
184
+ const fileName = path.basename(filePath);
185
+ const fileContent = fs.readFileSync(filePath);
186
+ const boundary = '----MetaMeBoundary' + Date.now();
187
+
188
+ // Build multipart form-data
189
+ let body = '';
190
+ body += `--${boundary}\r\n`;
191
+ body += `Content-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}\r\n`;
192
+ if (caption) {
193
+ body += `--${boundary}\r\n`;
194
+ body += `Content-Disposition: form-data; name="caption"\r\n\r\n${caption}\r\n`;
195
+ }
196
+ body += `--${boundary}\r\n`;
197
+ body += `Content-Disposition: form-data; name="document"; filename="${fileName}"\r\n`;
198
+ body += `Content-Type: application/octet-stream\r\n\r\n`;
199
+
200
+ const bodyStart = Buffer.from(body, 'utf8');
201
+ const bodyEnd = Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8');
202
+ const fullBody = Buffer.concat([bodyStart, fileContent, bodyEnd]);
203
+
204
+ return new Promise((resolve, reject) => {
205
+ const url = `${API_BASE}/bot${token}/sendDocument`;
206
+ const urlObj = new URL(url);
207
+ const options = {
208
+ hostname: urlObj.hostname,
209
+ path: urlObj.pathname,
210
+ method: 'POST',
211
+ headers: {
212
+ 'Content-Type': `multipart/form-data; boundary=${boundary}`,
213
+ 'Content-Length': fullBody.length,
214
+ },
215
+ timeout: 60000,
216
+ };
217
+
218
+ const req = https.request(options, (res) => {
219
+ let data = '';
220
+ res.on('data', (chunk) => { data += chunk; });
221
+ res.on('end', () => {
222
+ try {
223
+ const parsed = JSON.parse(data);
224
+ if (parsed.ok) resolve(parsed.result);
225
+ else reject(new Error(`Telegram API error: ${parsed.description || 'unknown'}`));
226
+ } catch (e) {
227
+ reject(new Error(`Failed to parse response: ${e.message}`));
228
+ }
229
+ });
230
+ });
231
+
232
+ req.on('error', reject);
233
+ req.on('timeout', () => { req.destroy(); reject(new Error('Upload timed out')); });
234
+ req.write(fullBody);
235
+ req.end();
236
+ });
237
+ },
238
+
172
239
  };
173
240
  }
174
241