Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 0 additions & 44 deletions src/core/dashboard-ipc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ function readIsolationEnforceable(larkAppId: string): boolean {
}
import * as observedBotsStore from '../services/observed-bots-store.js';
import { getDeploymentIdentity } from '../services/deployment-identity.js';
import { getBotUnionId } from '../services/bot-union-ids-store.js';
import * as grantPrefsStore from '../services/grant-prefs-store.js';
import { applyExactChatGrantRequest } from '../services/exact-chat-grant.js';
import { findConfigField, applyConfigField, coerceConfigValue, setChatFeedbackPolicy } from '../services/bot-config-store.js';
Expand Down Expand Up @@ -3241,49 +3240,6 @@ ipcRoute('POST', '/api/groups/:chatId/leave', async (_req, res, p) => {
jsonRes(res, 200, r);
});

// 平台团队大厅打卡:dashboard 在 team-sync 后编排本机 bot 往大厅(bot-only 群)
// 发登记消息。实测大厅只有「直接点名 @」会投递(普通消息/自 @/@all 全部静默),
// 所以打卡消息点名 @ 本机其他未入册 bot(mentionNames,open_id 由本 app 的
// cross-ref 解析——open_id 是 per-app 的,只有发送方自己能解析),被点到的 bot
// 从 mentions 学到自己的 union_id。回声路径保留(有 receive-all scope 的应用仍可
// 从自家消息学)。已入册且无人可教时幂等跳过。
ipcRoute('POST', '/api/platform/hall-announce', async (req, res) => {
if (!cachedLarkAppId) return jsonRes(res, 503, { ok: false, error: 'larkAppId_not_set' });
let body: { chatId?: unknown; mentionNames?: unknown };
try { body = await readJsonBody(req); } catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); }
const chatId = typeof body.chatId === 'string' ? body.chatId.trim() : '';
if (!/^oc_[0-9a-f]+$/i.test(chatId)) return jsonRes(res, 400, { ok: false, error: 'bad_chat_id' });
const mentionNames = Array.isArray(body.mentionNames)
? body.mentionNames.filter((x): x is string => typeof x === 'string' && !!x.trim())
: [];
// 解析点名目标:name → 本 app 视角的 open_id(cross-ref,来自历史 @ 事件)。解析不到的跳过。
const resolved: Array<{ name: string; openId: string }> = [];
if (mentionNames.length) {
try {
const map: Record<string, string> = JSON.parse(
readFileSync(join(config.session.dataDir, `bot-openids-${cachedLarkAppId}.json`), 'utf-8'),
);
for (const name of mentionNames) {
const openId = map[name];
if (typeof openId === 'string' && openId.startsWith('ou_')) resolved.push({ name, openId });
}
} catch { /* 无 cross-ref → 全部解析失败,退化为普通打卡 */ }
}
if (getBotUnionId(config.session.dataDir, cachedLarkAppId) && resolved.length === 0) {
return jsonRes(res, 200, { ok: true, skipped: 'already_learned' });
}
try {
const atPrefix = resolved.map((r) => `<at user_id="${r.openId}">${r.name}</at> `).join('');
// 自己还没入册 → 带 #hall-echo 请求回执:被点到的 bot 会 @ 回我们一次,
// 我们从回执的 mentions[] 学到自己的 union_id(见 event-dispatcher hall 分支)。
const echoTag = getBotUnionId(config.session.dataDir, cachedLarkAppId) ? '' : ' #hall-echo';
await sendMessage(cachedLarkAppId, chatId, atPrefix + t('platform.hall_announce', undefined, localeForBot(cachedLarkAppId)) + echoTag, 'text');
jsonRes(res, 200, { ok: true, mentioned: resolved.map((r) => r.name), unresolved: mentionNames.filter((n) => !resolved.some((r) => r.name === n)) });
} catch (e) {
jsonRes(res, 502, { ok: false, error: `send_failed: ${(e as Error).message}` });
}
});

