agim-cli 1.0.4 → 1.0.5

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.
@@ -402,6 +402,35 @@ export async function startWebServer(options) {
402
402
  if (url.pathname === '/api/invoke' && req.method === 'POST') {
403
403
  return handleInvoke(req, res, options.defaultAgent);
404
404
  }
405
+ // WeChat QR login — drives the "扫码登录" button in the web settings
406
+ // page. /qr-start returns the QR image URL + the token used to poll;
407
+ // /qr-status polls one cycle and, on confirmation, persists the
408
+ // credentials to disk AND adds 'wechat-ilink' into config.messengers
409
+ // so the next service restart picks the channel up.
410
+ if (url.pathname === '/api/messengers/wechat/qr-start' && req.method === 'POST') {
411
+ return handleWechatQrStart(res);
412
+ }
413
+ if (url.pathname === '/api/messengers/wechat/qr-status' && req.method === 'GET') {
414
+ return handleWechatQrStatus(res, url);
415
+ }
416
+ // Service management — status / start / stop / restart for the
417
+ // "service controls" card in the web settings page. start/stop are
418
+ // mostly no-ops from the web context (the web server itself IS the
419
+ // service, so the page wouldn't render if nothing was running), but
420
+ // restart is the headline use case: change a setting, click restart,
421
+ // wait ~2s, browser auto-reconnects.
422
+ if (url.pathname === '/api/service/status' && req.method === 'GET') {
423
+ return handleServiceStatus(res);
424
+ }
425
+ if (url.pathname === '/api/service/start' && req.method === 'POST') {
426
+ return handleServiceStart(res);
427
+ }
428
+ if (url.pathname === '/api/service/stop' && req.method === 'POST') {
429
+ return handleServiceStop(res);
430
+ }
431
+ if (url.pathname === '/api/service/restart' && req.method === 'POST') {
432
+ return handleServiceRestart(res);
433
+ }
405
434
  res.writeHead(404);
406
435
  res.end('Not found');
407
436
  });
