# Open Claudia — Channel design (Phase 0/2)

> Provider-parity update: current direct and agent modes both run through the
> modular `bot.js` entrypoint. `bot-agent.js` is now only a compatibility shim.
> Channel adapters dispatch normalized turns to the provider registry and do
> not choose a provider or construct provider-specific CLI arguments.
> Provider state/session/job changes use the verified snapshot and rollback
> process in [PROVIDER_MIGRATION.md](./PROVIDER_MIGRATION.md). References below
> to leaving the old monolith unchanged describe the historical channel refactor.

Companion to MULTI_CHANNEL_PLAN.md. Decides the concrete shape of channels in code before refactor begins.

---

## 1. Channel-config mechanism

Channels are a configurable list. Each entry declares an adapter type + adapter-specific env keys. No hardcoded if/else in core.

`.env` example (both enabled):
```
CHANNELS=telegram,kazee

# telegram adapter
TELEGRAM_BOT_TOKEN=...
TELEGRAM_CHAT_ID=6251055967

# kazee adapter
KAZEE_URL=https://chat.kazee.africa
KAZEE_BOT_TOKEN=bot_...
KAZEE_BOT_USER_ID=64f8...
```

Backwards compat: if `CHANNELS` is unset, default to `telegram` so existing installs keep working (no migration on v1.21.0).

`core/config.js` exports `loadChannels()` returning `[{id, type, opts}]`. Bot bootstrap iterates and calls `createAdapter(type, opts)` per entry. Unknown type → log + skip, not fatal.

## 2. ChannelAdapter contract

One JS class per channel. All identified IO with the outside world (Telegram API, Kazee REST/socket) lives behind this surface — core code never touches transport SDKs directly.

```js
// channels/types.js — JSDoc-only contract (no runtime base class)

/**
 * @typedef {Object} InboundEnvelope
 * @property {string} channelId       // raw transport id (telegram chatId, kazee chatId)
 * @property {string} canonicalUserId // "telegram:123" | "kazee:abc"
 * @property {string} userId          // raw transport user id
 * @property {string} [text]
 * @property {string} [replyToId]
 * @property {Array<MediaRef>} [mediaRefs]
 * @property {string} type            // "text" | "voice" | "audio" | "photo" | "document" | "action"
 * @property {any} raw                // original payload, adapter-specific
 */

/**
 * @typedef {Object} SendOpts
 * @property {string} [parseMode]
 * @property {Object} [keyboard]      // {inline_keyboard:[[{text,callback_data}]]} OR
 *                                    // {buttons:[{id,label,style,payload}]} for kazee interactive
 * @property {string|number} [replyTo]
 */

/**
 * Every adapter implements:
 *   id: string                            "telegram" | "kazee"
 *   start(): Promise<void>
 *   stop(): Promise<void>
 *   on(event, fn): unsubscribe            events: "message" | "action"
 *   send(channelId, text, opts): Promise<messageId|null>
 *   edit(channelId, messageId, text, opts): Promise<void>
 *   delete(channelId, messageId): Promise<void>
 *   sendVoice(channelId, oggPath): Promise<boolean>
 *   sendFile(channelId, filePath, caption?): Promise<boolean>
 *   typing(channelId): Promise<void>
 *   downloadMedia(mediaRef): Promise<localPath>
 */
```

Notes
- `keyboard` is the one leaky abstraction. Telegram's `inline_keyboard` and Kazee's `interactive.buttons` differ enough that core builds a portable representation and each adapter renders it natively. Core uses `{buttons: [{id, label, style?, payload?}]}` as the canonical shape; the telegram adapter converts to `inline_keyboard`.
- `channelId` is opaque to core. Adapters route on it. Same chatId may exist on different channels — disambiguation happens via the adapter the message arrived on.
- `canonicalUserId` is the only user identifier core code cares about. It maps to per-user state.

## 3. Channel-aware chat context

Today: `chatContext = new AsyncLocalStorage()` stores chatId as a plain string. Every `send/edit/...` reads `currentChatId()`.

After: context stores `{adapter, channelId, canonicalUserId}`. New helpers:
```js
core/context.js
  runInChat({adapter, channelId, canonicalUserId}, fn)
  currentAdapter()       // returns the ChannelAdapter for the in-flight request
  currentChannelId()     // raw channel id
  currentCanonicalUserId()
```

`send(text, opts)` becomes `currentAdapter().send(currentChannelId(), text, opts)`. All 19 Telegram primitives flatten down to ~7 adapter methods + utility helpers.

## 4. Command registry

