metame-cli 1.4.34 → 1.5.1
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 +136 -94
- package/index.js +312 -57
- package/package.json +8 -4
- package/scripts/agent-layer.js +320 -0
- package/scripts/daemon-admin-commands.js +328 -28
- package/scripts/daemon-agent-commands.js +145 -6
- package/scripts/daemon-agent-tools.js +163 -7
- package/scripts/daemon-bridges.js +110 -20
- package/scripts/daemon-checkpoints.js +36 -7
- package/scripts/daemon-claude-engine.js +849 -358
- package/scripts/daemon-command-router.js +31 -10
- package/scripts/daemon-default.yaml +28 -4
- package/scripts/daemon-engine-runtime.js +328 -0
- package/scripts/daemon-exec-commands.js +15 -7
- package/scripts/daemon-notify.js +37 -1
- package/scripts/daemon-ops-commands.js +8 -6
- package/scripts/daemon-runtime-lifecycle.js +129 -5
- package/scripts/daemon-session-commands.js +60 -25
- package/scripts/daemon-session-store.js +121 -13
- package/scripts/daemon-task-scheduler.js +129 -49
- package/scripts/daemon-user-acl.js +35 -9
- package/scripts/daemon.js +268 -33
- package/scripts/distill.js +327 -18
- package/scripts/docs/agent-guide.md +12 -0
- package/scripts/docs/maintenance-manual.md +155 -0
- package/scripts/docs/pointer-map.md +110 -0
- package/scripts/feishu-adapter.js +42 -13
- package/scripts/hooks/stop-session-capture.js +243 -0
- package/scripts/memory-extract.js +105 -6
- package/scripts/memory-nightly-reflect.js +199 -11
- package/scripts/memory.js +134 -3
- package/scripts/mentor-engine.js +405 -0
- package/scripts/platform.js +24 -0
- package/scripts/providers.js +182 -22
- package/scripts/schema.js +12 -0
- package/scripts/session-analytics.js +245 -12
- package/scripts/skill-changelog.js +245 -0
- package/scripts/skill-evolution.js +288 -5
- package/scripts/telegram-adapter.js +12 -8
- package/scripts/usage-classifier.js +1 -1
- package/scripts/daemon-admin-commands.test.js +0 -333
- package/scripts/daemon-task-envelope.test.js +0 -59
- package/scripts/daemon-task-scheduler.test.js +0 -106
- package/scripts/reliability-core.test.js +0 -280
- package/scripts/skill-evolution.test.js +0 -113
- package/scripts/task-board.test.js +0 -83
- package/scripts/test_daemon.js +0 -1407
- package/scripts/utils.test.js +0 -192
|
@@ -189,6 +189,7 @@ function createTaskScheduler(deps) {
|
|
|
189
189
|
recordTokens,
|
|
190
190
|
buildProfilePreamble,
|
|
191
191
|
getDaemonProviderEnv,
|
|
192
|
+
getDistillModel,
|
|
192
193
|
log,
|
|
193
194
|
physiologicalHeartbeat,
|
|
194
195
|
isUserIdle,
|
|
@@ -198,12 +199,31 @@ function createTaskScheduler(deps) {
|
|
|
198
199
|
skillEvolution,
|
|
199
200
|
} = deps;
|
|
200
201
|
|
|
201
|
-
//
|
|
202
|
+
// Max characters from precondition context to inject into prompts (prevents token bombs)
|
|
203
|
+
const MAX_PRECONDITION_CHARS = 4000;
|
|
204
|
+
|
|
205
|
+
// On Windows, resolve .cmd → actual Node.js entry to avoid cmd.exe flash
|
|
206
|
+
function _resolveNodeEntry(cmdPath) {
|
|
207
|
+
try {
|
|
208
|
+
const content = require('fs').readFileSync(cmdPath, 'utf8');
|
|
209
|
+
const m = content.match(/"([^"]+\.js)"\s*%\*\s*$/m);
|
|
210
|
+
if (m) {
|
|
211
|
+
const entry = m[1].replace(/%dp0%/gi, require('path').dirname(cmdPath) + require('path').sep);
|
|
212
|
+
if (require('fs').existsSync(entry)) return entry;
|
|
213
|
+
}
|
|
214
|
+
} catch { /* ignore */ }
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
202
218
|
function spawn(cmd, args, options) {
|
|
203
|
-
if (process.platform
|
|
204
|
-
|
|
219
|
+
if (process.platform !== 'win32') return _spawn(cmd, args, options);
|
|
220
|
+
const lowerCmd = String(cmd || '').toLowerCase();
|
|
221
|
+
if (lowerCmd.endsWith('.cmd') || lowerCmd.endsWith('.bat') || lowerCmd === 'claude' || lowerCmd === 'codex') {
|
|
222
|
+
const entry = _resolveNodeEntry(cmd);
|
|
223
|
+
if (entry) return _spawn(process.execPath, [entry, ...args], { ...options, windowsHide: true });
|
|
224
|
+
return _spawn(cmd, args, { ...options, shell: process.env.COMSPEC || true, windowsHide: true });
|
|
205
225
|
}
|
|
206
|
-
return _spawn(cmd, args, options);
|
|
226
|
+
return _spawn(cmd, args, { ...options, windowsHide: true });
|
|
207
227
|
}
|
|
208
228
|
|
|
209
229
|
function checkPrecondition(task) {
|
|
@@ -212,31 +232,31 @@ function createTaskScheduler(deps) {
|
|
|
212
232
|
try {
|
|
213
233
|
let cmd = task.precondition;
|
|
214
234
|
|
|
215
|
-
// Cross-platform: expand ~ to HOME and handle `test -s`
|
|
235
|
+
// Cross-platform: expand ~ to HOME and handle `test -s` via Node.js
|
|
216
236
|
cmd = cmd.replace(/^~|(?<=\s)~/g, HOME);
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
237
|
+
// Handle `test -s <file>` natively in JS on ALL platforms.
|
|
238
|
+
// Shell `test -s` produces no stdout on success, which previously
|
|
239
|
+
// caused the empty-output check below to incorrectly skip the task.
|
|
240
|
+
const testMatch = cmd.match(/^test\s+-s\s+(.+)$/);
|
|
241
|
+
if (testMatch) {
|
|
242
|
+
const filePath = testMatch[1].trim().replace(/["']/g, '');
|
|
243
|
+
const fs = require('fs');
|
|
244
|
+
try {
|
|
245
|
+
const content = fs.readFileSync(filePath, 'utf8').trim();
|
|
246
|
+
if (content.length > 0) {
|
|
247
|
+
log('INFO', `Precondition passed for ${task.name} (${content.split('\n').length} lines)`);
|
|
248
|
+
return { pass: true, context: content };
|
|
249
|
+
}
|
|
250
|
+
} catch { /* file doesn't exist or unreadable */ }
|
|
251
|
+
log('INFO', `Precondition failed for ${task.name}: file empty or missing`);
|
|
252
|
+
return { pass: false, context: '' };
|
|
234
253
|
}
|
|
235
254
|
|
|
236
255
|
const output = execSync(cmd, {
|
|
237
256
|
encoding: 'utf8',
|
|
238
257
|
timeout: 15000,
|
|
239
258
|
maxBuffer: 64 * 1024,
|
|
259
|
+
...(process.platform === 'win32' ? { windowsHide: true } : {}),
|
|
240
260
|
}).trim();
|
|
241
261
|
|
|
242
262
|
if (!output) {
|
|
@@ -351,17 +371,33 @@ function createTaskScheduler(deps) {
|
|
|
351
371
|
timeout: resolveTimeoutMs(task.timeout, 120),
|
|
352
372
|
maxBuffer: 1024 * 1024,
|
|
353
373
|
env: scriptEnv,
|
|
374
|
+
...(process.platform === 'win32' ? { windowsHide: true } : {}),
|
|
354
375
|
}).trim();
|
|
355
376
|
|
|
377
|
+
// Parse token report from script stdout: last line matching __TOKENS__:<number>
|
|
378
|
+
// Scripts that call LLM APIs should print this before exiting.
|
|
379
|
+
// Fallback: estimate from output length (rough, but better than 0).
|
|
380
|
+
let scriptTokens = 0;
|
|
381
|
+
const tokenMatch = output.match(/__TOKENS__:(\d+)/);
|
|
382
|
+
if (tokenMatch) {
|
|
383
|
+
scriptTokens = Number(tokenMatch[1]);
|
|
384
|
+
} else if (output.length > 100) {
|
|
385
|
+
// Conservative estimate for scripts that don't self-report
|
|
386
|
+
scriptTokens = Math.ceil(output.length / 4);
|
|
387
|
+
}
|
|
388
|
+
if (scriptTokens > 0) {
|
|
389
|
+
recordTokens(state, scriptTokens, { category: classifyTaskUsage(task) });
|
|
390
|
+
}
|
|
391
|
+
|
|
356
392
|
state.tasks[task.name] = {
|
|
357
393
|
last_run: new Date().toISOString(),
|
|
358
394
|
status: 'success',
|
|
359
395
|
output_preview: output.slice(0, 200),
|
|
360
396
|
};
|
|
361
397
|
saveState(state);
|
|
362
|
-
if (output) log('INFO', `Script task ${task.name} completed: ${output.slice(0, 300)}`);
|
|
398
|
+
if (output) log('INFO', `Script task ${task.name} completed (${scriptTokens} tokens): ${output.slice(0, 300)}`);
|
|
363
399
|
else log('INFO', `Script task ${task.name} completed`);
|
|
364
|
-
return { success: true, output, tokens:
|
|
400
|
+
return { success: true, output, tokens: scriptTokens };
|
|
365
401
|
} catch (e) {
|
|
366
402
|
log('ERROR', `Script task ${task.name} failed: ${e.message}`);
|
|
367
403
|
state.tasks[task.name] = {
|
|
@@ -375,11 +411,14 @@ function createTaskScheduler(deps) {
|
|
|
375
411
|
}
|
|
376
412
|
|
|
377
413
|
const preamble = buildProfilePreamble();
|
|
378
|
-
const model = normalizeModel(task.model ||
|
|
379
|
-
// If precondition returned context data, append it to the prompt
|
|
414
|
+
const model = normalizeModel(task.model || getDistillModel());
|
|
415
|
+
// If precondition returned context data, append it to the prompt (truncated to prevent token bombs)
|
|
380
416
|
let taskPrompt = task.prompt;
|
|
381
417
|
if (precheck.context) {
|
|
382
|
-
|
|
418
|
+
const ctx = precheck.context.length > MAX_PRECONDITION_CHARS
|
|
419
|
+
? precheck.context.slice(0, MAX_PRECONDITION_CHARS) + '\n... (truncated)'
|
|
420
|
+
: precheck.context;
|
|
421
|
+
taskPrompt += `\n\n以下是相关原始数据:\n\`\`\`\n${ctx}\n\`\`\``;
|
|
383
422
|
}
|
|
384
423
|
const fullPrompt = preamble + taskPrompt;
|
|
385
424
|
|
|
@@ -550,7 +589,9 @@ function createTaskScheduler(deps) {
|
|
|
550
589
|
const steps = task.steps || [];
|
|
551
590
|
if (steps.length === 0) return { success: false, error: 'No steps defined', output: '' };
|
|
552
591
|
|
|
553
|
-
|
|
592
|
+
// Workflow tasks match the user's session model setting (same quality as interactive)
|
|
593
|
+
const sessionModel = (config && config.daemon && config.daemon.model) || 'sonnet';
|
|
594
|
+
const model = normalizeModel(task.model || sessionModel);
|
|
554
595
|
const cwd = task.cwd ? task.cwd.replace(/^~/, HOME) : HOME;
|
|
555
596
|
const sessionId = crypto.randomUUID();
|
|
556
597
|
const outputs = [];
|
|
@@ -569,7 +610,12 @@ function createTaskScheduler(deps) {
|
|
|
569
610
|
for (let i = 0; i < steps.length; i++) {
|
|
570
611
|
const step = steps[i];
|
|
571
612
|
let prompt = (step.skill ? `/${step.skill} ` : '') + (step.prompt || '');
|
|
572
|
-
if (i === 0 && precheck.context)
|
|
613
|
+
if (i === 0 && precheck.context) {
|
|
614
|
+
const ctx = precheck.context.length > MAX_PRECONDITION_CHARS
|
|
615
|
+
? precheck.context.slice(0, MAX_PRECONDITION_CHARS) + '\n... (truncated)'
|
|
616
|
+
: precheck.context;
|
|
617
|
+
prompt += `\n\n相关数据:\n\`\`\`\n${ctx}\n\`\`\``;
|
|
618
|
+
}
|
|
573
619
|
const args = ['-p', '--model', model, '--dangerously-skip-permissions'];
|
|
574
620
|
for (const tool of allowed) args.push('--allowedTools', tool);
|
|
575
621
|
if (mcpConfig) args.push('--mcp-config', mcpConfig);
|
|
@@ -583,12 +629,14 @@ function createTaskScheduler(deps) {
|
|
|
583
629
|
timeout: resolveTimeoutMs(step.timeout, 300),
|
|
584
630
|
maxBuffer: 5 * 1024 * 1024,
|
|
585
631
|
cwd,
|
|
632
|
+
...(process.platform === 'win32' ? { windowsHide: true } : {}),
|
|
586
633
|
env: {
|
|
587
634
|
...process.env,
|
|
588
635
|
...getDaemonProviderEnv(),
|
|
589
636
|
CLAUDECODE: undefined,
|
|
590
637
|
METAME_INTERNAL_PROMPT: '1',
|
|
591
638
|
},
|
|
639
|
+
...(process.platform === 'win32' ? { shell: process.env.COMSPEC || true, windowsHide: true } : {}),
|
|
592
640
|
}).trim();
|
|
593
641
|
const tk = Math.ceil((prompt.length + output.length) / 4);
|
|
594
642
|
totalTokens += tk;
|
|
@@ -634,11 +682,22 @@ function createTaskScheduler(deps) {
|
|
|
634
682
|
return found || null;
|
|
635
683
|
}
|
|
636
684
|
|
|
637
|
-
function startHeartbeat(config, notifyFn) {
|
|
685
|
+
function startHeartbeat(config, notifyFn, notifyPersonalFn) {
|
|
638
686
|
const { all: tasks } = getAllTasks(config);
|
|
639
687
|
|
|
640
688
|
const enabledTasks = tasks.filter(t => t.enabled !== false);
|
|
641
689
|
const checkIntervalSec = (config.daemon && config.daemon.heartbeat_check_interval) || 60;
|
|
690
|
+
|
|
691
|
+
// Helper: compute next run time, falling back to 2-tick backoff on error.
|
|
692
|
+
function safeNextRun(taskName, schedule, now) {
|
|
693
|
+
try {
|
|
694
|
+
return { next: nextRunAfter(schedule, now), failed: false };
|
|
695
|
+
} catch (e) {
|
|
696
|
+
log('ERROR', `nextRunAfter failed for "${taskName}": ${e.message}`);
|
|
697
|
+
return { next: now + checkIntervalSec * 2 * 1000, failed: true };
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
642
701
|
const taskSchedules = new Map();
|
|
643
702
|
const runnableTasks = [];
|
|
644
703
|
for (const task of enabledTasks) {
|
|
@@ -671,7 +730,25 @@ function createTaskScheduler(deps) {
|
|
|
671
730
|
// Tracks tasks currently running (prevents concurrent runs of the same task)
|
|
672
731
|
const runningTasks = new Set();
|
|
673
732
|
|
|
733
|
+
// Wake detection: if tick interval far exceeds expected, system likely slept (macOS lid close).
|
|
734
|
+
// Use 5min floor to avoid false triggers from CPU load spikes or GC pauses.
|
|
735
|
+
let lastTickTime = Date.now();
|
|
736
|
+
const WAKE_THRESHOLD = Math.max(checkIntervalSec * 3 * 1000, 5 * 60 * 1000);
|
|
737
|
+
|
|
674
738
|
const timer = setInterval(() => {
|
|
739
|
+
const tickNow = Date.now();
|
|
740
|
+
const tickElapsed = tickNow - lastTickTime;
|
|
741
|
+
lastTickTime = tickNow;
|
|
742
|
+
|
|
743
|
+
if (tickElapsed > WAKE_THRESHOLD) {
|
|
744
|
+
log('FATAL', `[WAKE-DETECT] System resumed after ${Math.round(tickElapsed / 1000)}s sleep — restarting daemon to rebuild connections`);
|
|
745
|
+
const st = loadState();
|
|
746
|
+
st.wake_restart = new Date().toISOString();
|
|
747
|
+
st.wake_sleep_seconds = Math.round(tickElapsed / 1000);
|
|
748
|
+
saveState(st);
|
|
749
|
+
process.exit(1); // caffeinate will restart us with fresh connections
|
|
750
|
+
}
|
|
751
|
+
|
|
675
752
|
// ① Physiological heartbeat (zero token, pure awareness)
|
|
676
753
|
physiologicalHeartbeat(config);
|
|
677
754
|
|
|
@@ -703,24 +780,14 @@ function createTaskScheduler(deps) {
|
|
|
703
780
|
|
|
704
781
|
if (runningTasks.has(task.name)) {
|
|
705
782
|
// Task is still running; skip this cycle and keep full interval cadence.
|
|
706
|
-
|
|
707
|
-
nextRun[task.name] = nextRunAfter(schedule, currentTime);
|
|
708
|
-
} catch (schedErr) {
|
|
709
|
-
nextRun[task.name] = currentTime + checkIntervalSec * 2 * 1000;
|
|
710
|
-
log('ERROR', `nextRunAfter (running guard) failed for "${task.name}": ${schedErr.message}`);
|
|
711
|
-
}
|
|
783
|
+
nextRun[task.name] = safeNextRun(task.name, schedule, currentTime).next;
|
|
712
784
|
log('WARN', `Task ${task.name} still running — skipping this interval`);
|
|
713
785
|
continue;
|
|
714
786
|
}
|
|
715
787
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
// If next-run calculation fails, back off by at least 2 ticks to prevent infinite loop
|
|
720
|
-
nextRun[task.name] = currentTime + checkIntervalSec * 2 * 1000;
|
|
721
|
-
log('ERROR', `nextRunAfter failed for "${task.name}": ${schedErr.message} — backing off`);
|
|
722
|
-
continue;
|
|
723
|
-
}
|
|
788
|
+
const { next: nextRunTime, failed: schedFailed } = safeNextRun(task.name, schedule, currentTime);
|
|
789
|
+
nextRun[task.name] = nextRunTime;
|
|
790
|
+
if (schedFailed) continue; // back off, skip execution this cycle
|
|
724
791
|
runningTasks.add(task.name);
|
|
725
792
|
// executeTask now returns a Promise (async, non-blocking, process-group kill)
|
|
726
793
|
Promise.resolve(executeTask(task, config))
|
|
@@ -743,13 +810,20 @@ function createTaskScheduler(deps) {
|
|
|
743
810
|
}
|
|
744
811
|
|
|
745
812
|
// Skill evolution: check queue and notify user of actionable items
|
|
746
|
-
|
|
813
|
+
// Can be disabled via daemon.yaml: skill_evolution_notify: false
|
|
814
|
+
const skillEvolutionNotifyEnabled = !config.daemon || config.daemon.skill_evolution_notify !== false;
|
|
815
|
+
if (skillEvolution && skillEvolutionNotifyEnabled) {
|
|
747
816
|
try {
|
|
748
817
|
const notifications = skillEvolution.checkEvolutionQueue();
|
|
749
818
|
for (const item of notifications) {
|
|
750
819
|
let msg = '';
|
|
751
820
|
const idHint = item.id ? `\nID: \`${item.id}\`` : '';
|
|
752
|
-
if (item.type === '
|
|
821
|
+
if (item.type === 'workflow_proposal') {
|
|
822
|
+
msg = `🔄 *工作流技能建议*\n检测到重复工作流: ${item.search_hint || item.reason}`;
|
|
823
|
+
if (item.tools_signature && item.tools_signature.length) msg += `\n工具: ${item.tools_signature.join(', ')}`;
|
|
824
|
+
if (item.example_prompt) msg += `\n示例: "${item.example_prompt}"`;
|
|
825
|
+
if (item.evidence_count) msg += `\n出现次数: ${item.evidence_count}`;
|
|
826
|
+
} else if (item.type === 'skill_gap') {
|
|
753
827
|
msg = `🧬 *技能缺口检测*\n${item.reason}`;
|
|
754
828
|
if (item.search_hint) msg += `\n搜索建议: \`${item.search_hint}\``;
|
|
755
829
|
} else if (item.type === 'skill_fix') {
|
|
@@ -757,9 +831,14 @@ function createTaskScheduler(deps) {
|
|
|
757
831
|
} else if (item.type === 'user_complaint') {
|
|
758
832
|
msg = `⚠️ *技能反馈*\n技能 \`${item.skill_name}\` 收到用户反馈\n${item.reason}`;
|
|
759
833
|
}
|
|
760
|
-
if (msg && item.id
|
|
761
|
-
|
|
762
|
-
if (msg &&
|
|
834
|
+
if (msg && item.id && item.type === 'workflow_proposal') {
|
|
835
|
+
msg += `${idHint}\n处理: \`/skill-evo approve ${item.id}\` 或 \`/skill-evo dismiss ${item.id}\``;
|
|
836
|
+
} else if (msg && item.id) {
|
|
837
|
+
msg += `${idHint}\n处理: \`/skill-evo done ${item.id}\` 或 \`/skill-evo dismiss ${item.id}\``;
|
|
838
|
+
} else if (msg) msg += idHint;
|
|
839
|
+
// Skill notifications go only to personal chats, not agent group chats
|
|
840
|
+
const skillNotifyFn = notifyPersonalFn || notifyFn;
|
|
841
|
+
if (msg && skillNotifyFn) skillNotifyFn(msg);
|
|
763
842
|
}
|
|
764
843
|
} catch (e) { log('WARN', `Skill evolution queue check failed: ${e.message}`); }
|
|
765
844
|
}
|
|
@@ -780,6 +859,7 @@ function createTaskScheduler(deps) {
|
|
|
780
859
|
|
|
781
860
|
module.exports = {
|
|
782
861
|
createTaskScheduler,
|
|
862
|
+
normalizeModel,
|
|
783
863
|
_private: {
|
|
784
864
|
parseAtTime,
|
|
785
865
|
parseDays,
|
|
@@ -166,7 +166,7 @@ const ACTION_GROUPS = {
|
|
|
166
166
|
const ROLE_DEFAULT_ACTIONS = {
|
|
167
167
|
admin: Object.keys(ACTION_GROUPS), // 全部权限
|
|
168
168
|
member: ['query'], // 默认只能问答
|
|
169
|
-
stranger: [],
|
|
169
|
+
stranger: ['query'], // 仅基础问答(只读)
|
|
170
170
|
};
|
|
171
171
|
|
|
172
172
|
// 不可赋予 member 的 admin 专属 action
|
|
@@ -182,6 +182,16 @@ const ADMIN_ONLY_ACTIONS = new Set(['system', 'agent', 'config', 'admin_acl']);
|
|
|
182
182
|
*/
|
|
183
183
|
function resolveUserCtx(senderId, config) {
|
|
184
184
|
const userData = loadUsers();
|
|
185
|
+
// Per-platform bootstrap: Feishu open_id starts with "ou_", Telegram IDs are numeric.
|
|
186
|
+
const allUsers = userData && userData.users ? userData.users : {};
|
|
187
|
+
const isFeishuId = senderId && senderId.startsWith('ou_');
|
|
188
|
+
const isTelegramId = senderId && /^\d+$/.test(senderId);
|
|
189
|
+
const hasPlatformAdmin = isFeishuId
|
|
190
|
+
? Object.keys(allUsers).some(id => id.startsWith('ou_'))
|
|
191
|
+
: isTelegramId
|
|
192
|
+
? Object.keys(allUsers).some(id => /^\d+$/.test(id))
|
|
193
|
+
: Object.keys(allUsers).length > 0;
|
|
194
|
+
const hasConfiguredUsers = hasPlatformAdmin;
|
|
185
195
|
|
|
186
196
|
let role, name, allowedActions;
|
|
187
197
|
|
|
@@ -213,9 +223,25 @@ function resolveUserCtx(senderId, config) {
|
|
|
213
223
|
name = senderId.slice(-6);
|
|
214
224
|
allowedActions = ROLE_DEFAULT_ACTIONS.admin;
|
|
215
225
|
} else {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
226
|
+
if (!hasConfiguredUsers) {
|
|
227
|
+
// Bootstrap only once: persist the first seen sender as admin.
|
|
228
|
+
const next = {
|
|
229
|
+
default_role: userData.default_role || 'stranger',
|
|
230
|
+
users: { ...(userData.users || {}) },
|
|
231
|
+
};
|
|
232
|
+
next.users[senderId] = {
|
|
233
|
+
role: 'admin',
|
|
234
|
+
name: senderId.slice(-6),
|
|
235
|
+
};
|
|
236
|
+
try { saveUsers(next); } catch { /* non-fatal: fallback to in-memory role */ }
|
|
237
|
+
role = 'admin';
|
|
238
|
+
name = senderId.slice(-6);
|
|
239
|
+
allowedActions = ROLE_DEFAULT_ACTIONS.admin;
|
|
240
|
+
} else {
|
|
241
|
+
role = userData.default_role || 'stranger';
|
|
242
|
+
name = senderId.slice(-6);
|
|
243
|
+
allowedActions = ROLE_DEFAULT_ACTIONS[role] || [];
|
|
244
|
+
}
|
|
219
245
|
}
|
|
220
246
|
}
|
|
221
247
|
}
|
|
@@ -313,7 +339,7 @@ function handleUserCommand(text, userCtx) {
|
|
|
313
339
|
|
|
314
340
|
if (sub === 'add') {
|
|
315
341
|
// /user add <open_id> <role> [name...]
|
|
316
|
-
const [, ,
|
|
342
|
+
const [, , uid, role, ...nameParts] = args;
|
|
317
343
|
if (!uid || !role) return { handled: true, reply: '用法: /user add <open_id> <role> [name]' };
|
|
318
344
|
// [S2] open_id 格式校验
|
|
319
345
|
if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法(应为 10-64 位字母数字下划线)' };
|
|
@@ -328,7 +354,7 @@ function handleUserCommand(text, userCtx) {
|
|
|
328
354
|
}
|
|
329
355
|
|
|
330
356
|
if (sub === 'role') {
|
|
331
|
-
const [, ,
|
|
357
|
+
const [, , uid, role] = args;
|
|
332
358
|
if (!uid || !role) return { handled: true, reply: '用法: /user role <open_id> <role>' };
|
|
333
359
|
if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
|
|
334
360
|
if (!['admin', 'member', 'stranger'].includes(role)) {
|
|
@@ -343,7 +369,7 @@ function handleUserCommand(text, userCtx) {
|
|
|
343
369
|
}
|
|
344
370
|
|
|
345
371
|
if (sub === 'grant') {
|
|
346
|
-
const [, ,
|
|
372
|
+
const [, , uid, action] = args;
|
|
347
373
|
if (!uid || !action) return { handled: true, reply: '用法: /user grant <open_id> <action>' };
|
|
348
374
|
if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
|
|
349
375
|
if (ADMIN_ONLY_ACTIONS.has(action)) {
|
|
@@ -364,7 +390,7 @@ function handleUserCommand(text, userCtx) {
|
|
|
364
390
|
}
|
|
365
391
|
|
|
366
392
|
if (sub === 'revoke') {
|
|
367
|
-
const [, ,
|
|
393
|
+
const [, , uid, action] = args;
|
|
368
394
|
if (!uid || !action) return { handled: true, reply: '用法: /user revoke <open_id> <action>' };
|
|
369
395
|
if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
|
|
370
396
|
const data = loadUsers();
|
|
@@ -376,7 +402,7 @@ function handleUserCommand(text, userCtx) {
|
|
|
376
402
|
}
|
|
377
403
|
|
|
378
404
|
if (sub === 'remove') {
|
|
379
|
-
const [, ,
|
|
405
|
+
const [, , uid] = args;
|
|
380
406
|
if (!uid) return { handled: true, reply: '用法: /user remove <open_id>' };
|
|
381
407
|
if (!isValidOpenId(uid)) return { handled: true, reply: '❌ open_id 格式不合法' };
|
|
382
408
|
const data = loadUsers();
|