---
phase: 05-telegram-bot-core
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - plugins/claude-tts-companion/Sources/claude-tts-companion/Config.swift
  - plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift
  - plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramFormatter.swift
autonomous: true
requirements: [BOT-01, BOT-02, BOT-08]

must_haves:
  truths:
    - "Bot connects to Telegram via long polling and logs successful connection"
    - "Bot responds to /start, /stop, /status, /health, /sessions, /done, /commands with HTML-formatted messages"
    - "Messages over 4096 chars are split on fence boundaries without breaking code blocks"
  artifacts:
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift"
      provides: "TGBot wrapper with long polling, dispatcher, and 7 command handlers"
      exports: ["TelegramBot"]
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramFormatter.swift"
      provides: "HTML escaping, markdown-to-HTML conversion, fence-aware chunking"
      exports: ["TelegramFormatter"]
    - path: "plugins/claude-tts-companion/Sources/claude-tts-companion/Config.swift"
      provides: "telegramBotToken and telegramChatId config constants"
      contains: "telegramBotToken"
  key_links:
    - from: "TelegramBot.swift"
      to: "swift-telegram-sdk"
      via: "TGBot actor + TGClientDefault + TGCommandHandler"
      pattern: "TGBot.*connectionType.*longpolling"
    - from: "TelegramBot.swift"
      to: "TelegramFormatter.swift"
      via: "sendFormattedMessage calls chunkTelegramHtml"
      pattern: "TelegramFormatter\\.chunkTelegramHtml"
---

<objective>
Create the Telegram bot core: long-polling connection, 7 command handlers, and HTML message formatting with fence-aware chunking.

Purpose: Establishes the bot infrastructure that Phase 05 Plan 02 will wire to SummaryEngine and TTSEngine for notifications.
Output: TelegramBot.swift (bot actor), TelegramFormatter.swift (HTML formatting + chunking), updated Config.swift
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@plugins/claude-tts-companion/Sources/claude-tts-companion/Config.swift
@plugins/claude-tts-companion/Sources/claude-tts-companion/main.swift
@plugins/claude-tts-companion/Package.swift

<interfaces>
<!-- swift-telegram-sdk API contracts the executor needs -->

From swift-telegram-sdk TGBot.swift:

```swift
public actor TGBot {
    public init(
        connectionType: TGConnectionType,
        tgClient: TGClientPrtcl,
        tgURI: URL = TGBot.standardTGURL,
        botId: String,
        apiRequestLimitLongPolling: UInt? = 5,
        log: Logger = .init(label: "com.tgbot")
    ) async throws

    public func add(dispatcher: any TGDefaultDispatcherPrtcl) async throws
    public func start() async throws -> Bool
    public func stop() async throws -> Bool
    func sendMessage(params: TGSendMessageParams) async throws -> TGMessage
    func setMyCommands(params: TGSetMyCommandsParams) async throws -> Bool
}
```

From TGConnectionType:

```swift
public enum TGConnectionType: Sendable {
    case longpolling(limit: Int? = nil, timeout: Int? = nil, allowedUpdates: [TGUpdate.CodingKeys]? = nil)
}
```

From TGClientDefault:

```swift
public final class TGClientDefault: TGClientPrtcl {
    public init()
}
```

From TGCommandHandler:

```swift
public final class TGCommandHandler: TGHandlerPrtcl {
    public init(
        name: String = String(describing: TGCommandHandler.self),
        commands: [String],
        filters: TGFilter = .all,
        options: Options = [],
        botUsername: String? = nil,
        _ callback: @escaping TGHandlerCallbackAsync
    )
}
// TGHandlerCallbackAsync = @Sendable (TGUpdate) async throws -> Void
```

From TGDefaultDispatcher:

```swift
open class TGDefaultDispatcher: TGDefaultDispatcherPrtcl, @unchecked Sendable {
    public init(bot: TGBot, logger: Logger)
    open func handle() async { /* override this to register handlers */ }
}
```

From TGSendMessageParams:

```swift
public struct TGSendMessageParams: Encodable, Sendable {
    public init(chatId: TGChatId, text: String, parseMode: TGParseMode? = nil,
                linkPreviewOptions: TGLinkPreviewOptions? = nil, ...)
}
// TGChatId: .chat(Int64), .username(String)
// TGParseMode: .html, .markdownV2
```

From TGBotCommand:

```swift
public final class TGBotCommand: Codable, Sendable {
    public init(command: String, description: String)
}
```

From TGSetMyCommandsParams:

```swift
public struct TGSetMyCommandsParams: Encodable, Sendable {
    public init(commands: [TGBotCommand], scope: TGBotCommandScope? = nil, languageCode: String? = nil)
}
```

</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Config + TelegramFormatter (HTML formatting and fence-aware chunking)</name>
  <files>
    plugins/claude-tts-companion/Sources/claude-tts-companion/Config.swift
    plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramFormatter.swift
  </files>
  <read_first>
    @plugins/claude-tts-companion/Sources/claude-tts-companion/Config.swift
    @~/.claude/automation/claude-telegram-sync/src/telegram/format.ts
    @~/.claude/automation/claude-telegram-sync/src/telegram/fences.ts
  </read_first>
  <action>
    1. **Config.swift** -- Add two new config constants:
       ```swift
       // MARK: - Telegram Bot
       static let telegramBotToken: String? = ProcessInfo.processInfo.environment["TELEGRAM_BOT_TOKEN"]
       static let telegramChatId: Int64? = {
           guard let str = ProcessInfo.processInfo.environment["TELEGRAM_CHAT_ID"],
                 let val = Int64(str) else { return nil }
           return val
       }()
       ```

    2. **TelegramFormatter.swift** -- Port the TypeScript format.ts and fences.ts to Swift. This is a pure-function utility with no dependencies beyond Foundation.

       **HTML escaping** (port of `escapeHtml`):
       - `static func escapeHtml(_ text: String) -> String` -- Replace `&` -> `&amp;`, `<` -> `&lt;`, `>` -> `&gt;`

       **Fence span parser** (port of `parseFenceSpans` from fences.ts):
       - `struct FenceSpan { let start: Int; let end: Int; let openLine: String; let marker: String; let indent: String }`
       - `static func parseFenceSpans(_ text: String) -> [FenceSpan]` -- Walk lines, match /^( {0,3})(`{3,}|~{3,})(.*)$/, track open/close pairs. On EOF with unclosed fence, emit span to end of text.
       - `static func findFenceSpanAt(_ spans: [FenceSpan], index: Int) -> FenceSpan?`
       - `static func isSafeFenceBreak(_ spans: [FenceSpan], index: Int) -> Bool`

       **Fence-aware chunking** (port of `chunkTelegramHtml` from format.ts):
       - `static let telegramMaxLength = 4096`
       - `static func chunkTelegramHtml(_ text: String, limit: Int = 4096) -> [String]` -- Two-phase: (1) split on paragraph boundaries (`\n\t?\n+`) outside fences, (2) pack paragraphs into chunks, fence-aware split for oversized ones. When splitting inside a fence, close with the fence marker and re-open in next chunk.
       - `static func pickSafeBreakIndex(_ window: String, spans: [FenceSpan]) -> Int` -- Prefer newline > whitespace, only at positions outside fenced code blocks.

       Key porting notes:
       - Swift String is UTF-8/grapheme-based, not UTF-16. Use `.utf8` offsets or work with String.Index. The simplest approach: convert to `[Character]` array for O(1) index access during chunking, then reconvert to String for output. Or use `String.Index` arithmetic throughout.
       - The regex patterns in TS can be replaced with simple character-by-character parsing (the fence parser is already line-by-line).
       - Do NOT port the file reference wrapping (wrapFileReferencesInHtml) -- it handles Telegram link previews for auto-linked filenames, which is only relevant for grammY (the TS bot uses a different message pipeline). The Swift bot will use parseMode: .html directly.

       **Simple markdown-to-HTML** (subset of `markdownToTelegramHtml`):
       - `static func markdownToTelegramHtml(_ markdown: String) -> String` -- Handle code blocks (```), inline code (`), bold (**), italic (*), links [text](url). Code blocks MUST be processed first (before inline patterns). Escape HTML inside code blocks.

  </action>
  <verify>
    <automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "telegramBotToken" plugins/claude-tts-companion/Sources/claude-tts-companion/Config.swift
    - grep -q "telegramChatId" plugins/claude-tts-companion/Sources/claude-tts-companion/Config.swift
    - grep -q "chunkTelegramHtml" plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramFormatter.swift
    - grep -q "parseFenceSpans" plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramFormatter.swift
    - grep -q "escapeHtml" plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramFormatter.swift
    - grep -q "telegramMaxLength.*4096" plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramFormatter.swift
  </acceptance_criteria>
  <done>TelegramFormatter has escapeHtml, parseFenceSpans, chunkTelegramHtml, markdownToTelegramHtml. Config has telegramBotToken and telegramChatId. swift build succeeds.</done>
</task>

<task type="auto">
  <name>Task 2: TelegramBot actor with long polling and 7 command handlers</name>
  <files>
    plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift
  </files>
  <read_first>
    @plugins/claude-tts-companion/Sources/claude-tts-companion/Config.swift
    @plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramFormatter.swift
    @plugins/claude-tts-companion/Sources/claude-tts-companion/MiniMaxClient.swift
  </read_first>
  <action>
    Create `TelegramBot.swift` wrapping swift-telegram-sdk's TGBot actor.

    **Class structure:**
    ```swift
    import Foundation
    import Logging
    import SwiftTelegramBot

    /// Telegram bot using swift-telegram-sdk long polling.
    /// Wraps TGBot actor and provides command handlers + message sending.
    final class TelegramBot: @unchecked Sendable {
        private let logger = Logger(label: "telegram-bot")
        private var bot: TGBot?
        private let botToken: String
        private let chatId: Int64
        private var isWatching: Bool = false
        private var startTime: Date = Date()

        init(botToken: String, chatId: Int64) { ... }
    }
    ```

    **Boot sequence** (`func start() async throws`):
    1. Create `TGClientDefault()` as the HTTP client
    2. Create `TGBot(connectionType: .longpolling(limit: 100, timeout: 30, allowedUpdates: [.message]), tgClient: client, botId: botToken)`
    3. Create a custom dispatcher subclass that registers all 7 command handlers (see below)
    4. Add dispatcher via `bot.add(dispatcher:)`
    5. Register command menu via `bot.setMyCommands(params:)` with `TGBotCommand` array
    6. Call `bot.start()` to begin long polling
    7. Log success: "Telegram bot started (long polling)"

    **Custom Dispatcher** -- subclass `TGDefaultDispatcher`:
    ```swift
    final class BotDispatcher: TGDefaultDispatcher {
        let telegramBot: TelegramBot

        init(bot: TGBot, telegramBot: TelegramBot, logger: Logger) {
            self.telegramBot = telegramBot
            super.init(bot: bot, logger: logger)
        }

        override func handle() async {
            // Register all 7 command handlers
            await add(TGCommandHandler(commands: ["start"]) { [weak self] update in ... })
            await add(TGCommandHandler(commands: ["stop"]) { ... })
            // ... etc for all 7 commands
        }
    }
    ```

    **7 Command handlers** (matching TypeScript bot commands.ts patterns):

    1. `/start` -- Sets `isWatching = true`. Replies: "<b>Monitoring Active</b>\n\nNow watching Claude Code sessions." If already watching: "<b>Already Watching</b>\n\nThe bot is already monitoring sessions."

    2. `/stop` -- Sets `isWatching = false`. Replies: "<b>Monitoring Stopped</b>\n\nNo longer watching sessions." If not watching: "<b>Not Watching</b>\n\nMonitoring was not active."

    3. `/status` -- Replies with HTML showing: watching state, uptime (formatted as Xh Ym), bot version (Config.appName). Format: "<b>Bot Status</b>\n\n<b>Watching:</b> {yes/no}\n<b>Uptime:</b> {duration}\n<b>Version:</b> {appName}"

    4. `/health` -- Quick connectivity check. Replies: "<b>Health Check</b>\n\n<b>Telegram API:</b> Connected\n<b>TTS Engine:</b> Available\n<b>Summary Engine:</b> {Available/No API key}" (check Config.miniMaxAPIKey != nil)

    5. `/sessions` -- Replies with placeholder: "<b>Sessions</b>\n\nSession listing will be available in Phase 7 (File Watching)." (Real implementation needs file watcher from Phase 7)

    6. `/done` -- Replies with placeholder: "<b>Done</b>\n\nSession detach will be available in Phase 6 (Bot Commands)." (Real implementation needs CLI integration from Phase 6)

    7. `/commands` -- Lists all available commands with descriptions. Replies: "<b>Available Commands</b>\n\n/start - Start monitoring sessions\n/stop - Stop monitoring\n/status - View bot status\n/health - Check connectivity\n/sessions - List recent sessions\n/done - Detach from session\n/commands - Show this list"

    **Message sending helper** (`func sendMessage(_ text: String, parseMode: TGParseMode = .html) async`):
    - Chunk text via `TelegramFormatter.chunkTelegramHtml(text)` if it exceeds 4096 chars
    - For each chunk, create `TGSendMessageParams(chatId: .chat(chatId), text: chunk, parseMode: parseMode, linkPreviewOptions: TGLinkPreviewOptions(isDisabled: true))`
    - Call `bot.sendMessage(params:)`
    - On error, retry once stripping HTML tags (plain text fallback, matching TS safeEditMessage pattern)
    - Log errors but do not throw (fire-and-forget for notification use)

    **Public API for notifications** (used by Plan 02):
    - `func sendNotification(_ text: String) async` -- calls sendMessage with HTML parse mode
    - `var watching: Bool { isWatching }` -- read-only accessor

    **Shutdown** (`func stop() async`):
    - Call `bot.stop()`, log "Telegram bot stopped"

    **Swift 6 concurrency notes:**
    - TGBot is an actor, so all calls to it are `await`
    - TelegramBot itself is `@unchecked Sendable` (bot reference accessed via async methods)
    - Command handler closures capture `[weak self]` or `[unowned self]` to avoid retain cycles
    - The handler callback is `@Sendable (TGUpdate) async throws -> Void`
    - To extract chatId from update: `update.message?.chat.id` (Int64)
    - To send reply within handler: use `self.bot.sendMessage(params:)` directly since we're already in async context

    **TGLinkPreviewOptions**: Check if this type exists in the SDK. If not, omit linkPreviewOptions parameter (it's optional). The SDK may have a different name or not expose this type.

  </action>
  <verify>
    <automated>cd plugins/claude-tts-companion && swift build 2>&1 | tail -5</automated>
  </verify>
  <acceptance_criteria>
    - grep -q "class TelegramBot" plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift
    - grep -q "longpolling" plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift
    - grep -q "TGCommandHandler" plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift
    - grep -q "setMyCommands" plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift
    - grep -q "sendMessage" plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift
    - grep -q "chunkTelegramHtml" plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift
    - grep -qE "/start|/stop|/status|/health|/sessions|/done|/commands" plugins/claude-tts-companion/Sources/claude-tts-companion/TelegramBot.swift
  </acceptance_criteria>
  <done>TelegramBot connects via long polling, registers 7 command handlers, sends HTML messages with fence-aware chunking. swift build succeeds with zero errors.</done>
</task>

</tasks>

<verification>
1. `cd plugins/claude-tts-companion && swift build` succeeds with zero errors
2. Config.swift has telegramBotToken and telegramChatId environment variable lookups
3. TelegramFormatter.swift has escapeHtml, parseFenceSpans, chunkTelegramHtml, markdownToTelegramHtml
4. TelegramBot.swift has 7 command handlers, long polling setup, and sendMessage with chunking
</verification>

<success_criteria>

- swift build succeeds
- Bot infrastructure ready for Plan 02 to wire SummaryEngine and TTSEngine
- All 7 commands defined and responding with HTML messages
- Fence-aware chunking handles messages over 4096 chars
  </success_criteria>

<output>
After completion, create `.planning/phases/05-telegram-bot-core/05-01-SUMMARY.md`
</output>