// ─── Oncall bindings (dashboard) ───────────────────────────────────────────
// PUT /api/oncall/:chatId body: {workingDir} — bind or update workingDir
// DELETE /api/oncall/:chatId — unbind
Expand Down
103 changes: 2 additions & 101 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ import { mergeSafeInsightOverviews } from './services/insight/report.js';
import type { SafeInsightOverview } from './services/insight/types.js';
import { readPlatformBinding } from './platform/binding.js';
import { startPlatformTunnelClient, type PlatformBotInfo, type PlatformTeamSyncMessage } from './platform/tunnel-client.js';
import { applyPlatformTeamSync, getPlatformTeamSyncRev, listPlatformTeams } from './services/platform-team-store.js';
import { applyPlatformTeamSync, getPlatformTeamSyncRev } from './services/platform-team-store.js';
import { getBotUnionId } from './services/bot-union-ids-store.js';
import { getBotSpecialties } from './services/bot-profile-store.js';
import { cleanupIdleSessions, parseIdleCleanupHours } from './dashboard/session-cleanup.js';
Expand Down Expand Up @@ -7015,118 +7015,19 @@ function startPlatformTunnelIfBound(): void {
log: (msg, extra) => logger.info(`[platform-tunnel] ${msg}${extra ? ' ' + JSON.stringify(extra) : ''}`),
});
logger.info(`[platform-tunnel] 绑定到 ${binding.platformUrl},启动隧道`);
// 大厅打卡自愈重试:team-sync 应用时会立即尝试一次;这里的低频周期兜住
// "当时 daemon 离线 / bot 还没进大厅 / 发送失败"的漏拍。无平台绑定不启动。
const hallTimer = setInterval(() => { void maybeAnnounceHallPresence(); }, 5 * 60 * 1000);
hallTimer.unref();
} catch (e) {
logger.warn(`[platform-tunnel] 启动失败: ${(e as Error).message}`);
}
}

/** 平台 team-sync 落盘(roster + 团队群镜像),随后触发一轮大厅打卡检查。 */
/** 平台 team-sync 落盘(roster + 团队群镜像)。 */
function handlePlatformTeamSync(payload: PlatformTeamSyncMessage): void {
const applied = applyPlatformTeamSync(config.session.dataDir, payload);
if (!applied) {
logger.warn('[platform-tunnel] team-sync 负载无效,忽略');
return;
}
logger.info(`[platform-tunnel] team-sync 已应用 rev=${applied.rev} teams=${applied.teams.length}`);
void maybeAnnounceHallPresence();
}

// 大厅打卡节流:按「发送 bot ×大厅」记最小间隔与尝试上限——按 bot 记会让多团队
// bot 在第一个大厅烧光预算后,新加入的大厅永远轮空(实测踩过)。只有真正发出
// 消息才消耗次数;状态落盘,重启不重发(否则每次重启都往大厅刷一轮)。
const HALL_ANNOUNCE_MIN_INTERVAL_MS = 10 * 60 * 1000;
const HALL_ANNOUNCE_MAX_TRIES = 6;
const hallAnnounceStatePath = () => join(config.session.dataDir, 'hall-announce-state.json');
function readHallAnnounceState(): Record<string, { lastAt: number; tries: number }> {
try { return JSON.parse(readFileSync(hallAnnounceStatePath(), 'utf-8')); } catch { return {}; }
}
/** 记录一次打卡尝试。consumeTry=false 只刷新 lastAt(发送失败:保住 10 分钟退避
* 但不烧预算——否则 daemon 掉线期间就把 6 次上限烧光、恢复后永久跳过,Codex review)。 */
function bumpHallAnnounceState(key: string, consumeTry: boolean): void {
const all = readHallAnnounceState();
const cur = all[key];
all[key] = { lastAt: Date.now(), tries: (cur?.tries ?? 0) + (consumeTry ? 1 : 0) };
try { atomicWriteFileSync(hallAnnounceStatePath(), JSON.stringify(all, null, 2) + '\n'); } catch { /* 尽力而为 */ }
}
/** 发送方 daemon 的 mention cross-ref(name → 本 app 视角 open_id)。 */
function readBotCrossRef(appId: string): Record<string, string> {
try { return JSON.parse(readFileSync(join(config.session.dataDir, `bot-openids-${appId}.json`), 'utf-8')); } catch { return {}; }
}

