botmux 2.45.0 → 2.46.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/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +471 -2
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +114 -2
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/session-discovery.d.ts.map +1 -1
- package/dist/core/session-discovery.js +16 -4
- package/dist/core/session-discovery.js.map +1 -1
- package/dist/core/session-manager.d.ts +1 -1
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +10 -4
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/worker-pool.d.ts +105 -0
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +284 -4
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +7 -1
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/web/team-federation.d.ts.map +1 -1
- package/dist/dashboard/web/team-federation.js +17 -2
- package/dist/dashboard/web/team-federation.js.map +1 -1
- package/dist/dashboard-web/app.js +73 -73
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +61 -0
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +61 -0
- package/dist/i18n/zh.js.map +1 -1
- package/dist/im/lark/card-builder.d.ts +82 -0
- package/dist/im/lark/card-builder.d.ts.map +1 -1
- package/dist/im/lark/card-builder.js +315 -0
- package/dist/im/lark/card-builder.js.map +1 -1
- package/dist/im/lark/card-handler.d.ts.map +1 -1
- package/dist/im/lark/card-handler.js +197 -2
- package/dist/im/lark/card-handler.js.map +1 -1
- package/dist/im/lark/client.d.ts +24 -0
- package/dist/im/lark/client.d.ts.map +1 -1
- package/dist/im/lark/client.js +83 -0
- package/dist/im/lark/client.js.map +1 -1
- package/dist/im/lark/message-parser.d.ts.map +1 -1
- package/dist/im/lark/message-parser.js +1 -0
- package/dist/im/lark/message-parser.js.map +1 -1
- package/dist/services/relay-picker.d.ts +22 -0
- package/dist/services/relay-picker.d.ts.map +1 -0
- package/dist/services/relay-picker.js +62 -0
- package/dist/services/relay-picker.js.map +1 -0
- package/dist/setup/ensure-tmux.d.ts +0 -22
- package/dist/setup/ensure-tmux.d.ts.map +1 -1
- package/dist/setup/ensure-tmux.js +25 -1
- package/dist/setup/ensure-tmux.js.map +1 -1
- package/dist/types.d.ts +14 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/daemon-discovery.d.ts +11 -0
- package/dist/utils/daemon-discovery.d.ts.map +1 -0
- package/dist/utils/daemon-discovery.js +59 -0
- package/dist/utils/daemon-discovery.js.map +1 -0
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"command-handler.d.ts","sourceRoot":"","sources":["../../src/core/command-handler.ts"],"names":[],"mappings":"AAmBA,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAkD,KAAK,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAM/G,OAAO,KAAK,EAAE,WAAW,EAAkB,MAAM,aAAa,CAAC;AAE/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAKhD,eAAO,MAAM,eAAe,
|
|
1
|
+
{"version":3,"file":"command-handler.d.ts","sourceRoot":"","sources":["../../src/core/command-handler.ts"],"names":[],"mappings":"AAmBA,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAkD,KAAK,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAM/G,OAAO,KAAK,EAAE,WAAW,EAAkB,MAAM,aAAa,CAAC;AAE/D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAKhD,eAAO,MAAM,eAAe,aAAuK,CAAC;AAEpM;;;;;;GAMG;AACH,eAAO,MAAM,oBAAoB,aAO/B,CAAC;AAIH,MAAM,WAAW,sBAAsB;IACrC,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CACjB;AAMD,OAAO,EAAE,kBAAkB,EAAE,CAAC;AAE9B;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,EAAE,GACjB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CA0C9C;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAKpF;AAED;;;;mCAImC;AACnC,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,MAAM,GAAG,sBAAsB,GAAG,IAAI,CAqB1F;AA8DD,MAAM,WAAW,kBAAkB;IACjC,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;IAC3C,YAAY,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IACzG,cAAc,EAAE,MAAM,MAAM,CAAC;IAC7B,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,OAAO,gCAAgC,EAAE,WAAW,EAAE,CAAC,CAAC;CACnF;AAoOD,wBAAsB,aAAa,CACjC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,WAAW,EACpB,IAAI,EAAE,kBAAkB,EACxB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,IAAI,CAAC,CAsqCf;AAID,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,gBAAgB,EACxB,EAAE,EAAE,aAAa,EACjB,IAAI,EAAE,kBAAkB,EACxB,SAAS,CAAC,EAAE,MAAM,GACjB,OAAO,CAAC,IAAI,CAAC,CA+Bf"}
|
|
@@ -12,7 +12,7 @@ import * as scheduler from './scheduler.js';
|
|
|
12
12
|
import { scanMultipleProjects, describeProjectDir } from '../services/project-scanner.js';
|
|
13
13
|
import { buildRepoSelectCard, buildAdoptSelectCard, buildSessionClosedCard, getCliDisplayName } from '../im/lark/card-builder.js';
|
|
14
14
|
import { createCliAdapterSync } from '../adapters/cli/registry.js';
|
|
15
|
-
import { deleteMessage, listChatBotMembers, resolveUserUnionId, getChatModeStrict } from '../im/lark/client.js';
|
|
15
|
+
import { deleteMessage, sendMessage, listChatBotMembers, resolveUserUnionId, getChatModeStrict } from '../im/lark/client.js';
|
|
16
16
|
import { claimPairing } from '../services/pairing-store.js';
|
|
17
17
|
import { logger } from '../utils/logger.js';
|
|
18
18
|
import { killWorker, forkWorker, forkAdoptWorker, getCurrentCliVersion, postFreshStreamingCard, postPrivateSnapshotCard, resolvePrivateCardAudience } from './worker-pool.js';
|
|
@@ -27,7 +27,7 @@ import { getBotCapability, setBotCapability, clearBotCapability } from '../servi
|
|
|
27
27
|
import { sessionKey, sessionAnchorId } from './types.js';
|
|
28
28
|
import { t, localeForBot } from '../i18n/index.js';
|
|
29
29
|
// ─── Exported constants ──────────────────────────────────────────────────────
|
|
30
|
-
export const DAEMON_COMMANDS = new Set(['/close', '/restart', '/status', '/help', '/cd', '/repo', '/schedule', '/role', '/pair', '/login', '/adopt', '/oncall', '/group', '/g', '/card']);
|
|
30
|
+
export const DAEMON_COMMANDS = new Set(['/close', '/restart', '/status', '/help', '/cd', '/repo', '/schedule', '/role', '/pair', '/login', '/adopt', '/oncall', '/group', '/g', '/relay', '/card']);
|
|
31
31
|
/**
|
|
32
32
|
* Slash commands that are forwarded verbatim to the underlying CLI (e.g.
|
|
33
33
|
* Claude Code's `/compact`, `/model`, `/usage`). The daemon does NOT handle
|
|
@@ -1022,6 +1022,475 @@ export async function handleCommand(cmd, rootId, message, deps, larkAppId) {
|
|
|
1022
1022
|
}
|
|
1023
1023
|
break;
|
|
1024
1024
|
}
|
|
1025
|
+
/**
|
|
1026
|
+
* `/relay --create <群名> @bot [@bot...]` — create a new chat, invite
|
|
1027
|
+
* the @-mentioned bots, then migrate every bot's session in this
|
|
1028
|
+
* thread (including the leader's) into the new chat.
|
|
1029
|
+
*
|
|
1030
|
+
* Two-path command:
|
|
1031
|
+
* • `--create` (PR2) — implemented below; creates a new chat.
|
|
1032
|
+
* • no flag (PR3) — picker card listing user's relayable sessions
|
|
1033
|
+
* in OTHER chats so the user can pull one into
|
|
1034
|
+
* the current chat. Stubbed for now.
|
|
1035
|
+
*
|
|
1036
|
+
* Leader election is `mentions[0]` (identical to /group). The leader
|
|
1037
|
+
* is the only daemon that:
|
|
1038
|
+
* 1. Creates the new chat (createGroupWithBots)
|
|
1039
|
+
* 2. Sends the M1 announcement message (its message_id becomes the
|
|
1040
|
+
* shared rootMessageId for all relayed sessions — multi-bot
|
|
1041
|
+
* sessions co-anchor on the same root via different larkAppIds)
|
|
1042
|
+
* 3. Transfers its own session (if any) via local transferSession()
|
|
1043
|
+
* 4. POSTs /api/sessions/migrate-to-chat to every peer daemon to
|
|
1044
|
+
* ask them to transfer their own session at the same anchor
|
|
1045
|
+
* 5. Aggregates results into a single reply in the source thread
|
|
1046
|
+
*
|
|
1047
|
+
* Owner-only: only the source session's `ownerOpenId` may invoke. Peers
|
|
1048
|
+
* enforce the same check independently inside the migrate endpoint.
|
|
1049
|
+
*
|
|
1050
|
+
* Failure mode: best-effort, no rollback. Peers that timeout / fail /
|
|
1051
|
+
* are offline simply appear in the report as "skipped". The new chat
|
|
1052
|
+
* and any successful transfers stand.
|
|
1053
|
+
*/
|
|
1054
|
+
case '/relay': {
|
|
1055
|
+
const argsLine = message.content.replace(/^\/relay\s*/i, '').trim();
|
|
1056
|
+
if (!/^--create\b/i.test(argsLine)) {
|
|
1057
|
+
// ── Pull picker ───────────────────────────────────────────────────
|
|
1058
|
+
// /relay (no flag) lives in the *target* chat — list the operator's
|
|
1059
|
+
// own active sessions in OTHER chats so they can pull one in.
|
|
1060
|
+
//
|
|
1061
|
+
// Filter:
|
|
1062
|
+
// • same bot (this larkAppId)
|
|
1063
|
+
// • session is active (has a worker / appears in activeSessions)
|
|
1064
|
+
// • session NOT in the current chat (can't relay to yourself)
|
|
1065
|
+
// • operator IS the session owner (owner-only access)
|
|
1066
|
+
//
|
|
1067
|
+
// The button's `target_chat_id` / `target_root_id` are the chat we're
|
|
1068
|
+
// pulling INTO (the chat hosting this command). card-handler uses
|
|
1069
|
+
// them to invoke transferSession after sending the M1 announcement.
|
|
1070
|
+
const operatorOpenId = message.senderId;
|
|
1071
|
+
if (!operatorOpenId) {
|
|
1072
|
+
await sessionReply(rootId, t('cmd.relay.no_sender', undefined, loc));
|
|
1073
|
+
break;
|
|
1074
|
+
}
|
|
1075
|
+
const myAppId = larkAppId ?? ds?.larkAppId;
|
|
1076
|
+
if (!myAppId) {
|
|
1077
|
+
await sessionReply(rootId, t('cmd.group.no_bot', undefined, loc));
|
|
1078
|
+
break;
|
|
1079
|
+
}
|
|
1080
|
+
const targetChatId = ds?.chatId;
|
|
1081
|
+
if (!targetChatId) {
|
|
1082
|
+
await sessionReply(rootId, t('cmd.relay.no_session', undefined, loc));
|
|
1083
|
+
break;
|
|
1084
|
+
}
|
|
1085
|
+
// ── Chat-type guard ───────────────────────────────────────────────
|
|
1086
|
+
// Picker mode only makes sense in regular group chats. p2p (1:1 with
|
|
1087
|
+
// bot) has no relay concept — there's no other participant to
|
|
1088
|
+
// collaborate with — and topic chats route per-thread, so a chat-
|
|
1089
|
+
// scope session pulled in would have no thread anchor.
|
|
1090
|
+
//
|
|
1091
|
+
// p2p is detectable from `ds.chatType` locally (cheap). Topic vs
|
|
1092
|
+
// regular group is NOT captured in chatType — both record 'group'
|
|
1093
|
+
// — so we hit the Lark API (getChatNameAndMode) to resolve the
|
|
1094
|
+
// mode. One API call per /relay invocation; picker is user-
|
|
1095
|
+
// triggered so latency is acceptable.
|
|
1096
|
+
if (ds?.chatType === 'p2p') {
|
|
1097
|
+
await sessionReply(rootId, t('cmd.relay.picker_p2p_unsupported', undefined, loc));
|
|
1098
|
+
break;
|
|
1099
|
+
}
|
|
1100
|
+
{
|
|
1101
|
+
const { getChatNameAndMode } = await import('../im/lark/client.js');
|
|
1102
|
+
const info = await getChatNameAndMode(myAppId, targetChatId).catch(() => null);
|
|
1103
|
+
if (info?.mode === 'p2p') {
|
|
1104
|
+
await sessionReply(rootId, t('cmd.relay.picker_p2p_unsupported', undefined, loc));
|
|
1105
|
+
break;
|
|
1106
|
+
}
|
|
1107
|
+
if (info?.mode === 'topic') {
|
|
1108
|
+
await sessionReply(rootId, t('cmd.relay.picker_topic_unsupported', undefined, loc));
|
|
1109
|
+
break;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
// ── Existing-session guard ────────────────────────────────────────
|
|
1113
|
+
// If this bot already runs a real session in the target chat, pulling
|
|
1114
|
+
// another session in would collide on sessionKey(targetChatId, larkAppId)
|
|
1115
|
+
// — Map.set would silently overwrite, orphaning the existing worker.
|
|
1116
|
+
// Refuse upfront with an actionable message.
|
|
1117
|
+
//
|
|
1118
|
+
// Scratch sessions (the placeholder a `/relay` typed in a fresh chat
|
|
1119
|
+
// gets routed through) are filtered by `!!c.worker` — they have no
|
|
1120
|
+
// worker process. We do NOT exclude `ds` by sessionId: when `/relay`
|
|
1121
|
+
// rides an EXISTING real session (daemon.ts:2034's "existing-session
|
|
1122
|
+
// DAEMON_COMMANDS" path skips the scratch and binds `ds` to the
|
|
1123
|
+
// chat's real session), `ds` itself IS the conflict — excluding it
|
|
1124
|
+
// would let the picker render and the user pick a remote session
|
|
1125
|
+
// that the eventual transferSession would have to refuse anyway.
|
|
1126
|
+
const conflict = [...activeSessions.values()].find(c => c.larkAppId === myAppId
|
|
1127
|
+
&& c.chatId === targetChatId
|
|
1128
|
+
// chat-scope only: thread-scope sessions (e.g. a `/t` force-topic
|
|
1129
|
+
// session in a regular group) live at a different sessionKey
|
|
1130
|
+
// anchor (rootMessageId), so they don't collide on transfer.
|
|
1131
|
+
// transferSession's own pre-flight (worker-pool.ts) and card-
|
|
1132
|
+
// handler's confirm both filter the same way; align here so the
|
|
1133
|
+
// picker doesn't false-positive a thread-scope live session.
|
|
1134
|
+
&& c.scope === 'chat'
|
|
1135
|
+
&& !!c.worker // real running session, not a placeholder
|
|
1136
|
+
);
|
|
1137
|
+
if (conflict) {
|
|
1138
|
+
await sessionReply(rootId, t('cmd.relay.target_has_session', { title: conflict.session.title || conflict.session.sessionId.substring(0, 8) }, loc));
|
|
1139
|
+
break;
|
|
1140
|
+
}
|
|
1141
|
+
// Shared candidate-collection logic — used here at initial render
|
|
1142
|
+
// and again in card-handler when the user clicks a card to switch
|
|
1143
|
+
// selection (the card re-render needs the same filtered list).
|
|
1144
|
+
// Filters out: other bots / current chat / non-owned / adopt
|
|
1145
|
+
// sessions. Resolves friendly chat names + modes in parallel.
|
|
1146
|
+
const { collectRelayPickerEntries } = await import('../services/relay-picker.js');
|
|
1147
|
+
const entries = await collectRelayPickerEntries(activeSessions, myAppId, targetChatId, operatorOpenId);
|
|
1148
|
+
const { buildRelayPickerCard } = await import('../im/lark/card-builder.js');
|
|
1149
|
+
const card = buildRelayPickerCard(entries, targetChatId, rootId, operatorOpenId, loc);
|
|
1150
|
+
await sessionReply(rootId, card, 'interactive');
|
|
1151
|
+
break;
|
|
1152
|
+
}
|
|
1153
|
+
const afterFlag = argsLine.replace(/^--create\s*/i, '').trim();
|
|
1154
|
+
const creatorAppId = larkAppId ?? ds?.larkAppId;
|
|
1155
|
+
if (!creatorAppId) {
|
|
1156
|
+
await sessionReply(rootId, t('cmd.group.no_bot', undefined, loc));
|
|
1157
|
+
break;
|
|
1158
|
+
}
|
|
1159
|
+
const senderOpenId = message.senderId;
|
|
1160
|
+
// Cross-app stable identity — peer daemons can't compare against
|
|
1161
|
+
// leader's open_id directly because the same user has a different
|
|
1162
|
+
// open_id in each bot's namespace. union_id is shared per tenant.
|
|
1163
|
+
// We pass it through the migrate-to-chat HTTP body; peers compare
|
|
1164
|
+
// against their session's `ownerUnionId` (with fallback to
|
|
1165
|
+
// open_id for sessions persisted before this field existed).
|
|
1166
|
+
const senderUnionId = message.senderUnionId;
|
|
1167
|
+
if (!senderOpenId) {
|
|
1168
|
+
await sessionReply(rootId, t('cmd.relay.no_sender', undefined, loc));
|
|
1169
|
+
break;
|
|
1170
|
+
}
|
|
1171
|
+
// `--create` must be invoked inside an existing thread — the source
|
|
1172
|
+
// anchor for peer transfers comes from `ds`. (Picker mode in PR3 is
|
|
1173
|
+
// allowed without a session.)
|
|
1174
|
+
if (!ds) {
|
|
1175
|
+
await sessionReply(rootId, t('cmd.relay.no_session', undefined, loc));
|
|
1176
|
+
break;
|
|
1177
|
+
}
|
|
1178
|
+
// Front-loaded guards — transferSession refuses adoptedFrom /
|
|
1179
|
+
// pendingRepo too, but only after createGroupWithBots has already
|
|
1180
|
+
// built a new chat. Failing here keeps relay clean and avoids
|
|
1181
|
+
// orphan-chat garbage when the operation can't possibly succeed.
|
|
1182
|
+
if (ds.session.adoptedFrom) {
|
|
1183
|
+
await sessionReply(rootId, t('cmd.relay.adopt_not_relayable', undefined, loc));
|
|
1184
|
+
break;
|
|
1185
|
+
}
|
|
1186
|
+
if (ds.pendingRepo) {
|
|
1187
|
+
await sessionReply(rootId, t('cmd.relay.not_started_yet', undefined, loc));
|
|
1188
|
+
break;
|
|
1189
|
+
}
|
|
1190
|
+
// ── Mention parsing & leader election (mirror of /group) ───────────
|
|
1191
|
+
const mentions = message.mentions ?? [];
|
|
1192
|
+
const knownBotNames = globalKnownBotNames();
|
|
1193
|
+
if (knownBotNames.size === 0 && mentions.some(m => !!m.name)) {
|
|
1194
|
+
logger.warn(`[${logTag}] /relay --create: global bot registry empty; cannot elect a creator`);
|
|
1195
|
+
await sessionReply(rootId, t('cmd.relay.resolve_failed', undefined, loc));
|
|
1196
|
+
break;
|
|
1197
|
+
}
|
|
1198
|
+
const botMentions = mentions.filter(m => m.name && knownBotNames.has(m.name.toLowerCase()));
|
|
1199
|
+
if (botMentions.length === 0) {
|
|
1200
|
+
await sessionReply(rootId, t('cmd.relay.no_mentions', undefined, loc));
|
|
1201
|
+
break;
|
|
1202
|
+
}
|
|
1203
|
+
// Am I `mentions[0]`?
|
|
1204
|
+
const firstBot = botMentions[0];
|
|
1205
|
+
const myOpenId = getBotOpenId(creatorAppId);
|
|
1206
|
+
const myName = getBot(creatorAppId).botName?.toLowerCase();
|
|
1207
|
+
const myNameAmbiguous = !!myName
|
|
1208
|
+
&& botMentions.filter(m => m.name?.toLowerCase() === myName).length > 1;
|
|
1209
|
+
const iAmFirstBot = (!!myOpenId && firstBot.openId === myOpenId) ||
|
|
1210
|
+
(!myOpenId && !!myName && !myNameAmbiguous && firstBot.name?.toLowerCase() === myName);
|
|
1211
|
+
if (!iAmFirstBot) {
|
|
1212
|
+
logger.info(`[${logTag}] /relay --create: not the first @-mentioned bot, staying silent`);
|
|
1213
|
+
break;
|
|
1214
|
+
}
|
|
1215
|
+
// Owner-only — only the source session owner may relay this session.
|
|
1216
|
+
if (ds.session.ownerOpenId && ds.session.ownerOpenId !== senderOpenId) {
|
|
1217
|
+
await sessionReply(rootId, t('cmd.relay.not_owner', undefined, loc));
|
|
1218
|
+
break;
|
|
1219
|
+
}
|
|
1220
|
+
// ── Resolve @-bots to larkAppIds via the source chat's bot roster ──
|
|
1221
|
+
const sourceChatId = ds.chatId;
|
|
1222
|
+
let members = [];
|
|
1223
|
+
try {
|
|
1224
|
+
members = await listChatBotMembers(creatorAppId, sourceChatId);
|
|
1225
|
+
}
|
|
1226
|
+
catch (e) {
|
|
1227
|
+
logger.warn(`[${logTag}] /relay --create: failed to list source chat members: ${e?.message ?? e}`);
|
|
1228
|
+
}
|
|
1229
|
+
const memberByOpenId = new Map(members.map(m => [m.openId, m]));
|
|
1230
|
+
const appIdToName = new Map();
|
|
1231
|
+
for (const m of members) {
|
|
1232
|
+
if (m.larkAppId && m.displayName)
|
|
1233
|
+
appIdToName.set(m.larkAppId, m.displayName);
|
|
1234
|
+
}
|
|
1235
|
+
const mentionedBotAppIds = [];
|
|
1236
|
+
const seenApp = new Set();
|
|
1237
|
+
let unresolved;
|
|
1238
|
+
for (const bm of botMentions) {
|
|
1239
|
+
const mem = bm.openId ? memberByOpenId.get(bm.openId) : undefined;
|
|
1240
|
+
if (!mem || !mem.larkAppId) {
|
|
1241
|
+
unresolved = bm.name;
|
|
1242
|
+
break;
|
|
1243
|
+
}
|
|
1244
|
+
if (!seenApp.has(mem.larkAppId)) {
|
|
1245
|
+
seenApp.add(mem.larkAppId);
|
|
1246
|
+
mentionedBotAppIds.push(mem.larkAppId);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
if (unresolved) {
|
|
1250
|
+
logger.warn(`[${logTag}] /relay --create: unresolved bot "${unresolved}"`);
|
|
1251
|
+
await sessionReply(rootId, t('cmd.relay.resolve_failed', undefined, loc));
|
|
1252
|
+
break;
|
|
1253
|
+
}
|
|
1254
|
+
// ── Group name extraction (mirror of /group) ───────────────────────
|
|
1255
|
+
let rawArgs = afterFlag;
|
|
1256
|
+
for (const m of mentions) {
|
|
1257
|
+
if (m.name)
|
|
1258
|
+
rawArgs = rawArgs.split(`@${m.name}`).join(' ');
|
|
1259
|
+
}
|
|
1260
|
+
const firstLine = rawArgs.split(/\r?\n/).map(s => s.trim()).find(Boolean) ?? '';
|
|
1261
|
+
const MAX_NAME = 50;
|
|
1262
|
+
let groupName;
|
|
1263
|
+
if (firstLine) {
|
|
1264
|
+
groupName = firstLine.length > MAX_NAME ? firstLine.slice(0, MAX_NAME) + '…' : firstLine;
|
|
1265
|
+
}
|
|
1266
|
+
else {
|
|
1267
|
+
const now = new Date();
|
|
1268
|
+
const ts = `${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
|
1269
|
+
groupName = t('cmd.relay.empty_group_name', { ts }, loc);
|
|
1270
|
+
}
|
|
1271
|
+
// ── Create the new chat ────────────────────────────────────────────
|
|
1272
|
+
const nameOf = (id) => appIdToName.get(id) ?? botDisplayName(id);
|
|
1273
|
+
let newChatId;
|
|
1274
|
+
let inviteLink;
|
|
1275
|
+
try {
|
|
1276
|
+
const { createGroupWithBots } = await import('../services/group-creator.js');
|
|
1277
|
+
const result = await createGroupWithBots({
|
|
1278
|
+
creatorLarkAppId: creatorAppId,
|
|
1279
|
+
larkAppIds: mentionedBotAppIds,
|
|
1280
|
+
name: groupName,
|
|
1281
|
+
userOpenIds: [senderOpenId],
|
|
1282
|
+
transferOwnerTo: senderOpenId,
|
|
1283
|
+
});
|
|
1284
|
+
newChatId = result.chatId;
|
|
1285
|
+
const applink = `https://applink.feishu.cn/client/chat/open?openChatId=${encodeURIComponent(result.chatId)}`;
|
|
1286
|
+
inviteLink = result.shareLink ?? applink;
|
|
1287
|
+
}
|
|
1288
|
+
catch (err) {
|
|
1289
|
+
logger.error(`[${logTag}] /relay --create: createGroup failed: ${err?.message ?? err}`);
|
|
1290
|
+
await sessionReply(rootId, t('cmd.relay.failed', { error: err?.message ?? String(err) }, loc));
|
|
1291
|
+
break;
|
|
1292
|
+
}
|
|
1293
|
+
// Snapshot the pre-transfer source anchor — peers locate their own
|
|
1294
|
+
// session by this value, and `transferSession()` will overwrite
|
|
1295
|
+
// `ds.session.rootMessageId` once it runs. Must capture BEFORE the
|
|
1296
|
+
// leader transfer call (caught in review).
|
|
1297
|
+
const sourceAnchor = ds.session.rootMessageId;
|
|
1298
|
+
// ── M1 deferred: post the announcement AFTER all transfers settle ──
|
|
1299
|
+
// Previous flow sent an optimistic "已接力" M1 before running any
|
|
1300
|
+
// transfer. When leader/peers later failed, that M1 was a lie — and
|
|
1301
|
+
// the --create path had no orphan-cleanup (picker path did).
|
|
1302
|
+
//
|
|
1303
|
+
// New flow: pass `newChatId` as a placeholder for targetRootMessageId
|
|
1304
|
+
// into transferSession. Chat-scope routing ignores rootMessageId
|
|
1305
|
+
// (worker-pool transferSession only stores it for audit/UX), so the
|
|
1306
|
+
// placeholder doesn't break routing. Once all outcomes are in, we
|
|
1307
|
+
// post the real M1 with success/failure breakdown, then patch the
|
|
1308
|
+
// leader's session.rootMessageId to that final M1 id. Peer sessions
|
|
1309
|
+
// keep newChatId as a cosmetic placeholder — fixing them would
|
|
1310
|
+
// require another round-trip; chat-scope doesn't actually care.
|
|
1311
|
+
const placeholderRootMessageId = newChatId;
|
|
1312
|
+
// Resolve friendly source-chat label for the M1 body — falls back to
|
|
1313
|
+
// raw chatId if Lark can't return a name. Mirrors picker-path
|
|
1314
|
+
// (card-handler.ts:341) so the message reads the same in both UX
|
|
1315
|
+
// entry points.
|
|
1316
|
+
const { getChatName } = await import('../im/lark/client.js');
|
|
1317
|
+
const sourceLabel = (await getChatName(creatorAppId, sourceChatId).catch(() => null)) ?? sourceChatId;
|
|
1318
|
+
// ── Step 1: leader transfers its own session (if any) ───────────────
|
|
1319
|
+
// Empty-leader handling: daemon auto-creates a placeholder ds for any
|
|
1320
|
+
// DAEMON_COMMAND (worker:null + hasHistory:false). If the user typed
|
|
1321
|
+
// `/relay --create` in a chat where they never actually chatted with
|
|
1322
|
+
// the bot, ds IS that placeholder — there's no real session to
|
|
1323
|
+
// migrate. Pre-Codex-review we'd happily transferSession the empty
|
|
1324
|
+
// shell and report "已就绪:leader" as a lie. Now we detect this,
|
|
1325
|
+
// skip transferSession, mark leader as `no_session`, and close the
|
|
1326
|
+
// scratch so it doesn't linger as a ghost.
|
|
1327
|
+
//
|
|
1328
|
+
// The new chat is still created (createGroupWithBots already ran
|
|
1329
|
+
// above) — that itself is a valuable product outcome since the
|
|
1330
|
+
// mentioned bots were invited. Peers continue through their normal
|
|
1331
|
+
// path; the final M1 template adapts to "all_fresh" when no bot
|
|
1332
|
+
// actually had a session to bring along.
|
|
1333
|
+
const reportLines = [];
|
|
1334
|
+
const leaderName = nameOf(creatorAppId);
|
|
1335
|
+
const successBotNames = [];
|
|
1336
|
+
const failedBotNames = [];
|
|
1337
|
+
// Use the persisted-marker predicate, not runtime ds.hasHistory:
|
|
1338
|
+
// restoreActiveSessions sets hasHistory:true UNCONDITIONALLY on
|
|
1339
|
+
// restart (session-manager.ts:618), so a scratch that survives a
|
|
1340
|
+
// restart comes back with hasHistory:true and would defeat a
|
|
1341
|
+
// naive `!!ds.worker || ds.hasHistory` check. cliId / lastCliInput
|
|
1342
|
+
// are only written after a real worker started the CLI, so they
|
|
1343
|
+
// survive restart correctly.
|
|
1344
|
+
const { isRelayableRealSession } = await import('./worker-pool.js');
|
|
1345
|
+
const leaderHasRealSession = isRelayableRealSession(ds);
|
|
1346
|
+
if (leaderHasRealSession) {
|
|
1347
|
+
const { transferSession } = await import('./worker-pool.js');
|
|
1348
|
+
// Target chat was just built by createGroupWithBots — by
|
|
1349
|
+
// construction a regular group.
|
|
1350
|
+
const leaderResult = await transferSession(ds.session.sessionId, newChatId, placeholderRootMessageId, 'group');
|
|
1351
|
+
if (!leaderResult.ok) {
|
|
1352
|
+
// Real session, real failure (worker busy / unsupported target
|
|
1353
|
+
// / tmux issue). Abort the entire --create flow — the new chat
|
|
1354
|
+
// exists but is empty of any migrated session; we don't post
|
|
1355
|
+
// an M1 because there's nothing to announce.
|
|
1356
|
+
reportLines.push(t('cmd.relay.report_leader_failed', { bot: leaderName, error: leaderResult.error }, loc));
|
|
1357
|
+
await sessionReply(rootId, t('cmd.relay.created', { name: groupName, link: inviteLink, report: reportLines.join('\n') }, loc));
|
|
1358
|
+
break;
|
|
1359
|
+
}
|
|
1360
|
+
reportLines.push(t('cmd.relay.report_leader_ok', { bot: leaderName }, loc));
|
|
1361
|
+
successBotNames.push(leaderName);
|
|
1362
|
+
}
|
|
1363
|
+
else {
|
|
1364
|
+
// Empty leader: no real session to migrate.
|
|
1365
|
+
reportLines.push(t('cmd.relay.report_leader_no_session', { bot: leaderName }, loc));
|
|
1366
|
+
failedBotNames.push(leaderName);
|
|
1367
|
+
// Close the daemon-command scratch so it doesn't linger as a
|
|
1368
|
+
// ghost active row at the source anchor (same hygiene that
|
|
1369
|
+
// transferSession's pre-flight applies to target-chat scratches).
|
|
1370
|
+
const { closeSession } = await import('./worker-pool.js');
|
|
1371
|
+
await closeSession(ds.session.sessionId).catch(err => {
|
|
1372
|
+
logger.warn(`[${logTag}] /relay --create: failed to close empty-leader scratch: ${err instanceof Error ? err.message : err}`);
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
// ── Step 2: coordinate peer daemons (parallel) ─────────────────────
|
|
1376
|
+
const { findOnlineDaemon } = await import('../utils/daemon-discovery.js');
|
|
1377
|
+
const peerAppIds = mentionedBotAppIds.filter(id => id !== creatorAppId);
|
|
1378
|
+
const peerOutcomes = await Promise.all(peerAppIds.map(async (peerAppId) => {
|
|
1379
|
+
const botName = nameOf(peerAppId);
|
|
1380
|
+
const daemon = findOnlineDaemon(peerAppId);
|
|
1381
|
+
if (!daemon)
|
|
1382
|
+
return { peerAppId, botName, status: 'offline' };
|
|
1383
|
+
try {
|
|
1384
|
+
const ctrl = new AbortController();
|
|
1385
|
+
const tt = setTimeout(() => ctrl.abort(), 5000);
|
|
1386
|
+
const res = await fetch(`http://127.0.0.1:${daemon.ipcPort}/api/sessions/migrate-to-chat`, {
|
|
1387
|
+
method: 'POST',
|
|
1388
|
+
headers: { 'content-type': 'application/json' },
|
|
1389
|
+
body: JSON.stringify({
|
|
1390
|
+
sourceAnchor,
|
|
1391
|
+
targetChatId: newChatId,
|
|
1392
|
+
targetRootMessageId: placeholderRootMessageId,
|
|
1393
|
+
requesterLarkAppId: creatorAppId,
|
|
1394
|
+
requestingUserOpenId: senderOpenId,
|
|
1395
|
+
// union_id is cross-app stable within a tenant — peer
|
|
1396
|
+
// compares against its own session.ownerUnionId rather
|
|
1397
|
+
// than translating open_ids per bot. Optional for
|
|
1398
|
+
// backward compat with daemons older than this commit.
|
|
1399
|
+
requestingUserUnionId: senderUnionId,
|
|
1400
|
+
}),
|
|
1401
|
+
signal: ctrl.signal,
|
|
1402
|
+
}).finally(() => clearTimeout(tt));
|
|
1403
|
+
const body = await res.json().catch(() => ({}));
|
|
1404
|
+
if (res.ok && body.ok)
|
|
1405
|
+
return { peerAppId, botName, status: 'ok' };
|
|
1406
|
+
if (body.error === 'no_session_at_anchor')
|
|
1407
|
+
return { peerAppId, botName, status: 'no_session' };
|
|
1408
|
+
if (body.error === 'not_session_owner')
|
|
1409
|
+
return { peerAppId, botName, status: 'not_owner' };
|
|
1410
|
+
if (body.error === 'worker_busy')
|
|
1411
|
+
return { peerAppId, botName, status: 'busy' };
|
|
1412
|
+
return { peerAppId, botName, status: 'failed', error: body.error ?? `http_${res.status}` };
|
|
1413
|
+
}
|
|
1414
|
+
catch (err) {
|
|
1415
|
+
const reason = err?.name === 'AbortError' ? 'busy' : 'failed';
|
|
1416
|
+
return { peerAppId, botName, status: reason, error: err?.message ?? String(err) };
|
|
1417
|
+
}
|
|
1418
|
+
}));
|
|
1419
|
+
// Bucket peer outcomes for the final M1 (success / failure) AND extend the
|
|
1420
|
+
// source-chat report with per-peer detail. Leader was already bucketed
|
|
1421
|
+
// above (real-success → successBotNames; real-fail or empty-leader →
|
|
1422
|
+
// failedBotNames), so we only iterate peers here.
|
|
1423
|
+
for (const r of peerOutcomes) {
|
|
1424
|
+
if (r.status === 'ok') {
|
|
1425
|
+
successBotNames.push(r.botName);
|
|
1426
|
+
reportLines.push(t('cmd.relay.report_peer_ok', { bot: r.botName }, loc));
|
|
1427
|
+
}
|
|
1428
|
+
else {
|
|
1429
|
+
failedBotNames.push(r.botName);
|
|
1430
|
+
switch (r.status) {
|
|
1431
|
+
case 'no_session':
|
|
1432
|
+
reportLines.push(t('cmd.relay.report_peer_no_session', { bot: r.botName }, loc));
|
|
1433
|
+
break;
|
|
1434
|
+
case 'not_owner':
|
|
1435
|
+
reportLines.push(t('cmd.relay.report_peer_not_owner', { bot: r.botName }, loc));
|
|
1436
|
+
break;
|
|
1437
|
+
case 'offline':
|
|
1438
|
+
reportLines.push(t('cmd.relay.report_peer_offline', { bot: r.botName }, loc));
|
|
1439
|
+
break;
|
|
1440
|
+
case 'busy':
|
|
1441
|
+
reportLines.push(t('cmd.relay.report_peer_busy', { bot: r.botName }, loc));
|
|
1442
|
+
break;
|
|
1443
|
+
case 'failed':
|
|
1444
|
+
reportLines.push(t('cmd.relay.report_peer_failed', { bot: r.botName, error: r.error ?? 'unknown' }, loc));
|
|
1445
|
+
break;
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
// ── Step 3: post the real M1 with status breakdown ─────────────────
|
|
1450
|
+
// Three templates:
|
|
1451
|
+
// - all_ok : every bot migrated cleanly
|
|
1452
|
+
// - partial : some migrated, some didn't (failed list explains)
|
|
1453
|
+
// - all_fresh : nobody had a session to migrate (group's still
|
|
1454
|
+
// useful — bots were invited; user just @s to start)
|
|
1455
|
+
// Pass the raw text — sendMessage wraps `'text'` msgType bodies into
|
|
1456
|
+
// { text: content } itself.
|
|
1457
|
+
let finalM1Text;
|
|
1458
|
+
if (successBotNames.length === 0) {
|
|
1459
|
+
finalM1Text = t('cmd.relay.m1_final_all_fresh', { sourceChat: sourceLabel }, loc);
|
|
1460
|
+
}
|
|
1461
|
+
else if (failedBotNames.length === 0) {
|
|
1462
|
+
finalM1Text = t('cmd.relay.m1_final_all_ok', {
|
|
1463
|
+
sourceChat: sourceLabel,
|
|
1464
|
+
successBots: successBotNames.join('、'),
|
|
1465
|
+
}, loc);
|
|
1466
|
+
}
|
|
1467
|
+
else {
|
|
1468
|
+
finalM1Text = t('cmd.relay.m1_final_partial', {
|
|
1469
|
+
sourceChat: sourceLabel,
|
|
1470
|
+
successBots: successBotNames.join('、'),
|
|
1471
|
+
failedBots: failedBotNames.join('、'),
|
|
1472
|
+
}, loc);
|
|
1473
|
+
}
|
|
1474
|
+
try {
|
|
1475
|
+
const finalM1Id = await sendMessage(creatorAppId, newChatId, finalM1Text, 'text');
|
|
1476
|
+
// Patch the leader's session.rootMessageId to the real M1 id, but
|
|
1477
|
+
// only if the leader was actually transferred — for the empty-
|
|
1478
|
+
// leader / all_fresh path, ds was either closed or never moved,
|
|
1479
|
+
// so we don't touch it (would write to a closed/stale record).
|
|
1480
|
+
if (leaderHasRealSession && successBotNames.includes(leaderName)) {
|
|
1481
|
+
ds.session.rootMessageId = finalM1Id;
|
|
1482
|
+
sessionStore.updateSession(ds.session);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
catch (err) {
|
|
1486
|
+
// Non-fatal: transfers already succeeded. The source-chat report
|
|
1487
|
+
// (sessionReply below) is the user's authoritative status.
|
|
1488
|
+
logger.warn(`[${logTag}] /relay --create: final M1 send failed: ${err?.message ?? err}`);
|
|
1489
|
+
}
|
|
1490
|
+
await sessionReply(rootId, t('cmd.relay.created', { name: groupName, link: inviteLink, report: reportLines.join('\n') }, loc));
|
|
1491
|
+
logger.info(`[${logTag}] /relay --create completed: chat=${newChatId} leader=${creatorAppId} peers=[${peerAppIds.join(',')}]`);
|
|
1492
|
+
break;
|
|
1493
|
+
}
|
|
1025
1494
|
case '/card': {
|
|
1026
1495
|
if (!ds) {
|
|
1027
1496
|
await sessionReply(rootId, t('cmd.no_active_session', undefined, loc));
|