Ai-Mee Help Centre
Home
Features
How-To Guides
FAQ
Need Help?
Home
Features
How-To Guides
FAQ
Need Help?

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:

  • externalId is deliberately generic — Telegram chat_id, WhatsApp phone number, etc.
  • metadata carries channel-specific data without polluting the core interface
  • buttons are 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() — create Telegraf instance with TELEGRAM_BOT_TOKEN, register /start and /help commands, call bot.launch() (long-polling) or bot.createWebhook() (webhook mode)
  • stop() — call bot.stop()
  • sendMessage() — call bot.telegram.sendMessage(externalId, text, { parse_mode }). Map format to Telegram parse modes. Render buttons as InlineKeyboardMarkup
  • onMessage() — store the handler; in start(), register bot.on('text', ...) that transforms Telegraf's Context into IncomingMessage and 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 to messaging_users instead of goclaw_tenant)
  • Or create a new generic POST /messaging/pair endpoint 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, inserts messaging_users row

1.10 — Update env config

File: api/src/config/env.ts (modify)

  • Promote TELEGRAM_BOT_TOKEN to a first-class env var (it already exists as a fallback from GOCLAW_TELEGRAM_TOKEN)
  • Add BOT_ENABLED optional boolean to allow disabling the bot in dev/test environments

Files Created

FilePurpose
api/src/bot/types.tsChannelAdapter, IncomingMessage, OutgoingMessage
api/src/bot/channels/telegram.tsTelegraf adapter
api/src/bot/index.tsAdapter registry, boot, message routing, user resolution
front-end/supabase/migrations/…_messaging_users.sqlUser-channel pairing table
front-end/supabase/migrations/…_chat_history.sqlConversation persistence

Files Modified

FileChange
api/package.jsonAdd telegraf, node-cron, @types/node-cron
api/src/server.tsCall startBot() after listen, stopBot() on shutdown
api/src/config/env.tsAdd TELEGRAM_BOT_TOKEN, BOT_ENABLED

Verification

  • [x] Bot starts with API and connects to Telegram (visible in logs) — BOT_ENABLED=true gates startup; logged as [bot:telegram] Polling started
  • [x] /start command replies with welcome/pairing instructions
  • [ ] Pairing creates a messaging_users row with channel='telegram' — requires running migration + dashboard pairing flow (Phase 1.9)
  • [x] Incoming text messages resolve to correct user_id (or "not paired" response) — resolveUser() in bot/index.ts
  • [x] Unpaired users receive pairing instructions, not errors
  • [x] messaging_users table accepts rows with channel='whatsapp' (multi-channel readiness) — no channel constraint in migration
  • [x] Bot stops gracefully on SIGTERM (no hanging connections) — process.once('SIGTERM', shutdown) in index.ts
  • [x] All existing REST API endpoints still work (no regression) — tsc --noEmit passes cleanly