metame-cli 1.3.22 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +185 -26
- package/index.js +197 -171
- package/package.json +3 -3
- package/scripts/daemon-default.yaml +39 -1
- package/scripts/daemon.js +1178 -184
- package/scripts/distill.js +62 -106
- package/scripts/feishu-adapter.js +82 -130
- package/scripts/memory-extract.js +263 -0
- package/scripts/memory-search.js +99 -0
- package/scripts/memory.js +439 -0
- package/scripts/providers.js +32 -0
- package/scripts/qmd-client.js +276 -0
- package/scripts/schema.js +37 -40
- package/scripts/session-analytics.js +64 -7
- package/scripts/session-summarize.js +118 -0
- package/scripts/skill-evolution.js +23 -20
- package/scripts/telegram-adapter.js +12 -4
package/scripts/distill.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
const fs = require('fs');
|
|
13
13
|
const path = require('path');
|
|
14
14
|
const os = require('os');
|
|
15
|
-
const {
|
|
15
|
+
const { callHaiku, buildDistillEnv } = require('./providers');
|
|
16
16
|
|
|
17
17
|
const HOME = os.homedir();
|
|
18
18
|
const BUFFER_FILE = path.join(HOME, '.metame', 'raw_signals.jsonl');
|
|
@@ -31,15 +31,14 @@ try {
|
|
|
31
31
|
// Provider env for distillation (cheap relay for background tasks)
|
|
32
32
|
let distillEnv = {};
|
|
33
33
|
try {
|
|
34
|
-
const { buildDistillEnv } = require('./providers');
|
|
35
34
|
distillEnv = buildDistillEnv();
|
|
36
|
-
} catch { /* providers
|
|
35
|
+
} catch { /* providers not configured — use defaults */ }
|
|
37
36
|
|
|
38
37
|
/**
|
|
39
38
|
* Main distillation process.
|
|
40
39
|
* Returns { updated: boolean, summary: string }
|
|
41
40
|
*/
|
|
42
|
-
function distill() {
|
|
41
|
+
async function distill() {
|
|
43
42
|
// 1. Check if buffer exists and has content
|
|
44
43
|
if (!fs.existsSync(BUFFER_FILE)) {
|
|
45
44
|
return { updated: false, behavior: null, summary: 'No signals to process.' };
|
|
@@ -55,16 +54,32 @@ function distill() {
|
|
|
55
54
|
return { updated: false, behavior: null, summary: 'No signals to process.' };
|
|
56
55
|
}
|
|
57
56
|
|
|
58
|
-
// 2. Prevent concurrent distillation
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
57
|
+
// 2. Prevent concurrent distillation (atomic lock via O_EXCL)
|
|
58
|
+
let lockFd;
|
|
59
|
+
try {
|
|
60
|
+
lockFd = fs.openSync(LOCK_FILE, 'wx');
|
|
61
|
+
fs.writeSync(lockFd, process.pid.toString());
|
|
62
|
+
fs.closeSync(lockFd);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (e.code === 'EEXIST') {
|
|
65
|
+
// Another process holds the lock — check if stale
|
|
66
|
+
try {
|
|
67
|
+
const lockAge = Date.now() - fs.statSync(LOCK_FILE).mtimeMs;
|
|
68
|
+
if (lockAge < 120000) {
|
|
69
|
+
return { updated: false, behavior: null, summary: 'Distillation already in progress.' };
|
|
70
|
+
}
|
|
71
|
+
fs.unlinkSync(LOCK_FILE);
|
|
72
|
+
// Retry once after removing stale lock
|
|
73
|
+
lockFd = fs.openSync(LOCK_FILE, 'wx');
|
|
74
|
+
fs.writeSync(lockFd, process.pid.toString());
|
|
75
|
+
fs.closeSync(lockFd);
|
|
76
|
+
} catch {
|
|
77
|
+
return { updated: false, behavior: null, summary: 'Distillation already in progress.' };
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
throw e;
|
|
63
81
|
}
|
|
64
|
-
// Stale lock, remove it
|
|
65
|
-
fs.unlinkSync(LOCK_FILE);
|
|
66
82
|
}
|
|
67
|
-
fs.writeFileSync(LOCK_FILE, process.pid.toString());
|
|
68
83
|
|
|
69
84
|
try {
|
|
70
85
|
// 3. Parse signals (preserve confidence from signal-capture)
|
|
@@ -134,7 +149,7 @@ function distill() {
|
|
|
134
149
|
// Goal context section (~11 tokens when present)
|
|
135
150
|
let goalContext = '';
|
|
136
151
|
if (sessionAnalytics) {
|
|
137
|
-
try { goalContext = sessionAnalytics.formatGoalContext(BRAIN_FILE); } catch {}
|
|
152
|
+
try { goalContext = sessionAnalytics.formatGoalContext(BRAIN_FILE); } catch { }
|
|
138
153
|
}
|
|
139
154
|
const goalSection = goalContext ? `\n${goalContext}\n` : '';
|
|
140
155
|
|
|
@@ -156,8 +171,7 @@ RULES:
|
|
|
156
171
|
2. IGNORE task-specific messages. Only extract what persists across ALL sessions.
|
|
157
172
|
3. Only output fields from WRITABLE FIELDS. Any other key will be rejected.
|
|
158
173
|
4. For enum fields, use one of the listed values.
|
|
159
|
-
5.
|
|
160
|
-
6. Strong directives (以后一律/always/never/from now on) → _confidence: high. Otherwise: normal.
|
|
174
|
+
5. Strong directives (以后一律/always/never/from now on) → _confidence: high. Otherwise: normal.
|
|
161
175
|
7. Add _confidence and _source blocks mapping field keys to confidence level and triggering quote.
|
|
162
176
|
8. NEVER extract agent identity or role definitions. Messages like "你是贾维斯/你的角色是.../you are Jarvis" define the AGENT, not the USER. The profile is about the USER's cognition only.
|
|
163
177
|
|
|
@@ -184,19 +198,10 @@ Do NOT repeat existing unchanged values.`;
|
|
|
184
198
|
// 6. Call Claude in print mode with haiku (+ provider env for relay support)
|
|
185
199
|
let result;
|
|
186
200
|
try {
|
|
187
|
-
result =
|
|
188
|
-
`claude -p --model haiku --no-session-persistence`,
|
|
189
|
-
{
|
|
190
|
-
input: distillPrompt,
|
|
191
|
-
encoding: 'utf8',
|
|
192
|
-
timeout: 60000, // 60s — runs in background, no rush
|
|
193
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
194
|
-
env: { ...process.env, ...distillEnv },
|
|
195
|
-
}
|
|
196
|
-
).trim();
|
|
201
|
+
result = await callHaiku(distillPrompt, distillEnv, 60000);
|
|
197
202
|
} catch (err) {
|
|
198
203
|
// Don't cleanup buffer on API failure — retry next launch
|
|
199
|
-
try { fs.unlinkSync(LOCK_FILE); } catch {}
|
|
204
|
+
try { fs.unlinkSync(LOCK_FILE); } catch { }
|
|
200
205
|
const isTimeout = err.killed || (err.signal === 'SIGTERM');
|
|
201
206
|
if (isTimeout) {
|
|
202
207
|
return { updated: false, behavior: null, summary: 'Skipped — API too slow. Will retry next launch.' };
|
|
@@ -247,16 +252,13 @@ Do NOT repeat existing unchanged values.`;
|
|
|
247
252
|
if (Object.keys(filtered).length === 0 && behavior) {
|
|
248
253
|
cleanup();
|
|
249
254
|
if (skeleton && sessionAnalytics) {
|
|
250
|
-
try { sessionAnalytics.markAnalyzed(skeleton.session_id); } catch {}
|
|
255
|
+
try { sessionAnalytics.markAnalyzed(skeleton.session_id); } catch { }
|
|
251
256
|
}
|
|
252
257
|
return { updated: false, behavior, skeleton, signalCount: signals.length, summary: `Analyzed ${signals.length} messages — behavior logged, no profile changes.` };
|
|
253
258
|
}
|
|
254
259
|
|
|
255
260
|
const profile = yaml.load(fs.readFileSync(BRAIN_FILE, 'utf8')) || {};
|
|
256
261
|
|
|
257
|
-
// Auto-expire anti_patterns older than 60 days
|
|
258
|
-
expireAntiPatterns(profile);
|
|
259
|
-
|
|
260
262
|
// Read raw content to find locked lines and comments
|
|
261
263
|
const rawProfile = fs.readFileSync(BRAIN_FILE, 'utf8');
|
|
262
264
|
const lockedKeys = extractLockedKeys(rawProfile);
|
|
@@ -314,7 +316,7 @@ Do NOT repeat existing unchanged values.`;
|
|
|
314
316
|
|
|
315
317
|
// Mark session as analyzed after successful distill
|
|
316
318
|
if (skeleton && sessionAnalytics) {
|
|
317
|
-
try { sessionAnalytics.markAnalyzed(skeleton.session_id); } catch {}
|
|
319
|
+
try { sessionAnalytics.markAnalyzed(skeleton.session_id); } catch { }
|
|
318
320
|
}
|
|
319
321
|
|
|
320
322
|
cleanup();
|
|
@@ -446,18 +448,8 @@ function strategicMerge(profile, updates, lockedKeys, pendingTraits, confidenceM
|
|
|
446
448
|
}
|
|
447
449
|
|
|
448
450
|
case 'T4':
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
452
|
-
const existing = getNested(result, key) || [];
|
|
453
|
-
const existingTexts = new Set(existing.map(e => typeof e === 'string' ? e : e.text));
|
|
454
|
-
const stamped = value
|
|
455
|
-
.filter(v => !existingTexts.has(typeof v === 'string' ? v : v.text))
|
|
456
|
-
.map(v => typeof v === 'string' ? { text: v, added: today } : v);
|
|
457
|
-
setNested(result, key, [...existing, ...stamped].slice(-5));
|
|
458
|
-
} else {
|
|
459
|
-
setNested(result, key, value);
|
|
460
|
-
}
|
|
451
|
+
setNested(result, key, value);
|
|
452
|
+
|
|
461
453
|
// Auto-set focus_since when focus changes
|
|
462
454
|
if (key === 'context.focus') {
|
|
463
455
|
setNested(result, 'context.focus_since', new Date().toISOString().slice(0, 10));
|
|
@@ -563,30 +555,14 @@ function truncateArrays(obj) {
|
|
|
563
555
|
}
|
|
564
556
|
}
|
|
565
557
|
|
|
566
|
-
|
|
567
|
-
* Auto-expire anti_patterns older than 60 days.
|
|
568
|
-
* Each entry is stored as { text: "...", added: "2026-01-15" } internally.
|
|
569
|
-
* If legacy string entries exist, they are kept (no added date = never expire).
|
|
570
|
-
*/
|
|
571
|
-
function expireAntiPatterns(profile) {
|
|
572
|
-
if (!profile.context || !Array.isArray(profile.context.anti_patterns)) return;
|
|
573
|
-
const now = Date.now();
|
|
574
|
-
const SIXTY_DAYS = 60 * 24 * 60 * 60 * 1000;
|
|
575
|
-
profile.context.anti_patterns = profile.context.anti_patterns.filter(entry => {
|
|
576
|
-
if (typeof entry === 'string') return true; // legacy, keep
|
|
577
|
-
if (entry.added) {
|
|
578
|
-
return (now - new Date(entry.added).getTime()) < SIXTY_DAYS;
|
|
579
|
-
}
|
|
580
|
-
return true;
|
|
581
|
-
});
|
|
582
|
-
}
|
|
558
|
+
|
|
583
559
|
|
|
584
560
|
/**
|
|
585
561
|
* Clean up: remove buffer and lock
|
|
586
562
|
*/
|
|
587
563
|
function cleanup() {
|
|
588
|
-
try { fs.unlinkSync(BUFFER_FILE); } catch {}
|
|
589
|
-
try { fs.unlinkSync(LOCK_FILE); } catch {}
|
|
564
|
+
try { fs.unlinkSync(BUFFER_FILE); } catch { }
|
|
565
|
+
try { fs.unlinkSync(LOCK_FILE); } catch { }
|
|
590
566
|
}
|
|
591
567
|
|
|
592
568
|
// ---------------------------------------------------------
|
|
@@ -785,7 +761,7 @@ function bootstrapSessionLog() {
|
|
|
785
761
|
* Also force-runs after bootstrap (regardless of distill_count).
|
|
786
762
|
* Writes results to profile growth.patterns (max 3).
|
|
787
763
|
*/
|
|
788
|
-
function detectPatterns(forceRun) {
|
|
764
|
+
async function detectPatterns(forceRun) {
|
|
789
765
|
const yaml = require('js-yaml');
|
|
790
766
|
|
|
791
767
|
// Read session log
|
|
@@ -822,7 +798,7 @@ function detectPatterns(forceRun) {
|
|
|
822
798
|
// Read declared goals for pattern context
|
|
823
799
|
let declaredGoals = '';
|
|
824
800
|
if (sessionAnalytics) {
|
|
825
|
-
try { declaredGoals = sessionAnalytics.formatGoalContext(BRAIN_FILE); } catch {}
|
|
801
|
+
try { declaredGoals = sessionAnalytics.formatGoalContext(BRAIN_FILE); } catch { }
|
|
826
802
|
}
|
|
827
803
|
const goalLine = declaredGoals ? `\nUSER'S ${declaredGoals}\n` : '';
|
|
828
804
|
|
|
@@ -856,16 +832,7 @@ patterns:
|
|
|
856
832
|
If no clear patterns found: respond with exactly NO_PATTERNS`;
|
|
857
833
|
|
|
858
834
|
try {
|
|
859
|
-
const result =
|
|
860
|
-
`claude -p --model haiku --no-session-persistence`,
|
|
861
|
-
{
|
|
862
|
-
input: patternPrompt,
|
|
863
|
-
encoding: 'utf8',
|
|
864
|
-
timeout: 30000,
|
|
865
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
866
|
-
env: { ...process.env, ...distillEnv },
|
|
867
|
-
}
|
|
868
|
-
).trim();
|
|
835
|
+
const result = await callHaiku(patternPrompt, distillEnv, 30000);
|
|
869
836
|
|
|
870
837
|
if (!result || result.includes('NO_PATTERNS')) return;
|
|
871
838
|
|
|
@@ -917,39 +884,28 @@ module.exports = { distill, writeSessionLog, bootstrapSessionLog, detectPatterns
|
|
|
917
884
|
|
|
918
885
|
// Also allow direct execution
|
|
919
886
|
if (require.main === module) {
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
const result = distill();
|
|
929
|
-
// Write session log if behavior was detected
|
|
930
|
-
if (result.behavior) {
|
|
931
|
-
writeSessionLog(result.behavior, result.signalCount || 0, result.skeleton || null, result.sessionSummary || null);
|
|
932
|
-
}
|
|
933
|
-
// Run pattern detection (only triggers every 5th distill)
|
|
934
|
-
if (!bootstrapped) detectPatterns();
|
|
935
|
-
|
|
936
|
-
// Skill evolution: cold path — Haiku-powered batch analysis
|
|
937
|
-
try {
|
|
938
|
-
const skillEvo = require('./skill-evolution');
|
|
939
|
-
const evoResult = skillEvo.distillSkills();
|
|
940
|
-
if (evoResult && (evoResult.updates.length > 0 || evoResult.missing_skills.length > 0)) {
|
|
941
|
-
console.log(`🧬 Skill evolution: ${evoResult.updates.length} update(s), ${evoResult.missing_skills.length} gap(s) detected.`);
|
|
887
|
+
(async () => {
|
|
888
|
+
// Bootstrap: if session_log is thin, batch-fill from history
|
|
889
|
+
const bootstrapped = bootstrapSessionLog();
|
|
890
|
+
if (bootstrapped > 0) {
|
|
891
|
+
console.log(`📊 MetaMe: Bootstrapped ${bootstrapped} historical sessions.`);
|
|
892
|
+
// Force pattern detection immediately after bootstrap
|
|
893
|
+
await detectPatterns(true);
|
|
942
894
|
}
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
if
|
|
946
|
-
|
|
895
|
+
|
|
896
|
+
const result = await distill();
|
|
897
|
+
// Write session log if behavior was detected
|
|
898
|
+
if (result.behavior) {
|
|
899
|
+
writeSessionLog(result.behavior, result.signalCount || 0, result.skeleton || null, result.sessionSummary || null);
|
|
947
900
|
}
|
|
948
|
-
}
|
|
949
901
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
902
|
+
// Run pattern detection (only triggers every 5th distill)
|
|
903
|
+
if (!bootstrapped) await detectPatterns();
|
|
904
|
+
|
|
905
|
+
if (result.updated) {
|
|
906
|
+
console.log(`🧠 ${result.summary}`);
|
|
907
|
+
} else {
|
|
908
|
+
console.log(`💤 ${result.summary}`);
|
|
909
|
+
}
|
|
910
|
+
})();
|
|
955
911
|
}
|
|
@@ -34,6 +34,29 @@ function withTimeout(promise, ms = 10000) {
|
|
|
34
34
|
]);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// Max chars per lark_md element (Feishu limit ~4000)
|
|
38
|
+
const MAX_CHUNK = 3800;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Convert standard markdown to lark_md and split into chunks.
|
|
42
|
+
* Shared by sendMarkdown and sendCard.
|
|
43
|
+
*/
|
|
44
|
+
function toMdChunks(text) {
|
|
45
|
+
const content = text
|
|
46
|
+
.replace(/^(#{1,3})\s+(.+)$/gm, '**$2**') // headers → bold
|
|
47
|
+
.replace(/^---+$/gm, '─────────────────────'); // hr → unicode line
|
|
48
|
+
if (content.length <= MAX_CHUNK) return [content];
|
|
49
|
+
const paragraphs = content.split(/\n\n/);
|
|
50
|
+
const chunks = [];
|
|
51
|
+
let buf = '';
|
|
52
|
+
for (const p of paragraphs) {
|
|
53
|
+
if (buf.length + p.length + 2 > MAX_CHUNK && buf) { chunks.push(buf); buf = p; }
|
|
54
|
+
else { buf = buf ? buf + '\n\n' + p : p; }
|
|
55
|
+
}
|
|
56
|
+
if (buf) chunks.push(buf);
|
|
57
|
+
return chunks;
|
|
58
|
+
}
|
|
59
|
+
|
|
37
60
|
function createBot(config) {
|
|
38
61
|
const { app_id, app_secret } = config;
|
|
39
62
|
if (!app_id || !app_secret) throw new Error('app_id and app_secret are required');
|
|
@@ -44,6 +67,17 @@ function createBot(config) {
|
|
|
44
67
|
appSecret: app_secret,
|
|
45
68
|
});
|
|
46
69
|
|
|
70
|
+
// Private: send an interactive card JSON; returns { message_id } or null.
|
|
71
|
+
// All card functions funnel through here to avoid repeating the SDK call.
|
|
72
|
+
async function _sendInteractive(chatId, card) {
|
|
73
|
+
const res = await withTimeout(client.im.message.create({
|
|
74
|
+
params: { receive_id_type: 'chat_id' },
|
|
75
|
+
data: { receive_id: chatId, msg_type: 'interactive', content: JSON.stringify(card) },
|
|
76
|
+
}));
|
|
77
|
+
const msgId = res?.data?.message_id;
|
|
78
|
+
return msgId ? { message_id: msgId } : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
47
81
|
return {
|
|
48
82
|
/**
|
|
49
83
|
* Send a plain text message
|
|
@@ -84,119 +118,22 @@ function createBot(config) {
|
|
|
84
118
|
* Send markdown as Feishu interactive card (lark_md renders bold, lists, code, links)
|
|
85
119
|
*/
|
|
86
120
|
async sendMarkdown(chatId, markdown) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
.replace(/^(#{1,3})\s+(.+)$/gm, '**$2**') // headers → bold
|
|
90
|
-
.replace(/^---+$/gm, '─────────────────────'); // hr → unicode line
|
|
91
|
-
|
|
92
|
-
// Split into chunks if too long (element limit ~4000 chars)
|
|
93
|
-
const MAX_CHUNK = 3800;
|
|
94
|
-
const chunks = [];
|
|
95
|
-
if (content.length <= MAX_CHUNK) {
|
|
96
|
-
chunks.push(content);
|
|
97
|
-
} else {
|
|
98
|
-
const paragraphs = content.split(/\n\n/);
|
|
99
|
-
let buf = '';
|
|
100
|
-
for (const p of paragraphs) {
|
|
101
|
-
if (buf.length + p.length + 2 > MAX_CHUNK && buf) {
|
|
102
|
-
chunks.push(buf);
|
|
103
|
-
buf = p;
|
|
104
|
-
} else {
|
|
105
|
-
buf = buf ? buf + '\n\n' + p : p;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
if (buf) chunks.push(buf);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// V2 schema: markdown element with normal text size
|
|
112
|
-
const elements = chunks.map(c => ({
|
|
113
|
-
tag: 'markdown',
|
|
114
|
-
content: c,
|
|
115
|
-
text_size: 'x-large',
|
|
116
|
-
}));
|
|
117
|
-
|
|
118
|
-
const card = {
|
|
119
|
-
schema: '2.0',
|
|
120
|
-
body: { elements },
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
const res = await withTimeout(client.im.message.create({
|
|
124
|
-
params: { receive_id_type: 'chat_id' },
|
|
125
|
-
data: {
|
|
126
|
-
receive_id: chatId,
|
|
127
|
-
msg_type: 'interactive',
|
|
128
|
-
content: JSON.stringify(card),
|
|
129
|
-
},
|
|
130
|
-
}));
|
|
131
|
-
const msgId = res?.data?.message_id;
|
|
132
|
-
return msgId ? { message_id: msgId } : null;
|
|
121
|
+
const elements = toMdChunks(markdown).map(c => ({ tag: 'markdown', content: c }));
|
|
122
|
+
return _sendInteractive(chatId, { schema: '2.0', body: { elements } });
|
|
133
123
|
},
|
|
134
124
|
|
|
135
125
|
/**
|
|
136
|
-
* Send a colored interactive card (
|
|
126
|
+
* Send a colored interactive card with optional markdown body (V2 schema)
|
|
137
127
|
* @param {string} chatId
|
|
138
|
-
* @param {
|
|
139
|
-
* @param {string}
|
|
140
|
-
* @param {string}
|
|
128
|
+
* @param {object} opts
|
|
129
|
+
* @param {string} opts.title - card header text
|
|
130
|
+
* @param {string} [opts.body] - card body (standard markdown)
|
|
131
|
+
* @param {string} [opts.color='blue'] - header color: blue|orange|green|red|grey|purple|turquoise
|
|
141
132
|
*/
|
|
142
133
|
async sendCard(chatId, { title, body, color = 'blue' }) {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
schema: '2.0',
|
|
147
|
-
header: { title: { tag: 'plain_text', content: title }, template: color },
|
|
148
|
-
body: { elements: [] },
|
|
149
|
-
};
|
|
150
|
-
const res = await withTimeout(client.im.message.create({
|
|
151
|
-
params: { receive_id_type: 'chat_id' },
|
|
152
|
-
data: { receive_id: chatId, msg_type: 'interactive', content: JSON.stringify(card) },
|
|
153
|
-
}));
|
|
154
|
-
const msgId = res?.data?.message_id;
|
|
155
|
-
return msgId ? { message_id: msgId } : null;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Convert standard markdown → lark_md
|
|
159
|
-
let content = body
|
|
160
|
-
.replace(/^(#{1,3})\s+(.+)$/gm, '**$2**')
|
|
161
|
-
.replace(/^---+$/gm, '─────────────────────');
|
|
162
|
-
|
|
163
|
-
// Split into chunks (lark_md element limit ~4000 chars)
|
|
164
|
-
const MAX_CHUNK = 3800;
|
|
165
|
-
const chunks = [];
|
|
166
|
-
if (content.length <= MAX_CHUNK) {
|
|
167
|
-
chunks.push(content);
|
|
168
|
-
} else {
|
|
169
|
-
const paragraphs = content.split(/\n\n/);
|
|
170
|
-
let buf = '';
|
|
171
|
-
for (const p of paragraphs) {
|
|
172
|
-
if (buf.length + p.length + 2 > MAX_CHUNK && buf) {
|
|
173
|
-
chunks.push(buf);
|
|
174
|
-
buf = p;
|
|
175
|
-
} else {
|
|
176
|
-
buf = buf ? buf + '\n\n' + p : p;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
if (buf) chunks.push(buf);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// V2: use markdown element with text_size for readable font
|
|
183
|
-
const elements = chunks.map(c => ({
|
|
184
|
-
tag: 'markdown',
|
|
185
|
-
content: c,
|
|
186
|
-
text_size: 'x-large',
|
|
187
|
-
}));
|
|
188
|
-
|
|
189
|
-
const card = {
|
|
190
|
-
schema: '2.0',
|
|
191
|
-
header: { title: { tag: 'plain_text', content: title }, template: color },
|
|
192
|
-
body: { elements },
|
|
193
|
-
};
|
|
194
|
-
const res = await withTimeout(client.im.message.create({
|
|
195
|
-
params: { receive_id_type: 'chat_id' },
|
|
196
|
-
data: { receive_id: chatId, msg_type: 'interactive', content: JSON.stringify(card) },
|
|
197
|
-
}));
|
|
198
|
-
const msgId = res?.data?.message_id;
|
|
199
|
-
return msgId ? { message_id: msgId } : null;
|
|
134
|
+
const header = { title: { tag: 'plain_text', content: title }, template: color };
|
|
135
|
+
const elements = body ? toMdChunks(body).map(c => ({ tag: 'markdown', content: c })) : [];
|
|
136
|
+
return _sendInteractive(chatId, { schema: '2.0', header, body: { elements } });
|
|
200
137
|
},
|
|
201
138
|
|
|
202
139
|
/**
|
|
@@ -214,39 +151,54 @@ function createBot(config) {
|
|
|
214
151
|
async sendTyping(_chatId) {},
|
|
215
152
|
|
|
216
153
|
/**
|
|
217
|
-
* Send interactive card with action buttons
|
|
154
|
+
* Send interactive card with action buttons (V1 schema — required for card.action.trigger)
|
|
218
155
|
* @param {string} chatId
|
|
219
|
-
* @param {string} title - card header
|
|
156
|
+
* @param {string} title - card header (first line) + optional body (remaining lines)
|
|
220
157
|
* @param {Array<Array<{text: string, callback_data: string}>>} buttons - rows of buttons
|
|
221
158
|
*/
|
|
222
159
|
async sendButtons(chatId, title, buttons) {
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
const elements = buttons.map(row => ({
|
|
160
|
+
// Each row becomes one action element; a row can hold up to 3 buttons side-by-side.
|
|
161
|
+
const buttonElements = buttons.map(row => ({
|
|
226
162
|
tag: 'action',
|
|
227
|
-
actions:
|
|
163
|
+
actions: row.map(b => ({
|
|
228
164
|
tag: 'button',
|
|
229
|
-
text: { tag: 'plain_text', content:
|
|
165
|
+
text: { tag: 'plain_text', content: b.text },
|
|
230
166
|
type: 'default',
|
|
231
|
-
value: { cmd:
|
|
232
|
-
}
|
|
167
|
+
value: { cmd: b.callback_data },
|
|
168
|
+
})),
|
|
233
169
|
}));
|
|
234
|
-
|
|
170
|
+
|
|
171
|
+
// Feishu card header is single-line — split multi-line title into header + body
|
|
172
|
+
const lines = title.split('\n');
|
|
173
|
+
const headerText = lines[0].slice(0, 60);
|
|
174
|
+
const bodyText = lines.slice(1).join('\n').trim();
|
|
175
|
+
|
|
176
|
+
const elements = [];
|
|
177
|
+
if (bodyText) {
|
|
178
|
+
elements.push({ tag: 'div', text: { tag: 'lark_md', content: bodyText } });
|
|
179
|
+
elements.push({ tag: 'hr' });
|
|
180
|
+
}
|
|
181
|
+
elements.push(...buttonElements);
|
|
182
|
+
|
|
183
|
+
return _sendInteractive(chatId, {
|
|
235
184
|
config: { wide_screen_mode: true },
|
|
236
|
-
header: {
|
|
237
|
-
title: { tag: 'plain_text', content: title },
|
|
238
|
-
template: 'blue',
|
|
239
|
-
},
|
|
185
|
+
header: { title: { tag: 'plain_text', content: headerText }, template: 'blue' },
|
|
240
186
|
elements,
|
|
241
|
-
};
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Send a rich interactive card with pre-built elements (V1 schema — required for card.action.trigger)
|
|
192
|
+
* @param {string} chatId
|
|
193
|
+
* @param {string} headerText - single-line card header
|
|
194
|
+
* @param {Array} elements - Feishu V1 card elements array
|
|
195
|
+
*/
|
|
196
|
+
async sendRawCard(chatId, headerText, elements) {
|
|
197
|
+
return _sendInteractive(chatId, {
|
|
198
|
+
config: { wide_screen_mode: true },
|
|
199
|
+
header: { title: { tag: 'plain_text', content: headerText }, template: 'blue' },
|
|
200
|
+
elements,
|
|
201
|
+
});
|
|
250
202
|
},
|
|
251
203
|
|
|
252
204
|
/**
|