`core/commands.js` exports a registry:
```js
register({
  name: "model",
  description: "Pick or show active model",
  args: [{name: "model", required: false}],
  handler: async (ctx, args) => { ... },
});
```
- `ctx` = `{canonicalUserId, channelId, adapter, raw, replyToId}`.
- `args` = `string[]` (raw tokens after `/cmd`), parser is intentionally dumb so handlers keep flexibility.
- The router parses `/cmd args` from inbound text and dispatches. Both adapters share one parser.
- Telegram-specific behaviour (regex multimatch like `/cron add "..." "..." "..."`) — handlers that need richer parsing can fall back to raw text via `ctx.raw.text`.

On startup the bot:
1. Loads registry.
2. Telegram adapter: no-op (Telegram has its own BotFather menu we don't auto-register; current behaviour preserved).
3. Kazee adapter: `PUT /bot/<botId>/commands` with `[{name, description, args}]` so web/mobile slash menus populate.

## 5. Refactor strategy (low-risk, incremental)

The 3544-line bot.js gets split across multiple commits, each shippable on its own and behaviour-preserving for Telegram.

| Step | Commit | Scope | Risk |
|---|---|---|---|
| A | extract config | `core/config.js` (loadEnv + loadChannels) | nil |
| B | extract identity | `core/identity.js` (canonicalForChannel, identities file IO) | nil |
| C | extract state | `core/state.js` (userStates, savedState, sessions persistence) | low |
| D | extract commands registry | `core/commands.js` empty registry + router that delegates to existing `bot.onText` handlers | nil (no behaviour change) |
| E | introduce ChannelAdapter | `channels/telegram/adapter.js` wraps existing `bot.sendMessage/...`. `chatContext` now stores `{adapter,channelId}`. All `send/edit/delete/sendVoice` in bot.js refactored to delegate to `currentAdapter()` | medium — touches every IO call site |
| F | migrate handlers to registry | Convert each `bot.onText` into a `register({...})` entry. Telegram adapter routes inbound text → registry. | medium — 35+ touchpoints, but mechanical |
| G | ship v1.21.0 | smoke test Telegram parity end-to-end | — |
| H | kazee adapter | `channels/kazee/adapter.js` — socket client, send/edit/delete/sendFile/sendVoice, interactive button shape | new code, no Telegram risk |
| I | ship v1.22.0 | kazee adapter behind `CHANNELS=telegram,kazee` once dev verified | — |

After step G the file tree looks like:
```
open-claudia/
  bot.js                  # ~200 lines — bootstrap, signal handling, channel loop
  core/
    config.js
    identity.js
    state.js
    context.js
    commands.js
    router.js             # parses /cmd, dispatches registry, handles voice/media
    transcripts.js        # re-export of project-transcripts.js
  channels/
    types.js              # JSDoc
    telegram/
      adapter.js
      format.js           # Telegram-MD quirks (single-asterisk bold)
  bot-agent.js            # unchanged
  health.js               # unchanged
  web.js                  # unchanged
```

After step H:
```
  channels/kazee/
    adapter.js
    format.js             # standard markdown, no chunking
    socket.js             # socket.io-client wrapper
```

## 6. Open items I'm flagging now

1. **Telegram `parseMode: "Markdown"` vs `MarkdownV2`** — many existing `send(text, {parseMode: "Markdown"})` calls. Kazee adapter ignores parseMode (always standard MD). Need adapter-side normalisation if we ever ship the same text through both. For Phase 0, parseMode flows through unchanged for Telegram; Kazee adapter silently drops it.
2. **Reply IDs across channels** — `replyTo` is a Telegram `message_id`. Kazee uses Mongo `_id`. Already opaque in `SendOpts`. Fine.
3. **`bot.on("callback_query")`** in Telegram becomes a `{type:"action", actionId, payload}` envelope on the adapter. Same path Kazee uses for button presses. Core router sees both the same way.
4. **Voice download path** — `downloadFile(fileId, ext)` is Telegram-specific. Becomes `adapter.downloadMedia(mediaRef)` returning a local file path. Telegram adapter wraps `bot.getFile + https.get`; Kazee adapter does `GET <minio-url>`.
5. **Crons & notifyError** — both currently call `bot.sendMessage(CHAT_ID, ...)` directly. Need to route through the *owner's* preferred adapter. Cron entries store `canonicalUserId`; lookup their preferred channel via identities and dispatch via that adapter. (`notifyError` keeps Telegram-only fallback — last-resort crash channel.)

## 7. What I won't change in this refactor

- At that time, `bot-agent.js` stayed as the Claude runner subprocess wrapper; it is now a provider-neutral compatibility shim.
- `health.js`, `web.js`, `setup.js` — untouched.
- Historical subprocess management stayed unchanged during this channel-only refactor; provider convergence happened later.
- Vault, soul, crons schemas on disk — same files, same format. No migration.
- Identities file format — adds `kazee:<userId>` keys naturally; no schema change needed.
