Skip to content
WaifuStack
Go back

Building a Telegram Roleplay Bot from Scratch: The Complete Stack

Telegram is the best platform for AI roleplay bots. Bold claim? Here’s why:

This guide walks through building a roleplay bot on Telegram from zero to functional, based on how Suzune is built.

Table of contents

Open Table of contents

The Tech Stack

ComponentChoiceWhy
LanguagePython 3.11+Best LLM library ecosystem, async support
Telegram Libraryaiogram v3Modern async design, type hints, middleware support
LLM ClientOpenAI SDK (for OpenRouter)Works with any OpenAI-compatible API
DatabaseSQLite (WAL mode)Zero config, surprisingly fast, perfect for single-server bots
LLM ProviderOpenRouterOne API key for all models (DeepSeek, Claude, Gemini, etc.)

Why Not Discord?

Discord works too, but Telegram has advantages for RP bots:

TelegramDiscord
Message length4,096 chars2,000 chars
Content policyPermissiveStricter (NSFW channels exist but policy changes)
Bot costFreeFree
Image sendingEasyEasy
Bot per characterOne bot token per characterOne bot, multiple personalities

Telegram’s “one bot per character” model is surprisingly useful — each character gets its own chat window, profile picture, and name. It feels like texting a real person.


Step 1: Create the Bot

Talk to @BotFather on Telegram:

/newbot
→ Name: Sakura
→ Username: sakura_rp_bot
→ Receive bot token: 123456:ABC-DEF...

Save the token. You’ll need it in your config.

Bot Settings (via BotFather)

/setdescription → "AI roleplay character. Send any message to start."
/setuserpic → Upload a character portrait
/setcommands → leave empty (commands are optional for RP bots)

Step 2: The Minimal Bot

Install dependencies:

pip install aiogram openai pyyaml

The simplest working RP bot:

# bot.py
import asyncio
from aiogram import Bot, Dispatcher, types
from openai import AsyncOpenAI

BOT_TOKEN = "your-telegram-bot-token"
OPENROUTER_KEY = "your-openrouter-key"

SYSTEM_PROMPT = """You are Sakura, a 24-year-old freelance writer.
You're sharp-tongued and sarcastic, but caring underneath.
Stay in character at all times. Use *asterisks* for actions.
Never acknowledge being an AI."""

bot = Bot(token=BOT_TOKEN)
dp = Dispatcher()
llm = AsyncOpenAI(
    api_key=OPENROUTER_KEY,
    base_url="https://openrouter.ai/api/v1"
)

# Simple in-memory history
history: dict[int, list] = {}

@dp.message()
async def handle_message(message: types.Message):
    user_id = message.from_user.id
    
    if user_id not in history:
        history[user_id] = []
    
    # Add user message
    history[user_id].append({
        "role": "user",
        "content": message.text
    })
    
    # Keep last 20 messages
    history[user_id] = history[user_id][-20:]
    
    # Show typing indicator
    await bot.send_chat_action(message.chat.id, "typing")
    
    # Call LLM
    response = await llm.chat.completions.create(
        model="deepseek/deepseek-v3.2",
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            *history[user_id]
        ],
        max_tokens=1024,
    )
    
    reply = response.choices[0].message.content
    
    # Save assistant response
    history[user_id].append({
        "role": "assistant",
        "content": reply
    })
    
    await message.answer(reply)

async def main():
    await dp.start_polling(bot)

if __name__ == "__main__":
    asyncio.run(main())

Run it:

python bot.py

That’s a working RP bot. ~50 lines. It handles messages, maintains conversation history, shows a typing indicator, and generates responses via DeepSeek through OpenRouter.

Total cost to run: the OpenRouter API calls (~$0.25/million input tokens for DeepSeek V3.2).


Step 3: Add Persistence (SQLite)

In-memory history disappears when the bot restarts. Let’s add SQLite:

import aiosqlite
from datetime import datetime

DB_PATH = "data/bot.db"

async def init_db():
    async with aiosqlite.connect(DB_PATH) as db:
        await db.execute("""
            CREATE TABLE IF NOT EXISTS messages (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                user_id INTEGER NOT NULL,
                role TEXT NOT NULL,
                content TEXT NOT NULL,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        """)
        await db.commit()

async def save_message(user_id: int, role: str, content: str):
    async with aiosqlite.connect(DB_PATH) as db:
        await db.execute(
            "INSERT INTO messages (user_id, role, content) VALUES (?, ?, ?)",
            (user_id, role, content)
        )
        await db.commit()

async def get_history(user_id: int, limit: int = 20):
    async with aiosqlite.connect(DB_PATH) as db:
        cursor = await db.execute(
            "SELECT role, content FROM messages WHERE user_id = ? ORDER BY id DESC LIMIT ?",
            (user_id, limit)
        )
        rows = await cursor.fetchall()
        return [{"role": r, "content": c} for r, c in reversed(rows)]

