@rizzclub/channels
Multi-channel messaging library with a grammy-compatible API for Telegram, Webchat, WhatsApp, and SMS.
Features
- 🎯 Grammy-compatible API - Drop-in replacement for grammy with the same interface
- 🔌 Multiple Channels - Telegram, Webchat, WhatsApp (coming soon), SMS (coming soon)
- 🚀 Cloudflare Workers - Optimized for serverless deployment
- 📦 TypeScript - Full type safety and autocompletion
- 🎨 Unified Interface - Write once, deploy to multiple channels
Code Style: camelCase Standard
IMPORTANT: This library uses camelCase for all property names and object keys:
- ✅
callbackQuery,messageId,firstName,inlineKeyboard - ❌
callback_query,message_id,first_name,inline_keyboard
This applies to:
- All TypeScript interfaces (
Update,Message,CallbackQuery, etc.) - All internal data structures
- Bot API methods and context properties
Exception - Grammy Filter Strings: Event filter strings remain snake_case for Grammy compatibility:
- ✅
bot.on('callback_query:data', handler)- Filter string (snake_case) - ✅
ctx.callbackQuery.data- Data access (camelCase) - This maintains Grammy API compatibility while using TypeScript conventions internally
Adapters handle conversion: Each adapter (TelegramAdapter, WhatsAppAdapter, etc.) converts from the external API's format (usually snake_case) to our camelCase standard at the boundary.
Rationale: This library is platform-agnostic and follows TypeScript/JavaScript naming conventions, not Telegram-specific conventions. Filter strings are kept as Grammy DSL for API compatibility.
Installation
npm install @rizzclub/channelsQuick Start
Telegram Bot (Recommended Pattern)
⚠️ IMPORTANT: Always use createBotHandler to prevent handler accumulation. This ensures handlers are registered only once, not on every request.
import { createBotHandler } from '@rizzclub/channels';
interface Env {
BOT_TOKEN: string;
}
export default createBotHandler<Env>((bot, env) => {
// Handlers are registered ONCE on first request
bot.command('start', (ctx) => {
return ctx.reply('Hello! I am your bot.');
});
bot.on('message:text', (ctx) => {
return ctx.reply(`You said: ${ctx.text}`);
});
}, {
getToken: (env) => env.BOT_TOKEN
});typescript
import { Bot, TelegramAdapter } from '@rizzclub/channels';
const adapter = new TelegramAdapter({
token: process.env.TELEGRAM_TOKEN
});
const bot = new Bot(adapter);
bot.command('start', (ctx) => {
return ctx.reply('Hello! I am your bot.');
});
bot.on('message:text', (ctx) => {
return ctx.reply(`You said: ${ctx.text}`);
});
// Cloudflare Worker
export default {
async fetch(request: Request, env: Env) {
return bot.handleWebhook(request);
}
};
Webchat Bot
import { createBotHandler, WebchatAdapter } from '@rizzclub/channels';
interface Env {
WEBHOOK_SECRET: string;
CALLBACK_URL: string;
}
export default createBotHandler<Env>((bot, env) => {
bot.on('message:text', async (ctx) => {
await ctx.reply(`Echo: ${ctx.text}`);
});
bot.callbackQuery(/^button:/, async (ctx) => {
await ctx.answerAlert('Button clicked!');
});
}, {
createAdapter: (env) => new WebchatAdapter({
webhookSecret: env.WEBHOOK_SECRET,
callbackUrl: env.CALLBACK_URL
})
});Cloudflare Workers Best Practices
Using createBotHandler (Recommended)
Why it's important: In Cloudflare Workers, the fetch() function is called on every request. If you create a new Bot instance and register handlers inside fetch(), you'll re-register all handlers on every request, causing:
- 📈 Handler accumulation - Middleware array grows unbounded (300 handlers × N requests)
- 🐌 Performance degradation - Each request gets slower as middleware grows
- 💸 Increased costs - More CPU time per request
Solution: Use createBotHandler to register handlers exactly once:
import { createBotHandler } from '@rizzclub/channels';
interface Env {
BOT_TOKEN: string;
}
// ✅ CORRECT - Handlers registered ONCE on first request
export default createBotHandler<Env>((bot, env) => {
bot.command('start', (ctx) => ctx.reply('Hello!'));
bot.on('message:text', (ctx) => ctx.reply(`Echo: ${ctx.text}`));
// ... all your handlers
}, {
getToken: (env) => env.BOT_TOKEN
});Compare with the problematic pattern:
// ❌ WRONG - Handlers re-registered on EVERY request
export default {
async fetch(request: Request, env: Env) {
const bot = new Bot(new TelegramAdapter({ token: env.BOT_TOKEN }));
// These handlers are added to middleware array on EVERY request
bot.command('start', (ctx) => ctx.reply('Hello!'));
bot.on('message:text', (ctx) => ctx.reply(`Echo: ${ctx.text}`));
return bot.handleWebhook(request);
}
}How it works:
- On the first request,
createBotHandlerinitializes the bot and registers all handlers - On subsequent requests, the same bot instance is reused (handlers already registered)
- Bot instance persists across requests in the same Worker instance
Options:
getToken: (env) => string- Extract bot token from env (for TelegramAdapter)createAdapter: (env) => ChannelAdapter- Create custom adapter (for other adapters)
API Reference
createBotHandler
function createBotHandler<Env = any>(
registerHandlers: (bot: Bot, env: Env) => void | Promise<void>,
options: {
getToken?: (env: Env) => string;
createAdapter?: (env: Env) => ChannelAdapter;
}
): { fetch: (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response> }Creates a Cloudflare Workers handler with lazy bot initialization. See Cloudflare Workers Best Practices for details.
Bot
The Bot class is the main entry point, providing a grammy-compatible API.
Methods
bot.command(command, handler)- Handle commands (e.g.,/start)bot.on(event, handler)- Handle events (e.g.,'message:text')bot.hears(trigger, handler)- Handle messages matching text/regexbot.callbackQuery(data, handler)- Handle inline button callbacksbot.use(middleware)- Add middlewarebot.filter(filter, handler)- Handle messages matching custom filterbot.handleWebhook(request)- Process incoming webhook requests
Events
'message'- Any message'message:text'- Text messages only'callback_query'- Inline button clicks'edited_message'- Message edits
Context
The Context object provides convenient access to update data and reply methods.
Properties
ctx.message- The message objectctx.chat- The chat objectctx.from- The user objectctx.text- Message textctx.callbackQuery- Callback query objectctx.callbackData- Callback query datactx.channel- Current channel type
Methods
ctx.reply(text, options?)- Reply to the messagectx.send(text, options?)- Send without replyingctx.editMessageText(text, options?)- Edit the messagectx.deleteMessage()- Delete the messagectx.answerCallbackQuery(options?)- Answer callback queryctx.answerAlert(text)- Answer with alert popupctx.hasCommand(command?)- Check if message is a command
Adapters
TelegramAdapter
Wraps grammy for Telegram integration.
import { TelegramAdapter } from '@rizzclub/channels';
const adapter = new TelegramAdapter({
token: 'YOUR_BOT_TOKEN'
});Options:
token- Telegram bot token from @BotFatherbot?- Optional grammy Bot instance for advanced usage
WebchatAdapter
Custom adapter for web-based chat interfaces.
import { WebchatAdapter, InMemoryMessageStore } from '@rizzclub/channels';
const adapter = new WebchatAdapter({
webhookSecret: 'your-secret',
callbackUrl: 'https://your-app.com/api/send',
callbackHeaders: {
'Authorization': 'Bearer token'
},
messageStore: new InMemoryMessageStore()
});Options:
webhookSecret?- Secret for validating webhook requestscallbackUrl?- URL to POST messages tocallbackHeaders?- Headers for callback requestsmessageStore?- Store for tracking messages
Webhook Payload Format:
{
type: 'message' | 'callback_query',
sessionId: string,
userId: string,
userName?: string,
timestamp: number,
message?: {
id: string,
text?: string
},
callbackQuery?: {
id: string,
data: string,
messageId: string
}
}WhatsAppAdapter (Coming Soon)
Placeholder for WhatsApp Business API integration.
SMSAdapter (Coming Soon)
Placeholder for SMS integration via Twilio/Vonage.
Multi-Channel Support
Option 1: Using Router (Recommended)
Handle multiple channels with shared bot logic using the built-in Router:
import {
createRouter,
TelegramAdapter,
WebchatAdapter
} from '@rizzclub/channels';
// Shared bot setup
function setupBot(bot) {
bot.command('start', (ctx) => {
return ctx.reply(`Welcome to ${ctx.channel}!`);
});
bot.on('message:text', (ctx) => {
return ctx.reply(`[${ctx.channel}] You said: ${ctx.text}`);
});
}
// Cloudflare Worker
export default {
async fetch(request: Request, env: Env) {
const router = createRouter()
.route('/telegram/webhook', new TelegramAdapter({ token: env.TELEGRAM_TOKEN }), setupBot)
.route('/webchat/webhook', new WebchatAdapter({ webhookSecret: env.WEBHOOK_SECRET }), setupBot);
return router.handleRequest(request);
}
};Option 2: Manual Routing
import {
Bot,
TelegramAdapter,
WebchatAdapter,
type ChannelAdapter
} from '@rizzclub/channels';
// Shared bot logic
function createBot(adapter: ChannelAdapter) {
const bot = new Bot(adapter);
bot.command('start', (ctx) => {
return ctx.reply(`Welcome to ${ctx.channel}!`);
});
bot.on('message:text', (ctx) => {
return ctx.reply(`[${ctx.channel}] You said: ${ctx.text}`);
});
return bot;
}
// Cloudflare Worker
export default {
async fetch(request: Request, env: Env) {
const url = new URL(request.url);
// Telegram webhook
if (url.pathname === '/telegram') {
const bot = createBot(
new TelegramAdapter({ token: env.TELEGRAM_TOKEN })
);
return bot.handleWebhook(request);
}
// Webchat webhook
if (url.pathname === '/webchat') {
const bot = createBot(
new WebchatAdapter({ webhookSecret: env.WEBHOOK_SECRET })
);
return bot.handleWebhook(request);
}
return new Response('Not Found', { status: 404 });
}
};Inline Keyboards
Create interactive inline keyboards:
bot.command('menu', (ctx) => {
return ctx.reply('Choose an option:', {
replyMarkup: {
inlineKeyboard: [
[
{ text: '✅ Option 1', callbackData: 'opt1' },
{ text: '❌ Option 2', callbackData: 'opt2' }
],
[
{ text: '🔗 Visit Website', url: 'https://rizz.club' }
]
]
}
});
});
bot.callbackQuery('opt1', async (ctx) => {
await ctx.answerAlert('You chose Option 1!');
await ctx.editMessageText('Option 1 selected ✅');
});Reply Keyboards
Create reply keyboards (Telegram):
bot.command('keyboard', (ctx) => {
return ctx.reply('Choose a category:', {
replyMarkup: {
keyboard: [
[{ text: '📱 Tech' }, { text: '🎮 Gaming' }],
[{ text: '🎨 Art' }, { text: '🎵 Music' }]
],
resizeKeyboard: true,
oneTimeKeyboard: true
}
});
});Middleware
Add custom middleware for logging, authentication, etc:
// Logging middleware
bot.use(async (ctx, next) => {
console.log(`Incoming from ${ctx.channel}: ${ctx.text}`);
await next();
});
// Auth middleware
bot.use(async (ctx, next) => {
const userId = ctx.from?.id;
if (!userId) return;
const isAuthorized = await checkAuth(userId);
if (!isAuthorized) {
return ctx.reply('Unauthorized');
}
await next();
});TypeScript
Full TypeScript support with type inference:
import { Bot, Context, TelegramAdapter } from '@rizzclub/channels';
const adapter = new TelegramAdapter({ token: 'token' });
const bot = new Bot(adapter);
bot.on('message:text', (ctx: Context) => {
// ctx.text is automatically typed as string | undefined
if (ctx.text) {
console.log(ctx.text.toUpperCase());
}
});Comparison with Grammy
@rizzclub/channels provides the same API as grammy while supporting multiple channels:
| Feature | Grammy | @rizzclub/channels |
|---|---|---|
| Telegram | ✅ | ✅ |
| Webchat | ❌ | ✅ |
| ❌ | 🚧 Coming soon | |
| SMS | ❌ | 🚧 Coming soon |
| API compatibility | - | 100% |
| TypeScript | ✅ | ✅ |
| Cloudflare Workers | ✅ | ✅ |
License
MIT
Contributing
Contributions welcome! Please open an issue or PR.