/**
* 大厅打卡编排(union_id 自学)。实测大厅(bot-only 群)只有「直接点名 @」会
* 投递事件——普通消息、自 @、@all 全部静默,自家回声在大多数应用上永远等不来。
* 机制(与 event-dispatcher 的 hall 分支对偶):
* - 有未入册成员的大厅里,每个本机 bot 点名 @ 自己 cross-ref 能解析到的未入册
* 成员(含别的机器的——mention 跨机器投递,对方跑新版即可学);被点到的直接
* 从 mentions[] 学到自己的 union_id。已入册 bot 也参与——纯教学。
* - 自己未入册时消息带 #hall-echo,被点到的 bot 回 @ 一次(open_id 取事件
* sender_id,无需 cross-ref)→ 打卡者从回执学到自己。任一方向可解析即收敛。
* 消息只在有意义时才发:解析不到任何目标时不发不计次(唯一例外:未入册 bot 的
* 首次尝试发一条裸打卡,给有 receive-all scope 的应用留回声机会)。状态落盘,
* 重启不重发——解析不到目标反复裸发刷屏这个坑踩过了(自动review 实测)。
*/
async function maybeAnnounceHallPresence(): Promise<void> {
try {
const dataDir = config.session.dataDir;
const teams = listPlatformTeams(dataDir);
if (teams.length === 0) return;
const localBotIds = new Set(readPlatformBotsInfo().map(b => b.appId));
const now = Date.now();
const state = readHallAnnounceState();
for (const team of teams) {
const hallChatId = team.groupChatIds[0];
if (!hallChatId) continue;
// 未入册成员(全大厅,含别的机器):本机的以本地 store 为准(比 roster 新鲜),
// 远端的以 roster 的 unionId 为准。
const isLearned = (b: { appId: string; unionId?: string }) =>
localBotIds.has(b.appId) ? !!getBotUnionId(dataDir, b.appId) : !!b.unionId;
const unlearned = team.bots.filter(b => !isLearned(b));
if (unlearned.length === 0) continue;
const unlearnedNames = new Set(unlearned.map(b => b.name).filter(Boolean) as string[]);
for (const bot of team.bots) {
if (!localBotIds.has(bot.appId)) continue; // 只编排本机 bot
const selfLearned = isLearned(bot);
const throttleKey = `${bot.appId}::${hallChatId}`;
const st = state[throttleKey];
if (st && (now - st.lastAt < HALL_ANNOUNCE_MIN_INTERVAL_MS || st.tries >= HALL_ANNOUNCE_MAX_TRIES)) continue;
// 点名目标 = 自己 cross-ref 能解析到的未入册成员(发不出 @ 的目标点了也白点)。
const crossRef = readBotCrossRef(bot.appId);
const targets = [...unlearnedNames].filter(n => n !== bot.name && typeof crossRef[n] === 'string').slice(0, 4);
// 没有可教的目标:已入册 → 无事可做;未入册 → 仅首次发裸打卡碰回声运气,
// 之后静默等别人教(不发不计次,cross-ref 或 roster 变化后自然恢复)。
if (targets.length === 0 && (selfLearned || (st?.tries ?? 0) > 0)) continue;
// 成功发出才消耗预算;失败只刷新 lastAt 保住退避间隔(见 bumpHallAnnounceState)。
let sent = false;
try {
const r = await proxyToDaemon(bot.appId, '/api/platform/hall-announce', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ chatId: hallChatId, mentionNames: targets }),
});
const j = await r.json().catch(() => ({} as { ok?: boolean; error?: string; mentioned?: string[]; unresolved?: string[]; skipped?: string }));
if (!r.ok || !(j as { ok?: boolean }).ok) {
logger.warn(`[platform-tunnel] 大厅打卡失败 bot=${bot.appId} chat=${hallChatId.substring(0, 12)}: ${(j as { error?: string }).error ?? r.status}`);
} else {
sent = !(j as { skipped?: string }).skipped;
const mentioned = (j as { mentioned?: string[] }).mentioned ?? [];
const unresolved = (j as { unresolved?: string[] }).unresolved ?? [];
if (sent) logger.info(`[platform-tunnel] 大厅打卡已发 bot=${bot.appId} chat=${hallChatId.substring(0, 12)}${mentioned.length ? ` 点名=[${mentioned.join(',')}]` : ''}${unresolved.length ? ` 未解析=[${unresolved.join(',')}]` : ''}`);
}
} catch (e) {
logger.warn(`[platform-tunnel] 大厅打卡请求异常 bot=${bot.appId}: ${(e as Error).message}`);
}
bumpHallAnnounceState(throttleKey, sent);
state[throttleKey] = { lastAt: now, tries: (st?.tries ?? 0) + (sent ? 1 : 0) };
}
}
} catch (e) {
logger.warn(`[platform-tunnel] 大厅打卡检查异常: ${(e as Error).message}`);
}
}

