diff --git a/CONTEXT.md b/CONTEXT.md index c6c9c669c..7d9bf618e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -19,6 +19,14 @@ _Avoid_: agent, app A continuing conversation between one chat anchor and one **Agent CLI**. _Avoid_: thread, task +**Sender Identity**: +The best-effort identity of the person or Bot that authored one inbound chat +message. It follows each message turn rather than being fixed to a **Session**; +multiple senders may therefore appear in one Session. Human sender identity may +include an app-scoped open ID, display name, and email when the chat platform +makes them available. Missing optional fields never block message delivery. +_Avoid_: session owner, card recipient + **Token Usage**: Token counts reported by an **Agent CLI** or its persisted transcript for a **Session**. Token In is the Agent CLI's native input-side total, including diff --git a/src/bot-registry.ts b/src/bot-registry.ts index cfe1b1455..7627b826d 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -27,6 +27,10 @@ import type { FeedbackPolicy, FeedbackPolicyInput } from './services/feedback-po import { normalizeFeedbackPolicyLayer } from './services/feedback-policy-resolver.js'; import type { FeedbackWebhookDestination } from './services/feedback-outbox.js'; import { cliModelSupportsReasoningEffort, isConfigurableReasoningCliId, isCodexReasoningEffort } from './services/codex-reasoning-effort.js'; +import { + normalizeSessionOwnerReminderConfig, + type SessionOwnerReminderConfig, +} from './core/session-owner-reminder.js'; import type { VcMeetingConsumerAgentConfig, VcMeetingConsumerConfig, @@ -1383,6 +1387,9 @@ export interface BotConfig { * sessions are never suspended. See core/idle-worker-sweeper.ts. */ maxLiveWorkers?: number; + /** Periodically @ the persisted Session owner while selected actionable + * runtime states remain unchanged. Missing means disabled. */ + sessionOwnerReminder?: SessionOwnerReminderConfig; /** * When true, THIS bot's daemon watches host load/memory and DMs the bot owner * when the machine crosses into (and back out of) an overloaded state — a @@ -2942,6 +2949,7 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] { && Number.isInteger(entry.maxLiveWorkers) && entry.maxLiveWorkers > 0 ? entry.maxLiveWorkers : undefined, + sessionOwnerReminder: normalizeSessionOwnerReminderConfig(entry.sessionOwnerReminder), // Only explicit true persisted (undefined = off), same as restrictGrantCommands. overloadAlert: entry.overloadAlert === true || undefined, vcMeetingAgent, diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index e15ee465b..4290e04cc 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -98,6 +98,9 @@ import { cleanupMaterializedDashboardImages, materializeDashboardImages } from ' import { getCliDisplayName } from '../im/lark/card-builder.js'; import { sessionConfiguredRuntimeDisplayName } from './cli-runtime-display.js'; import { locateLimiter } from './dashboard-locate.js'; +import { DEFAULT_SESSION_OWNER_REMINDER } from './session-owner-reminder.js'; +import { updateSessionOwnerReminderConfig } from '../services/session-owner-reminder-config-store.js'; +import { sendSessionOwnerThreadNotification } from '../services/session-owner-notification.js'; import { buildTerminalUrl } from './terminal-url.js'; import { dashboardEventBus } from './dashboard-events.js'; import { validateWorkingDir } from './working-dir.js'; @@ -2660,13 +2663,11 @@ ipcRoute('POST', '/api/sessions/:sessionId/locate', async (_req, res, params) => return jsonRes(res, 200, { ok: false, error: 'no_feishu_transport' }); } try { - const messageId = await replyMessage( - ctx.larkAppId, - ctx.rootMessageId, - ``, - 'text', - true, - ); + const messageId = await sendSessionOwnerThreadNotification({ + larkAppId: ctx.larkAppId, + rootMessageId: ctx.rootMessageId, + ownerOpenId: ctx.ownerOpenId, + }); jsonRes(res, 200, { ok: true, messageId }); } catch (err) { jsonRes(res, 502, { ok: false, error: String(err) }); @@ -3772,9 +3773,12 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { agentSelectionKey = selectionKeyForBot(cliId, wrapperCli ?? undefined); } catch { /* no registered bot */ } let maxLiveWorkers: number | null = null; + let sessionOwnerReminder = DEFAULT_SESSION_OWNER_REMINDER; try { - const m = getBot(cachedLarkAppId).config.maxLiveWorkers; + const botConfig = getBot(cachedLarkAppId).config; + const m = botConfig.maxLiveWorkers; if (typeof m === 'number' && Number.isInteger(m) && m > 0) maxLiveWorkers = m; + sessionOwnerReminder = botConfig.sessionOwnerReminder ?? DEFAULT_SESSION_OWNER_REMINDER; } catch { /* default unlimited */ } let logicalSessionCount = 0; let residentSessionCount = 0; @@ -3891,6 +3895,7 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => { // value when this bot has no explicit override (prompt/global/off). skillInjectionDefault: globalBuiltinSkillInjectionDefault(), maxLiveWorkers, + sessionOwnerReminder, logicalSessionCount, residentSessionCount, dormantSessionCount, @@ -4828,6 +4833,16 @@ ipcRoute('PUT', '/api/bot-max-live-workers', async (req, res) => { jsonRes(res, 200, { ok: true, maxLiveWorkers: value }); }); +ipcRoute('PUT', '/api/bot-session-owner-reminder', async (req, res) => { + if (!cachedLarkAppId) return jsonRes(res, 503, { error: 'larkAppId_not_set' }); + let raw: unknown; + try { raw = await readJsonBody(req); } + catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); } + const result = await updateSessionOwnerReminderConfig(cachedLarkAppId, raw); + if (!result.ok) return jsonRes(res, 400, { ok: false, error: result.reason }); + return jsonRes(res, 200, { ok: true, sessionOwnerReminder: result.config }); +}); + // Per-bot skill policy. Dashboard uses this for attach/detach; JSON policy // still shares the same applyConfigField path as /botconfig. ipcRoute('PUT', '/api/bot-skills', async (req, res) => { diff --git a/src/core/session-manager.ts b/src/core/session-manager.ts index 73a787d6d..1b489d46a 100644 --- a/src/core/session-manager.ts +++ b/src/core/session-manager.ts @@ -735,7 +735,7 @@ function renderChatContextBlock(chatContext?: ChatContext): string { /** * Render a `` tag for prompt injection. Caller resolves the sender - * (open_id + type + optional name) via `resolveSender(...)` in identity-cache. + * (open_id + type + optional name/email) via `resolveSender(...)` in identity-cache. * Returns empty string when no sender data is available so the prompt stays * clean for synthetic flows (scheduled tasks, no-op spawns). */ @@ -743,6 +743,7 @@ export function renderSenderTag(sender?: ResolvedSender): string { if (!sender || !sender.openId) return ''; const attrs: string[] = [`type="${xmlEscape(sender.type)}"`, `open_id="${xmlEscape(sender.openId)}"`]; if (sender.name) attrs.push(`name="${xmlEscape(sender.name)}"`); + if (sender.email) attrs.push(`email="${xmlEscape(sender.email)}"`); return ``; } diff --git a/src/core/session-owner-reminder.ts b/src/core/session-owner-reminder.ts new file mode 100644 index 000000000..5caf803dc --- /dev/null +++ b/src/core/session-owner-reminder.ts @@ -0,0 +1,234 @@ +import { createHash } from 'node:crypto'; +import type { DaemonSession } from './types.js'; + +export const SESSION_OWNER_REMINDER_STATES = [ + 'idle', + 'dormant', + 'pending_repo', + 'tui_prompt', + 'agent_attention', + 'limited', +] as const; + +export type SessionOwnerReminderState = typeof SESSION_OWNER_REMINDER_STATES[number]; + +export interface SessionOwnerReminderConfig { + enabled: boolean; + intervalMinutes: number; + text: string; + states: SessionOwnerReminderState[]; +} + +export interface SessionOwnerReminderRecord { + sessionId: string; + stateFingerprint: string; + actionableSince: number; + lastObservedActivityAt: number; + lastRemindedAt?: number; + retryAfterAt?: number; +} + +export type SessionOwnerReminderRecords = Record; + +export const DEFAULT_SESSION_OWNER_REMINDER: SessionOwnerReminderConfig = { + enabled: false, + intervalMinutes: 30, + text: '该会话已等待处理,请继续跟进。', + states: [...SESSION_OWNER_REMINDER_STATES], +}; + +const MIN_INTERVAL_MINUTES = 1; +const MAX_INTERVAL_MINUTES = 10_080; +const MAX_TEXT_CHARS = 500; +const FAILURE_RETRY_MAX_MS = 5 * 60_000; + +function isState(value: unknown): value is SessionOwnerReminderState { + return typeof value === 'string' + && (SESSION_OWNER_REMINDER_STATES as readonly string[]).includes(value); +} + +/** Strict normalizer shared by config loading and write validation. */ +export function normalizeSessionOwnerReminderConfig( + raw: unknown, +): SessionOwnerReminderConfig | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const value = raw as Record; + if (typeof value.enabled !== 'boolean') return undefined; + if (!Number.isInteger(value.intervalMinutes) + || (value.intervalMinutes as number) < MIN_INTERVAL_MINUTES + || (value.intervalMinutes as number) > MAX_INTERVAL_MINUTES) return undefined; + if (typeof value.text !== 'string') return undefined; + const text = value.text.trim(); + if (!text || Array.from(text).length > MAX_TEXT_CHARS || /<\s*at\b/i.test(text)) return undefined; + if (!Array.isArray(value.states)) return undefined; + const states = [...new Set(value.states.filter(isState))]; + if (states.length !== value.states.length) return undefined; + if (value.enabled && states.length === 0) return undefined; + return { + enabled: value.enabled, + intervalMinutes: value.intervalMinutes as number, + text, + states, + }; +} + +export function deriveSessionOwnerReminderStates(ds: DaemonSession): SessionOwnerReminderState[] { + const states: SessionOwnerReminderState[] = []; + const workerAlive = !!ds.worker && !ds.worker.killed; + if (workerAlive && ds.lastScreenStatus === 'idle') states.push('idle'); + // A pre-spawn repository picker is its own actionable state, not a released + // CLI. Likewise stale screen status belongs to the old worker lifetime. + if (!workerAlive && !ds.pendingRepo) states.push('dormant'); + if (ds.pendingRepo) states.push('pending_repo'); + if (ds.tuiPromptCardId) states.push('tui_prompt'); + if (ds.agentAttention) states.push('agent_attention'); + if (workerAlive && ds.lastScreenStatus === 'limited') states.push('limited'); + return states; +} + +/** + * Observation fingerprint used both to detect a state transition (reset the + * quiet period) and to seed the per-cycle delivery UUID. It must capture not + * only WHICH runtime signals are present but also their INSTANCE identity for + * the signals that get replaced in place while the label stays constant: + * + * - `agent_attention` → a fresh `{kind, reason, at}` is written on every new + * attention raise, so key on `agentAttention.at`. + * - `tui_prompt` → an old TUI prompt card is cleared and a new + * `tuiPromptCardId` set between scans, so key on that id. + * + * Without the instance component, the old signal being resolved and a brand-new + * same-category signal appearing (e.g. a second, unrelated attention request) + * would inherit the previous signal's elapsed quiet time and could @ the owner + * almost immediately — undercutting the "give N minutes to handle THIS" promise. + * + * The remaining states (idle / dormant / pending_repo / limited) have no + * distinct replaceable instance, so their label alone is a faithful key. + * + * INVARIANT: for a fixed instance the fingerprint is stable across scans, so a + * within-cycle failed-send retry recomputes the SAME fingerprint → the same + * delivery UUID → the existing Lark dedupe window still suppresses duplicates. + * A new instance changes the fingerprint, which resets the record and thereby + * starts a fresh cycle whose eventual UUID is legitimately different. + */ +export function sessionOwnerReminderObservationFingerprint( + ds: DaemonSession, + states: SessionOwnerReminderState[], +): string { + return states + .map(state => { + if (state === 'agent_attention') return `agent_attention:${ds.agentAttention?.at ?? ''}`; + if (state === 'tui_prompt') return `tui_prompt:${ds.tuiPromptCardId ?? ''}`; + return state; + }) + .join(','); +} + +export interface SessionOwnerReminderControllerDeps { + load(): SessionOwnerReminderRecords; + save(records: SessionOwnerReminderRecords): void; + send(ds: DaemonSession, text: string, uuid: string): Promise; + canSend(ds: DaemonSession): boolean; + onError?(ds: DaemonSession, error: unknown): void; +} + +function recordsSnapshot(value: unknown): string { + return JSON.stringify(value); +} + +export function sessionOwnerReminderDeliveryUuid( + sessionId: string, + stateFingerprint: string, + dueBase: number, +): string { + const digest = createHash('sha256') + .update(`${sessionId}\0${stateFingerprint}\0${dueBase}`, 'utf8') + .digest('hex') + .slice(0, 32); + return `owner-reminder-${digest}`; +} + +/** Durable, deterministic scan engine. Scheduling and Lark IO are injected. */ +export class SessionOwnerReminderController { + constructor(private readonly deps: SessionOwnerReminderControllerDeps) {} + + async scan( + sessions: Iterable, + config: SessionOwnerReminderConfig, + now: number = Date.now(), + ): Promise { + const current = this.deps.load(); + const before = recordsSnapshot(current); + if (!config.enabled || config.states.length === 0) { + if (Object.keys(current).length > 0) this.deps.save({}); + return; + } + + const configured = new Set(config.states); + const seen = new Set(); + const intervalMs = config.intervalMinutes * 60_000; + + for (const ds of sessions) { + const sessionId = ds.session.sessionId; + if (ds.session.status !== 'active' + || ds.session.queued === true + || (ds.scope ?? ds.session.scope) !== 'thread' + || !ds.session.ownerOpenId + || !this.deps.canSend(ds)) { + delete current[sessionId]; + continue; + } + + const projectedStates = deriveSessionOwnerReminderStates(ds); + const matched = projectedStates.filter(state => configured.has(state)); + if (matched.length === 0) { + delete current[sessionId]; + continue; + } + seen.add(sessionId); + // Eligibility follows the configured subset, but timer resets follow the + // complete runtime state. An unselected attention signal still represents + // a state transition and starts a fresh quiet period. The fingerprint also + // captures instance identity (attention.at / tuiPromptCardId) so a NEW + // same-category signal replacing a resolved one resets the timer instead + // of inheriting the old quiet period — see the fingerprint helper above. + const stateFingerprint = sessionOwnerReminderObservationFingerprint(ds, projectedStates); + const activityAt = Number.isFinite(ds.lastMessageAt) ? ds.lastMessageAt : 0; + let record = current[sessionId]; + + if (!record + || record.stateFingerprint !== stateFingerprint + || activityAt > record.lastObservedActivityAt) { + record = current[sessionId] = { + sessionId, + stateFingerprint, + actionableSince: Math.max(now, activityAt), + lastObservedActivityAt: activityAt, + }; + continue; + } + + const dueBase = record.lastRemindedAt ?? record.actionableSince; + if (now - dueBase < intervalMs) continue; + if (record.retryAfterAt !== undefined && now < record.retryAfterAt) continue; + + try { + await this.deps.send( + ds, + config.text, + sessionOwnerReminderDeliveryUuid(sessionId, stateFingerprint, dueBase), + ); + record.lastRemindedAt = now; + record.retryAfterAt = undefined; + } catch (error) { + record.retryAfterAt = now + Math.max(60_000, Math.min(intervalMs, FAILURE_RETRY_MAX_MS)); + this.deps.onError?.(ds, error); + } + } + + for (const sessionId of Object.keys(current)) { + if (!seen.has(sessionId)) delete current[sessionId]; + } + if (recordsSnapshot(current) !== before) this.deps.save(current); + } +} diff --git a/src/core/types.ts b/src/core/types.ts index d254cbe43..4c36095fa 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -216,7 +216,7 @@ export interface DaemonSession { pendingAttachments?: LarkAttachment[]; pendingMentions?: LarkMention[]; // @mentions from initial message, used when building prompt after repo selection pendingSubstituteTrigger?: import('../types.js').SubstituteTrigger; - /** Sender (open_id + type + resolved name) of the initial message — stashed + /** Sender (open_id + type + resolved name/email) of the initial message — stashed * so the deferred spawn after repo-selection still injects a tag * matching the original caller, not the user who clicked the card. */ pendingSender?: import('../im/lark/identity-cache.js').ResolvedSender; diff --git a/src/daemon.ts b/src/daemon.ts index 67edd3676..1d8a99c24 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -272,6 +272,15 @@ import { HerdrBackend } from './adapters/backend/herdr-backend.js'; import { ZellijBackend } from './adapters/backend/zellij-backend.js'; import { ZmxBackend } from './adapters/backend/zmx-backend.js'; import { sweepIdleWorkersAfterTurnDrain, DEFAULT_MAX_LIVE_WORKERS } from './core/idle-worker-sweeper.js'; +import { + DEFAULT_SESSION_OWNER_REMINDER, + SessionOwnerReminderController, +} from './core/session-owner-reminder.js'; +import { + loadSessionOwnerReminderRecords, + saveSessionOwnerReminderRecords, +} from './services/session-owner-reminder-store.js'; +import { sendSessionOwnerThreadNotification } from './services/session-owner-notification.js'; import { getSessionPersistentBackendType, isRemoteBackendSession, @@ -22046,6 +22055,45 @@ export async function startDaemon(botIndex?: number): Promise { }, 60_000); idleWorkerSweepTimer.unref?.(); + const sessionOwnerReminder = new SessionOwnerReminderController({ + load: () => loadSessionOwnerReminderRecords(config.session.dataDir, cfg.larkAppId), + save: records => saveSessionOwnerReminderRecords(config.session.dataDir, cfg.larkAppId, records), + canSend: ds => larkTransportEnabled({ + chatId: ds.chatId, + apiOnly: getBot(ds.larkAppId).config.apiOnly, + }), + send: async (ds, text, uuid) => { + await sendSessionOwnerThreadNotification({ + larkAppId: ds.larkAppId, + rootMessageId: ds.session.rootMessageId, + ownerOpenId: ds.session.ownerOpenId!, + }, text, uuid); + }, + onError: (ds, error) => logger.warn( + `[session-owner-reminder] send failed session=${ds.session.sessionId}: ` + + `${error instanceof Error ? error.message : String(error)}`, + ), + }); + let sessionOwnerReminderScanInFlight = false; + const scanSessionOwnerReminders = async (): Promise => { + if (sessionOwnerReminderScanInFlight) return; + sessionOwnerReminderScanInFlight = true; + try { + const currentConfig = getBot(cfg.larkAppId).config.sessionOwnerReminder + ?? DEFAULT_SESSION_OWNER_REMINDER; + await sessionOwnerReminder.scan(activeSessions.values(), currentConfig); + } catch (error) { + logger.warn(`[session-owner-reminder] scan failed: ${error instanceof Error ? error.message : String(error)}`); + } finally { + sessionOwnerReminderScanInFlight = false; + } + }; + const sessionOwnerReminderTimer = setInterval(() => { + void scanSessionOwnerReminders(); + }, 60_000); + sessionOwnerReminderTimer.unref?.(); + void scanSessionOwnerReminders(); + // Periodic sandbox reconciler: the daemon's SIGKILL straggler-reaper (and any // worker SIGKILL) bypasses worker-side killCli(), so the pre-created deny-mask // mountpoints + per-session tree of a killed-but-still-active sandboxed session @@ -22402,6 +22450,7 @@ export async function startDaemon(botIndex?: number): Promise { for (const key of [...vcMeetingPendingInvites.keys()]) deleteVcMeetingPendingInvite(key); clearInterval(descriptorHeartbeat); clearInterval(idleWorkerSweepTimer); + clearInterval(sessionOwnerReminderTimer); if (memoryDiagnostics) clearInterval(memoryDiagnostics); removeDaemonDescriptor(cfg.larkAppId); ipcHandle.close().catch(() => { /* swallow */ }); @@ -22613,6 +22662,7 @@ export async function startDaemon(botIndex?: number): Promise { setSupervisorShutdownHandler(null); clearInterval(descriptorHeartbeat); clearInterval(idleWorkerSweepTimer); + clearInterval(sessionOwnerReminderTimer); clearInterval(docCommentPollTimer); if (memoryDiagnostics) clearInterval(memoryDiagnostics); removeDaemonDescriptor(cfg.larkAppId); diff --git a/src/dashboard.ts b/src/dashboard.ts index aded2b0a7..3c05fcbba 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -6221,6 +6221,24 @@ const server = createServer(async (req, res) => { }); } + // PUT /api/bots/:appId/session-owner-reminder — per-Bot periodic owner + // reminder policy. The owning daemon validates, persists, and hot-applies. + let mBotOwnerReminder: RegExpMatchArray | null; + if (req.method === 'PUT' && (mBotOwnerReminder = url.pathname.match(/^\/api\/bots\/([^/]+)\/session-owner-reminder$/))) { + const appId = decodeURIComponent(mBotOwnerReminder[1]); + const chunks: Buffer[] = []; + for await (const c of req) chunks.push(c as Buffer); + const raw = Buffer.concat(chunks).toString('utf8') || '{}'; + const upstream = await proxyToDaemon(appId, `/api/bot-session-owner-reminder`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: raw, + }); + res.writeHead(upstream.status, { 'content-type': 'application/json' }); + res.end(await upstream.text()); + return; + } + // Create a new chat — pick a creator from the user-selected larkAppIds // (Feishu makes the calling bot the implicit first member, so picking // anything else would silently add an unwanted bot). Auto-invite the diff --git a/src/dashboard/bot-payload.ts b/src/dashboard/bot-payload.ts index 57cbbab8f..d50f51701 100644 --- a/src/dashboard/bot-payload.ts +++ b/src/dashboard/bot-payload.ts @@ -133,6 +133,9 @@ export function botDefaultsPayload(bot: DashboardBotDescriptor, j?: any, error?: logicalSessionCount: typeof j?.logicalSessionCount === 'number' ? j.logicalSessionCount : 0, residentSessionCount: typeof j?.residentSessionCount === 'number' ? j.residentSessionCount : 0, dormantSessionCount: typeof j?.dormantSessionCount === 'number' ? j.dormantSessionCount : 0, + sessionOwnerReminder: j?.sessionOwnerReminder && typeof j.sessionOwnerReminder === 'object' + ? j.sessionOwnerReminder + : null, startupCommands: typeof j?.startupCommands === 'string' ? j.startupCommands : '', customPassthroughCommands: typeof j?.customPassthroughCommands === 'string' ? j.customPassthroughCommands : '', canTalkDaemonCommands: typeof j?.canTalkDaemonCommands === 'string' ? j.canTalkDaemonCommands : '', diff --git a/src/dashboard/web/bot-defaults-page.tsx b/src/dashboard/web/bot-defaults-page.tsx index 666296f48..9f7776628 100644 --- a/src/dashboard/web/bot-defaults-page.tsx +++ b/src/dashboard/web/bot-defaults-page.tsx @@ -939,6 +939,7 @@ function BotDefaultsCard(props: {
) : null}
+
@@ -1022,6 +1023,129 @@ function RuntimeEnvironmentSection(props: { bot: BotDefaultsRow; patchBot: Patch ); } +type OwnerReminderState = NonNullable['states'][number]; +const OWNER_REMINDER_STATE_OPTIONS = [ + { value: 'idle', labelKey: 'botDefaults.ownerReminderStateIdle' }, + { value: 'dormant', labelKey: 'botDefaults.ownerReminderStateDormant' }, + { value: 'pending_repo', labelKey: 'botDefaults.ownerReminderStatePendingRepo' }, + { value: 'tui_prompt', labelKey: 'botDefaults.ownerReminderStateTuiPrompt' }, + { value: 'agent_attention', labelKey: 'botDefaults.ownerReminderStateAgentAttention' }, + { value: 'limited', labelKey: 'botDefaults.ownerReminderStateLimited' }, +] as const; + +// Offline/error rows can lack the daemon-provided default payload. Keep this +// browser fallback aligned with DEFAULT_SESSION_OWNER_REMINDER. +const DEFAULT_OWNER_REMINDER = { + enabled: false, + intervalMinutes: 30, + text: '该会话已等待处理,请继续跟进。', + states: OWNER_REMINDER_STATE_OPTIONS.map(option => option.value), +}; + +function SessionOwnerReminderSection(props: { bot: BotDefaultsRow; patchBot: PatchBot }) { + const tr = useT(); + const initial = props.bot.sessionOwnerReminder ?? DEFAULT_OWNER_REMINDER; + const [enabled, setEnabled] = useState(initial.enabled === true); + const [interval, setIntervalValue] = useState(String(initial.intervalMinutes)); + const [text, setText] = useState(initial.text); + const [states, setStates] = useState([...initial.states]); + const [status, setStatus] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + const next = props.bot.sessionOwnerReminder ?? DEFAULT_OWNER_REMINDER; + setEnabled(next.enabled === true); + setIntervalValue(String(next.intervalMinutes)); + setText(next.text); + setStates([...next.states]); + }, [props.bot.sessionOwnerReminder]); + + function toggleState(state: OwnerReminderState, checked: boolean): void { + setStates(current => checked + ? (current.includes(state) ? current : [...current, state]) + : current.filter(item => item !== state)); + } + + async function save(): Promise { + const minutes = Number(interval); + const cleanText = text.trim(); + if (!Number.isInteger(minutes) || minutes < 1 || minutes > 10_080) { + setStatus({ text: `✗ ${tr('botDefaults.ownerReminderIntervalInvalid')}` }); + return; + } + if (!cleanText || Array.from(cleanText).length > 500 || /<\s*at\b/i.test(cleanText)) { + setStatus({ text: `✗ ${tr('botDefaults.ownerReminderTextInvalid')}` }); + return; + } + if (enabled && states.length === 0) { + setStatus({ text: `✗ ${tr('botDefaults.ownerReminderStatesInvalid')}` }); + return; + } + setBusy(true); + setStatus(null); + try { + const payload = { enabled, intervalMinutes: minutes, text: cleanText, states }; + const res = await sendJson( + 'PUT', + `/api/bots/${encodeURIComponent(props.bot.larkAppId)}/session-owner-reminder`, + payload, + ); + if (res.ok && res.body.ok) { + const next = res.body.sessionOwnerReminder ?? payload; + props.patchBot(props.bot.larkAppId, { sessionOwnerReminder: next }); + setStatus({ text: `✓ ${tr('botDefaults.cardPrefSaved')}`, ok: true }); + } else { + setStatus({ text: `✗ ${responseErrorText(res)}` }); + } + } catch (error: any) { + setStatus({ text: `✗ ${caughtErrorText(error)}` }); + } finally { + setBusy(false); + } + } + + return ( +
+

{tr('botDefaults.ownerReminderTitle')}

+ +
+ +
+
+

{tr('botDefaults.ownerReminderStates')}

+
+ {OWNER_REMINDER_STATE_OPTIONS.map(option => ( + + ))} +
+
+
+