Write once. Deploy everywhere.
A unified multi-platform, multi-instance chatbot framework for Discord, Telegram, Facebook Page, and Facebook Messenger β managed from a single dashboard.
Most chatbot projects are locked into one platform and one running instance. Deploying on Discord and Telegram means writing two separate codebases. Each SDK has its own event model, attachment format, button system, and conversation-state pattern β quadruple the surface area, quadruple the maintenance.
Cat-Bot solves both problems simultaneously:
- Multi-platform β one command module runs natively on Discord, Telegram, Facebook Page, and Facebook Messenger. No
if platform === 'discord'branches in your handler code. - Multi-instance β any number of independent bot sessions run concurrently, each with its own credentials, prefix, command roster, and admin list, all controlled from a single web dashboard.
The platform transport layer absorbs every SDK difference (discord.js gateway, Telegraf polling, fca-unofficial MQTT, Graph API webhooks). Your command code calls await chat.replyMessage({ message: 'Hello!' }) and it works everywhere.
- Quick Start β 5 Minutes
- What Cat-Bot Provides
- Philosophy
- Platform API Comparison: Native vs Unified
- Demo
- Screenshots
- Features
- Architecture
- Production Setup
- Cloud Deployment
- Writing Commands
- Converting Existing Commands
- Writing Event Handlers
- Constants & Type Safety
- Developer Reference
- Database Adapters
- Environment Variables
- npm Scripts
- Authors
The json adapter stores everything in a single flat file with no external database. It is the fastest path from clone to running bot.
Prerequisites: Node.js 20+, npm 10+
git clone https://github.com/johnlester-0369/Cat-Bot.git
cd Cat-Bot
npm installcd packages/cat-bot
cp .env.example .envBETTER_AUTH_SECRET and ENCRYPTION_KEY require a cryptographically-secure random value β never use a simple password or a hardcoded string. If you don't have OpenSSL, pick one of the options below.
Option A β Install OpenSSL locally (recommended)
Running openssl rand on your own machine means the generated secret never touches any external server.
| OS | How to install |
|---|---|
| Windows | Via Git for Windows (easiest β OpenSSL is bundled), or with a package manager: choco install openssl (Chocolatey) / scoop install openssl (Scoop) |
| macOS | LibreSSL is pre-installed and compatible for key generation. For full OpenSSL: brew install openssl |
| Linux (Debian / Ubuntu) | sudo apt install openssl |
| Linux (Fedora / RHEL) | sudo dnf install openssl |
Verify your installation:
openssl versionOption B β Generate online (no install required)
Both tools use the Web Crypto API β the same CSPRNG source as OpenSSL β and generate entirely in your browser. Nothing is transmitted to any server.
| Tool | URL | Notes |
|---|---|---|
| HexHero Random Key Generator β | hexhero.com/tools/random-key-generator | Explicitly matches openssl rand output; selectable Base64 / hex / 128β512 bit |
| RandomKeygen | randomkeygen.com | Quick all-purpose generator; widely used |
Security reminder: Never commit generated secrets to version control. Always load them exclusively from your
.envfile.
Minimum required fields for local development:
PORT=3000
DATABASE_TYPE=json
# Generate with: openssl rand -base64 32
BETTER_AUTH_SECRET=your_secret_here
BETTER_AUTH_URL=http://localhost:3000
VITE_URL=http://localhost:5173
# Generate with: openssl rand -hex 32
ENCRYPTION_KEY=your_64_hex_char_key_herenpm run seed:adminThis account works for both the user portal (/login) and the admin portal (/admin).
npm run dev:all- Dashboard: http://localhost:5173
- API: http://localhost:3000
- Open http://localhost:5173 and sign in.
- Click Create New Bot.
- Select a platform and paste your credentials (Discord bot token, Telegram token, etc.).
- Click Verify β Cat-Bot validates credentials against the live platform API before saving.
- Click Create. The bot starts automatically.
Hot reload: Command files in
packages/cat-bot/src/app/commands/are watched bytsx watch. Save a file and the changes are live.
The core insight is that the bot problem and the platform problem are separate concerns. Cat-Bot handles the platform problem so your code only addresses the bot problem.
Every platform SDK solves the same tasks differently. Here is what sending a single message looks like natively, and what it looks like in Cat-Bot:
| Native (four different SDKs) | Cat-Bot (one call) |
|---|---|
// discord.js β slash command
await interaction.deferReply();
await interaction.editReply("Hello!");
// Telegraf
await ctx.reply("Hello!");
// fca-unofficial
api.sendMessage({ body: "Hello!" }, threadID, cb);
// Facebook Page β raw HTTP
await axios.post(graphUrl, {
recipient: { id: psid },
message: { text: "Hello!" },
}); |
await chat.replyMessage({
style: MessageStyle.MARKDOWN,
message: "**Hello!**",
}); |
The same unification applies to file attachments, interactive buttons, conversation flows, and group management β all documented in the Developer Reference.
The standard global-array pattern creates race conditions the moment two users run the same command simultaneously:
// β Old pattern β shared mutable global, concurrent users corrupt each other's state
global.client.handleReply.push({
name: "quiz",
messageID: info.messageID,
author: event.senderID,
answer: "True",
});Cat-Bot scopes every pending state to a composite key (messageId:userId for private flows, messageId:threadId for public flows):
// β
Cat-Bot β isolated per message and per user, zero global mutations
state.create({
id: state.generateID({ id: String(messageID) }),
state: "awaiting_answer",
context: { answer: "True" },
});Two users running the same flow simultaneously each have a completely independent state entry.
Cat-Bot is built on one foundational idea: every handler owns exactly one responsibility.
This shapes the entire API β from how conversation states are defined to how button actions are declared.
In classic bot frameworks, all conversation flows are routed through a single monolithic dispatcher. Every step in a conversation adds another case to the same switch:
// GoatBot / Mirai pattern β the entire state machine lives in one function
module.exports.handleReply = async ({ event, handleReply, api }) => {
switch (handleReply.type) {
case "userCallAdmin": {
/* forward message to admin */ break;
}
case "adminReply": {
/* forward reply to user */ break;
}
case "awaiting_name": {
/* step 1 of registration */ break;
}
case "awaiting_age": {
/* step 2 of registration */ break;
}
}
};The function has no bounded scope β it owns the entire conversation state machine. Adding a new step means opening this function and modifying it. Changing one case risks regressions in all the others.
Cat-Bot inverts this. Each state gets its own named function:
// Cat-Bot β each step is a self-contained function with one job
export const onReply = {
awaiting_name: async ({ chat, event, state, session }: AppCtx) => {
// Only responsibility: receive the name, ask for age, register the next state
},
awaiting_age: async ({ chat, event, state, session }: AppCtx) => {
// Only responsibility: receive the age, complete the registration flow
},
};Adding a new step is a new key. Modifying step 2 cannot break step 1. The same principle applies to onReact β each emoji maps to its own independent function, never sharing a dispatcher.
discord.js v14 β Buttons require ActionRowBuilder and ButtonBuilder. The click handler is a global interactionCreate listener registered separately on the Client β the code that sends buttons and the code that handles clicks are structurally disconnected, linked only by a raw string ID embedded manually in customId. Every interaction must be acknowledged within 3 seconds or Discord shows "interaction failed":
// discord.js v14 β send and handle are two separate registration sites
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('confirm:12345') // embed userId manually for ownership check
.setLabel('β
Confirm')
.setStyle(ButtonStyle.Success),
new ButtonBuilder()
.setCustomId('cancel:12345')
.setLabel('β Cancel')
.setStyle(ButtonStyle.Danger)
)
await message.channel.send({ content: 'Are you sure?', components: [row] })
// Registered globally on the Client β completely separate from the send site
client.on('interactionCreate', async interaction => {
if (!interaction.isButton()) return
await interaction.deferUpdate() // MUST call within 3 seconds
const [action, userId] = interaction.customId.split(':')
if (interaction.user.id !== userId) return // manual ownership check
if (action === 'confirm') await interaction.editReply({ content: 'β
Confirmed!', components: [] })
})Telegraf v4 β Inline keyboard buttons carry callback_data (max 64 bytes). Clicks arrive via bot.on('callback_query') registered separately from the command handler. ctx.answerCbQuery() must be called to dismiss the loading spinner:
// Telegraf v4 β inline keyboard (send site and click handler are separate)
await ctx.reply('Are you sure?', {
reply_markup: {
inline_keyboard: [[
{ text: 'β
Confirm', callback_data: `confirm:${ctx.from.id}` },
{ text: 'β Cancel', callback_data: `cancel:${ctx.from.id}` }
]]
}
})
bot.on('callback_query', async ctx => {
await ctx.answerCbQuery() // dismiss the loading spinner β must call explicitly
const [action, userId] = ctx.callbackQuery.data.split(':')
if (ctx.from.id.toString() !== userId) return
if (action === 'confirm') await ctx.editMessageText('β
Confirmed!')
})fca-unofficial β No native button component exists on Messenger's MQTT protocol. Buttons must be emulated with numbered text menus. State is stored in a global mutable array shared across all concurrent commands, creating race conditions when two users invoke the same command simultaneously:
// fca-unofficial β text menu + global handleReply array (no native buttons)
api.sendMessage(
'Are you sure?\n1. β
Confirm\n2. β Cancel',
threadID,
(err, info) => {
if (err) return
global.client.handleReply.push({
name: 'myCommand', messageID: info.messageID,
author: event.senderID, type: 'awaiting_confirm',
})
}
)
module.exports.handleReply = async ({ event, handleReply, api }) => {
if (handleReply.author !== event.senderID) return // manual ownership check
const idx = global.client.handleReply.findIndex(r => r.messageID === handleReply.messageID)
global.client.handleReply.splice(idx, 1) // manual cleanup β races with concurrent pushes
const choice = event.body.trim()
if (choice === '1') api.sendMessage('β
Confirmed!', event.threadID, () => {})
else api.sendMessage('β Cancelled.', event.threadID, () => {})
}Facebook Page Graph API β The Button Template is the only interactive construct, limited to 3 buttons with titles capped at 20 characters. Clicks arrive as postback webhook events in a completely separate Express handler β there is no concept of collocating the send and the click handler:
// Facebook Page β Button Template (max 3 buttons) + postback handler (separate file)
await axios.post(`${GRAPH}?access_token=${TOKEN}`, {
recipient: { id: psid },
message: {
attachment: {
type: 'template',
payload: {
template_type: 'button', text: 'Are you sure?',
buttons: [
{ type: 'postback', title: 'β
Confirm', payload: `CONFIRM_${psid}` },
{ type: 'postback', title: 'β Cancel', payload: `CANCEL_${psid}` },
]
}
}
}
})
app.post('/webhook', (req, res) => {
res.sendStatus(200)
req.body.entry.forEach(entry => entry.messaging.forEach(async event => {
if (!event.postback) return
const [action, userId] = event.postback.payload.split('_')
if (event.sender.id !== userId) return
if (action === 'CONFIRM') await axios.post(`${GRAPH}?access_token=${TOKEN}`,
{ recipient: { id: event.sender.id }, message: { text: 'β
Confirmed!' } })
}))
})Cat-Bot β all four platforms:
const BUTTON_ID = { confirm: 'confirm', cancel: 'cancel' }
export const button = {
[BUTTON_ID.confirm]: {
label: 'β
Confirm',
style: ButtonStyle.SUCCESS,
onClick: async ({ chat, event }: AppCtx) => {
await chat.editMessage({
message_id_to_edit: event['messageID'] as string,
message: 'β
Confirmed!',
button: [],
})
},
},
[BUTTON_ID.cancel]: {
label: 'β Cancel',
style: ButtonStyle.DANGER,
onClick: async ({ chat, event }: AppCtx) => {
await chat.editMessage({
message_id_to_edit: event['messageID'] as string,
message: 'β Cancelled.',
button: [],
})
},
},
}
export const onCommand = async ({ chat, button: btn }: AppCtx) => {
const confirmId = btn.generateID({ id: BUTTON_ID.confirm })
const cancelId = btn.generateID({ id: BUTTON_ID.cancel })
await chat.replyMessage({
style: MessageStyle.MARKDOWN,
message: '**Are you sure?**',
button: [confirmId, cancelId],
})
}Every button is a self-contained object β its label, style, and onClick live in the same file as onCommand. On Discord: an ActionRowBuilder with deferUpdate() called by the adapter before onClick fires. On Telegram: an inline_keyboard with answerCbQuery() handled transparently. On Messenger: a numbered text menu routed to the matching onClick. On Facebook Page: a Button Template. Your command code is identical for all four outcomes.
When every handler has a single, bounded responsibility:
- Reading is linear. Follow one function to understand one outcome β no
switchto navigate. - Changes are local. Modifying
awaiting_agecannot introduce a regression inawaiting_name. - New features are additive. A new reply step or a new button is a new key; existing logic is untouched.
- Bugs are isolated. A failure in one
onClickdoes not affect other buttons in the same command.
This is the Single Responsibility Principle applied consistently at every level of the bot API: each state has one function, each button has one object, each function has one job.
Most chatbot frameworks wire validation logic directly inside each dispatcher or command handler. Permission checks, cooldown guards, and ban enforcement end up scattered across individual command files β or worse, embedded inline inside the routing function that also decides which handler to call. When something fails, you have to chase through multiple dispatchers to find out which guard ran, in what order, and why it blocked execution.
Cat-Bot's middleware pipeline is inspired by Express.js. Guards are registered once as discrete middleware functions and run in a declared, auditable order before any dispatcher or command handler executes.
// Cat-Bot middleware/index.ts β registered once at boot; applies to every command automatically
use.onCommand([
enforceNotBanned, // β first: banned actors never reach any further check
enforcePermission, // β second: unauthorized users are rejected before cooldown is consumed
enforceCooldown, // β third: rate-limited after auth; options parsing never runs on blocked commands
validateCommandOptions, // β fourth: parse and validate typed options only for commands that will actually run
]);Each function in the chain calls next() to continue or returns early to halt β exactly like Express middleware. The order is declared in one place and applies uniformly to every command:
// src/engine/middleware/on-command.middleware.ts
export const enforceNotBanned: MiddlewareFn<OnCommandCtx> = async (
ctx,
next,
) => {
const banned = await isUserBanned(
sessionUserId,
platform,
sessionId,
senderID,
);
if (banned) {
await ctx.chat.replyMessage({ message: "you are unable to use bot" });
return; // β omitting next() halts the chain; the command never executes
}
await next();
};Command modules never implement guards. They receive control only after the full pipeline has passed β ban cleared, permission granted, cooldown window open, options validated. Adding a new cross-cutting concern (audit logging, feature flags, IP filtering) means registering one middleware function, not editing every command file.
The execution contract is always visible at the registration site:
onCommand: enforceNotBanned β enforcePermission β enforceCooldown
β validateCommandOptions β [your middlewares] β onCommand handler
onChat: chatPassthrough β chatLogThread β [your middlewares] β onChat fan-out
onReply: replyStateValidation β [your middlewares] β onReply handler
onReact: reactStateValidation β [your middlewares] β onReact handler
onButtonClick: enforceButtonScope β [your middlewares] β button.onClick handler
When a command does not execute, the failure belongs to exactly one middleware. You do not grep through dispatcher files β you look at the registered chain and the function that did not call next(). The flow is linear, the order is explicit, and the extension point is always use.onCommand([yourMiddleware]) in a single file.
Every platform SDK solves the same problems differently, forcing bot authors to maintain four separate mental models simultaneously. discord.js, Telegraf, fca-unofficial, and the Facebook Graph API are each well-designed for their own domain. Cat-Bot does not replace them β it sits on top of all four, absorbing every per-platform difference so your feature code never needs to.
This section shows, side-by-side, how each native library approaches common bot tasks and how Cat-Bot's unified surface eliminates that per-platform boilerplate. The goal is to make the architectural choices concrete: not "why abstraction is good in theory," but "here is the code you no longer have to write."
discord.js v14 has two distinct code paths depending on whether the trigger is a slash command (interaction) or a text-prefix message. Slash interactions must be acknowledged within 3 seconds or Discord renders "interaction failed" for the user. The methods differ between the two paths:
// discord.js v14 β text-prefix message
await message.channel.send('Hello, world!')
// discord.js v14 β slash command (must deferReply within 3 seconds)
await interaction.deferReply()
// ... async work ...
await interaction.editReply('Hello, world!')
// Subsequent sends after the first reply use followUp
await interaction.followUp('Here is more information.')Telegraf v4 provides ctx.reply() as a shortcut for same-chat replies, but sending to a different destination (admin DM, relay channel) requires the explicit ctx.telegram.sendMessage(chatId, text) form. The two paths have different method names and argument shapes:
// Telegraf v4 β same-chat reply
await ctx.reply('Hello, world!')
// Telegraf v4 β send to a different chat (e.g. admin DM)
await ctx.telegram.sendMessage(adminChatId, 'You have a new message.')fca-unofficial is callback-based with positional arguments. The text field is body, not text or message. Every send is asynchronous through a Node.js-style (err, info) callback:
// fca-unofficial
api.sendMessage({ body: 'Hello, world!' }, threadID, (err, info) => {
if (err) return console.error(err)
const sentMessageID = info.messageID
})Facebook Page Graph API requires a raw HTTP POST with a structured recipient + message JSON body. There is no SDK; every operation is a manual HTTP call:
// Facebook Page β raw Graph API via axios
await axios.post(
`https://graph.facebook.com/v22.0/me/messages?access_token=${PAGE_TOKEN}`,
{ recipient: { id: psid }, message: { text: 'Hello, world!' } }
)Cat-Bot β all four platforms:
await chat.replyMessage({
style: MessageStyle.MARKDOWN,
message: '**Hello, world!**',
})One call. The adapter handles the deferral window on Discord, the ctx.reply routing on Telegram, the api.sendMessage callback on Messenger, and the Graph API POST on Facebook Page.
discord.js v14 uses AttachmentBuilder for binary data. The send call differs between slash interactions and text-prefix commands β two different method chains for the same outcome:
// discord.js v14
const { AttachmentBuilder } = require('discord.js')
const file = new AttachmentBuilder(imageBuffer, { name: 'photo.jpg' })
// Text-prefix path
await message.channel.send({ content: 'Here is your image:', files: [file] })
// Slash interaction path (after deferReply)
await interaction.editReply({ content: 'Here is your image:', files: [file] })Telegraf v4 uses separate methods per media type. replyWithPhoto, replyWithDocument, replyWithAudio β the caller must know the media type at the call site. URL, Buffer, and Readable stream each have slightly different argument shapes:
// Telegraf v4 β must select the correct method per media type
await ctx.replyWithPhoto({ source: imageBuffer }, { caption: 'Here is your image.' })
// Photo from URL β different argument shape from buffer
await ctx.replyWithPhoto('https://example.com/image.jpg')
// Generic file upload β different method name entirely
await ctx.replyWithDocument({ source: pdfBuffer, filename: 'report.pdf' })fca-unofficial requires a Readable stream with a .path property set. MIME type is inferred from the .path file extension β Buffers must be manually wrapped into a named PassThrough stream:
// fca-unofficial β Buffer must be wrapped; .path drives MIME detection
const { Readable } = require('stream')
const stream = Readable.from(imageBuffer)
stream.path = 'photo.jpg' // must be set β fca reads this for Content-Type
api.sendMessage(
{ body: 'Here is your image:', attachment: stream },
threadID,
callback
)Facebook Page Graph API has two entirely separate code paths: a FormData multipart upload for binary content, and a different JSON payload for URL-based assets:
// Facebook Page β URL reference (Graph API fetches server-side)
await axios.post(`${GRAPH}?access_token=${TOKEN}`, {
recipient: { id: psid },
message: { attachment: { type: 'image', payload: { url: imageUrl } } }
})
// Facebook Page β binary upload (multipart FormData β different code path entirely)
const form = new FormData()
form.append('recipient', JSON.stringify({ id: psid }))
form.append('message', JSON.stringify({ attachment: { type: 'image', payload: {} } }))
form.append('filedata', imageBuffer, { filename: 'photo.jpg', contentType: 'image/jpeg' })
await axios.post(`${GRAPH}?access_token=${TOKEN}`, form, { headers: form.getHeaders() })Cat-Bot β all four platforms:
// Buffer or Readable stream
await chat.replyMessage({
message: 'Here is your image:',
attachment: [{ name: 'photo.jpg', stream: imageBuffer }],
})
// URL-based (platform adapter handles server-side fetch vs local download)
await chat.replyMessage({
message: 'Here is your image:',
attachment_url: [{ name: 'photo.jpg', url: 'https://example.com/image.jpg' }],
})The platform wrapper selects AttachmentBuilder on Discord, chooses replyWithPhoto/replyWithDocument/replyWithAudio on Telegram by filename extension, wraps the buffer as a named stream on Messenger, and dispatches to multipart upload or URL-reference on Facebook Page. You never choose a method based on media type.
This is where per-platform divergence becomes most costly. Each platform has a completely different button model, different routing mechanism, and different acknowledgment requirements.
discord.js v14 β Buttons require ActionRowBuilder and ButtonBuilder. The click handler is a global interactionCreate listener registered separately on the Client, and must acknowledge within 3 seconds or Discord shows "interaction failed." Button IDs are plain global strings β nothing prevents an unrelated user from matching a customId:
// discord.js v14 β build and send buttons
const { ActionRowBuilder, ButtonBuilder, ButtonStyle } = require('discord.js')
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder()
.setCustomId('confirm:12345') // embed userId manually for ownership check
.setLabel('β
Confirm')
.setStyle(ButtonStyle.Success),
new ButtonBuilder()
.setCustomId('cancel:12345')
.setLabel('β Cancel')
.setStyle(ButtonStyle.Danger)
)
await message.channel.send({ content: 'Are you sure?', components: [row] })
// discord.js β handle click (registered separately on the Client globally)
client.on('interactionCreate', async interaction => {
if (!interaction.isButton()) return
await interaction.deferUpdate() // MUST call within 3 seconds
const [action, userId] = interaction.customId.split(':')
if (interaction.user.id !== userId) {
return interaction.followUp({ content: 'Not your button!', ephemeral: true })
}
if (action === 'confirm') {
await interaction.editReply({ content: 'β
Confirmed!', components: [] })
}
})Telegraf v4 β Inline keyboard buttons carry callback_data (max 64 bytes). Clicks arrive via bot.on('callback_query') registered separately from the command. ctx.answerCbQuery() must be called to dismiss the loading spinner:
// Telegraf v4 β send inline keyboard
await ctx.reply('Are you sure?', {
reply_markup: {
inline_keyboard: [[
{ text: 'β
Confirm', callback_data: `confirm:${ctx.from.id}` },
{ text: 'β Cancel', callback_data: `cancel:${ctx.from.id}` }
]]
}
})
// Telegraf β handle click (registered separately)
bot.on('callback_query', async ctx => {
await ctx.answerCbQuery() // dismiss the loading spinner
const [action, userId] = ctx.callbackQuery.data.split(':')
if (ctx.from.id.toString() !== userId) {
return ctx.answerCbQuery('Not your button!', { show_alert: true })
}
if (action === 'confirm') await ctx.editMessageText('β
Confirmed!')
})fca-unofficial β The Messenger MQTT protocol has no native button component. Buttons must be emulated with numbered text menus. Conversation state is stored in a global mutable array that all concurrent commands share:
// fca-unofficial β "buttons" via numbered text menu (no native buttons exist)
api.sendMessage(
'Are you sure?\n1. β
Confirm\n2. β Cancel',
threadID,
(err, info) => {
if (err) return
global.client.handleReply.push({
name: 'myCommand', messageID: info.messageID,
author: event.senderID, type: 'awaiting_confirm',
})
}
)
module.exports.handleReply = async ({ event, handleReply, api }) => {
if (handleReply.author !== event.senderID) return // manual ownership check
const idx = global.client.handleReply.findIndex(r => r.messageID === handleReply.messageID)
global.client.handleReply.splice(idx, 1) // manual cleanup β races with concurrent handlers
const choice = event.body.trim()
if (choice === '1') api.sendMessage('β
Confirmed!', event.threadID, () => {})
else api.sendMessage('β Cancelled.', event.threadID, () => {})
}Facebook Page Graph API β The Button Template is the only interactive construct, limited to 3 buttons per message with titles capped at 20 characters. Clicks arrive as postback webhook events routed by string matching:
// Facebook Page β Button Template (max 3 buttons, title β€20 chars)
await axios.post(`${GRAPH}?access_token=${TOKEN}`, {
recipient: { id: psid },
message: {
attachment: {
type: 'template',
payload: {
template_type: 'button',
text: 'Are you sure?',
buttons: [
{ type: 'postback', title: 'β
Confirm', payload: `CONFIRM_${psid}` },
{ type: 'postback', title: 'β Cancel', payload: `CANCEL_${psid}` },
]
}
}
}
})
// Facebook Page β postback handler (in the Express webhook, completely separate file)
app.post('/webhook', (req, res) => {
res.sendStatus(200) // must respond within 20 seconds
req.body.entry.forEach(entry =>
entry.messaging.forEach(async event => {
if (!event.postback) return
const [action, userId] = event.postback.payload.split('_')
if (event.sender.id !== userId) return // manual ownership check
if (action === 'CONFIRM') {
await axios.post(`${GRAPH}?access_token=${TOKEN}`, {
recipient: { id: event.sender.id },
message: { text: 'β
Confirmed!' }
})
}
})
)
})Cat-Bot β all four platforms:
const BUTTON_ID = {
confirm: 'confirm',
cancel: 'cancel'
}
// Button handlers and the command that sends them live in the same file
export const button = {
[BUTTON_ID.confirm]: {
label: 'β
Confirm',
style: ButtonStyle.SUCCESS,
// Called after the adapter has already handled the acknowledgment window
onClick: async ({ chat, event }: AppCtx) => {
await chat.editMessage({
message_id_to_edit: event['messageID'] as string,
style: MessageStyle.MARKDOWN,
message: 'β
**Confirmed!**',
button: [], // clear buttons after the action
})
},
},
[BUTTON_ID.cancel]: {
label: 'β Cancel',
style: ButtonStyle.DANGER,
onClick: async ({ chat, event }: AppCtx) => {
await chat.editMessage({
message_id_to_edit: event['messageID'] as string,
message: 'β Cancelled.',
button: [],
})
},
},
}
export const onCommand = async ({ chat, button: btn }: AppCtx) => {
// Private scope by default β only the invoking user can click
const confirmId = btn.generateID({ id: BUTTON_ID.confirm })
const cancelId = btn.generateID({ id: BUTTON_ID.cancel })
await chat.replyMessage({
style: MessageStyle.MARKDOWN,
message: '**Are you sure?**',
button: [confirmId, cancelId],
})
}On Discord: becomes an ActionRowBuilder with two ButtonBuilder entries; deferUpdate() is called by the adapter before onClick receives control. On Telegram: becomes an inline_keyboard; answerCbQuery() is handled before onClick fires. On Messenger: "1. β
Confirm\n2. β Cancel" is appended to the message body, and the numbered reply is transparently routed to the matching onClick handler. On Facebook Page: becomes a Button Template with two postback buttons. Your command code is identical for all four outcomes.
Chaining a multi-step conversation β ask a question, wait for the user to quote-reply to that specific message, ask another, complete β requires very different approaches per platform.
discord.js v14 β createMessageCollector() with a filter function and a timeout. Steps are nested callbacks, and timeout handling must be replicated at every step:
// discord.js β two-step conversation flow
await message.reply('What is your name?')
const filter = m => m.author.id === message.author.id
const nameCollector = message.channel.createMessageCollector({ filter, max: 1, time: 30_000 })
nameCollector.on('collect', async nameMsg => {
const name = nameMsg.content
const nextMsg = await nameMsg.reply('How old are you?')
const ageCollector = nextMsg.channel.createMessageCollector({ filter, max: 1, time: 30_000 })
ageCollector.on('collect', async ageMsg => {
await ageMsg.reply(`Done! ${name}, ${ageMsg.content}`)
})
ageCollector.on('end', (_, reason) => {
if (reason === 'time') nextMsg.reply('Timed out.')
})
})
nameCollector.on('end', (_, reason) => {
if (reason === 'time') message.reply('Timed out.')
})Telegraf v4 β Scenes.WizardScene with session() middleware. A separate concept to learn and register as middleware. Wizard state is Telegram-only; there is no equivalent abstraction that carries to other platforms:
// Telegraf β two-step conversation via WizardScene
const { Scenes, session } = require('telegraf')
const wizard = new Scenes.WizardScene('my-wizard',
async ctx => { await ctx.reply('What is your name?'); return ctx.wizard.next() },
async ctx => {
ctx.wizard.state.name = ctx.message.text
await ctx.reply('How old are you?')
return ctx.wizard.next()
},
async ctx => {
await ctx.reply(`Done! ${ctx.wizard.state.name}, ${ctx.message.text}`)
return ctx.scene.leave()
}
)
const stage = new Scenes.Stage([wizard])
bot.use(session()) // must register β stores wizard state between updates
bot.use(stage.middleware()) // must register β activates scene routing
bot.command('register', ctx => ctx.scene.enter('my-wizard'))fca-unofficial β Global handleReply array with manual push, manual cleanup via splice, and manual ownership check. Two simultaneous users running the same command share the same global array, creating a real race condition:
// fca-unofficial β two-step conversation via global array
api.sendMessage('What is your name?', threadID, (err, info) => {
if (err) return
global.client.handleReply.push({
name: 'myCommand', messageID: info.messageID,
author: event.senderID, type: 'awaiting_name', data: {}
})
})
module.exports.handleReply = async ({ event, handleReply, api }) => {
if (handleReply.author !== event.senderID) return // manual ownership check
const idx = global.client.handleReply.findIndex(r => r.messageID === handleReply.messageID)
global.client.handleReply.splice(idx, 1) // manual cleanup β races with concurrent pushes
if (handleReply.type === 'awaiting_name') {
handleReply.data.name = event.body
api.sendMessage('How old are you?', event.threadID, (err, info) => {
global.client.handleReply.push({
name: 'myCommand', messageID: info.messageID,
author: event.senderID, type: 'awaiting_age', data: handleReply.data
})
})
} else if (handleReply.type === 'awaiting_age') {
api.sendMessage(
`Done! ${handleReply.data.name}, ${event.body}`,
event.threadID, () => {}
)
}
}Cat-Bot β all four platforms:
const STATE = { awaiting_name: 'awaiting_name', awaiting_age: 'awaiting_age' }
export const onReply = {
[STATE.awaiting_name]: async ({ chat, event, session, state }: AppCtx) => {
const name = event['message'] as string
const msgId = await chat.replyMessage({
style: MessageStyle.MARKDOWN, message: '**How old are you?**',
})
state.delete(session.id) // remove the current step before creating the next
if (msgId) {
state.create({
id: state.generateID({ id: String(msgId) }),
state: STATE.awaiting_age,
context: { name }, // carry data forward in the context object
})
}
},
[STATE.awaiting_age]: async ({ chat, event, session, state }: AppCtx) => {
const { name } = session.context as { name: string }
state.delete(session.id)
await chat.replyMessage({
style: MessageStyle.MARKDOWN,
message: `Done! **${name}**, ${event['message'] as string}`,
})
},
}
export const onCommand = async ({ chat, state }: AppCtx) => {
const msgId = await chat.replyMessage({
style: MessageStyle.MARKDOWN, message: '**What is your name?**',
})
if (msgId) {
state.create({
id: state.generateID({ id: String(msgId) }),
state: STATE.awaiting_name,
context: {},
})
}
}No nested callbacks. No wizard middleware to register. No global array to splice. State is scoped to messageID:senderID automatically β two users running this command simultaneously each have a completely isolated conversation with zero interference.
Updating the text on a button that is already rendered in a message forces each native SDK to rebuild the entire component tree β there is no in-place label mutation API in either discord.js or Telegraf.
discord.js v14 β components received from the API are frozen by design. The v14 migration
guide documents ButtonBuilder.from()
as the canonical way to clone an existing component into a mutable builder, but you must still
reconstruct a fresh ActionRowBuilder around the updated button and call interaction.editReply().
There is no "change label" method on a live button:
// discord.js v14 β update a button label inside a button-click handler
client.on('interactionCreate', async interaction => {
if (!interaction.isButton()) return
await interaction.deferUpdate() // must acknowledge within 3 seconds
// API-received components are immutable β clone into a mutable builder first
const original = interaction.message.components[0].components[0]
const count = parseInt(original.label.match(/\d+/)?.[0] ?? '0') + 1
const updatedButton = ButtonBuilder.from(original).setLabel(`π Refresh (${count})`)
const updatedRow = new ActionRowBuilder().addComponents(updatedButton)
// Must rebuild the entire ActionRow β no direct label setter on a live button
await interaction.editReply({ components: [updatedRow] })
})Telegraf v4 β there is no mutable button object. To change a label you call
ctx.editMessageText() or ctx.editMessageReplyMarkup() and pass a completely reconstructed
inline_keyboard array. Every button in every row must be redeclared, even when only one label
is changing:
// Telegraf v4 β update a button label inside a callback_query handler
bot.action('refresh', async ctx => {
await ctx.answerCbQuery() // dismiss loading spinner β required
// No direct access to button state β must parse count from the rendered label string
const currentLabel = ctx.callbackQuery.message.reply_markup.inline_keyboard[0][0].text
const count = parseInt(currentLabel.match(/\d+/)?.[0] ?? '0') + 1
// Must redeclare the entire keyboard just to change one button label
await ctx.editMessageText(ctx.callbackQuery.message.text, {
reply_markup: {
inline_keyboard: [[
{ text: `π Refresh (${count})`, callback_data: 'refresh' }
]]
}
})
})Cat-Bot β all four platforms:
const BUTTON_ID = { refresh: 'refresh' } as const
export const button = {
[BUTTON_ID.refresh]: {
label: 'π Refresh (1)',
style: ButtonStyle.SECONDARY,
onClick: async ({ chat, startTime, event, button, session }: AppCtx) => {
const count = (session.context.count as number) + 1
// One call β the registry update is all that is needed; the platform adapter
// rebuilds the native component automatically on the next editMessage call.
button.update({ id: session.id, label: `π Refresh (${count})` })
button.createContext({ id: session.id, context: { count } })
await chat.editMessage({
style: MessageStyle.MARKDOWN,
message_id_to_edit: event['messageID'] as string,
message: `π Pong! Latency: \`${Date.now() - startTime}ms\``,
button: [session.id],
})
},
},
}No ButtonBuilder.from(). No ActionRowBuilder reconstruction. No answerCbQuery() call. No
regex-parsing of the current label from a message payload. button.update() stores the new label
in the button registry; chat.editMessage() tells the adapter to re-render β it reads the
registry and builds the correct native component for Discord, Telegram, Messenger, or Facebook
Page automatically.
The 3-Second Acknowledgment Window. Discord's slash commands and button interactions must be acknowledged within 3 seconds or the user sees "interaction failed." Telegraf's callback queries must be answered within ~10 seconds to dismiss the loading spinner. In Cat-Bot, the platform adapter calls deferReply() or deferUpdate() immediately when the interaction arrives β before dispatching to your handler. Your onCommand and button.onClick functions receive control only after the acknowledgment has already been sent. You never race a timing window in your command code.
The Global State Race Condition. global.client.handleReply is a mutable array shared across every active conversation. When two users run the same command simultaneously, their state objects coexist in the same array. A splice(idx, 1) in one handler races with a push in another, producing entries that point to the wrong conversation context. Cat-Bot's state.create() stores each entry under a composite key: ${messageID}:${senderID} for private flows, ${messageID}:${threadID} for public flows. Two simultaneous conversations produce two distinct keys. There is no array to splice and no possibility of one user's flow advancing another's.
The Platform Branching Problem. Any feature that must run on more than one platform requires branching in native code β three separate implementation paths maintained in sync forever. Cat-Bot's UnifiedApi contract eliminates this. chat.replyMessage({ button: [...] }) produces an ActionRowBuilder on Discord, an inline_keyboard on Telegram, a numbered text menu on Messenger (handled transparently by createChatContext), and a Button Template on Facebook Page. The feature is implemented once and runs correctly on all four platforms.
The Button Ownership Problem. Discord's customId is a global string. Any user who intercepts the interaction payload can trigger a button they did not generate. Telegraf's callback_data has the same property. Cat-Bot's button.generateID({ id: 'confirm' }) embeds the invoking user's ID in the generated key. The enforceButtonScope middleware rejects clicks from users who did not generate the button. Passing { public: true } explicitly opts into thread-scoped buttons when you want group interaction. The default is always private.
The Handler Colocation Problem. In every native SDK, the code that sends a button and the code that handles its click live in different places. A client.on('interactionCreate') in discord.js, a bot.on('callback_query') in Telegraf, a handleReply export in fca β all are global registrations that route by string matching. Reading the command that sends a button tells you nothing about what happens when it is clicked. In Cat-Bot, export const button lives in the same file as export const onCommand. A developer reading the file sees the complete behavior: what is sent, what each button does, and how the conversation ends.
The Architectural Insight. The breakthrough in Cat-Bot's design is that the bot problem and the platform problem are separate concerns. The bot problem is: how do I model a conversation, route commands, manage state, and respond to the user? The platform problem is: how do I translate that model into the specific API calls, acknowledgment windows, and data shapes that Discord, Telegram, or Facebook require? Every native SDK conflates these two β you write bot logic in the SDK's own idiom, and the logic is inseparable from the transport mechanism. Cat-Bot separates them cleanly:
Your command module (bot logic β onCommand, onReply, button.onClick)
β
βΌ
UnifiedApi + context factories (shared vocabulary β chat, state, button, thread, user)
β
βΌ
Platform adapter (transport translation β the code you no longer write)
β
βΌ
discord.js v14 / Telegraf v4 / fca-unofficial / Facebook Graph API
Your command module never imports discord.js. Your button handler never calls ctx.answerCbQuery(). Your reply handler never pushes to a global array. The adapter layer absorbs every platform difference and presents a uniform surface to your code.
| Home | Login | Sign Up |
![]() |
![]() |
![]() |
| Bot Manager | User Settings |
![]() |
![]() |
Create New Bot β 3-Step Wizard
| Step 1 β Identity | Step 2 β Platform & Credentials |
![]() |
![]() |
| Step 2 β Verified | Step 3 β Review |
![]() |
![]() |
Bot Detail Tabs
| Live Console | Commands |
![]() |
![]() |
| Event Handlers | Bot Settings |
![]() |
![]() |
| Admin Login | Admin Dashboard |
![]() |
![]() |
| User Management | Bot Sessions (All Users) |
![]() |
![]() |
| Admin Settings | |
![]() |
| Feature | Description |
|---|---|
| Multi-platform | One command module runs on Discord, Telegram, Facebook Page, and Facebook Messenger β no per-platform branches in your handler code |
| Multi-instance | Run any number of independent bot sessions concurrently, each with its own credentials, prefix, and admin list |
| Unified Dashboard | React 19 SPA β monitor live logs, toggle commands on/off per session, update credentials, start/stop/restart bots |
| Conversation State | Scoped onReply and onReact flows replace the global-array anti-pattern; concurrent users never interfere with each other's flow |
| Interactive Buttons | export const button in your command file β Discord gets ActionRowBuilder, Telegram gets inline keyboards, Messenger gets a numbered text menu, Facebook Page gets a Button Template |
| Admin Portal | Independent admin dashboard with separate session cookies β ban users, halt their sessions, manage system admins |
| Pluggable Database | Four interchangeable backends β JSON (zero dependencies), SQLite via Prisma, MongoDB, and Neon PostgreSQL β selected at runtime with a single DATABASE_TYPE environment variable; 12 bidirectional migration scripts cover every adapter pair so switching storage backends never means re-entering data |
| Role-Based Access | Five role levels (ANYONE, THREAD_ADMIN, BOT_ADMIN, PREMIUM, SYSTEM_ADMIN) enforced by middleware before onCommand runs |
| Cooldown & Ban System | Per-user cooldown and per-user/per-thread bans enforced by the middleware pipeline |
| Slash Command Sync | Discord and Telegram slash menus stay current with a SHA-based idempotency gate β no redundant REST calls on restart |
| Economy API | Built-in currencies context (getMoney, increaseMoney, decreaseMoney) backed by the active database adapter |
| AI Agent | Groq-powered ReAct agent with execute_command, test_command, and help tools accessible from chat |
| Live Log Streaming | Socket.IO pushes bot console output to the dashboard in real time with a 100-entry sliding window buffer |
| LRU Cache Layer | A 2,000-entry LRU cache sits between the bot engine and every database adapter β permission checks, cooldown lookups, and credential reads are served from memory on repeated access; all writes are write-through so command handlers never observe stale data |
Cat-Bot is an ESM TypeScript monorepo with three independent packages.
Cat-Bot/
βββ packages/
β βββ cat-bot/ β Bot engine + Express REST API + Socket.IO
β β βββ src/engine/ β Platform adapters, middleware pipeline, controller/dispatcher layer
β β βββ src/server/ β Dashboard API, better-auth, Facebook Page webhook receiver
β βββ database/ β Raw database adapters; selected by DATABASE_TYPE env var
β β βββ adapters/
β β βββ json/ β Flat JSON file; zero runtime dependencies
β β βββ prisma-sqlite/ β Prisma v7 + better-sqlite3 (default)
β β βββ mongodb/ β MongoDB driver adapter
β β βββ neondb/ β Neon PostgreSQL (node-postgres)
β βββ web/ β Vite + React 19 management dashboard SPA
βββ packages/cat-bot/src/app/
βββ commands/ β Your command modules (one file each)
βββ events/ β Your event handler modules
Every incoming message from every platform follows this fixed path:
Platform Transport β Middleware Chain β Controller Dispatch
(Discord / enforceNotBanned onCommand / onReply /
Telegram / enforcePermission onReact / onEvent /
Messenger / enforceCooldown button.onClick
Facebook Page) chatPassthrough
The UnifiedApi abstract class sits between your command code and the platform SDKs. Calling chat.replyMessage() triggers editReply() on Discord, ctx.reply() on Telegram, api.sendMessage() on Messenger, and a Graph API POST on Facebook Page β all from the same call site.
For deep-dive architecture documentation covering each platform adapter, the middleware pipeline, the database access pattern, and the web dashboard: see docs/ARCHITECTURE.md.
For production deployments use NeonDB (serverless PostgreSQL) or MongoDB for durable persistence. Both support the full feature set.
β οΈ CRITICAL: RemoveVITE_URLin Production EnsureVITE_URLis completely removed from your environment variables when deploying to platforms like Render or Railway. Leaving it set (e.g., tohttp://localhost:5173) will cause "trusted origin" errors in the authentication layer (better-auth). In production, same-origin is inherently trusted.
NeonDB runs schema initialization automatically at boot via the dbReady promise β no manual migration step.
-
Create a project at console.neon.tech and copy the connection string.
-
Set environment variables:
DATABASE_TYPE=neondb
NEON_DATABASE_URL=postgres://username:password@ep-xxxx.us-east-2.aws.neon.tech/neondb?sslmode=require
BETTER_AUTH_SECRET=your_production_secret
BETTER_AUTH_URL=https://your-domain.com
ENCRYPTION_KEY=your_64_hex_char_key_here
LOG_LEVEL=warn- Seed the admin account:
npm run seed:admin- Build the project:
npm install && npm run build:all- Start the production server:
npm startMongoDB Atlas M0 (free tier) works without changes.
DATABASE_TYPE=mongodb
MONGODB_URI=mongodb+srv://username:<PASSWORD>@cluster0.mongodb.net?retryWrites=true&w=majority
MONGO_PASSWORD=your_mongodb_password
MONGO_DATABASE_NAME=catbotThen seed, build, and start as above.
When both Gmail variables are set, Cat-Bot sends a verification link to every new user on sign-up. Users must click the link before they can sign in. If either variable is missing, sign-ups succeed but verification emails are silently skipped (a warning is logged per attempt).
Google App Password setup:
- Enable 2-Step Verification: myaccount.google.com β Security β 2-Step Verification
- Go to myaccount.google.com/apppasswords
- Name it "Cat-Bot" and click Create
- Copy the 16-character password (spaces included or removed β both work)
Note: Use a dedicated Gmail address for sending; never use your primary account password.
Add to your .env:
GMAIL_USER=your-gmail@gmail.com
GOOGLE_APP_PASSWORD=xxxx xxxx xxxx xxxx
# Enables email verification on sign-up and password-reset flows in the dashboard
VITE_EMAIL_SERVICES_ENABLE=trueThe /ai command and the Agentic AI features (onChat conversational trigger, test_command, send_result, help tools) all require a Groq API key. Without it, the bot starts normally but every AI invocation returns an error.
Free tier: Groq's hosted inference API has a generous free tier with no credit card required β ideal for development and personal deployments.
How to get your GROQ_API_KEY:
- Go to console.groq.com and sign up with your email or Google account.
- After logging in, open the API Keys section from the left sidebar (or navigate directly to console.groq.com/keys).
- Click Create API Key, give it a descriptive name (e.g.
cat-bot), and click Submit. - Copy the key immediately β Groq only shows it once. Store it in a password manager or secure notes application.
Security note: Your API key is equivalent to a password. Never commit it to version control or share it publicly. Always load it from your
.envfile or an environment variable.
Add to your .env:
GROQ_API_KEY=gsk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxRate limits (free tier, 2026): Rate limits are per organization, not per API key. Creating multiple keys under the same organization shares the same request bucket β create a separate organization if you need independent rate limit pools.
If GROQ_API_KEY is absent, the bot starts normally. The /ai command and the conversational onChat trigger will respond with:
AI Error: GROQ_API_KEY environment variable is not set. AI capabilities are disabled.
By default, Telegram sessions use long-polling β no public domain required. However, for production deployments, webhook mode is highly recommended for better reliability and scalability:
TELEGRAM_WEBHOOK_DOMAIN=https://your-domain.comThe Telegram adapter switches to webhook mode automatically when this variable is present.
The build and start commands are the same for every platform:
| Command | |
|---|---|
| Build | npm install && npm run build:all |
| Start | npm start |
BETTER_AUTH_URL=TELEGRAM_WEBHOOK_DOMAINβ both must be set to your public deployment URL.
Render provisions a unique *.onrender.com HTTPS subdomain automatically and manages TLS certificates.
Steps:
- Go to render.com and sign in (or create an account).
- In the dashboard, click New β Web Service.
- Select Build and deploy from a Git repository β click Next.
- Connect your GitHub account and select the Cat-Bot repository β click Connect.
- Fill in the service creation form:
- Name: any name (becomes your subdomain, e.g.
cat-bot.onrender.com) - Region: closest to your users
- Branch:
main - Build Command:
npm install && npm run build:all - Start Command:
npm start
- Name: any name (becomes your subdomain, e.g.
- Choose an instance type and click Create Web Service. Render kicks off the first build β the deploy log streams in real time.
- Once the first deploy finishes, copy your assigned
onrender.comURL (e.g.https://cat-bot.onrender.com). - Open the Environment tab β click Add Environment Variable and add all required variables (see Environment Variables):
BETTER_AUTH_URLβhttps://your-service.onrender.comTELEGRAM_WEBHOOK_DOMAINβ same value asBETTER_AUTH_URL- Remove
VITE_URLentirely β it must not be set in production (causes trusted-origin errors in better-auth)
- Click Save Changes β Render triggers an automatic redeploy with the new variables applied.
Free tier note: Free Render instances spin down after 15 minutes of inactivity and spin back up on the next request (cold start ~30 s). Use a paid instance for always-on bot sessions.
Railway does not assign a public domain until you explicitly generate one β the domain is needed before you can fill in BETTER_AUTH_URL and TELEGRAM_WEBHOOK_DOMAIN, so the sequence differs from Render.
Steps:
- Go to railway.com and sign in with your GitHub account.
- In the dashboard, click New Project β Deploy from GitHub repo.
- Select the Cat-Bot repository β click Deploy Now. Railway detects the Node.js project via Railpack and kicks off an initial build on your default branch.
- Once the project canvas appears, click on your service to open the service panel.
- Go to Settings β Networking and click Generate Domain β Railway provisions a
*.up.railway.appsubdomain (e.g.https://cat-bot-production.up.railway.app). Copy it. - Open the Variables tab and add all required environment variables (see Environment Variables):
BETTER_AUTH_URLβ your Railway domain (e.g.https://cat-bot-production.up.railway.app)TELEGRAM_WEBHOOK_DOMAINβ same value asBETTER_AUTH_URL- Remove
VITE_URLentirely β it must not be set in production
- Click Deploy (or push a new commit to your linked branch) β Railway redeploys with the variables applied.
Auto-deploys: Every push to your linked branch (default
main) triggers an automatic rebuild and redeploy with zero downtime.
Create a file in packages/cat-bot/src/app/commands/. The engine loads every .ts/.js file in this directory at startup.
// src/app/commands/hello.ts
import type { AppCtx } from "@/engine/types/controller.types.js";
import type { CommandConfig } from "@/engine/types/module-config.types.js";
import { Role } from "@/engine/constants/role.constants.js";
import { MessageStyle } from "@/engine/constants/message-style.constants.js";
export const config: CommandConfig = {
name: "hello",
version: "1.0.0",
role: Role.ANYONE,
author: "your-name",
description: "Says hello",
usage: "",
cooldown: 5,
hasPrefix: true,
};
export const onCommand = async ({ chat }: AppCtx): Promise<void> => {
await chat.replyMessage({
style: MessageStyle.MARKDOWN,
message: "π **Hello, world!**",
});
};| Field | Required | Description |
|---|---|---|
name |
β | Command name (lowercase). Matched after the prefix is stripped. |
version |
β | Semantic version string. |
role |
β | Minimum role. Use Role.ANYONE for public commands. |
author |
β | Author name shown in help output. |
description |
β | One-line description; shown in Discord's / menu. |
cooldown |
β | Per-user cooldown in seconds. 0 disables. |
aliases |
β | Alternative command names that map to the same handler. |
platform |
β | Restrict to specific platforms. Absent = all platforms. |
hasPrefix |
β | Set false for prefix-less (on-chat) commands. |
options |
β | Named options for slash command typed arguments. |
guide |
β | Multi-line usage guide shown by ctx.usage(). |
const STATE = { awaiting_name: "awaiting_name", awaiting_age: "awaiting_age" };
export const onReply = {
[STATE.awaiting_name]: async ({ chat, session, event, state }: AppCtx) => {
const name = event["message"] as string;
const msgId = await chat.replyMessage({
style: MessageStyle.MARKDOWN,
message: "**How old are you?**",
});
state.delete(session.id);
if (msgId) {
state.create({
id: state.generateID({ id: String(msgId) }),
state: STATE.awaiting_age,
context: { name },
});
}
},
[STATE.awaiting_age]: async ({ chat, session, event, state }: AppCtx) => {
const { name } = session.context as { name: string };
state.delete(session.id);
await chat.replyMessage({
style: MessageStyle.MARKDOWN,
message: `β
Registered: **${name}**, age **${event["message"] as string}**`,
});
},
};
export const onCommand = async ({ chat, state }: AppCtx) => {
const msgId = await chat.replyMessage({
style: MessageStyle.MARKDOWN,
message: "**Step 1/2:** What is your name?",
});
if (!msgId) return;
state.create({
id: state.generateID({ id: String(msgId) }),
state: STATE.awaiting_name,
context: {},
});
};import { ButtonStyle } from "@/engine/constants/button-style.constants.js";
const BUTTON_ID = {
confirm: "confirm",
cancel: "cancel",
};
export const button = {
[BUTTON_ID.confirm]: {
label: "β
Confirm",
style: ButtonStyle.SUCCESS,
onClick: async ({ chat, event }: AppCtx) => {
await chat.editMessage({
message_id_to_edit: event["messageID"] as string,
message: "β
Confirmed!",
button: [], // clear buttons after the action
});
},
},
[BUTTON_ID.cancel]: {
label: "β Cancel",
style: ButtonStyle.DANGER,
onClick: async ({ chat, event }: AppCtx) => {
await chat.editMessage({
message_id_to_edit: event["messageID"] as string,
message: "β Cancelled.",
button: [],
});
},
},
};
export const onCommand = async ({ chat, button: btn }: AppCtx) => {
const confirmId = btn.generateID({ id: BUTTON_ID.confirm });
const cancelId = btn.generateID({ id: BUTTON_ID.cancel });
await chat.replyMessage({
style: MessageStyle.MARKDOWN,
message: "**Are you sure?**",
button: [confirmId, cancelId],
});
};On Discord this produces an
ActionRowBuilderwith two buttons. On Telegram it produces an inline keyboard. On Messenger it produces a numbered text menu. On Facebook Page it produces a Button Template. The samebuttonexport drives all four outcomes.
import { Platforms } from "@/engine/modules/platform/platform.constants.js";
export const config: CommandConfig = {
// ...
platform: [Platforms.Discord, Platforms.Telegram],
};| Field | Description |
|---|---|
chat |
Send, edit, react β reply, replyMessage, editMessage, reactMessage, unsendMessage |
thread |
Group operations β setName, setImage, addUser, removeUser, getInfo |
user |
getInfo(uid), getName(uid), getAvatarUrl(uid) |
state |
Pending state CRUD β generateID, create, delete |
button |
Button lifecycle β generateID, createContext, update |
session |
Auto-resolved flow context in onReply/onReact/onClick β id, state, context |
db |
Per-user and per-thread collections β db.users.collection(uid), db.threads.collection(tid) |
currencies |
Economy β getMoney, increaseMoney, decreaseMoney |
args |
Token array after the command name |
options |
Named slash-command / key:value options |
event |
Raw unified event (senderID, threadID, messageID, message, β¦) |
native |
Platform identity + raw platform object for SDK-level access |
logger |
Session-scoped structured logger |
prefix |
Active command prefix |
usage |
Replies with the formatted usage guide |
If you have command files from another bot project β GoatBot, Mirai, fca-unofficial-based bots, or any other framework β and want to port them to Cat-Bot, use the prompt below with Claude AI.
How to use:
- Open Claude AI
- Copy the entire prompt block below
- Replace
[your code]at the bottom with the command file you want to convert - Paste it into Claude and send
> **β οΈ CRITICAL β Read Before Proceeding**
> You MUST fetch the documentation URL in Step 1 **before** writing any code.
> The fetched documentation is your **only** source of truth.
> Do NOT invent, assume, or borrow patterns from other bot frameworks (e.g. Discord.js, Telegraf, Baileys, or any other project). If it is not in the documentation, it does not exist in Cat Bot.
---
## Task: Convert Code to Cat Bot
### Step 1 β Fetch Documentation (Required)
Fetch the URL below and confirm it is successfully retrieved before doing anything else.
- [ ] `https://raw.githubusercontent.com/johnlester-0369/Cat-Bot/refs/heads/main/docs/llms.txt?v=7`
---
### Step 2 β Acknowledge & Ground Yourself
After fetching, confirm the following before proceeding:
- Summarize Cat Bot's structure, code patterns, and conventions **as described in the documentation only**
- Flag anything in the code to convert that has **no equivalent** in Cat Bot's documented API β do not silently fill gaps with assumptions
---
### Step 3 β Convert the Code
Convert the code below into Cat Bot **strictly and exclusively** using what is documented in the fetched URL.
**Rules:**
- β
Only use APIs, methods, patterns, and structures that exist in the documentation
- β Do not invent helper functions or abstractions not shown in the docs
- β Do not mirror patterns from other frameworks even if they "seem right"
- β If something cannot be done within Cat Bot's documented API, say so explicitly β do not improvise
```
[your code]
```Create a file in packages/cat-bot/src/app/events/.
// src/app/events/join.ts
import type { AppCtx } from "@/engine/types/controller.types.js";
import type { EventConfig } from "@/engine/types/module-config.types.js";
import { MessageStyle } from "@/engine/constants/message-style.constants.js";
export const config: EventConfig = {
name: "join",
eventType: ["log:subscribe"],
version: "1.0.0",
author: "your-name",
description: "Welcomes new members",
};
export const onEvent = async ({ chat, event }: AppCtx): Promise<void> => {
const data = event["logMessageData"] as Record<string, unknown> | undefined;
const added =
(data?.["addedParticipants"] as Record<string, unknown>[]) ?? [];
for (const p of added) {
await chat.replyMessage({
style: MessageStyle.MARKDOWN,
message: `π Welcome **${String(p["fullName"] ?? p["firstName"] ?? "new member")}**!`,
});
}
};Common eventType values:
| Value | Trigger |
|---|---|
log:subscribe |
Member(s) joined a group |
log:unsubscribe |
Member left or was removed |
log:thread-name |
Group name changed |
log:thread-image |
Group photo changed |
log:thread-icon |
Group emoji changed |
log:user-nickname |
A nickname was changed |
change_thread_admins |
Admin status changed |
Cat-Bot ships a set of frozen const objects that act as single sources of truth for every value the engine tests at runtime. Using these constants instead of raw string or numeric literals prevents silent failures β a typo like 'Discord' instead of 'discord' compiles cleanly but silently skips every platform check at runtime.
Every place the engine compares a value β role enforcement, platform filtering, event routing, message rendering β it tests against the exact values these constants define. A raw literal that differs by one character silently misses the comparison:
// β Magic number β no autocomplete, no refactor safety, silently broken if Role values shift
export const config = { role: 4 };
// β
Single source of truth β TypeScript flags a stale value immediately if the constant changes
export const config = { role: Role.SYSTEM_ADMIN };import { Role } from "@/engine/constants/role.constants.js";| Constant | Value | Who can invoke |
|---|---|---|
Role.ANYONE |
0 |
All users β every role can invoke ANYONE commands |
Role.THREAD_ADMIN |
1 |
Thread/group admins β also: PREMIUM, BOT_ADMIN, SYSTEM_ADMIN |
Role.PREMIUM |
2 |
Premium users β also: BOT_ADMIN, SYSTEM_ADMIN; thread admins alone denied |
Role.BOT_ADMIN |
3 |
Bot admins β also: SYSTEM_ADMIN only; premium-only users denied |
Role.SYSTEM_ADMIN |
4 |
System admins only β bypasses every gate |
Access Truth Table β invoker role (rows) vs required command role (columns):
| Invoker β / Required β | ANYONE (0) | THREAD_ADMIN (1) | PREMIUM (2) | BOT_ADMIN (3) | SYSTEM_ADMIN (4) |
|---|---|---|---|---|---|
| Any user | β | β | β | β | β |
| THREAD_ADMIN | β | β | β | β | β |
| PREMIUM | β | β | β | β | β |
| BOT_ADMIN | β | β | β | β | β |
| SYSTEM_ADMIN | β | β | β | β | β |
Role access is strictly hierarchical by numeric value: higher value = stricter gate and greater authority. A role can always invoke commands requiring a lower-numbered role.
import { MessageStyle } from "@/engine/constants/message-style.constants.js";
// β Raw string β if the engine's value set changes, this silently stops rendering Markdown
await chat.replyMessage({ style: "markdown", message: "**Hello**" });
// β
TypeScript flags any mismatch at compile time
await chat.replyMessage({ style: MessageStyle.MARKDOWN, message: "**Hello**" });| Constant | Behaviour |
|---|---|
MessageStyle.MARKDOWN |
Renders Markdown on Discord/Telegram; converts **bold** and _italic_ to styled Unicode on Messenger/Page |
MessageStyle.TEXT |
Escapes Markdown syntax β content displays literally |
import { ButtonStyle } from "@/engine/constants/button-style.constants.js";
export const button = {
confirm: {
label: "β
Confirm",
style: ButtonStyle.SUCCESS, // not the raw string 'success'
onClick: async (ctx: AppCtx) => {
/* ... */
},
},
};| Constant | Discord colour |
|---|---|
ButtonStyle.PRIMARY |
Blue |
ButtonStyle.SECONDARY |
Grey (default when omitted) |
ButtonStyle.SUCCESS |
Green |
ButtonStyle.DANGER |
Red |
Telegram and Facebook Page render the button label only β style has no visual effect on those platforms.
import { Platforms } from "@/engine/modules/platform/platform.constants.js";
// β Subtle capitalisation difference β 'Facebook-Messenger' never matches 'facebook-messenger'
export const config = { platform: ["Facebook-Messenger"] };
// β
Autocompleted, typo-proof, refactor-safe
export const config = { platform: [Platforms.FacebookMessenger] };| Constant | Value |
|---|---|
Platforms.Discord |
'discord' |
Platforms.Telegram |
'telegram' |
Platforms.FacebookMessenger |
'facebook-messenger' |
Platforms.FacebookPage |
'facebook-page' |
The same constants are used for runtime narrowing inside handlers:
export const onCommand = async ({ native, chat }: AppCtx) => {
if (native.platform === Platforms.Telegram) {
// Telegram-only logic
}
};The eventType[] array in EventConfig is matched against the engine's internal routing table. A single character off means the handler is registered but never called:
// β 'log:subscibe' β one missing letter, handler silently receives zero events
export const config: EventConfig = { eventType: ["log:subscibe"] };
// β
Exact string matched against the LogMessageType registry
export const config: EventConfig = { eventType: ["log:subscribe"] };| String | Trigger |
|---|---|
'log:subscribe' |
Member(s) joined a group |
'log:unsubscribe' |
Member left or was removed |
'log:thread-name' |
Group name changed |
'log:thread-image' |
Group photo changed |
'log:thread-icon' |
Group emoji changed |
'log:user-nickname' |
A nickname was changed |
'change_thread_admins' |
Admin status changed |
The full reference β including OptionType constants for slash command options β is in DOCS.md.
The complete API reference for command and event module authors
It covers, among other things:
- Side-by-side comparisons of native SDK code vs. the Cat-Bot equivalent for every major operation
- How the 3-second Discord acknowledgment window is handled transparently
- The button ownership model and how
public: trueopts into thread-scoped buttons - How to extend the middleware pipeline with custom guards
- The full
onReply,onReact, andbutton.onClicklifecycle contract - Native platform access patterns (
native.ctxon Telegram,native.messageon Discord,native.apion Messenger,native.messagingon Facebook Page)
| Adapter | DATABASE_TYPE |
Best For | Notes |
|---|---|---|---|
| JSON | json |
Local development, demos | Zero runtime deps; data in packages/database/database/database.json; not suitable for production |
| Prisma + SQLite | prisma-sqlite |
Single-server production | Requires prisma generate + prisma migrate dev; WAL mode enabled for concurrent reads |
| MongoDB | mongodb |
Production, cloud | Atlas M0 free tier supported; non-transactional on M0 |
| NeonDB | neondb |
Production, serverless | Schema auto-initialized at boot via dbReady promise; connection pooling via pg.Pool |
Change DATABASE_TYPE in .env and restart. To migrate existing data, use one of the 12 cross-adapter scripts:
# Example: move data from JSON to NeonDB
npx tsx packages/database/scripts/migrate-json-to-neondb.tsAll bidirectional migration directions (json β sqlite β mongodb β neondb) are available in packages/database/scripts/.
Full reference from packages/cat-bot/.env.example:
# Server
PORT=3000
LOG_LEVEL=info # error | warn | info | http | verbose | debug | silly
# Auth
BETTER_AUTH_SECRET= # openssl rand -base64 32
BETTER_AUTH_URL=http://localhost:3000
VITE_URL=http://localhost:5173 # dev proxy origin (REMOVE in production to avoid trusted origin errors)
# Database β choose one
DATABASE_TYPE=json # json | mongodb | neondb | prisma-sqlite
# NeonDB (when DATABASE_TYPE=neondb)
NEON_DATABASE_URL=postgres://...
# MongoDB (when DATABASE_TYPE=mongodb)
MONGODB_URI=mongodb+srv://username:<PASSWORD>@cluster0.mongodb.net?...
MONGO_PASSWORD=
MONGO_DATABASE_NAME=catbot
# Telegram Webhooks (optional β recommended for production)
TELEGRAM_WEBHOOK_DOMAIN=https://your-domain.com
# Gmail SMTP / Email Verification (optional β recommended for production)
GMAIL_USER=your-gmail@gmail.com
GOOGLE_APP_PASSWORD=xxxx xxxx xxxx xxxx
VITE_EMAIL_SERVICES_ENABLE=false # set to true in production when SMTP is configured
# Credential encryption at rest
ENCRYPTION_KEY= # openssl rand -hex 32| Script | Description |
|---|---|
npm run dev |
Start bot engine in watch mode (tsx watch) |
npm run dev:web |
Start Vite dev server for the dashboard |
npm run build:db |
Compile the database package |
npm run build |
Compile cat-bot (TypeScript + tsc-alias) |
npm run format |
Prettier |
npm run test |
Run Vitest unit and integration tests |
npm run test:watch |
Vitest in watch mode |
npm run seed:admin |
Create the initial system admin account |
npm run dev:all |
Start bot engine + web dashboard concurrently |
npm run build:all |
Compile bot + web dashboard concurrently |
| Script | Description |
|---|---|
npm run seed:admin |
Create the initial system admin account |
npm run reset:password |
Reset an admin account password |
npm run lint |
ESLint |
npm run format |
Prettier |
npm run test:watch |
Vitest in watch mode |
![]() John Lester |
![]() Lance Cochangco |
https://github.com/johnlester-0369/Cat-Bot Β· ISC License



















