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 +21 -0
- package/package.json +1 -1
- package/scripts/daemon.js +310 -16
- package/scripts/distill.js +2 -2
- package/scripts/feishu-adapter.js +79 -0
- package/scripts/telegram-adapter.js +67 -0
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
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
|
-
'
|
|
908
|
-
'
|
|
909
|
-
'
|
|
910
|
-
'/
|
|
911
|
-
'/
|
|
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
|
-
'
|
|
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
|
|
1305
|
-
const daemonHint =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1610
|
+
const retry = await spawnClaudeStreaming(retryArgs, prompt, session.cwd, onStatus);
|
|
1338
1611
|
if (retry.output) {
|
|
1339
1612
|
markSessionStarted(chatId);
|
|
1340
|
-
|
|
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 { /* */ }
|
package/scripts/distill.js
CHANGED
|
@@ -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
|
|