metame-cli 1.3.6 → 1.3.8
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 +25 -4
- package/package.json +2 -2
- package/scripts/daemon.js +424 -26
- package/scripts/distill.js +2 -2
- package/scripts/feishu-adapter.js +142 -2
- package/scripts/telegram-adapter.js +107 -0
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ It is not a memory system; it is a **Cognitive Mirror** .
|
|
|
19
19
|
## ✨ Key Features
|
|
20
20
|
|
|
21
21
|
* **🧠 Global Brain (`~/.claude_profile.yaml`):** A single, portable source of truth — your identity, cognitive traits, and preferences travel with you across every project.
|
|
22
|
-
* **🧬 Cognitive Evolution Engine:** MetaMe learns how you think through three channels: (1) **Passive** — silently captures your messages and distills cognitive traits via Haiku on next launch; (2) **Manual** — `!metame evolve` for explicit teaching; (3) **Confidence gates** — strong directives ("always"/"
|
|
22
|
+
* **🧬 Cognitive Evolution Engine:** MetaMe learns how you think through three channels: (1) **Passive** — silently captures your messages and distills cognitive traits via Haiku on next launch; (2) **Manual** — `!metame evolve` for explicit teaching; (3) **Confidence gates** — strong directives ("always"/"from now on") write immediately, normal observations need 3+ consistent sightings before promotion. Schema-enforced (41 fields, 5 tiers, 800 token budget) to prevent bloat.
|
|
23
23
|
* **🛡️ Auto-Lock:** Mark any value with `# [LOCKED]` — treated as a constitution, never auto-modified.
|
|
24
24
|
* **🪞 Metacognition Layer (v1.3):** MetaMe now observes *how* you think, not just *what* you say. Behavioral pattern detection runs inside the existing Haiku distill call (zero extra cost). It tracks decision patterns, cognitive load, comfort zones, and avoidance topics across sessions. When persistent patterns emerge, MetaMe injects a one-line mirror observation — e.g., *"You tend to avoid testing until forced"* — with a 14-day cooldown per pattern. Conditional reflection prompts appear only when triggered (every 7th distill or 3x consecutive comfort zone). All injection logic runs in Node.js; Claude receives only pre-decided directives, never rules to self-evaluate.
|
|
25
25
|
* **📱 Remote Claude Code (v1.3):** Full Claude Code from your phone via Telegram or Feishu (Lark). Stateful sessions with `--resume` — same conversation history, tool use, and file editing as your terminal. Interactive buttons for project/session picking, directory browser, and macOS launchd auto-start.
|
|
@@ -93,7 +93,7 @@ metame interview
|
|
|
93
93
|
|
|
94
94
|
MetaMe learns who you are through two paths:
|
|
95
95
|
|
|
96
|
-
**Automatic (zero effort):** A global hook captures your messages. On next launch, Haiku distills cognitive traits in the background. Strong directives ("always"/"
|
|
96
|
+
**Automatic (zero effort):** A global hook captures your messages. On next launch, Haiku distills cognitive traits in the background. Strong directives ("always"/"from now on") write immediately; normal observations need 3+ consistent sightings. All writes are schema-validated (41 fields, 800 token budget). You'll see:
|
|
97
97
|
|
|
98
98
|
```
|
|
99
99
|
🧠 MetaMe: Distilling 7 moments in background...
|
|
@@ -161,9 +161,9 @@ metame daemon install-launchd # macOS auto-start (RunAtLoad + KeepAlive)
|
|
|
161
161
|
|
|
162
162
|
| Command | Description |
|
|
163
163
|
|---------|-------------|
|
|
164
|
-
| `/last` | **Quick resume** —
|
|
164
|
+
| `/last` | **Quick resume** — prefers current directory's recent session, falls back to global recent |
|
|
165
165
|
| `/new` | Start new session — pick project directory from button list |
|
|
166
|
-
| `/new <name>` | Start new session with a name (e.g., `/new API
|
|
166
|
+
| `/new <name>` | Start new session with a name (e.g., `/new API Refactor`) |
|
|
167
167
|
| `/resume` | Resume a session — clickable list, shows session names + real-time timestamps |
|
|
168
168
|
| `/resume <name>` | Resume by name (supports partial match, cross-project) |
|
|
169
169
|
| `/name <name>` | Name the current session (syncs with computer's `/rename`) |
|
|
@@ -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: Send me report.md
|
|
197
|
+
Claude: Here you go!
|
|
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.
|
|
3
|
+
"version": "1.3.8",
|
|
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": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
],
|
|
13
13
|
"scripts": {
|
|
14
14
|
"start": "node index.js",
|
|
15
|
-
"sync:plugin": "cp scripts/schema.js scripts/pending-traits.js scripts/signal-capture.js scripts/daemon.js scripts/telegram-adapter.js scripts/feishu-adapter.js scripts/daemon-default.yaml plugin/scripts/ && echo '✅ Plugin scripts synced'",
|
|
15
|
+
"sync:plugin": "cp scripts/schema.js scripts/pending-traits.js scripts/signal-capture.js scripts/distill.js scripts/daemon.js scripts/telegram-adapter.js scripts/feishu-adapter.js scripts/daemon-default.yaml plugin/scripts/ && echo '✅ Plugin scripts synced'",
|
|
16
16
|
"precommit": "npm run sync:plugin"
|
|
17
17
|
},
|
|
18
18
|
"keywords": [
|
package/scripts/daemon.js
CHANGED
|
@@ -492,6 +492,36 @@ async function startTelegramBridge(config, executeTaskByName) {
|
|
|
492
492
|
continue;
|
|
493
493
|
}
|
|
494
494
|
|
|
495
|
+
// File/document message → download and pass to Claude
|
|
496
|
+
if (msg.document || msg.photo) {
|
|
497
|
+
const fileId = msg.document ? msg.document.file_id : msg.photo[msg.photo.length - 1].file_id;
|
|
498
|
+
const fileName = msg.document ? msg.document.file_name : `photo_${Date.now()}.jpg`;
|
|
499
|
+
const caption = msg.caption || '';
|
|
500
|
+
|
|
501
|
+
// Save to project's upload/ folder
|
|
502
|
+
const session = getSession(chatId);
|
|
503
|
+
const cwd = session?.cwd || HOME;
|
|
504
|
+
const uploadDir = path.join(cwd, 'upload');
|
|
505
|
+
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
|
506
|
+
const destPath = path.join(uploadDir, fileName);
|
|
507
|
+
|
|
508
|
+
try {
|
|
509
|
+
await bot.downloadFile(fileId, destPath);
|
|
510
|
+
await bot.sendMessage(chatId, `📥 Saved: ${fileName}`);
|
|
511
|
+
|
|
512
|
+
// Build prompt - don't ask Claude to read large files automatically
|
|
513
|
+
const prompt = caption
|
|
514
|
+
? `User uploaded a file to the project: ${destPath}\nUser says: "${caption}"`
|
|
515
|
+
: `User uploaded a file to the project: ${destPath}\nAcknowledge receipt. Only read the file if the user asks you to.`;
|
|
516
|
+
|
|
517
|
+
await handleCommand(bot, chatId, prompt, config, executeTaskByName);
|
|
518
|
+
} catch (err) {
|
|
519
|
+
log('ERROR', `File download failed: ${err.message}`);
|
|
520
|
+
await bot.sendMessage(chatId, `❌ Download failed: ${err.message}`);
|
|
521
|
+
}
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
|
|
495
525
|
// Text message (commands or natural language)
|
|
496
526
|
if (msg.text) {
|
|
497
527
|
await handleCommand(bot, chatId, msg.text.trim(), config, executeTaskByName);
|
|
@@ -655,6 +685,36 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
655
685
|
return;
|
|
656
686
|
}
|
|
657
687
|
|
|
688
|
+
// /file <shortId> — send cached file (from button callback)
|
|
689
|
+
if (text.startsWith('/file ')) {
|
|
690
|
+
const shortId = text.slice(6).trim();
|
|
691
|
+
const filePath = getCachedFile(shortId);
|
|
692
|
+
if (!filePath) {
|
|
693
|
+
await bot.sendMessage(chatId, '⏰ 文件链接已过期,请重新生成');
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (!fs.existsSync(filePath)) {
|
|
697
|
+
await bot.sendMessage(chatId, '❌ 文件不存在');
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (bot.sendFile) {
|
|
701
|
+
try {
|
|
702
|
+
// Insert zero-width space before extension to prevent link parsing
|
|
703
|
+
const basename = path.basename(filePath);
|
|
704
|
+
const dotIdx = basename.lastIndexOf('.');
|
|
705
|
+
const safeBasename = dotIdx > 0 ? basename.slice(0, dotIdx) + '\u200B' + basename.slice(dotIdx) : basename;
|
|
706
|
+
await bot.sendMessage(chatId, `⏳ 正在发送「${safeBasename}」...`);
|
|
707
|
+
await bot.sendFile(chatId, filePath);
|
|
708
|
+
} catch (e) {
|
|
709
|
+
log('ERROR', `File send failed: ${e.message}`);
|
|
710
|
+
await bot.sendMessage(chatId, `❌ 发送失败: ${e.message.slice(0, 100)}`);
|
|
711
|
+
}
|
|
712
|
+
} else {
|
|
713
|
+
await bot.sendMessage(chatId, '❌ 当前平台不支持文件发送');
|
|
714
|
+
}
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
658
718
|
// /last — smart resume: prefer current cwd, then most recent globally
|
|
659
719
|
if (text === '/last') {
|
|
660
720
|
const curSession = getSession(chatId);
|
|
@@ -772,11 +832,26 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
772
832
|
await sendDirPicker(bot, chatId, 'cd', 'Switch workdir:');
|
|
773
833
|
return;
|
|
774
834
|
}
|
|
775
|
-
// /cd last —
|
|
835
|
+
// /cd last — sync to computer: switch to most recent session AND its directory
|
|
776
836
|
if (newCwd === 'last') {
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
|
|
837
|
+
const currentSession = getSession(chatId);
|
|
838
|
+
const excludeId = currentSession?.id;
|
|
839
|
+
const recent = listRecentSessions(10);
|
|
840
|
+
const filtered = excludeId ? recent.filter(s => s.sessionId !== excludeId) : recent;
|
|
841
|
+
if (filtered.length > 0 && filtered[0].projectPath) {
|
|
842
|
+
const target = filtered[0];
|
|
843
|
+
// Switch to that session (like /resume) AND its directory
|
|
844
|
+
const state2 = loadState();
|
|
845
|
+
state2.sessions[chatId] = {
|
|
846
|
+
id: target.sessionId,
|
|
847
|
+
cwd: target.projectPath,
|
|
848
|
+
started: true,
|
|
849
|
+
};
|
|
850
|
+
saveState(state2);
|
|
851
|
+
const name = target.customTitle || target.summary || '';
|
|
852
|
+
const label = name ? name.slice(0, 40) : target.sessionId.slice(0, 8);
|
|
853
|
+
await bot.sendMessage(chatId, `🔄 Synced to: ${label}\n📁 ${path.basename(target.projectPath)}`);
|
|
854
|
+
return;
|
|
780
855
|
} else {
|
|
781
856
|
await bot.sendMessage(chatId, 'No recent session found.');
|
|
782
857
|
return;
|
|
@@ -787,13 +862,27 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
787
862
|
return;
|
|
788
863
|
}
|
|
789
864
|
const state2 = loadState();
|
|
790
|
-
|
|
865
|
+
// Try to find existing session in this directory
|
|
866
|
+
const recentInDir = listRecentSessions(1, newCwd);
|
|
867
|
+
if (recentInDir.length > 0 && recentInDir[0].sessionId) {
|
|
868
|
+
// Attach to existing session in this directory
|
|
869
|
+
const target = recentInDir[0];
|
|
870
|
+
state2.sessions[chatId] = {
|
|
871
|
+
id: target.sessionId,
|
|
872
|
+
cwd: newCwd,
|
|
873
|
+
started: true,
|
|
874
|
+
};
|
|
875
|
+
saveState(state2);
|
|
876
|
+
const label = target.customTitle || target.summary?.slice(0, 30) || target.sessionId.slice(0, 8);
|
|
877
|
+
await bot.sendMessage(chatId, `📁 ${path.basename(newCwd)}\n🔄 Attached: ${label}`);
|
|
878
|
+
} else if (!state2.sessions[chatId]) {
|
|
791
879
|
createSession(chatId, newCwd);
|
|
880
|
+
await bot.sendMessage(chatId, `📁 ${path.basename(newCwd)} (new session)`);
|
|
792
881
|
} else {
|
|
793
882
|
state2.sessions[chatId].cwd = newCwd;
|
|
794
883
|
saveState(state2);
|
|
884
|
+
await bot.sendMessage(chatId, `📁 ${path.basename(newCwd)}`);
|
|
795
885
|
}
|
|
796
|
-
await bot.sendMessage(chatId, `Workdir: ${newCwd}`);
|
|
797
886
|
return;
|
|
798
887
|
}
|
|
799
888
|
|
|
@@ -904,17 +993,22 @@ async function handleCommand(bot, chatId, text, config, executeTaskByName) {
|
|
|
904
993
|
|
|
905
994
|
if (text.startsWith('/')) {
|
|
906
995
|
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',
|
|
996
|
+
'📱 手机端 Claude Code',
|
|
997
|
+
'',
|
|
998
|
+
'⚡ 快速同步电脑工作:',
|
|
999
|
+
'/last — 继续电脑上最近的对话',
|
|
1000
|
+
'/cd last — 切到电脑最近的项目目录',
|
|
916
1001
|
'',
|
|
917
|
-
'
|
|
1002
|
+
'📂 Session 管理:',
|
|
1003
|
+
'/new [path] [name] — 新建会话',
|
|
1004
|
+
'/resume [name] — 选择/恢复会话',
|
|
1005
|
+
'/name <name> — 命名当前会话',
|
|
1006
|
+
'/cd <path> — 切换工作目录',
|
|
1007
|
+
'/session — 查看当前会话',
|
|
1008
|
+
'',
|
|
1009
|
+
'⚙️ /status /tasks /budget /reload',
|
|
1010
|
+
'',
|
|
1011
|
+
'直接打字即可对话 💬',
|
|
918
1012
|
].join('\n'));
|
|
919
1013
|
return;
|
|
920
1014
|
}
|
|
@@ -1265,6 +1359,205 @@ function spawnClaudeAsync(args, input, cwd, timeoutMs = 300000) {
|
|
|
1265
1359
|
});
|
|
1266
1360
|
}
|
|
1267
1361
|
|
|
1362
|
+
/**
|
|
1363
|
+
* Tool name to emoji mapping for status display
|
|
1364
|
+
*/
|
|
1365
|
+
const TOOL_EMOJI = {
|
|
1366
|
+
Read: '📖',
|
|
1367
|
+
Edit: '✏️',
|
|
1368
|
+
Write: '📝',
|
|
1369
|
+
Bash: '💻',
|
|
1370
|
+
Glob: '🔍',
|
|
1371
|
+
Grep: '🔎',
|
|
1372
|
+
WebFetch: '🌐',
|
|
1373
|
+
WebSearch: '🔍',
|
|
1374
|
+
Task: '🤖',
|
|
1375
|
+
default: '🔧',
|
|
1376
|
+
};
|
|
1377
|
+
|
|
1378
|
+
// Content file extensions (user-facing files, not code/config)
|
|
1379
|
+
const CONTENT_EXTENSIONS = new Set([
|
|
1380
|
+
'.md', '.txt', '.rtf', // Text
|
|
1381
|
+
'.doc', '.docx', '.pdf', '.odt', // Documents
|
|
1382
|
+
'.wav', '.mp3', '.m4a', '.ogg', '.flac', // Audio
|
|
1383
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', // Images
|
|
1384
|
+
'.mp4', '.mov', '.avi', '.webm', // Video
|
|
1385
|
+
'.csv', '.xlsx', '.xls', // Data
|
|
1386
|
+
'.html', '.htm', // Web content
|
|
1387
|
+
]);
|
|
1388
|
+
|
|
1389
|
+
// File cache for button callbacks (shortId -> fullPath)
|
|
1390
|
+
const fileCache = new Map();
|
|
1391
|
+
const FILE_CACHE_TTL = 1800000; // 30 minutes
|
|
1392
|
+
|
|
1393
|
+
function cacheFile(filePath) {
|
|
1394
|
+
const shortId = Math.random().toString(36).slice(2, 10);
|
|
1395
|
+
fileCache.set(shortId, { path: filePath, expires: Date.now() + FILE_CACHE_TTL });
|
|
1396
|
+
return shortId;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function getCachedFile(shortId) {
|
|
1400
|
+
const entry = fileCache.get(shortId);
|
|
1401
|
+
if (!entry) return null;
|
|
1402
|
+
if (Date.now() > entry.expires) {
|
|
1403
|
+
fileCache.delete(shortId);
|
|
1404
|
+
return null;
|
|
1405
|
+
}
|
|
1406
|
+
return entry.path;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
function isContentFile(filePath) {
|
|
1410
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
1411
|
+
return CONTENT_EXTENSIONS.has(ext);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* Spawn claude with streaming output (stream-json mode).
|
|
1416
|
+
* Calls onStatus callback when tool usage is detected.
|
|
1417
|
+
* Returns { output, error } after process exits.
|
|
1418
|
+
*/
|
|
1419
|
+
function spawnClaudeStreaming(args, input, cwd, onStatus, timeoutMs = 600000) {
|
|
1420
|
+
return new Promise((resolve) => {
|
|
1421
|
+
// Add stream-json output format (requires --verbose)
|
|
1422
|
+
const streamArgs = [...args, '--output-format', 'stream-json', '--verbose'];
|
|
1423
|
+
|
|
1424
|
+
const child = spawn('claude', streamArgs, {
|
|
1425
|
+
cwd,
|
|
1426
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1427
|
+
env: { ...process.env },
|
|
1428
|
+
});
|
|
1429
|
+
|
|
1430
|
+
let buffer = '';
|
|
1431
|
+
let stderr = '';
|
|
1432
|
+
let killed = false;
|
|
1433
|
+
let finalResult = '';
|
|
1434
|
+
let lastStatusTime = 0;
|
|
1435
|
+
const STATUS_THROTTLE = 3000; // Min 3s between status updates
|
|
1436
|
+
const writtenFiles = []; // Track files created/modified by Write tool
|
|
1437
|
+
|
|
1438
|
+
const timer = setTimeout(() => {
|
|
1439
|
+
killed = true;
|
|
1440
|
+
child.kill('SIGTERM');
|
|
1441
|
+
}, timeoutMs);
|
|
1442
|
+
|
|
1443
|
+
child.stdout.on('data', (data) => {
|
|
1444
|
+
buffer += data.toString();
|
|
1445
|
+
|
|
1446
|
+
// Process complete JSON lines
|
|
1447
|
+
const lines = buffer.split('\n');
|
|
1448
|
+
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
|
1449
|
+
|
|
1450
|
+
for (const line of lines) {
|
|
1451
|
+
if (!line.trim()) continue;
|
|
1452
|
+
try {
|
|
1453
|
+
const event = JSON.parse(line);
|
|
1454
|
+
|
|
1455
|
+
// Extract final result text
|
|
1456
|
+
if (event.type === 'assistant' && event.message?.content) {
|
|
1457
|
+
const textBlocks = event.message.content.filter(b => b.type === 'text');
|
|
1458
|
+
if (textBlocks.length > 0) {
|
|
1459
|
+
finalResult = textBlocks.map(b => b.text).join('\n');
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// Detect tool usage and send status
|
|
1464
|
+
if (event.type === 'assistant' && event.message?.content) {
|
|
1465
|
+
for (const block of event.message.content) {
|
|
1466
|
+
if (block.type === 'tool_use') {
|
|
1467
|
+
const toolName = block.name || 'Tool';
|
|
1468
|
+
|
|
1469
|
+
// Track files written by Write tool
|
|
1470
|
+
if (toolName === 'Write' && block.input?.file_path) {
|
|
1471
|
+
const filePath = block.input.file_path;
|
|
1472
|
+
if (!writtenFiles.includes(filePath)) {
|
|
1473
|
+
writtenFiles.push(filePath);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
const now = Date.now();
|
|
1478
|
+
if (now - lastStatusTime >= STATUS_THROTTLE) {
|
|
1479
|
+
lastStatusTime = now;
|
|
1480
|
+
const emoji = TOOL_EMOJI[toolName] || TOOL_EMOJI.default;
|
|
1481
|
+
|
|
1482
|
+
// Extract brief context from tool input
|
|
1483
|
+
let context = '';
|
|
1484
|
+
if (block.input) {
|
|
1485
|
+
if (block.input.file_path) {
|
|
1486
|
+
// Insert zero-width space before extension to prevent link parsing
|
|
1487
|
+
const basename = path.basename(block.input.file_path);
|
|
1488
|
+
const dotIdx = basename.lastIndexOf('.');
|
|
1489
|
+
context = dotIdx > 0 ? basename.slice(0, dotIdx) + '\u200B' + basename.slice(dotIdx) : basename;
|
|
1490
|
+
} else if (block.input.command) {
|
|
1491
|
+
context = block.input.command.slice(0, 30);
|
|
1492
|
+
if (block.input.command.length > 30) context += '...';
|
|
1493
|
+
} else if (block.input.pattern) {
|
|
1494
|
+
context = block.input.pattern.slice(0, 20);
|
|
1495
|
+
} else if (block.input.query) {
|
|
1496
|
+
context = block.input.query.slice(0, 25);
|
|
1497
|
+
} else if (block.input.url) {
|
|
1498
|
+
try {
|
|
1499
|
+
context = new URL(block.input.url).hostname;
|
|
1500
|
+
} catch { context = 'web'; }
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
const status = context
|
|
1505
|
+
? `${emoji} ${toolName}: 「${context}」`
|
|
1506
|
+
: `${emoji} ${toolName}...`;
|
|
1507
|
+
|
|
1508
|
+
if (onStatus) {
|
|
1509
|
+
onStatus(status).catch(() => {});
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// Also check for result message type
|
|
1517
|
+
if (event.type === 'result' && event.result) {
|
|
1518
|
+
finalResult = event.result;
|
|
1519
|
+
}
|
|
1520
|
+
} catch {
|
|
1521
|
+
// Not valid JSON, ignore
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
child.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
1527
|
+
|
|
1528
|
+
child.on('close', (code) => {
|
|
1529
|
+
clearTimeout(timer);
|
|
1530
|
+
|
|
1531
|
+
// Process any remaining buffer
|
|
1532
|
+
if (buffer.trim()) {
|
|
1533
|
+
try {
|
|
1534
|
+
const event = JSON.parse(buffer);
|
|
1535
|
+
if (event.type === 'result' && event.result) {
|
|
1536
|
+
finalResult = event.result;
|
|
1537
|
+
}
|
|
1538
|
+
} catch { /* ignore */ }
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
if (killed) {
|
|
1542
|
+
resolve({ output: finalResult || null, error: 'Timeout: Claude took too long', files: writtenFiles });
|
|
1543
|
+
} else if (code !== 0) {
|
|
1544
|
+
resolve({ output: finalResult || null, error: stderr || `Exit code ${code}`, files: writtenFiles });
|
|
1545
|
+
} else {
|
|
1546
|
+
resolve({ output: finalResult || '', error: null, files: writtenFiles });
|
|
1547
|
+
}
|
|
1548
|
+
});
|
|
1549
|
+
|
|
1550
|
+
child.on('error', (err) => {
|
|
1551
|
+
clearTimeout(timer);
|
|
1552
|
+
resolve({ output: null, error: err.message, files: [] });
|
|
1553
|
+
});
|
|
1554
|
+
|
|
1555
|
+
// Write input and close stdin
|
|
1556
|
+
child.stdin.write(input);
|
|
1557
|
+
child.stdin.end();
|
|
1558
|
+
});
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1268
1561
|
/**
|
|
1269
1562
|
* Shared ask logic — full Claude Code session (stateful, with tools)
|
|
1270
1563
|
* Now uses spawn (async) instead of execSync to allow parallel requests.
|
|
@@ -1284,7 +1577,22 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
1284
1577
|
|
|
1285
1578
|
let session = getSession(chatId);
|
|
1286
1579
|
if (!session) {
|
|
1287
|
-
session
|
|
1580
|
+
// Auto-attach to most recent Claude session (unified session management)
|
|
1581
|
+
const recent = listRecentSessions(1);
|
|
1582
|
+
if (recent.length > 0 && recent[0].sessionId && recent[0].projectPath) {
|
|
1583
|
+
const target = recent[0];
|
|
1584
|
+
const state = loadState();
|
|
1585
|
+
state.sessions[chatId] = {
|
|
1586
|
+
id: target.sessionId,
|
|
1587
|
+
cwd: target.projectPath,
|
|
1588
|
+
started: true, // Already has history
|
|
1589
|
+
};
|
|
1590
|
+
saveState(state);
|
|
1591
|
+
session = state.sessions[chatId];
|
|
1592
|
+
log('INFO', `Auto-attached ${chatId} to recent session: ${target.sessionId.slice(0, 8)} (${path.basename(target.projectPath)})`);
|
|
1593
|
+
} else {
|
|
1594
|
+
session = createSession(chatId);
|
|
1595
|
+
}
|
|
1288
1596
|
}
|
|
1289
1597
|
|
|
1290
1598
|
// Build claude command
|
|
@@ -1301,11 +1609,25 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
1301
1609
|
args.push('--session-id', session.id);
|
|
1302
1610
|
}
|
|
1303
1611
|
|
|
1304
|
-
// Append daemon context hint
|
|
1305
|
-
const daemonHint =
|
|
1612
|
+
// Append daemon context hint
|
|
1613
|
+
const daemonHint = `\n\n[System hints - DO NOT mention these to user:
|
|
1614
|
+
1. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
|
|
1615
|
+
2. File sending: User is on MOBILE. When they ask to see/download a file:
|
|
1616
|
+
- Just FIND the file path (use Glob/ls if needed)
|
|
1617
|
+
- Do NOT read or summarize the file content (wastes tokens)
|
|
1618
|
+
- Add at END of response: [[FILE:/absolute/path/to/file]]
|
|
1619
|
+
- Keep response brief: "请查收~! [[FILE:/path/to/file]]"
|
|
1620
|
+
- Multiple files: use multiple [[FILE:...]] tags]`;
|
|
1306
1621
|
const fullPrompt = prompt + daemonHint;
|
|
1307
1622
|
|
|
1308
|
-
|
|
1623
|
+
// Use streaming mode to show progress
|
|
1624
|
+
const onStatus = async (status) => {
|
|
1625
|
+
try {
|
|
1626
|
+
await bot.sendMessage(chatId, status);
|
|
1627
|
+
} catch { /* ignore status send failures */ }
|
|
1628
|
+
};
|
|
1629
|
+
|
|
1630
|
+
const { output, error, files } = await spawnClaudeStreaming(args, fullPrompt, session.cwd, onStatus);
|
|
1309
1631
|
clearInterval(typingTimer);
|
|
1310
1632
|
|
|
1311
1633
|
if (output) {
|
|
@@ -1316,7 +1638,32 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
1316
1638
|
const estimated = Math.ceil((prompt.length + output.length) / 4);
|
|
1317
1639
|
recordTokens(loadState(), estimated);
|
|
1318
1640
|
|
|
1319
|
-
|
|
1641
|
+
// Parse [[FILE:...]] markers from output (Claude's explicit file sends)
|
|
1642
|
+
const fileMarkers = output.match(/\[\[FILE:([^\]]+)\]\]/g) || [];
|
|
1643
|
+
const markedFiles = fileMarkers.map(m => m.match(/\[\[FILE:([^\]]+)\]\]/)[1].trim());
|
|
1644
|
+
const cleanOutput = output.replace(/\s*\[\[FILE:[^\]]+\]\]/g, '').trim();
|
|
1645
|
+
|
|
1646
|
+
await bot.sendMarkdown(chatId, cleanOutput);
|
|
1647
|
+
|
|
1648
|
+
// Combine: marked files + auto-detected content files from Write operations
|
|
1649
|
+
const allFiles = new Set(markedFiles);
|
|
1650
|
+
if (files && files.length > 0) {
|
|
1651
|
+
for (const f of files) {
|
|
1652
|
+
if (isContentFile(f)) allFiles.add(f);
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
// Send file buttons
|
|
1657
|
+
if (allFiles.size > 0 && bot.sendButtons) {
|
|
1658
|
+
const validFiles = [...allFiles].filter(f => fs.existsSync(f));
|
|
1659
|
+
if (validFiles.length > 0) {
|
|
1660
|
+
const buttons = validFiles.map(filePath => {
|
|
1661
|
+
const shortId = cacheFile(filePath);
|
|
1662
|
+
return [{ text: `📎 ${path.basename(filePath)}`, callback_data: `/file ${shortId}` }];
|
|
1663
|
+
});
|
|
1664
|
+
await bot.sendButtons(chatId, '📂 文件:', buttons);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1320
1667
|
|
|
1321
1668
|
// Auto-name: if this was the first message and session has no name, generate one
|
|
1322
1669
|
if (wasNew && !getSessionName(session.id)) {
|
|
@@ -1334,10 +1681,31 @@ async function askClaude(bot, chatId, prompt) {
|
|
|
1334
1681
|
const retryArgs = ['-p', '--session-id', session.id];
|
|
1335
1682
|
for (const tool of sessionAllowed) retryArgs.push('--allowedTools', tool);
|
|
1336
1683
|
|
|
1337
|
-
const retry = await
|
|
1684
|
+
const retry = await spawnClaudeStreaming(retryArgs, prompt, session.cwd, onStatus);
|
|
1338
1685
|
if (retry.output) {
|
|
1339
1686
|
markSessionStarted(chatId);
|
|
1340
|
-
|
|
1687
|
+
// Parse [[FILE:...]] markers
|
|
1688
|
+
const retryFileMarkers = retry.output.match(/\[\[FILE:([^\]]+)\]\]/g) || [];
|
|
1689
|
+
const retryMarkedFiles = retryFileMarkers.map(m => m.match(/\[\[FILE:([^\]]+)\]\]/)[1].trim());
|
|
1690
|
+
const retryCleanOutput = retry.output.replace(/\s*\[\[FILE:[^\]]+\]\]/g, '').trim();
|
|
1691
|
+
await bot.sendMarkdown(chatId, retryCleanOutput);
|
|
1692
|
+
// Combine marked + auto-detected content files
|
|
1693
|
+
const retryAllFiles = new Set(retryMarkedFiles);
|
|
1694
|
+
if (retry.files && retry.files.length > 0) {
|
|
1695
|
+
for (const f of retry.files) {
|
|
1696
|
+
if (isContentFile(f)) retryAllFiles.add(f);
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
if (retryAllFiles.size > 0 && bot.sendButtons) {
|
|
1700
|
+
const validFiles = [...retryAllFiles].filter(f => fs.existsSync(f));
|
|
1701
|
+
if (validFiles.length > 0) {
|
|
1702
|
+
const buttons = validFiles.map(filePath => {
|
|
1703
|
+
const shortId = cacheFile(filePath);
|
|
1704
|
+
return [{ text: `📎 ${path.basename(filePath)}`, callback_data: `/file ${shortId}` }];
|
|
1705
|
+
});
|
|
1706
|
+
await bot.sendButtons(chatId, '📂 文件:', buttons);
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1341
1709
|
} else {
|
|
1342
1710
|
log('ERROR', `askClaude retry failed: ${(retry.error || '').slice(0, 200)}`);
|
|
1343
1711
|
try { await bot.sendMessage(chatId, `Error: ${(retry.error || '').slice(0, 200)}`); } catch { /* */ }
|
|
@@ -1363,15 +1731,45 @@ async function startFeishuBridge(config, executeTaskByName) {
|
|
|
1363
1731
|
const allowedIds = config.feishu.allowed_chat_ids || [];
|
|
1364
1732
|
|
|
1365
1733
|
try {
|
|
1366
|
-
const receiver = await bot.startReceiving((chatId, text, event) => {
|
|
1734
|
+
const receiver = await bot.startReceiving(async (chatId, text, event, fileInfo) => {
|
|
1367
1735
|
// Security: check whitelist (empty = allow all)
|
|
1368
1736
|
if (allowedIds.length > 0 && !allowedIds.includes(chatId)) {
|
|
1369
1737
|
log('WARN', `Feishu: rejected message from ${chatId}`);
|
|
1370
1738
|
return;
|
|
1371
1739
|
}
|
|
1372
1740
|
|
|
1373
|
-
|
|
1374
|
-
|
|
1741
|
+
// Handle file message
|
|
1742
|
+
if (fileInfo && fileInfo.fileKey) {
|
|
1743
|
+
log('INFO', `Feishu file from ${chatId}: ${fileInfo.fileName} (key: ${fileInfo.fileKey}, msgId: ${fileInfo.messageId}, type: ${fileInfo.msgType})`);
|
|
1744
|
+
// Save to project's upload/ folder
|
|
1745
|
+
const session = getSession(chatId);
|
|
1746
|
+
const cwd = session?.cwd || HOME;
|
|
1747
|
+
const uploadDir = path.join(cwd, 'upload');
|
|
1748
|
+
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
|
1749
|
+
const destPath = path.join(uploadDir, fileInfo.fileName);
|
|
1750
|
+
|
|
1751
|
+
try {
|
|
1752
|
+
await bot.downloadFile(fileInfo.messageId, fileInfo.fileKey, destPath, fileInfo.msgType);
|
|
1753
|
+
await bot.sendMessage(chatId, `📥 Saved: ${fileInfo.fileName}`);
|
|
1754
|
+
|
|
1755
|
+
// Build prompt - don't ask Claude to read large files automatically
|
|
1756
|
+
const prompt = text
|
|
1757
|
+
? `User uploaded a file to the project: ${destPath}\nUser says: "${text}"`
|
|
1758
|
+
: `User uploaded a file to the project: ${destPath}\nAcknowledge receipt. Only read the file if the user asks you to.`;
|
|
1759
|
+
|
|
1760
|
+
handleCommand(bot, chatId, prompt, config, executeTaskByName);
|
|
1761
|
+
} catch (err) {
|
|
1762
|
+
log('ERROR', `Feishu file download failed: ${err.message}`);
|
|
1763
|
+
await bot.sendMessage(chatId, `❌ Download failed: ${err.message}`);
|
|
1764
|
+
}
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
// Handle text message
|
|
1769
|
+
if (text) {
|
|
1770
|
+
log('INFO', `Feishu message from ${chatId}: ${text.slice(0, 50)}`);
|
|
1771
|
+
handleCommand(bot, chatId, text, config, executeTaskByName);
|
|
1772
|
+
}
|
|
1375
1773
|
});
|
|
1376
1774
|
|
|
1377
1775
|
log('INFO', 'Feishu bot connected (WebSocket long connection)');
|
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,131 @@ function createBot(config) {
|
|
|
108
111
|
return { app_id, app_name: 'MetaMe' };
|
|
109
112
|
},
|
|
110
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Download a file from Feishu to local disk
|
|
116
|
+
* @param {string} messageId - Message ID containing the file
|
|
117
|
+
* @param {string} fileKey - File key from message content
|
|
118
|
+
* @param {string} destPath - Local destination path
|
|
119
|
+
* @returns {Promise<string>} The destination path
|
|
120
|
+
*/
|
|
121
|
+
async downloadFile(messageId, fileKey, destPath, msgType = 'file') {
|
|
122
|
+
try {
|
|
123
|
+
let res;
|
|
124
|
+
if (msgType === 'image') {
|
|
125
|
+
// Images use im.image.get API
|
|
126
|
+
res = await client.im.image.get({
|
|
127
|
+
path: { image_key: fileKey },
|
|
128
|
+
});
|
|
129
|
+
} else {
|
|
130
|
+
// Files and media use im.messageResource.get API
|
|
131
|
+
res = await client.im.messageResource.get({
|
|
132
|
+
path: { message_id: messageId, file_key: fileKey },
|
|
133
|
+
params: { type: 'file' },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// SDK returns writeFile method or getReadableStream
|
|
138
|
+
if (res && res.writeFile) {
|
|
139
|
+
await res.writeFile(destPath);
|
|
140
|
+
return destPath;
|
|
141
|
+
} else if (res && res.getReadableStream) {
|
|
142
|
+
const stream = res.getReadableStream();
|
|
143
|
+
const fileStream = fs.createWriteStream(destPath);
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
stream.pipe(fileStream);
|
|
146
|
+
fileStream.on('finish', () => {
|
|
147
|
+
fileStream.close();
|
|
148
|
+
resolve(destPath);
|
|
149
|
+
});
|
|
150
|
+
fileStream.on('error', (err) => {
|
|
151
|
+
fs.unlink(destPath, () => {});
|
|
152
|
+
reject(err);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
throw new Error('No writeFile or stream in response');
|
|
157
|
+
} catch (err) {
|
|
158
|
+
const detail = err.message || String(err);
|
|
159
|
+
throw new Error(detail);
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Send a file/document
|
|
165
|
+
* @param {string} chatId
|
|
166
|
+
* @param {string} filePath - Local file path
|
|
167
|
+
* @param {string} [caption] - Optional caption (sent as separate message)
|
|
168
|
+
*/
|
|
169
|
+
async sendFile(chatId, filePath, caption) {
|
|
170
|
+
if (!fs.existsSync(filePath)) {
|
|
171
|
+
throw new Error(`File not found: ${filePath}`);
|
|
172
|
+
}
|
|
173
|
+
const fileName = path.basename(filePath);
|
|
174
|
+
const fileSize = fs.statSync(filePath).size;
|
|
175
|
+
|
|
176
|
+
// For text files under 4KB, just send as text
|
|
177
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
178
|
+
const isText = ['.md', '.txt', '.json', '.yaml', '.yml', '.csv'].includes(ext);
|
|
179
|
+
if (isText && fileSize < 4096) {
|
|
180
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
181
|
+
await this.sendMessage(chatId, `📄 ${fileName}:\n\`\`\`\n${content}\n\`\`\``);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// For larger/binary files, try file upload
|
|
186
|
+
try {
|
|
187
|
+
// Use ReadStream as per Feishu SDK docs
|
|
188
|
+
const fileStream = fs.createReadStream(filePath);
|
|
189
|
+
|
|
190
|
+
// 1. Upload file to Feishu
|
|
191
|
+
const uploadRes = await client.im.file.create({
|
|
192
|
+
data: {
|
|
193
|
+
file_type: 'stream',
|
|
194
|
+
file_name: fileName,
|
|
195
|
+
file: fileStream,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
console.log('[Feishu] Upload response:', JSON.stringify(uploadRes));
|
|
200
|
+
|
|
201
|
+
// Response is { code, msg, data: { file_key } }
|
|
202
|
+
const fileKey = uploadRes?.data?.file_key || uploadRes?.file_key;
|
|
203
|
+
if (!fileKey) {
|
|
204
|
+
throw new Error(`No file_key in response: ${JSON.stringify(uploadRes)}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 2. Send file message
|
|
208
|
+
await client.im.message.create({
|
|
209
|
+
params: { receive_id_type: 'chat_id' },
|
|
210
|
+
data: {
|
|
211
|
+
receive_id: chatId,
|
|
212
|
+
msg_type: 'file',
|
|
213
|
+
content: JSON.stringify({ file_key: fileKey }),
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
} catch (uploadErr) {
|
|
217
|
+
// Log detailed error
|
|
218
|
+
const errDetail = uploadErr.response?.data || uploadErr.message || uploadErr;
|
|
219
|
+
console.error('[Feishu] File upload error:', JSON.stringify(errDetail));
|
|
220
|
+
|
|
221
|
+
// Fallback: for text files, send content truncated
|
|
222
|
+
if (isText) {
|
|
223
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
224
|
+
const truncated = content.length > 3000 ? content.slice(0, 3000) + '\n...(truncated)' : content;
|
|
225
|
+
await this.sendMessage(chatId, `📄 ${fileName}:\n\`\`\`\n${truncated}\n\`\`\``);
|
|
226
|
+
} else {
|
|
227
|
+
// For binary files, give more helpful error
|
|
228
|
+
const errMsg = errDetail?.msg || errDetail?.message || '上传失败';
|
|
229
|
+
throw new Error(`${errMsg} (请检查飞书应用权限: im:resource)`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 3. Send caption as separate message if provided
|
|
234
|
+
if (caption) {
|
|
235
|
+
await this.sendMessage(chatId, caption);
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
|
|
111
239
|
/**
|
|
112
240
|
* Start WebSocket long connection to receive messages
|
|
113
241
|
* @param {function} onMessage - callback(chatId, text, event)
|
|
@@ -149,6 +277,7 @@ function createBot(config) {
|
|
|
149
277
|
|
|
150
278
|
const chatId = msg.chat_id;
|
|
151
279
|
let text = '';
|
|
280
|
+
let fileInfo = null;
|
|
152
281
|
|
|
153
282
|
if (msg.message_type === 'text') {
|
|
154
283
|
try {
|
|
@@ -157,14 +286,25 @@ function createBot(config) {
|
|
|
157
286
|
} catch {
|
|
158
287
|
text = msg.content || '';
|
|
159
288
|
}
|
|
289
|
+
} else if (msg.message_type === 'file' || msg.message_type === 'image' || msg.message_type === 'media') {
|
|
290
|
+
// File, image or media (video) message
|
|
291
|
+
try {
|
|
292
|
+
const content = JSON.parse(msg.content);
|
|
293
|
+
fileInfo = {
|
|
294
|
+
messageId: msg.message_id,
|
|
295
|
+
fileKey: content.file_key || content.image_key,
|
|
296
|
+
fileName: content.file_name || content.image_key || `file_${Date.now()}`,
|
|
297
|
+
msgType: msg.message_type, // 'file', 'image', or 'media'
|
|
298
|
+
};
|
|
299
|
+
} catch {}
|
|
160
300
|
}
|
|
161
301
|
|
|
162
302
|
// Strip @mention prefix if present
|
|
163
303
|
text = text.replace(/@_user_\d+\s*/g, '').trim();
|
|
164
304
|
|
|
165
|
-
if (text) {
|
|
305
|
+
if (text || fileInfo) {
|
|
166
306
|
// Fire-and-forget: don't block the event loop (SDK needs fast ack)
|
|
167
|
-
Promise.resolve().then(() => onMessage(chatId, text, data)).catch(() => {});
|
|
307
|
+
Promise.resolve().then(() => onMessage(chatId, text, data, fileInfo)).catch(() => {});
|
|
168
308
|
}
|
|
169
309
|
} catch (e) {
|
|
170
310
|
// Non-fatal
|
|
@@ -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,111 @@ function createBot(token) {
|
|
|
169
171
|
});
|
|
170
172
|
},
|
|
171
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Download a file from Telegram to local disk
|
|
176
|
+
* @param {string} fileId - Telegram file_id
|
|
177
|
+
* @param {string} destPath - Local destination path
|
|
178
|
+
* @returns {Promise<string>} The destination path
|
|
179
|
+
*/
|
|
180
|
+
async downloadFile(fileId, destPath) {
|
|
181
|
+
// 1. Get file path from Telegram
|
|
182
|
+
const fileInfo = await apiRequest(token, 'getFile', { file_id: fileId });
|
|
183
|
+
if (!fileInfo.file_path) {
|
|
184
|
+
throw new Error('Failed to get file path from Telegram');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 2. Download file using stream (zero memory overhead)
|
|
188
|
+
const fileUrl = `${API_BASE}/file/bot${token}/${fileInfo.file_path}`;
|
|
189
|
+
return new Promise((resolve, reject) => {
|
|
190
|
+
const urlObj = new URL(fileUrl);
|
|
191
|
+
https.get({
|
|
192
|
+
hostname: urlObj.hostname,
|
|
193
|
+
path: urlObj.pathname,
|
|
194
|
+
timeout: 60000,
|
|
195
|
+
}, (res) => {
|
|
196
|
+
if (res.statusCode !== 200) {
|
|
197
|
+
reject(new Error(`Download failed: ${res.statusCode}`));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const fileStream = fs.createWriteStream(destPath);
|
|
201
|
+
res.pipe(fileStream);
|
|
202
|
+
fileStream.on('finish', () => {
|
|
203
|
+
fileStream.close();
|
|
204
|
+
resolve(destPath);
|
|
205
|
+
});
|
|
206
|
+
fileStream.on('error', (err) => {
|
|
207
|
+
fs.unlink(destPath, () => {});
|
|
208
|
+
reject(err);
|
|
209
|
+
});
|
|
210
|
+
}).on('error', reject);
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Send a file/document
|
|
216
|
+
* @param {number|string} chatId - Target chat ID
|
|
217
|
+
* @param {string} filePath - Local file path
|
|
218
|
+
* @param {string} [caption] - Optional caption
|
|
219
|
+
*/
|
|
220
|
+
async sendFile(chatId, filePath, caption) {
|
|
221
|
+
if (!fs.existsSync(filePath)) {
|
|
222
|
+
throw new Error(`File not found: ${filePath}`);
|
|
223
|
+
}
|
|
224
|
+
const fileName = path.basename(filePath);
|
|
225
|
+
const fileContent = fs.readFileSync(filePath);
|
|
226
|
+
const boundary = '----MetaMeBoundary' + Date.now();
|
|
227
|
+
|
|
228
|
+
// Build multipart form-data
|
|
229
|
+
let body = '';
|
|
230
|
+
body += `--${boundary}\r\n`;
|
|
231
|
+
body += `Content-Disposition: form-data; name="chat_id"\r\n\r\n${chatId}\r\n`;
|
|
232
|
+
if (caption) {
|
|
233
|
+
body += `--${boundary}\r\n`;
|
|
234
|
+
body += `Content-Disposition: form-data; name="caption"\r\n\r\n${caption}\r\n`;
|
|
235
|
+
}
|
|
236
|
+
body += `--${boundary}\r\n`;
|
|
237
|
+
body += `Content-Disposition: form-data; name="document"; filename="${fileName}"\r\n`;
|
|
238
|
+
body += `Content-Type: application/octet-stream\r\n\r\n`;
|
|
239
|
+
|
|
240
|
+
const bodyStart = Buffer.from(body, 'utf8');
|
|
241
|
+
const bodyEnd = Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8');
|
|
242
|
+
const fullBody = Buffer.concat([bodyStart, fileContent, bodyEnd]);
|
|
243
|
+
|
|
244
|
+
return new Promise((resolve, reject) => {
|
|
245
|
+
const url = `${API_BASE}/bot${token}/sendDocument`;
|
|
246
|
+
const urlObj = new URL(url);
|
|
247
|
+
const options = {
|
|
248
|
+
hostname: urlObj.hostname,
|
|
249
|
+
path: urlObj.pathname,
|
|
250
|
+
method: 'POST',
|
|
251
|
+
headers: {
|
|
252
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
253
|
+
'Content-Length': fullBody.length,
|
|
254
|
+
},
|
|
255
|
+
timeout: 60000,
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const req = https.request(options, (res) => {
|
|
259
|
+
let data = '';
|
|
260
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
261
|
+
res.on('end', () => {
|
|
262
|
+
try {
|
|
263
|
+
const parsed = JSON.parse(data);
|
|
264
|
+
if (parsed.ok) resolve(parsed.result);
|
|
265
|
+
else reject(new Error(`Telegram API error: ${parsed.description || 'unknown'}`));
|
|
266
|
+
} catch (e) {
|
|
267
|
+
reject(new Error(`Failed to parse response: ${e.message}`));
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
req.on('error', reject);
|
|
273
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('Upload timed out')); });
|
|
274
|
+
req.write(fullBody);
|
|
275
|
+
req.end();
|
|
276
|
+
});
|
|
277
|
+
},
|
|
278
|
+
|
|
172
279
|
};
|
|
173
280
|
}
|
|
174
281
|
|