Phase 1: Bot Core + Telegram Adapter
Depends on: Nothing (first phase)
Blocks: Phase 2 (Agent + Tools), Phase 3 (Cron Jobs)
Goal
Stand up the foundational bot infrastructure: channel abstraction layer, Telegraf adapter, database tables for user pairing and chat history, and the pairing flow. After this phase, the bot can receive Telegram messages, resolve users, and send replies — but without the LLM agent (that's Phase 2).
Steps
1.1 — Add dependencies
Add to api/package.json:
pnpm add telegraf node-cron
pnpm add -D @types/node-cron
1.2 — Create ChannelAdapter interface
File: api/src/bot/types.ts
export interface ChannelAdapter {
/** Channel identifier: 'telegram', 'whatsapp', etc. */
name: string
/** Initialize webhook/polling and start listening */
start(): Promise<void>
/** Graceful shutdown */
stop(): Promise<void>
/** Send a message to a user on this channel */
sendMessage(externalId: string, msg: OutgoingMessage): Promise<void>
/** Register the handler that processes incoming messages */
onMessage(handler: (msg: IncomingMessage) => Promise<void>): void
}
export interface IncomingMessage {
/** Channel-specific user identifier (Telegram chat_id, WhatsApp phone, etc.) */
externalId: string
/** Which channel this message came from */
channel: string
/** Message text content */
text: string
/** Channel-specific extras (photos, voice messages, etc.) */
metadata?: Record<string, any>
}
export interface OutgoingMessage {
text: string
format?: 'markdown' | 'html' | 'plain'
buttons?: Array<{ label: string; data: string }>
}
Design notes:
externalIdis deliberately generic — Telegram chat_id, WhatsApp phone number, etc.metadatacarries channel-specific data without polluting the core interfacebuttonsare optional — adapters that don't support inline buttons can ignore them or render as text
1.3 — Create Telegram adapter
File: api/src/bot/channels/telegram.ts
Implement ChannelAdapter using Telegraf:
start()— createTelegrafinstance withTELEGRAM_BOT_TOKEN, register/startand/helpcommands, callbot.launch()(long-polling) orbot.createWebhook()(webhook mode)stop()— callbot.stop()sendMessage()— callbot.telegram.sendMessage(externalId, text, { parse_mode }). Mapformatto Telegram parse modes. RenderbuttonsasInlineKeyboardMarkuponMessage()— store the handler; instart(), registerbot.on('text', ...)that transforms Telegraf'sContextintoIncomingMessageand calls the handler
Command handlers:
/start— check if user is paired. If yes: "Welcome back!". If no: send pairing instructions with deep-link to dashboard/help— list available commands and capabilities
1.4 — Create adapter registry + boot
File: api/src/bot/index.ts
import { TelegramAdapter } from './channels/telegram.js'
import type { ChannelAdapter } from './types.js'
const adapters: ChannelAdapter[] = []
export function getAdapter(channel: string): ChannelAdapter | undefined {
return adapters.find((a) => a.name === channel)
}
export function getAllAdapters(): ChannelAdapter[] {
return adapters
}
export async function startBot() {
// Only start if token is configured
if (env.TELEGRAM_BOT_TOKEN) {
const telegram = new TelegramAdapter(env.TELEGRAM_BOT_TOKEN)
adapters.push(telegram)
}
// Future: if (env.WHATSAPP_TOKEN) { ... }
for (const adapter of adapters) {
adapter.onMessage(handleIncomingMessage)
await adapter.start()
}
}
export async function stopBot() {
for (const adapter of adapters) {
await adapter.stop()
}
}
async function handleIncomingMessage(msg: IncomingMessage) {
// Phase 1: echo or stub response
// Phase 2: replaced with agent invocation
const userId = await resolveUser(msg.channel, msg.externalId)
if (!userId) {
const adapter = getAdapter(msg.channel)
await adapter?.sendMessage(msg.externalId, {
text: "You're not paired yet. Visit the dashboard to link your account.",
})
return
}
// Stub: acknowledge receipt
const adapter = getAdapter(msg.channel)
await adapter?.sendMessage(msg.externalId, {
text: `Received: "${msg.text}" — agent coming in Phase 2`,
})
}
1.5 — Wire bot into API server startup
File: api/src/server.ts (modify)
After app.listen() resolves, call startBot(). On shutdown signal (SIGTERM/SIGINT), call stopBot() before closing Fastify.
import { startBot, stopBot } from './bot/index.js'
// After app.listen()
await startBot()
// On shutdown
process.on('SIGTERM', async () => {
await stopBot()
await app.close()
})
1.6 — Create database migration: messaging_users
File: front-end/supabase/migrations/<timestamp>_messaging_users.sql
-- Replaces goclaw_tenant for user-channel pairing
CREATE TABLE public.messaging_users (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
channel TEXT NOT NULL, -- 'telegram', 'whatsapp', ...
external_id TEXT NOT NULL, -- chat_id, phone number, etc.
display_name TEXT, -- user's display name on the channel
paired_at TIMESTAMPTZ DEFAULT now(),
is_active BOOLEAN DEFAULT true,
UNIQUE(channel, external_id)
);
-- RLS: users can read/manage their own pairings
ALTER TABLE public.messaging_users ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users_select_own" ON public.messaging_users
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "users_insert_own" ON public.messaging_users
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "users_delete_own" ON public.messaging_users
FOR DELETE USING (auth.uid() = user_id);
-- Service role bypasses RLS (for bot-side lookups by external_id)
-- Index for fast bot-side lookups: channel + external_id → user_id
CREATE INDEX idx_messaging_users_channel_ext ON public.messaging_users (channel, external_id);
1.7 — Create database migration: chat_history
File: front-end/supabase/migrations/<timestamp>_chat_history.sql
CREATE TABLE public.chat_history (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
channel TEXT NOT NULL,
external_id TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'tool')),
content TEXT NOT NULL,
metadata JSONB, -- tool calls, token usage, model, etc.
created_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE public.chat_history ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users_select_own" ON public.chat_history
FOR SELECT USING (auth.uid() = user_id);
-- Index for loading recent conversation by user+channel
CREATE INDEX idx_chat_history_user_channel ON public.chat_history (user_id, channel, external_id, created_at DESC);
-- Service role inserts (bot writes history on behalf of user)
1.8 — Implement user resolution
File: api/src/bot/index.ts (or a dedicated api/src/bot/auth.ts)
async function resolveUser(channel: string, externalId: string): Promise<string | null> {
const { data } = await adminSupabase
.from('messaging_users')
.select('user_id')
.eq('channel', channel)
.eq('external_id', externalId)
.eq('is_active', true)
.single()
return data?.user_id ?? null
}
Uses the admin Supabase client (service role) because the bot process doesn't have a user JWT — it looks up users by their channel identity.
1.9 — Implement pairing flow
Two pairing paths:
A) Dashboard pairing — user enters their Telegram chat_id in Settings → Messaging Connections:
- Frontend calls
POST /telegram/pair(modify existing endpoint to write tomessaging_usersinstead ofgoclaw_tenant) - Or create a new generic
POST /messaging/pairendpoint that accepts{ channel, external_id }
B) Bot deep-link pairing — user clicks a link from the dashboard that opens Telegram with a start parameter:
- Dashboard generates link:
https://t.me/AimeeBot?start=<pairing_token> - Token is a short-lived JWT or UUID stored in Redis/Supabase
/start <token>handler in Telegram adapter validates token, insertsmessaging_usersrow
1.10 — Update env config
File: api/src/config/env.ts (modify)
- Promote
TELEGRAM_BOT_TOKENto a first-class env var (it already exists as a fallback fromGOCLAW_TELEGRAM_TOKEN) - Add
BOT_ENABLEDoptional boolean to allow disabling the bot in dev/test environments
Files Created
| File | Purpose |
|---|---|
api/src/bot/types.ts | ChannelAdapter, IncomingMessage, OutgoingMessage |
api/src/bot/channels/telegram.ts | Telegraf adapter |
api/src/bot/index.ts | Adapter registry, boot, message routing, user resolution |
front-end/supabase/migrations/…_messaging_users.sql | User-channel pairing table |
front-end/supabase/migrations/…_chat_history.sql | Conversation persistence |
Files Modified
| File | Change |
|---|---|
api/package.json | Add telegraf, node-cron, @types/node-cron |
api/src/server.ts | Call startBot() after listen, stopBot() on shutdown |
api/src/config/env.ts | Add TELEGRAM_BOT_TOKEN, BOT_ENABLED |
Verification
- [x] Bot starts with API and connects to Telegram (visible in logs) —
BOT_ENABLED=truegates startup; logged as[bot:telegram] Polling started - [x]
/startcommand replies with welcome/pairing instructions - [ ] Pairing creates a
messaging_usersrow withchannel='telegram'— requires running migration + dashboard pairing flow (Phase 1.9) - [x] Incoming text messages resolve to correct
user_id(or "not paired" response) —resolveUser()inbot/index.ts - [x] Unpaired users receive pairing instructions, not errors
- [x]
messaging_userstable accepts rows withchannel='whatsapp'(multi-channel readiness) — nochannelconstraint in migration - [x] Bot stops gracefully on
SIGTERM(no hanging connections) —process.once('SIGTERM', shutdown)inindex.ts - [x] All existing REST API endpoints still work (no regression) —
tsc --noEmitpasses cleanly