metame-cli 1.3.6 → 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.6",
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": {
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 切到电脑最近的项目目录',
916
942
  '',
917
- 'Or just type naturally.',
943
+ '📂 Session 管理:',
944
+ '/new [path] [name] — 新建会话',
945
+ '/resume [name] — 选择/恢复会话',
946
+ '/name <name> — 命名当前会话',
947
+ '/cd <path> — 切换工作目录',
948
+ '/session — 查看当前会话',
949
+ '',
950
+ '⚙️ /status /tasks /budget /reload',
951
+ '',
952
+ '直接打字即可对话 💬',
918
953
  ].join('\n'));
919
954
  return;
920
955
  }
@@ -1265,6 +1300,205 @@ function spawnClaudeAsync(args, input, cwd, timeoutMs = 300000) {
1265
1300
  });
1266
1301
  }
1267
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
+
1268
1502
  /**
1269
1503
  * Shared ask logic — full Claude Code session (stateful, with tools)
1270
1504
  * Now uses spawn (async) instead of execSync to allow parallel requests.
@@ -1301,11 +1535,25 @@ async function askClaude(bot, chatId, prompt) {
1301
1535
  args.push('--session-id', session.id);
1302
1536
  }
1303
1537
 
1304
- // Append daemon context hint so Claude reports reload status after editing daemon.yaml
1305
- 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]`;
1306
1547
  const fullPrompt = prompt + daemonHint;
1307
1548
 
1308
- 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);
1309
1557
  clearInterval(typingTimer);
1310
1558
 
1311
1559
  if (output) {
@@ -1316,7 +1564,32 @@ async function askClaude(bot, chatId, prompt) {
1316
1564
  const estimated = Math.ceil((prompt.length + output.length) / 4);
1317
1565
  recordTokens(loadState(), estimated);
1318
1566
 
1319
- 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
+ }
1320
1593
 
1321
1594
  // Auto-name: if this was the first message and session has no name, generate one
1322
1595
  if (wasNew && !getSessionName(session.id)) {
@@ -1334,10 +1607,31 @@ async function askClaude(bot, chatId, prompt) {
1334
1607
  const retryArgs = ['-p', '--session-id', session.id];
1335
1608
  for (const tool of sessionAllowed) retryArgs.push('--allowedTools', tool);
1336
1609
 
1337
- const retry = await spawnClaudeAsync(retryArgs, prompt, session.cwd);
1610
+ const retry = await spawnClaudeStreaming(retryArgs, prompt, session.cwd, onStatus);
1338
1611
  if (retry.output) {
1339
1612
  markSessionStarted(chatId);
1340
- 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
+ }
1341
1635
  } else {
1342
1636
  log('ERROR', `askClaude retry failed: ${(retry.error || '').slice(0, 200)}`);
1343
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