@@ -640,6 +669,17 @@ async function handleGetConfig(_req, res) {
640
669
  feishu: config.feishu
641
670
  ? { appId: config.feishu.appId, appSecret: mask(config.feishu.appSecret) }
642
671
  : undefined,
672
+ dingtalk: config.dingtalk
673
+ ? { clientId: config.dingtalk.clientId, clientSecret: mask(config.dingtalk.clientSecret), channelId: config.dingtalk.channelId }
674
+ : undefined,
675
+ discord: config.discord
676
+ ? {
677
+ botToken: mask(config.discord.botToken),
678
+ channelId: config.discord.channelId,
679
+ allowedGuilds: config.discord.allowedGuilds,
680
+ allowedChannels: config.discord.allowedChannels,
681
+ }
682
+ : undefined,
643
683
  acpAgents: config.acpAgents?.map(a => ({
644
684
  ...a,
645
685
  auth: a.auth
@@ -681,6 +721,24 @@ async function handlePutConfig(req, res) {
681
721
  };
682
722
  continue;
683
723
  }
724
+ if (key === 'dingtalk' && typeof val === 'object' && val !== null) {
725
+ const d = val;
726
+ merged.dingtalk = {
727
+ ...(existing.dingtalk || {}),
728
+ ...d,
729
+ clientSecret: typeof d.clientSecret === 'string' && isMasked(d.clientSecret) ? existing.dingtalk?.clientSecret : d.clientSecret,
730
+ };
731
+ continue;
732
+ }
733
+ if (key === 'discord' && typeof val === 'object' && val !== null) {
734
+ const d = val;
735
+ merged.discord = {
736
+ ...(existing.discord || {}),
737
+ ...d,
738
+ botToken: typeof d.botToken === 'string' && isMasked(d.botToken) ? existing.discord?.botToken : d.botToken,
739
+ };
740
+ continue;
741
+ }
684
742
  if (key === 'acpAgents' && Array.isArray(val)) {
685
743
  merged.acpAgents = val.map((item, i) => {
686
744
  const a = item;
@@ -845,6 +903,189 @@ async function handleMetrics(_req, res, url) {
845
903
  sendJson(res, 500, { error: msg });
846
904
  }
847
905
  }
906
+ // ============================================================
907
+ // WeChat QR-login endpoints (drive the "扫码登录" web button)
908
+ // ============================================================
909
+ async function handleWechatQrStart(res) {
910
+ try {
911
+ const { ILinkWeChatAdapter } = await import('../plugins/messengers/wechat/ilink-adapter.js');
912
+ const adapter = new ILinkWeChatAdapter();
913
+ const { qrUrl, qrToken } = await adapter.startQRLogin();
914
+ sendJson(res, 200, { qrUrl, qrToken });
915
+ }
916
+ catch (err) {
917
+ const msg = err instanceof Error ? err.message : String(err);
918
+ sendJson(res, 500, { error: msg });
919
+ }
920
+ }
921
+ async function handleWechatQrStatus(res, url) {
922
+ const qrToken = url.searchParams.get('token') || '';
923
+ if (!qrToken) {
924
+ sendJson(res, 400, { error: 'token query param required' });
925
+ return;
926
+ }
927
+ try {
928
+ const { ILinkWeChatAdapter } = await import('../plugins/messengers/wechat/ilink-adapter.js');
929
+ const adapter = new ILinkWeChatAdapter();
930
+ const result = await adapter.pollQRLogin(qrToken);
931
+ if (result.status === 'confirmed') {
932
+ // Add 'wechat-ilink' into config.messengers so the next restart of
933
+ // the service brings the channel up. The credentials file was
934
+ // already written by adapter.pollQRLogin().
935
+ try {
936
+ const cfg = await loadConfig();
937
+ if (!(cfg.messengers || []).includes('wechat-ilink')) {
938
+ const next = {
939
+ ...cfg,
940
+ messengers: [...(cfg.messengers || []), 'wechat-ilink'],
941
+ };
942
+ await saveConfig(next);
943
+ }
944
+ }
945
+ catch (err) {
946
+ webLog.warn({ event: 'wechat.qr.config_persist_failed', err: String(err) });
947
+ }
948
+ }
949
+ sendJson(res, 200, result);
950
+ }
951
+ catch (err) {
952
+ const msg = err instanceof Error ? err.message : String(err);
953
+ sendJson(res, 500, { error: msg });
954
+ }
955
+ }
956
+ // ============================================================
957
+ // Service-management endpoints (start / stop / restart / status)
958
+ // ============================================================
959
+ //
960
+ // The web server itself runs inside the service process, so /stop and
961
+ // /restart kill the very process handling the request. We respond
962
+ // first, then schedule the kill in a tick so the HTTP reply has time
963
+ // to flush. For /restart we additionally spawn a detached "wait + start"
964
+ // child so the daemon comes back without manual intervention.
965
+ async function handleServiceStatus(res) {
966
+ try {
967
+ const { detectService, formatUptime, readWebEndpoint } = await import('../cli-ui/service.js');
968
+ const st = detectService();
969
+ const web = readWebEndpoint();
970
+ sendJson(res, 200, {
971
+ mode: st.mode,
972
+ pid: st.pid,
973
+ uptimeSec: st.uptimeSec ?? null,
974
+ uptime: typeof st.uptimeSec === 'number' ? formatUptime(st.uptimeSec) : null,
975
+ web,
976
+ });
977
+ }
978
+ catch (err) {
979
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
980
+ }
981
+ }
982
+ async function handleServiceStart(res) {
983
+ try {
984
+ const { detectService, spawnBackground } = await import('../cli-ui/service.js');
985
+ const st = detectService();
986
+ if (st.mode !== 'none') {
987
+ sendJson(res, 200, { ok: true, alreadyRunning: true, mode: st.mode, pid: st.pid });
988
+ return;
989
+ }
990
+ const { fileURLToPath: f } = await import('node:url');
991
+ const __dn = dirname(f(import.meta.url));
992
+ const cliJs = join(__dn, '..', 'cli.js');
993
+ const { pid } = spawnBackground(process.execPath, [cliJs, 'start']);
994
+ sendJson(res, 200, { ok: true, started: true, pid });
995
+ }
996
+ catch (err) {
997
+ sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
998
+ }
999
+ }
1000
+ async function handleServiceStop(res) {
1001
+ const { detectService } = await import('../cli-ui/service.js');
1002
+ const st = detectService();
1003
+ if (st.mode === 'systemd') {
1004
+ // systemctl stop handles the kill for us — no self-exit needed.
1005
+ try {
1006
+ const { execSync } = await import('node:child_process');
1007
+ // Match service.ts's detection: prefer agim.service, fall back.
1008
+ const unitName = existsSync('/etc/systemd/system/agim.service') ? 'agim.service' : 'im-hub.service';
1009
+ execSync(`systemctl stop ${unitName}`);
1010
+ sendJson(res, 200, { ok: true, mode: 'systemd' });
1011
+ }
1012
+ catch (err) {
1013
+ sendJson(res, 500, { error: 'systemctl stop failed: ' + (err instanceof Error ? err.message : String(err)) });
1014
+ }
1015
+ return;
1016
+ }
1017
+ // Background / foreground: respond, then SIGTERM self in next tick.
1018
+ sendJson(res, 200, { ok: true, mode: st.mode, exiting: true });
1019
+ setTimeout(() => {
1020
+ try {
1021
+ process.kill(process.pid, 'SIGTERM');
1022
+ }
1023
+ catch {
1024
+ process.exit(0);
1025
+ }
1026
+ }, 200);
1027
+ }
1028
+ async function handleServiceRestart(res) {
1029
+ const { detectService, PID_FILE, LOG_FILE } = await import('../cli-ui/service.js');
1030
+ const st = detectService();
1031
+ if (st.mode === 'systemd') {
1032
+ try {
1033
+ const { execSync } = await import('node:child_process');
1034
+ const unitName = existsSync('/etc/systemd/system/agim.service') ? 'agim.service' : 'im-hub.service';
1035
+ execSync(`systemctl restart ${unitName}`);
1036
+ sendJson(res, 200, { ok: true, mode: 'systemd' });
1037
+ }
1038
+ catch (err) {
1039
+ sendJson(res, 500, { error: 'systemctl restart failed: ' + (err instanceof Error ? err.message : String(err)) });
1040
+ }
1041
+ return;
1042
+ }
1043
+ if (st.mode === 'foreground') {
1044
+ // Can't safely restart someone else's foreground process from the
1045
+ // web context — the owning shell still attached to its stdio
1046
+ // expects to Ctrl-C it manually.
1047
+ sendJson(res, 409, { error: 'foreground service is running in another terminal; restart it manually (Ctrl-C + re-run)' });
1048
+ return;
1049
+ }
1050
+ // background or 'none': spawn a detached node -e helper that waits
1051
+ // for our PID to die, then starts a fresh daemon. Then SIGTERM self
1052
+ // so the helper's wait-loop unblocks and the new daemon takes over.
1053
+ const __dn = dirname(fileURLToPath(import.meta.url));
1054
+ const cliJs = join(__dn, '..', 'cli.js');
1055
+ const parentPid = process.pid;
1056
+ const helperCode = 'const{spawn}=require("child_process");' +
1057
+ 'const fs=require("fs");' +
1058
+ `const pid=${parentPid};` +
1059
+ 'let n=0;' +
1060
+ 'const t=setInterval(()=>{' +
1061
+ ' try{process.kill(pid,0)}catch{' +
1062
+ ' clearInterval(t);' +
1063
+ ` const fd=fs.openSync(${JSON.stringify(LOG_FILE)},"a");` +
1064
+ ` const c=spawn(${JSON.stringify(process.execPath)},[${JSON.stringify(cliJs)},"start"],{detached:true,stdio:["ignore",fd,fd],env:process.env});` +
1065
+ ' c.unref();' +
1066
+ ` fs.writeFileSync(${JSON.stringify(PID_FILE)},String(c.pid)+"\\n");` +
1067
+ ' fs.closeSync(fd);' +
1068
+ ' process.exit(0);' +
1069
+ ' }' +
1070
+ ' if(++n>150){clearInterval(t);process.exit(1)}' +
1071
+ '},200);';
1072
+ const { spawn: spawnChild } = await import('node:child_process');
1073
+ const helper = spawnChild(process.execPath, ['-e', helperCode], {
1074
+ detached: true,
1075
+ stdio: 'ignore',
1076
+ env: process.env,
1077
+ });
1078
+ helper.unref();
1079
+ sendJson(res, 200, { ok: true, mode: st.mode, restarting: true });
1080
+ setTimeout(() => {
1081
+ try {
1082
+ process.kill(process.pid, 'SIGTERM');
1083
+ }
1084
+ catch {
1085
+ process.exit(0);
1086
+ }
1087
+ }, 300);
1088
+ }
848
1089
  /**
849
1090
  * POST /api/notify → push a message to an IM thread.
850
1091
  *