// Graceful shutdown
Expand Down
2 changes: 0 additions & 2 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,6 @@ export const messages: Record<string, string> = {
'card.repo.worktree_rolled_back': 'Worktree creation failed on {repo}: {error}. Rolled back {count} worktree(s) already created in this batch.',
'card.repo.toast_worktree_creating': 'Creating worktree — will post in the thread when done…',

// Platform team hall check-in (bot-only group, visible to bots only)
'platform.hall_announce': '🤖 Team check-in: this bot is online (registers its union_id, no reply needed)',

// In-group authorization card
'card.grant.title': '🔑 Access Request',
Expand Down
3 changes: 0 additions & 3 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,6 @@ export const messages: Record<string, string> = {
'card.repo.worktree_rolled_back': '{repo} 创建 worktree 失败:{error}。已回滚本批次此前创建的 {count} 个 worktree。',
'card.repo.toast_worktree_creating': '正在创建 worktree,完成后会在话题里通知…',

// 平台团队大厅打卡(bot-only 群,仅 bot 可见)
'platform.hall_announce': '🤖 团队登记打卡:本 bot 上线(登记身份 union_id 用,无需回复)',

// 群内授权卡片
'card.grant.title': '🔑 使用授权',
'card.grant.body_request': '发送方 **{name}** 申请在本群使用我。<at id={owner}></at> 是否允许 ta 在本群与我对话?',
Expand Down
24 changes: 2 additions & 22 deletions src/im/lark/event-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,6 @@ import type { VcMeetingImTurnOrigin } from '../../types.js';
import { DEFAULT_GRANT_DURATION_MS, DEFAULT_GRANT_QUOTA } from '../../services/grant-policy.js';
import { readPeerCrossRef, writePeerCrossRef } from '../../services/peer-cross-ref-store.js';

// 大厅回执互教的防环闸:每进程对同一打卡者只回一次(见 hall swallow 分支)。
const hallEchoReplied = new Set<string>();

function vcMeetingEventPayloadForLog(data: any): string {
try {
return JSON.stringify(data?.event ?? data);
Expand Down Expand Up @@ -3151,27 +3148,10 @@ export function startLarkEventDispatcher(larkAppId: string, larkAppSecret: strin
recordTeamBot(config.session.dataDir, { unionId: senderUnionId });
}
// 机器人大厅:bot 消息只用于身份登记(上面已学 sender union / mentions
// 自学 / cross-ref),绝不当任务路由——大厅打卡会点名 @ 同伴,不吞掉的话
// 接收 bot 会把打卡当任务拉起会话在大厅里回话(实测)。只吞 bot 发送方:
// 自学),绝不当任务路由——平台 bot 会 @ 新 bot 教它 union,不吞掉的话
// 接收 bot 会把这类消息当任务拉起会话在大厅里回话(实测)。只吞 bot 发送方:
// 人类经隐藏入口进大厅后 @ bot 仍正常应答。
if (isPlatformHallChat(config.session.dataDir, chatId)) {
// 回执互教:打卡者点名了我们且带 #hall-echo(= 它还没学到自己的
// union_id)→ @ 回它一次。open_id 直接取事件 sender_id(本 app 视角,
// 无需 cross-ref),打卡者从回执的 mentions[] 学到自己。每进程每发送者
// 只回一次;回执不带标记,链路必然终止。
try {
const text: unknown = JSON.parse(message.content ?? '{}')?.text;
if (
typeof text === 'string' && text.includes('#hall-echo') && senderOpenId &&
isBotMentioned(larkAppId, message, undefined) &&
!hallEchoReplied.has(`${larkAppId}::${senderOpenId}`)
) {
hallEchoReplied.add(`${larkAppId}::${senderOpenId}`);
void sendMessage(larkAppId, chatId, `<at user_id="${senderOpenId}"></at> 已登记`, 'text')
.then(() => logger.info(`[${larkAppId}] hall echo reply sent to ${senderOpenId.substring(0, 12)}`))
.catch((e) => logger.warn(`[${larkAppId}] hall echo reply failed: ${(e as Error).message}`));
}
} catch { /* content 非 JSON → 忽略 */ }
logger.debug(`[${larkAppId}] hall bot message swallowed after learning (chat=${chatId.substring(0, 12)})`);
return;
}
Expand Down