{"version":3,"file":"forum-topics.mjs","names":[],"sources":["../../../src/services/forum-topics.ts"],"sourcesContent":["/**\n * Forum Topics Service — Telegram threaded mode topic management.\n *\n * When Telegram \"Topics\" mode is enabled (via BotFather), each topic\n * in a group becomes an isolated conversation thread. This service:\n *\n * 1. Maps well-known topic names to purpose categories\n * 2. Routes notifications (heartbeat, cron, alerts) to the right topic\n * 3. Maintains topic ID ↔ purpose mapping per chat\n * 4. Provides session isolation — each topic gets its own LLM context\n *\n * Topic IDs are Telegram message_thread_id values. The \"General\" topic\n * has thread_id = undefined (or 1 in some API versions).\n *\n * State is persisted to disk on shutdown and restored on startup.\n *\n * Usage:\n *   const topics = getForumTopics();\n *   topics.registerTopic(chatId, threadId, 'trading');\n *   const threadId = topics.getTopicForPurpose(chatId, 'alerts');\n */\n\nimport { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';\nimport { join } from 'node:path';\n\n// ── Types ────────────────────────────────────────────────────────────────\n\nexport type TopicPurpose =\n  | 'general'     // Default / catch-all\n  | 'trading'     // Swap, transfer, bridge operations\n  | 'portfolio'   // Balance checks, PnL, cost basis\n  | 'research'    // Price lookups, analytics, market intel\n  | 'alerts'      // Heartbeat, price alerts, cron notifications\n  | 'governance'  // DAO proposals, voting\n  | 'social'      // Farcaster, ClawnX posting\n  | 'admin';      // Bot settings, /setup, /flykeys\n\nexport interface TopicConfig {\n  /** Telegram message_thread_id */\n  threadId: number;\n  /** Human-readable topic name (as set in Telegram) */\n  name: string;\n  /** Mapped purpose category */\n  purpose: TopicPurpose;\n  /** Whether notifications should be routed here */\n  receivesNotifications: boolean;\n}\n\nexport interface ChatTopics {\n  /** Telegram chat ID (group/supergroup) */\n  chatId: string;\n  /** Whether forum mode is enabled for this chat */\n  forumEnabled: boolean;\n  /** Registered topics */\n  topics: Map<number, TopicConfig>;\n  /** Purpose → threadId quick lookup */\n  purposeMap: Map<TopicPurpose, number>;\n}\n\n// ── Default Topic Names → Purpose Mapping ─────────────────────────────\n\nconst NAME_TO_PURPOSE: Record<string, TopicPurpose> = {\n  // Trading\n  'trading': 'trading', 'trades': 'trading', 'swap': 'trading', 'swaps': 'trading',\n  'defi': 'trading', 'transactions': 'trading',\n  // Portfolio\n  'portfolio': 'portfolio', 'balances': 'portfolio', 'positions': 'portfolio',\n  'wallet': 'portfolio', 'holdings': 'portfolio',\n  // Research\n  'research': 'research', 'analysis': 'research', 'prices': 'research',\n  'market': 'research', 'charts': 'research', 'intel': 'research',\n  // Alerts\n  'alerts': 'alerts', 'notifications': 'alerts', 'heartbeat': 'alerts',\n  'monitor': 'alerts', 'watchlist': 'alerts',\n  // Governance\n  'governance': 'governance', 'dao': 'governance', 'voting': 'governance',\n  'proposals': 'governance',\n  // Social\n  'social': 'social', 'farcaster': 'social', 'twitter': 'social', 'x': 'social',\n  // Admin\n  'admin': 'admin', 'settings': 'admin', 'config': 'admin', 'setup': 'admin',\n  'bot': 'admin',\n};\n\n// ── Service ──────────────────────────────────────────────────────────────\n\nexport class ForumTopicsService {\n  private chats = new Map<string, ChatTopics>();\n\n  /**\n   * Register a topic for a chat. Auto-maps name to purpose.\n   */\n  registerTopic(\n    chatId: string,\n    threadId: number,\n    name: string,\n    purpose?: TopicPurpose,\n  ): TopicConfig {\n    const chat = this.getOrCreateChat(chatId);\n    const resolvedPurpose = purpose ?? this.resolvePurpose(name);\n\n    const config: TopicConfig = {\n      threadId,\n      name,\n      purpose: resolvedPurpose,\n      receivesNotifications: resolvedPurpose === 'alerts' || resolvedPurpose === 'general',\n    };\n\n    chat.topics.set(threadId, config);\n    chat.purposeMap.set(resolvedPurpose, threadId);\n    chat.forumEnabled = true;\n\n    return config;\n  }\n\n  /**\n   * Remove a topic registration.\n   */\n  unregisterTopic(chatId: string, threadId: number): boolean {\n    const chat = this.chats.get(chatId);\n    if (!chat) return false;\n\n    const config = chat.topics.get(threadId);\n    if (!config) return false;\n\n    chat.topics.delete(threadId);\n    // Remove from purpose map if it was the mapped topic\n    if (chat.purposeMap.get(config.purpose) === threadId) {\n      chat.purposeMap.delete(config.purpose);\n    }\n\n    return true;\n  }\n\n  /**\n   * Get the thread ID for a given purpose in a chat.\n   * Returns undefined if no topic is mapped for that purpose.\n   */\n  getTopicForPurpose(chatId: string, purpose: TopicPurpose): number | undefined {\n    return this.chats.get(chatId)?.purposeMap.get(purpose);\n  }\n\n  /**\n   * Get the thread ID for routing a notification.\n   * Falls back: alerts topic → general topic → undefined.\n   */\n  getNotificationTopic(chatId: string): number | undefined {\n    const chat = this.chats.get(chatId);\n    if (!chat?.forumEnabled) return undefined;\n    return chat.purposeMap.get('alerts') ?? chat.purposeMap.get('general');\n  }\n\n  /**\n   * Get the purpose for a specific thread in a chat.\n   */\n  getTopicPurpose(chatId: string, threadId: number): TopicPurpose | undefined {\n    return this.chats.get(chatId)?.topics.get(threadId)?.purpose;\n  }\n\n  /**\n   * Generate a session key suffix for a topic, enabling isolated LLM sessions.\n   * Returns '-topic-{threadId}' or empty string if no forum mode.\n   */\n  getSessionKeySuffix(chatId: string, threadId?: number): string {\n    if (!threadId) return '';\n    const chat = this.chats.get(chatId);\n    if (!chat?.forumEnabled) return '';\n    return `-topic-${threadId}`;\n  }\n\n  /**\n   * Check if forum mode is enabled for a chat.\n   */\n  isForumEnabled(chatId: string): boolean {\n    return this.chats.get(chatId)?.forumEnabled ?? false;\n  }\n\n  /**\n   * Enable/disable forum mode for a chat.\n   */\n  setForumEnabled(chatId: string, enabled: boolean): void {\n    const chat = this.getOrCreateChat(chatId);\n    chat.forumEnabled = enabled;\n  }\n\n  /**\n   * List all registered topics for a chat.\n   */\n  listTopics(chatId: string): TopicConfig[] {\n    const chat = this.chats.get(chatId);\n    if (!chat) return [];\n    return [...chat.topics.values()];\n  }\n\n  /**\n   * List all chats with forum mode enabled.\n   */\n  listForumChats(): string[] {\n    return [...this.chats.entries()]\n      .filter(([, chat]) => chat.forumEnabled)\n      .map(([chatId]) => chatId);\n  }\n\n  /**\n   * Get suggested topic structure for a new forum-enabled chat.\n   */\n  getSuggestedTopics(): Array<{ name: string; purpose: TopicPurpose; emoji: string }> {\n    return [\n      { name: 'Trading', purpose: 'trading', emoji: '📊' },\n      { name: 'Portfolio', purpose: 'portfolio', emoji: '💰' },\n      { name: 'Research', purpose: 'research', emoji: '🔍' },\n      { name: 'Alerts', purpose: 'alerts', emoji: '🔔' },\n      { name: 'Governance', purpose: 'governance', emoji: '🗳️' },\n      { name: 'Admin', purpose: 'admin', emoji: '⚙️' },\n    ];\n  }\n\n  // ── Internal ──────────────────────────────────────────────────────\n\n  private getOrCreateChat(chatId: string): ChatTopics {\n    let chat = this.chats.get(chatId);\n    if (!chat) {\n      chat = {\n        chatId,\n        forumEnabled: false,\n        topics: new Map(),\n        purposeMap: new Map(),\n      };\n      this.chats.set(chatId, chat);\n    }\n    return chat;\n  }\n\n  private resolvePurpose(name: string): TopicPurpose {\n    const lower = name.toLowerCase().trim();\n    return NAME_TO_PURPOSE[lower] ?? 'general';\n  }\n}\n\n// ── Singleton ────────────────────────────────────────────────────────────\n\nlet _instance: ForumTopicsService | null = null;\n\nexport function getForumTopics(): ForumTopicsService {\n  if (!_instance) _instance = new ForumTopicsService();\n  return _instance;\n}\n\nexport function resetForumTopics(): void {\n  _instance = null;\n}\n\n// ── Persistence ──────────────────────────────────────────────────────────\n\nfunction getForumStateDir(): string {\n  return process.env.OPENCLAWNCH_TX_DIR\n    ? join(process.env.OPENCLAWNCH_TX_DIR, '..', 'forum-topics')\n    : join(process.env.HOME ?? '/tmp', '.openclawnch', 'forum-topics');\n}\n\nfunction getForumStatePath(): string {\n  return join(getForumStateDir(), 'topics.json');\n}\n\ninterface PersistedTopicConfig {\n  threadId: number;\n  name: string;\n  purpose: TopicPurpose;\n  receivesNotifications: boolean;\n}\n\ninterface PersistedChatTopics {\n  chatId: string;\n  forumEnabled: boolean;\n  topics: PersistedTopicConfig[];\n}\n\n/** Persist all forum topic state to disk. Called on graceful shutdown. */\nexport function persistForumTopics(): void {\n  if (!_instance) return;\n  const svc = _instance;\n\n  const chats: PersistedChatTopics[] = [];\n  for (const chatId of svc.listForumChats()) {\n    const topics = svc.listTopics(chatId);\n    if (topics.length > 0) {\n      chats.push({\n        chatId,\n        forumEnabled: true,\n        topics: topics.map(t => ({\n          threadId: t.threadId,\n          name: t.name,\n          purpose: t.purpose,\n          receivesNotifications: t.receivesNotifications,\n        })),\n      });\n    }\n  }\n\n  if (chats.length === 0) return;\n\n  const dir = getForumStateDir();\n  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });\n  writeFileSync(getForumStatePath(), JSON.stringify(chats, null, 2), 'utf8');\n}\n\n/** Restore forum topic state from disk. Called on startup. */\nexport function restoreForumTopics(): void {\n  const path = getForumStatePath();\n  try {\n    if (!existsSync(path)) return;\n    const data = JSON.parse(readFileSync(path, 'utf8')) as PersistedChatTopics[];\n    const svc = getForumTopics();\n    for (const chat of data) {\n      for (const topic of chat.topics) {\n        svc.registerTopic(chat.chatId, topic.threadId, topic.name, topic.purpose);\n      }\n    }\n  } catch { /* corrupt file — start fresh */ }\n}\n"],"mappings":"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"}