import { resolveThreadBindingConversationIdFromBindingId } from "../../../src/channels/thread-binding-id.js"; import { resolveThreadBindingIdleTimeoutMsForChannel, resolveThreadBindingMaxAgeMsForChannel, } from "../../../src/channels/thread-bindings-policy.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { registerSessionBindingAdapter, unregisterSessionBindingAdapter, type BindingTargetKind, type SessionBindingRecord, } from "../../../src/infra/outbound/session-binding-service.js"; import { normalizeAccountId, resolveAgentIdFromSessionKey, } from "../../../src/routing/session-key.js"; import { resolveGlobalSingleton } from "../../../src/shared/global-singleton.js"; type FeishuBindingTargetKind = "subagent" | "acp"; type FeishuThreadBindingRecord = { accountId: string; conversationId: string; parentConversationId?: string; deliveryTo?: string; deliveryThreadId?: string; targetKind: FeishuBindingTargetKind; targetSessionKey: string; agentId?: string; label?: string; boundBy?: string; boundAt: number; lastActivityAt: number; }; type FeishuThreadBindingManager = { accountId: string; getByConversationId: (conversationId: string) => FeishuThreadBindingRecord | undefined; listBySessionKey: (targetSessionKey: string) => FeishuThreadBindingRecord[]; bindConversation: (params: { conversationId: string; parentConversationId?: string; targetKind: BindingTargetKind; targetSessionKey: string; metadata?: Record; }) => FeishuThreadBindingRecord | null; touchConversation: (conversationId: string, at?: number) => FeishuThreadBindingRecord | null; unbindConversation: (conversationId: string) => FeishuThreadBindingRecord | null; unbindBySessionKey: (targetSessionKey: string) => FeishuThreadBindingRecord[]; stop: () => void; }; type FeishuThreadBindingsState = { managersByAccountId: Map; bindingsByAccountConversation: Map; }; const FEISHU_THREAD_BINDINGS_STATE_KEY = Symbol.for("openclaw.feishuThreadBindingsState"); const state = resolveGlobalSingleton( FEISHU_THREAD_BINDINGS_STATE_KEY, () => ({ managersByAccountId: new Map(), bindingsByAccountConversation: new Map(), }), ); const MANAGERS_BY_ACCOUNT_ID = state.managersByAccountId; const BINDINGS_BY_ACCOUNT_CONVERSATION = state.bindingsByAccountConversation; function resolveBindingKey(params: { accountId: string; conversationId: string }): string { return `${params.accountId}:${params.conversationId}`; } function toSessionBindingTargetKind(raw: FeishuBindingTargetKind): BindingTargetKind { return raw === "subagent" ? "subagent" : "session"; } function toFeishuTargetKind(raw: BindingTargetKind): FeishuBindingTargetKind { return raw === "subagent" ? "subagent" : "acp"; } function toSessionBindingRecord( record: FeishuThreadBindingRecord, defaults: { idleTimeoutMs: number; maxAgeMs: number }, ): SessionBindingRecord { const idleExpiresAt = defaults.idleTimeoutMs > 0 ? record.lastActivityAt + defaults.idleTimeoutMs : undefined; const maxAgeExpiresAt = defaults.maxAgeMs > 0 ? record.boundAt + defaults.maxAgeMs : undefined; const expiresAt = idleExpiresAt != null && maxAgeExpiresAt != null ? Math.min(idleExpiresAt, maxAgeExpiresAt) : (idleExpiresAt ?? maxAgeExpiresAt); return { bindingId: resolveBindingKey({ accountId: record.accountId, conversationId: record.conversationId, }), targetSessionKey: record.targetSessionKey, targetKind: toSessionBindingTargetKind(record.targetKind), conversation: { channel: "feishu", accountId: record.accountId, conversationId: record.conversationId, parentConversationId: record.parentConversationId, }, status: "active", boundAt: record.boundAt, expiresAt, metadata: { agentId: record.agentId, label: record.label, boundBy: record.boundBy, deliveryTo: record.deliveryTo, deliveryThreadId: record.deliveryThreadId, lastActivityAt: record.lastActivityAt, idleTimeoutMs: defaults.idleTimeoutMs, maxAgeMs: defaults.maxAgeMs, }, }; } export function createFeishuThreadBindingManager(params: { accountId?: string; cfg: OpenClawConfig; }): FeishuThreadBindingManager { const accountId = normalizeAccountId(params.accountId); const existing = MANAGERS_BY_ACCOUNT_ID.get(accountId); if (existing) { return existing; } const idleTimeoutMs = resolveThreadBindingIdleTimeoutMsForChannel({ cfg: params.cfg, channel: "feishu", accountId, }); const maxAgeMs = resolveThreadBindingMaxAgeMsForChannel({ cfg: params.cfg, channel: "feishu", accountId, }); const manager: FeishuThreadBindingManager = { accountId, getByConversationId: (conversationId) => BINDINGS_BY_ACCOUNT_CONVERSATION.get(resolveBindingKey({ accountId, conversationId })), listBySessionKey: (targetSessionKey) => [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( (record) => record.accountId === accountId && record.targetSessionKey === targetSessionKey, ), bindConversation: ({ conversationId, parentConversationId, targetKind, targetSessionKey, metadata, }) => { const normalizedConversationId = conversationId.trim(); if (!normalizedConversationId || !targetSessionKey.trim()) { return null; } const now = Date.now(); const record: FeishuThreadBindingRecord = { accountId, conversationId: normalizedConversationId, parentConversationId: parentConversationId?.trim() || undefined, deliveryTo: typeof metadata?.deliveryTo === "string" && metadata.deliveryTo.trim() ? metadata.deliveryTo.trim() : undefined, deliveryThreadId: typeof metadata?.deliveryThreadId === "string" && metadata.deliveryThreadId.trim() ? metadata.deliveryThreadId.trim() : undefined, targetKind: toFeishuTargetKind(targetKind), targetSessionKey: targetSessionKey.trim(), agentId: typeof metadata?.agentId === "string" && metadata.agentId.trim() ? metadata.agentId.trim() : resolveAgentIdFromSessionKey(targetSessionKey), label: typeof metadata?.label === "string" && metadata.label.trim() ? metadata.label.trim() : undefined, boundBy: typeof metadata?.boundBy === "string" && metadata.boundBy.trim() ? metadata.boundBy.trim() : undefined, boundAt: now, lastActivityAt: now, }; BINDINGS_BY_ACCOUNT_CONVERSATION.set( resolveBindingKey({ accountId, conversationId: normalizedConversationId }), record, ); return record; }, touchConversation: (conversationId, at = Date.now()) => { const key = resolveBindingKey({ accountId, conversationId }); const existingRecord = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key); if (!existingRecord) { return null; } const updated = { ...existingRecord, lastActivityAt: at }; BINDINGS_BY_ACCOUNT_CONVERSATION.set(key, updated); return updated; }, unbindConversation: (conversationId) => { const key = resolveBindingKey({ accountId, conversationId }); const existingRecord = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key); if (!existingRecord) { return null; } BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); return existingRecord; }, unbindBySessionKey: (targetSessionKey) => { const removed: FeishuThreadBindingRecord[] = []; for (const record of [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()]) { if (record.accountId !== accountId || record.targetSessionKey !== targetSessionKey) { continue; } BINDINGS_BY_ACCOUNT_CONVERSATION.delete( resolveBindingKey({ accountId, conversationId: record.conversationId }), ); removed.push(record); } return removed; }, stop: () => { for (const key of [...BINDINGS_BY_ACCOUNT_CONVERSATION.keys()]) { if (key.startsWith(`${accountId}:`)) { BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); } } MANAGERS_BY_ACCOUNT_ID.delete(accountId); unregisterSessionBindingAdapter({ channel: "feishu", accountId }); }, }; registerSessionBindingAdapter({ channel: "feishu", accountId, capabilities: { placements: ["current"], }, bind: async (input) => { if (input.conversation.channel !== "feishu" || input.placement === "child") { return null; } const bound = manager.bindConversation({ conversationId: input.conversation.conversationId, parentConversationId: input.conversation.parentConversationId, targetKind: input.targetKind, targetSessionKey: input.targetSessionKey, metadata: input.metadata, }); return bound ? toSessionBindingRecord(bound, { idleTimeoutMs, maxAgeMs }) : null; }, listBySession: (targetSessionKey) => manager .listBySessionKey(targetSessionKey) .map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs })), resolveByConversation: (ref) => { if (ref.channel !== "feishu") { return null; } const found = manager.getByConversationId(ref.conversationId); return found ? toSessionBindingRecord(found, { idleTimeoutMs, maxAgeMs }) : null; }, touch: (bindingId, at) => { const conversationId = resolveThreadBindingConversationIdFromBindingId({ accountId, bindingId, }); if (conversationId) { manager.touchConversation(conversationId, at); } }, unbind: async (input) => { if (input.targetSessionKey?.trim()) { return manager .unbindBySessionKey(input.targetSessionKey.trim()) .map((entry) => toSessionBindingRecord(entry, { idleTimeoutMs, maxAgeMs })); } const conversationId = resolveThreadBindingConversationIdFromBindingId({ accountId, bindingId: input.bindingId, }); if (!conversationId) { return []; } const removed = manager.unbindConversation(conversationId); return removed ? [toSessionBindingRecord(removed, { idleTimeoutMs, maxAgeMs })] : []; }, }); MANAGERS_BY_ACCOUNT_ID.set(accountId, manager); return manager; } export function getFeishuThreadBindingManager( accountId?: string, ): FeishuThreadBindingManager | null { return MANAGERS_BY_ACCOUNT_ID.get(normalizeAccountId(accountId)) ?? null; } export const __testing = { resetFeishuThreadBindingsForTests() { for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) { manager.stop(); } MANAGERS_BY_ACCOUNT_ID.clear(); BINDINGS_BY_ACCOUNT_CONVERSATION.clear(); }, };