diff --git a/src/bot-registry.ts b/src/bot-registry.ts index 2d4cb7f8b0..81e50677db 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -39,6 +39,7 @@ import { normalizeSessionOwnerReminderConfig, type SessionOwnerReminderConfig, } from './core/session-owner-reminder.js'; +import { normalizeMessageListenerCleanupConfig } from './services/message-listener-session-cleanup.js'; import type { VcMeetingConsumerAgentConfig, VcMeetingConsumerConfig, @@ -237,6 +238,12 @@ export interface MessageListenerConfig { /** V1 starts one session per matched message. */ sessionMode?: 'per_message'; }; + cleanup?: { + /** Default true. */ + enabled?: boolean; + /** Default 168 hours. */ + retentionHours?: number; + }; } export interface SummaryRangeConfig { @@ -1060,6 +1067,7 @@ function normalizeMessageListenerConfig(raw: unknown, botIndex: number, chatId: const includeMsgTypes = normalizeMessageListenerStringList(messageRaw.includeMsgTypes); if (includeMsgTypes) messagePolicy.includeMsgTypes = includeMsgTypes; messagePolicy.scope = 'top_level'; + const cleanup = normalizeMessageListenerCleanupConfig(entry.cleanup); const contentRaw = entry.contentPolicy && typeof entry.contentPolicy === 'object' && !Array.isArray(entry.contentPolicy) ? entry.contentPolicy as Record @@ -1089,6 +1097,7 @@ function normalizeMessageListenerConfig(raw: unknown, botIndex: number, chatId: ...(Object.keys(messagePolicy).length > 0 ? { messagePolicy } : {}), ...(contentPolicy ? { contentPolicy } : {}), replyPolicy: { mode: 'thread', sessionMode: 'per_message' }, + cleanup, }; } diff --git a/src/config.ts b/src/config.ts index c0740082da..5de1dea854 100644 --- a/src/config.ts +++ b/src/config.ts @@ -184,12 +184,11 @@ export const config = { cliId: (process.env.CLI_ID ?? 'claude-code') as import('./adapters/cli/types.js').CliId, cliPathOverride: process.env.CLI_PATH, backendType: (process.env.BACKEND_TYPE ?? detectDefaultBackend()) as BackendType, - /** Auto-recovery throttle: on restart every surviving persistent-backend - * session is eagerly re-forked to re-attach its pane. With dozens of - * sessions per daemon (and many daemons on one box) firing them all at - * once spikes CPU/IO, so the re-fork is staggered: spawn `batchSize` - * workers, wait `delayMs`, repeat. Tune via BOTMUX_RECOVERY_FORK_BATCH / - * BOTMUX_RECOVERY_FORK_DELAY_MS. */ + /** Auto-recovery re-attach is opt-in: keeping restored persistent sessions + * lazy protects daemon startup paths like message listeners from thousands + * of worker re-forks. Tune via BOTMUX_RECOVERY_FORK_ENABLED, + * BOTMUX_RECOVERY_FORK_BATCH, and BOTMUX_RECOVERY_FORK_DELAY_MS. */ + recoveryForkEnabled: (process.env.BOTMUX_RECOVERY_FORK_ENABLED ?? 'false').toLowerCase() === 'true', recoveryForkBatchSize: Math.max(1, Number(process.env.BOTMUX_RECOVERY_FORK_BATCH) || 5), recoveryForkDelayMs: Math.max(0, Number(process.env.BOTMUX_RECOVERY_FORK_DELAY_MS ?? 250)), forwardFollowupWaitMs: resolveForwardFollowupWaitMs(), diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index 985cf6e3b5..80f9bd4f81 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -967,16 +967,18 @@ export { composeRowFromActive, composeRowFromClosed, composeRowFromPersistedActi // holder. export function setBotName(name: string): void { setRowsBotName(name); } +const DASHBOARD_SNAPSHOT_ROW_OPTS = { lightweight: true } as const; + function composeDashboardSessionRows(): SessionRow[] { - const active = listActiveSessions().map((ds) => composeRowFromActive(ds)); + const active = listActiveSessions().map(ds => composeRowFromActive(ds, DASHBOARD_SNAPSHOT_ROW_OPTS)); const activeIds = new Set(active.map(row => row.sessionId)); const persisted = sessionStore.listSessions(); const unregisteredActive = persisted .filter(session => session.status === 'active' && !activeIds.has(session.sessionId)) - .map(composeRowFromPersistedActive); + .map(session => composeRowFromPersistedActive(session, DASHBOARD_SNAPSHOT_ROW_OPTS)); const closed = persisted .filter(session => session.status === 'closed' && !activeIds.has(session.sessionId)) - .map(composeRowFromClosed); + .map(session => composeRowFromClosed(session, DASHBOARD_SNAPSHOT_ROW_OPTS)); return [...active, ...unregisteredActive, ...closed]; } @@ -3519,6 +3521,7 @@ async function collectMessageListenerPreviewMatches( prompt: listener.prompt, ...(listener.senderPolicy && Object.keys(listener.senderPolicy).length > 0 ? { senderPolicy: listener.senderPolicy } : {}), ...(listener.messagePolicy ? { messagePolicy: { ...listener.messagePolicy, scope: 'top_level' } } : { messagePolicy: { scope: 'top_level' } }), + ...(listener.cleanup ? { cleanup: listener.cleanup } : {}), replyPolicy: { mode: 'thread', sessionMode: 'per_message' }, }; const previewBot = { @@ -5593,7 +5596,7 @@ ipcRoute('GET', '/api/events', (_req, res) => { const activeIds = new Set(); for (const ds of listActiveSessions()) { activeIds.add(ds.session.sessionId); - res.write(`event: session.spawned\ndata: ${JSON.stringify({ session: composeRowFromActive(ds) })}\n\n`); + res.write(`event: session.spawned\ndata: ${JSON.stringify({ session: composeRowFromActive(ds, DASHBOARD_SNAPSHOT_ROW_OPTS) })}\n\n`); } // Persisted active rows may be intentionally absent from the runtime Map // after an inconclusive exact-backend teardown. Replay them as dormant @@ -5601,7 +5604,7 @@ ipcRoute('GET', '/api/events', (_req, res) => { // /api/sessions and never synthesize a closed row. for (const s of sessionStore.listSessions()) { if (s.status !== 'active' || activeIds.has(s.sessionId)) continue; - res.write(`event: session.spawned\ndata: ${JSON.stringify({ session: composeRowFromPersistedActive(s) })}\n\n`); + res.write(`event: session.spawned\ndata: ${JSON.stringify({ session: composeRowFromPersistedActive(s, DASHBOARD_SNAPSHOT_ROW_OPTS) })}\n\n`); } // Also replay sessions CLOSED during this run as `session.spawned` carrying a // closed row. The active-only replay above can't cover a restore-time zombie: @@ -5621,7 +5624,7 @@ ipcRoute('GET', '/api/events', (_req, res) => { if (s.status !== 'closed' || activeIds.has(s.sessionId)) continue; const closedMs = s.closedAt ? Date.parse(s.closedAt) : NaN; if (!Number.isFinite(closedMs) || closedMs < PROCESS_START_MS) continue; - res.write(`event: session.spawned\ndata: ${JSON.stringify({ session: composeRowFromClosed(s) })}\n\n`); + res.write(`event: session.spawned\ndata: ${JSON.stringify({ session: composeRowFromClosed(s, DASHBOARD_SNAPSHOT_ROW_OPTS) })}\n\n`); } } catch (err) { logger.warn(`[dashboard-ipc] /api/events snapshot replay failed: ${err}`); diff --git a/src/core/dashboard-rows.ts b/src/core/dashboard-rows.ts index 5132480e3b..a14041150e 100644 --- a/src/core/dashboard-rows.ts +++ b/src/core/dashboard-rows.ts @@ -142,6 +142,11 @@ let cachedBotName = ''; export function setBotName(name: string): void { cachedBotName = name; } export function getBotName(): string { return cachedBotName; } +export interface ComposeSessionRowOptions { + fresh?: boolean; + lightweight?: boolean; +} + function parseSessionTime(iso: string | undefined): number | undefined { if (!iso) return undefined; const ms = Date.parse(iso); @@ -212,10 +217,13 @@ function sessionRuntimeFields(s: Session): Pick void, + batchSize: number = RECOVERY_FORK_BATCH_SIZE, + delayMs: number = RECOVERY_FORK_DELAY_MS, + stillOwned: (ds: DaemonSession) => boolean = ds => ds.session.status === 'active', +): void { + void staggeredRecoveryFork(sessions, fork, batchSize, delayMs, stillOwned) + .catch(err => { + logger.error( + `[restore] background recovery re-attach failed: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + }); +} + export async function restoreActiveSessions( activeSessions: Map, quarantinedSessionIds: ReadonlySet = new Set(), @@ -2139,7 +2152,7 @@ export async function restoreActiveSessions( continue; } restoredByThisInvocation.push(ds); - announceSessionRow(ds); + announceSessionRow(ds, { lightweight: true }); forkAdoptWorker(ds, { restoredFromMetadata: true }); logger.info(`[${session.sessionId.substring(0, 8)}] Restored adopt session (target: ${adoptTargetLabel(adopted)}, scope: ${scope})`); continue; @@ -2245,7 +2258,7 @@ export async function restoreActiveSessions( restoredByThisInvocation.push(ds); // 重启后把待办池卡片重新广播给 dashboard,否则会从看板消失(#277 同款修复, // 我这条 queued 分支提前 continue 绕过了下面的 announceSessionRow,要自己补)。 - announceSessionRow(ds); + announceSessionRow(ds, { lightweight: true }); if (restoredPendingRepo) { try { await resumeRestoredPendingRepoSetup(ds, activeSessions); @@ -2474,7 +2487,7 @@ export async function restoreActiveSessions( continue; } restoredByThisInvocation.push(ds); - announceSessionRow(ds); + announceSessionRow(ds, { lightweight: true }); if (session.initialUserTurnPending) { // `hasHistory: true` above means "there may be a CLI process/transcript to @@ -2518,6 +2531,7 @@ export async function restoreActiveSessions( backendName: string; }> = []; const namesByBackend = new Map>(); + const skippedReattachByBackend = new Map(); for (const ds of restoredByThisInvocation) { // A later restore CAS awaited after this row was registered. During that // yield the user may have closed/resumed/replaced it; never carry the stale @@ -2541,7 +2555,10 @@ export async function restoreActiveSessions( } continue; } - if (!shouldAutoForkOnRestore(backendType)) continue; + if (!shouldAutoForkOnRestore(backendType)) { + skippedReattachByBackend.set(backendType, (skippedReattachByBackend.get(backendType) ?? 0) + 1); + continue; + } // Honour the worker-selected target (Herdr may own an agent inside a shared // host session) rather than assuming the deterministic whole-session name. const backendTarget = persistentBackendTargetForSession(ds)!; @@ -2556,6 +2573,16 @@ export async function restoreActiveSessions( names.add(backendTarget.sessionName); namesByBackend.set(backendType, names); } + if (skippedReattachByBackend.size > 0) { + const total = [...skippedReattachByBackend.values()].reduce((sum, count) => sum + count, 0); + const detail = [...skippedReattachByBackend.entries()] + .map(([backendType, count]) => `${backendType}=${count}`) + .join(', '); + logger.info( + `[restore] skipped eager re-attach for ${total} persistent session(s) ` + + `(${detail}); lazy recovery remains available`, + ); + } // ZMX/Zellij can classify every requested name from one control-plane list. // This is both a consistent restore snapshot and avoids an O(N²) ZMX restart // when each per-row probe would otherwise scan every per-session daemon. @@ -2658,11 +2685,7 @@ export async function restoreActiveSessions( toReattach.push(ds); } - // Staggered re-fork (see staggeredRecoveryFork): empty prompt = re-attach - // only, no new turn — same as the old per-session eager fork. - await staggeredRecoveryFork( - toReattach, - (ds) => { + const reattach = (ds: DaemonSession): void => { // A quarantined tail-only owner (restore promotion failed transiently) is // handled by the CENTRAL guard inside forkWorker: this blank fork retries // the old head's promotion first and, if it still fails, refuses to fork @@ -2686,11 +2709,24 @@ export async function restoreActiveSessions( } : true, ); - }, - RECOVERY_FORK_BATCH_SIZE, - RECOVERY_FORK_DELAY_MS, - ds => activeSessions.get(activeSessionKey(ds)) === ds, - ); + }; + + // Staggered re-fork (see staggeredRecoveryFork): empty prompt = re-attach + // only, no new turn — same as the old per-session eager fork. Keep it off the + // restore critical path: on long-lived installations thousands of restored + // active rows can otherwise hold daemon readiness and message-listener + // backfill behind worker re-attach for minutes, even though the sessions are + // already registered and can lazy cold-resume on demand. + if (toReattach.length > 0) { + logger.info(`[restore] scheduling ${toReattach.length} persistent session(s) for background re-attach`); + scheduleStaggeredRecoveryFork( + toReattach, + reattach, + RECOVERY_FORK_BATCH_SIZE, + RECOVERY_FORK_DELAY_MS, + ds => activeSessions.get(activeSessionKey(ds)) === ds, + ); + } const hasPersistentBackend = [...activeSessions.values()].some(ds => !!getSessionPersistentBackendType(ds)); logger.info(`Restored ${active.length} session(s)${hasPersistentBackend ? '' : ', waiting for messages to resume'}`); diff --git a/src/daemon.ts b/src/daemon.ts index 412f674170..aa30b4bcea 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -192,6 +192,7 @@ import { readableTerminalUrlFor, findActiveBySessionId, withActiveSessionKeyLock, + destroyUnregisteredPersistentBacking, getDaemonBootId, getDaemonStreamingCardUsageSnapshot, postTurnStartingCard, @@ -283,6 +284,10 @@ import { } from './core/session-title.js'; import { settleDeferredScheduleRun } from './core/deferred-schedule-settlement.js'; import { renderMessageListenerPrompt, refreshListenerCardTextFromResolved } from './services/message-listener.js'; +import { + MESSAGE_LISTENER_CLEANUP_INTERVAL_MS, + selectExpiredMessageListenerSessions, +} from './services/message-listener-session-cleanup.js'; import { sweepOrphanSandboxes } from './adapters/backend/sandbox.js'; import { TmuxBackend } from './adapters/backend/tmux-backend.js'; import { HerdrBackend } from './adapters/backend/herdr-backend.js'; @@ -3359,6 +3364,74 @@ function startMemoryDiagnostics(): ReturnType | undefined { return timer; } +function createMessageListenerSessionCleanupRunner(larkAppId: string): { + run(reason: 'startup' | 'interval'): Promise; + start(): ReturnType; +} { + let running = false; + const run = async (reason: 'startup' | 'interval'): Promise => { + if (running) return; + running = true; + try { + const bot = getBot(larkAppId); + const expired = selectExpiredMessageListenerSessions({ + sessions: sessionStore.listSessions(), + listeners: bot.config.messageListeners, + }); + if (expired.length === 0) return; + let closed = 0; + let failed = 0; + const inactive: string[] = []; + logger.info(`[message-listener-cleanup] ${reason}: closing ${expired.length} expired listener session(s) for ${larkAppId}`); + for (const session of expired) { + if (!findActiveBySessionId(session.sessionId)) { + try { + destroyUnregisteredPersistentBacking(session); + inactive.push(session.sessionId); + } catch (err) { + failed += 1; + logger.warn( + `[message-listener-cleanup] backing cleanup failed session=${session.sessionId}: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + } + continue; + } + try { + const result = await closeSessionHelper(session.sessionId); + if (result.ok && result.known) closed += 1; + else failed += 1; + } catch (err) { + failed += 1; + logger.warn( + `[message-listener-cleanup] close failed session=${session.sessionId}: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + } + } + if (inactive.length > 0) { + const inactiveIds = new Set(inactive); + closed += sessionStore.closeSessionsMatching(session => inactiveIds.has(session.sessionId)); + } + logger.info(`[message-listener-cleanup] ${reason}: closed=${closed} failed=${failed} bot=${larkAppId}`); + } catch (err) { + logger.warn(`[message-listener-cleanup] ${reason} failed: ${err instanceof Error ? err.message : String(err)}`); + } finally { + running = false; + } + }; + return { + run, + start() { + const timer = setInterval(() => { + void run('interval'); + }, MESSAGE_LISTENER_CLEANUP_INTERVAL_MS); + timer.unref?.(); + return timer; + }, + }; +} + /** * Reply into a session — scope-aware. * @@ -18199,6 +18272,7 @@ async function handleNewTopicAdmitted(data: any, ctx: RoutingContext): Promise { sessionStore.init(cfg.larkAppId); chatFirstSeenStore.init(cfg.larkAppId); initSessionGroups(cfg.larkAppId); + const messageListenerCleanup = createMessageListenerSessionCleanupRunner(cfg.larkAppId); const ambiguousOnBoot = reconcileVcMeetingDeliveriesOnBoot( config.session.dataDir, { receiverBootId: getDaemonBootId(), agentAppId: cfg.larkAppId }, @@ -22307,7 +22382,7 @@ export async function startDaemon(botIndex?: number): Promise { reapOrphanWorkers(); // Restore active sessions from previous run - // Restore active sessions from previous run + await messageListenerCleanup.run('startup'); await restoreActiveSessions(activeSessions, idempotencyQuarantinedSessionIds); // Restore complete → /api/asks may now safely 403 unknown sessions again; a // reconnecting ask hook that raced the restore got retryable 503s until here. @@ -22465,6 +22540,7 @@ export async function startDaemon(botIndex?: number): Promise { enforceLiveSessionCap('periodic'); }, 60_000); idleWorkerSweepTimer.unref?.(); + const messageListenerCleanupTimer = messageListenerCleanup.start(); const sessionOwnerReminder = new SessionOwnerReminderController({ load: () => loadSessionOwnerReminderRecords(config.session.dataDir, cfg.larkAppId), @@ -22862,6 +22938,7 @@ export async function startDaemon(botIndex?: number): Promise { clearInterval(descriptorHeartbeat); clearInterval(idleWorkerSweepTimer); clearInterval(sessionOwnerReminderTimer); + clearInterval(messageListenerCleanupTimer); if (memoryDiagnostics) clearInterval(memoryDiagnostics); removeDaemonDescriptor(cfg.larkAppId); ipcHandle.close().catch(() => { /* swallow */ }); @@ -23074,6 +23151,7 @@ export async function startDaemon(botIndex?: number): Promise { clearInterval(descriptorHeartbeat); clearInterval(idleWorkerSweepTimer); clearInterval(sessionOwnerReminderTimer); + clearInterval(messageListenerCleanupTimer); clearInterval(docCommentPollTimer); if (memoryDiagnostics) clearInterval(memoryDiagnostics); removeDaemonDescriptor(cfg.larkAppId); diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index 677a972d35..eaccc6c7f9 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -2435,6 +2435,8 @@ const zh = { 'roles.listenerReplyCardTitlePlaceholder': '留空使用默认标题', 'roles.listenerWorkingDir': '工作目录', 'roles.listenerWorkingDirPlaceholder': '留空使用 Bot 默认工作目录', + 'roles.listenerCleanupEnabled': '自动清理监听会话', + 'roles.listenerCleanupRetentionHours': '保留小时数', 'roles.listenerSenderTypes': '发送者类型', 'roles.listenerSenderUser': '用户', 'roles.listenerSenderBot': '机器人', @@ -4991,6 +4993,8 @@ const en: Record = { 'roles.listenerReplyCardTitlePlaceholder': 'Leave empty to use the default title', 'roles.listenerWorkingDir': 'Working directory', 'roles.listenerWorkingDirPlaceholder': 'Leave empty to use the bot default', + 'roles.listenerCleanupEnabled': 'Auto-clean listener sessions', + 'roles.listenerCleanupRetentionHours': 'Retention hours', 'roles.listenerSenderTypes': 'Sender types', 'roles.listenerSenderUser': 'Users', 'roles.listenerSenderBot': 'Bots', diff --git a/src/dashboard/web/roles-page.tsx b/src/dashboard/web/roles-page.tsx index c43a710025..b3ab348854 100644 --- a/src/dashboard/web/roles-page.tsx +++ b/src/dashboard/web/roles-page.tsx @@ -47,6 +47,7 @@ import { MAX_MESSAGE_LISTENER_PROMPT_BYTES, MAX_ROLE_BYTES, MESSAGE_LISTENER_WARN_BYTES, + DEFAULT_MESSAGE_LISTENER_CLEANUP_RETENTION_HOURS, DEFAULT_MESSAGE_LISTENER_PREVIEW_LIMIT, MAX_MESSAGE_LISTENER_PREVIEW_LIMIT, roleKey, @@ -116,6 +117,10 @@ const DEFAULT_LISTENER: MessageListenerData = { includeMsgTypes: [...LISTENER_MESSAGE_TYPES], scope: 'top_level', }, + cleanup: { + enabled: true, + retentionHours: DEFAULT_MESSAGE_LISTENER_CLEANUP_RETENTION_HOURS, + }, }; function cloneListener(listener: MessageListenerData | null | undefined): MessageListenerData { @@ -152,9 +157,24 @@ function cloneListener(listener: MessageListenerData | null | undefined): Messag ...(listener.contentPolicy.matchMode ? { matchMode: listener.contentPolicy.matchMode } : {}), }, } : {}), + cleanup: { + enabled: listener?.cleanup?.enabled !== false, + retentionHours: normalizeCleanupRetentionHours(listener?.cleanup?.retentionHours), + }, }; } +function normalizeCleanupRetentionHours(raw: unknown): number { + const value = typeof raw === 'string' && raw.trim() + ? Number(raw.trim()) + : raw; + if (typeof value !== 'number' || !Number.isFinite(value)) { + return DEFAULT_MESSAGE_LISTENER_CLEANUP_RETENTION_HOURS; + } + const normalized = Math.floor(value); + return normalized > 0 ? normalized : DEFAULT_MESSAGE_LISTENER_CLEANUP_RETENTION_HOURS; +} + function listenerHasConfig(listener: MessageListenerData | null): boolean { // A persisted listener is worth loading into the editor whenever it carries a // prompt — INCLUDING a disabled draft (enabled:false + non-empty prompt). The @@ -599,6 +619,17 @@ function RolesPage(props: { tab: RolesTab }) { })); } + function updateListenerCleanup(patch: Partial>): void { + setEditingListener(prev => ({ + ...prev, + cleanup: { + enabled: prev.cleanup?.enabled !== false, + retentionHours: normalizeCleanupRetentionHours(prev.cleanup?.retentionHours), + ...patch, + }, + })); + } + function toggleListenerSenderType(type: SenderTypeOption, checked: boolean): void { setEditingListener(prev => { const current = new Set(prev.senderPolicy?.includeSenderTypes ?? []); @@ -720,6 +751,10 @@ function RolesPage(props: { tab: RolesTab }) { ...(raw.matchMode === 'all' ? { matchMode: 'all' as const } : {}), }; })(); + const cleanup = { + enabled: editingListener.cleanup?.enabled !== false, + retentionHours: normalizeCleanupRetentionHours(editingListener.cleanup?.retentionHours), + }; return { enabled: editingListener.enabled, ...(editingListener.name?.trim() ? { name: editingListener.name.trim() } : {}), @@ -741,6 +776,7 @@ function RolesPage(props: { tab: RolesTab }) { scope: 'top_level', }, ...(contentPolicy ? { contentPolicy } : {}), + cleanup, }; } @@ -1203,6 +1239,7 @@ function RolesPage(props: { tab: RolesTab }) { onSenderPolicyPatch={updateListenerSenderPolicy} onMessagePolicyPatch={updateListenerMessagePolicy} onContentPolicyPatch={updateListenerContentPolicy} + onCleanupPatch={updateListenerCleanup} onToggleSenderType={toggleListenerSenderType} onToggleMsgType={toggleListenerMsgType} onSetTargetPolicy={setListenerTargetPolicy} @@ -1545,6 +1582,7 @@ function MessageListenerEditor(props: { onSenderPolicyPatch(patch: NonNullable): void; onMessagePolicyPatch(patch: NonNullable): void; onContentPolicyPatch(patch: NonNullable): void; + onCleanupPatch(patch: Partial>): void; onToggleSenderType(type: SenderTypeOption, checked: boolean): void; onToggleMsgType(msgType: string, checked: boolean): void; onSetTargetPolicy(openId: string, listening: boolean): void; @@ -1754,6 +1792,27 @@ function MessageListenerEditor(props: { +
+ + +
{tr('roles.listenerContentPolicy')}