Why SQLite?

For a single-server bot, SQLite is perfect:

Suzune stores all conversation history in a single SQLite database. It handles thousands of messages across multiple characters without breaking a sweat.

# Enable WAL mode for better concurrency
async with aiosqlite.connect(DB_PATH) as db:
    await db.execute("PRAGMA journal_mode=WAL")

Step 4: Character Configuration (YAML)

Move the character definition out of code and into YAML:

# characters/sakura.yaml
name: sakura
display_name: Sakura
telegram:
  bot_token: ${SAKURA_BOT_TOKEN}

system_prompt: |
  You are Sakura, a 24-year-old freelance writer.
  You're sharp-tongued and sarcastic, but caring underneath.
  
  ## Speech Rules
  - Use short, punchy sentences
  - Express actions with *asterisks*
  - Never acknowledge being an AI
  - Drop casual slang when comfortable
  
  ## Example Dialogue
  User: How was your day?
  Sakura: *sighs and stretches* Don't ask. My editor called
  three times about the deadline. *pauses* ...The café near
  the station had good matcha though. So not all bad.

Load it:

import yaml

def load_character(path: str) -> dict:
    with open(path) as f:
        return yaml.safe_load(f)

character = load_character("characters/sakura.yaml")

Now you can add characters by adding YAML files — no code changes needed. See our detailed guide: How to Design AI Personalities with YAML.


Step 5: Typing Indicator and Message Splitting

Two small details that make a big difference:

Continuous Typing Indicator

Telegram’s “typing…” indicator expires after 5 seconds. For LLM calls that take 10-30 seconds, you need to keep refreshing it:

async def send_with_typing(chat_id: int, generate_fn):
    """Send typing indicator while waiting for LLM response."""
    typing_task = asyncio.create_task(
        keep_typing(chat_id)
    )
    try:
        response = await generate_fn()
        return response
    finally:
        typing_task.cancel()

async def keep_typing(chat_id: int):
    while True:
        await bot.send_chat_action(chat_id, "typing")
        await asyncio.sleep(4)

Message Splitting

Telegram has a 4,096 character limit. Long RP responses need splitting:

def split_message(text: str, max_length: int = 4096) -> list[str]:
    if len(text) <= max_length:
        return [text]
    
    chunks = []
    while text:
        if len(text) <= max_length:
            chunks.append(text)
            break
        
        # Split at last paragraph or sentence break
        split_point = text.rfind("\n\n", 0, max_length)
        if split_point == -1:
            split_point = text.rfind(". ", 0, max_length)
        if split_point == -1:
            split_point = max_length
        
        chunks.append(text[:split_point])
        text = text[split_point:].lstrip()
    
    return chunks

Step 6: Where to Go From Here

The bot we’ve built so far handles the basics: message receiving, LLM calls, history, persistence, and character config. Here’s what to add next, in priority order:

FeatureImpactComplexityGuide
Dynamic system promptsHighMediumPrompt Engineering
Multi-model routingHighMediumDeepSeek vs Claude vs Gemini
Image generationHighHighDynamic Character Visuals
Affection systemHighMediumComing soon
LorebooksMediumMediumComing soon
Context compressionMediumHighComing soon

Each of these is covered (or will be covered) in dedicated articles on WaifuStack.


Deployment

Where to Host

For a Telegram bot, you need a server that runs 24/7. Options:

ProviderMonthly CostBest For
Oracle Cloud Free Tier$0Testing (limited but free forever)
Hetzner VPS~€4/moBudget production
DigitalOcean Droplet$6/moEasy setup
Contabo VPS~€5/moBest value for specs

The bot itself uses minimal resources — a $5/month VPS can handle it. Your costs are dominated by LLM API calls, not compute.

Running as a Service

Use systemd to keep the bot running:

# /etc/systemd/system/rp-bot.service
[Unit]
Description=AI Roleplay Bot
After=network.target

[Service]
User=bot
WorkingDirectory=/opt/rp-bot
ExecStart=/opt/rp-bot/.venv/bin/python main.py
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
sudo systemctl enable rp-bot
sudo systemctl start rp-bot

Cost Breakdown

Running a basic RP bot:

ItemMonthly Cost
VPS hosting$5–10
DeepSeek V3.2 API$5–25 (depends on usage)
Domain (optional)~$1
Total$10–35/month

That’s a fully functional AI roleplay bot with persistent memory, character configuration, and uncensored content — for less than a Netflix subscription.


Not Ready to Build?

If coding isn’t your thing but you want the AI roleplay experience:

See our full comparison: Best NSFW AI Chatbot Platforms 2026


This guide covers the foundation. Each feature we add to Suzune gets its own deep-dive article on WaifuStack. Follow us on X to catch them as they publish.


Share this post on:

Previous Post
How to Design AI Personalities with YAML: A Character System That Scales
Next Post
Building an Affection System for AI Characters: The Feature Users Love Most