@igniter-js/bot
Version:
A modern, type-safe multi-platform bot framework for the Igniter.js ecosystem (adapters, middleware, commands, rich content).
991 lines (982 loc) • 34.2 kB
JavaScript
import { z } from 'zod';
/**
* @igniter-js/bot
* Build: 2025-10-14T20:13:23.459Z
* Format: {format}
*/
// src/bot.provider.ts
var BotErrorCodes = {
PROVIDER_NOT_FOUND: "PROVIDER_NOT_FOUND",
COMMAND_NOT_FOUND: "COMMAND_NOT_FOUND",
INVALID_COMMAND_PARAMETERS: "INVALID_COMMAND_PARAMETERS",
ADAPTER_HANDLE_RETURNED_NULL: "ADAPTER_HANDLE_RETURNED_NULL"
};
var BotError = class extends Error {
constructor(code, message, meta) {
super(message || code);
this.code = code;
this.meta = meta;
this.name = "BotError";
}
};
var Bot = class _Bot {
/**
* Creates a new Bot instance.
*/
constructor(config) {
/** Indexed / normalized command lookup */
this.commandIndex = /* @__PURE__ */ new Map();
/** Event listeners */
this.listeners = {};
/**
* Optional hook executed just before middleware pipeline
* to allow last‑minute context enrichment (e.g., session loading).
*/
this.preProcessHooks = [];
/**
* Optional hook executed after successful processing (not on thrown errors).
*/
this.postProcessHooks = [];
this.id = config.id;
this.name = config.name;
this.adapters = config.adapters;
this.middlewares = config.middlewares || [];
this.commands = config.commands || {};
this.logger = config.logger;
if (config.on) {
for (const evt of Object.keys(config.on)) {
const handler = config.on[evt];
if (handler) this.on(evt, handler);
}
}
this.rebuildCommandIndex();
}
/**
* Rebuilds the internal command index (idempotent).
* Called at construction and whenever a command is dynamically registered.
*/
rebuildCommandIndex() {
this.commandIndex.clear();
for (const key of Object.keys(this.commands)) {
const cmd = this.commands[key];
const entry = {
name: cmd.name.toLowerCase(),
command: cmd,
aliases: cmd.aliases.map((a) => a.toLowerCase())
};
this.commandIndex.set(entry.name, entry);
for (const alias of entry.aliases) {
this.commandIndex.set(alias, entry);
}
}
}
/**
* Dynamically register a new command at runtime.
* Useful for plugin systems / hot-reload dev flows.
*/
registerCommand(name, command) {
this.commands[name] = command;
this.rebuildCommandIndex();
this.logger?.debug?.(`Command registered '${name}'`, `Bot:${this.name}#${this.id}`);
return this;
}
/**
* Dynamically register a middleware (appended to the chain).
*/
use(mw) {
this.middlewares.push(mw);
this.logger?.debug?.(`Middleware registered (#${this.middlewares.length})`, `Bot:${this.name}#${this.id}`);
return this;
}
/**
* Dynamically register an adapter.
*/
registerAdapter(key, adapter) {
this.adapters[key] = adapter;
this.logger?.debug?.(`Adapter registered '${key}'`, `Bot:${this.name}#${this.id}`);
return this;
}
/**
* Hook executed before processing pipeline. Runs in registration order.
*/
onPreProcess(hook) {
this.preProcessHooks.push(hook);
return this;
}
/**
* Hook executed after successful processing (not on thrown errors).
*/
onPostProcess(hook) {
this.postProcessHooks.push(hook);
return this;
}
/**
* Emits a bot event to registered listeners manually.
*/
async emit(event, ctx) {
const listeners = this.listeners[event];
if (listeners?.length) {
await Promise.all(listeners.map((l) => l(ctx)));
}
}
/**
* Adapter factory helper (legacy static name kept for backwards compatibility).
* Now logger-aware: logger will be injected at call sites (init/send/handle).
*/
static adapter(adapter) {
return (config) => ({
name: adapter.name,
parameters: adapter.parameters,
async send(params) {
return adapter.send({ ...params, config, logger: params.logger });
},
async handle(params) {
return adapter.handle({ ...params, config, logger: params.logger });
},
async init(options) {
await adapter.init({
config,
commands: options?.commands || [],
logger: options?.logger
});
}
});
}
/**
* Factory for constructing a Bot with strong typing.
*/
static create(config) {
return new _Bot(config);
}
/**
* Register (subscribe) to a lifecycle/event stream.
*/
on(event, callback) {
if (!this.listeners[event]) {
this.listeners[event] = [];
}
this.listeners[event].push(callback);
this.logger?.debug?.(`Listener registered '${event}'`, `Bot:${this.name}#${this.id}`);
}
/**
* Resolve command by name or alias (case-insensitive).
*/
resolveCommand(raw) {
return this.commandIndex.get(raw.toLowerCase());
}
/**
* Sends a message (provider abstraction).
*/
async send(params) {
const adapter = this.adapters[params.provider];
if (!adapter) {
const err = new BotError(BotErrorCodes.PROVIDER_NOT_FOUND, `Provider ${params.provider} not found`, {
provider: params.provider
});
this.logger?.error?.(err.message, `Bot:${this.name}#${this.id}`, err.meta);
throw err;
}
await adapter.send({
provider: params.provider,
channel: params.channel,
content: params.content,
logger: this.logger
});
this.logger?.debug?.(
`Message sent {provider=${params.provider}, channel=${params.channel}}`,
`Bot:${this.name}#${this.id}`
);
}
/**
* Core processing pipeline:
* 1. preProcess hooks
* 2. middleware chain
* 3. listeners for event
* 4. command execution (if command)
* 5. postProcess hooks
*/
async process(ctx) {
for (const hook of this.preProcessHooks) {
await hook(ctx);
}
let index = 0;
const runner = async () => {
if (index < this.middlewares.length) {
const current = this.middlewares[index++];
try {
await current(ctx, runner);
} catch (err) {
this.logger?.warn?.(
`Middleware error at position ${index - 1}: ${err?.message || err}`,
`Bot:${this.name}#${this.id}`
);
await this.emit("error", {
...ctx,
// @ts-expect-error extension (not public on type)
error: err
});
}
}
};
await runner();
const listeners = this.listeners[ctx.event];
if (listeners?.length) {
await Promise.all(listeners.map((l) => l(ctx)));
}
if (ctx.event === "message" && ctx.message.content?.type === "command") {
const cmdToken = ctx.message.content.command;
const entry = this.resolveCommand(cmdToken);
if (!entry) {
this.logger?.warn?.(
`Command not found '${cmdToken}'`,
`Bot:${this.name}#${this.id}`
);
await this.emit("error", {
...ctx,
// @ts-expect-error augment error
error: new BotError(BotErrorCodes.COMMAND_NOT_FOUND, `Command '${cmdToken}' not registered`)
});
} else {
try {
await entry.command.handle(ctx, ctx.message.content.params);
this.logger?.debug?.(
`Command executed '${entry.name}' (alias used: ${cmdToken !== entry.name ? cmdToken : "no"})`,
`Bot:${this.name}#${this.id}`
);
} catch (err) {
this.logger?.warn?.(
`Command error '${entry.name}': ${err?.message || err}`,
`Bot:${this.name}#${this.id}`
);
if (entry.command.help) {
await this.send({
provider: ctx.provider,
channel: ctx.channel.id,
content: { type: "text", content: entry.command.help }
});
}
await this.emit("error", {
...ctx,
// @ts-expect-error augment
error: new BotError(
BotErrorCodes.INVALID_COMMAND_PARAMETERS,
err?.message || "Invalid command parameters"
)
});
}
}
}
for (const hook of this.postProcessHooks) {
await hook(ctx);
}
}
/**
* Handle an incoming request from a provider (adapter).
*
* Contract:
* - If adapter returns `null`, we respond 204 (ignored update).
* - If adapter returns a context object, we process it and return 200.
* - Any error thrown bubbles up unless caught externally.
*/
async handle(adapter, request) {
const selectedAdapter = this.adapters[adapter];
if (!selectedAdapter) {
const err = new BotError(BotErrorCodes.PROVIDER_NOT_FOUND, `No adapter '${String(adapter)}'`);
this.logger?.error?.(err.message, `Bot:${this.name}#${this.id}`);
throw err;
}
const rawContext = await selectedAdapter.handle({ request, logger: this.logger });
if (!rawContext) {
this.logger?.debug?.(
`Adapter '${String(adapter)}' returned null (ignored update)`,
`Bot:${this.name}#${this.id}`
);
return new Response(null, { status: 204 });
}
const ctx = {
...rawContext,
bot: {
id: this.id,
name: this.name,
send: async (params) => this.send(params)
}
};
this.logger?.debug?.(
`Inbound event '${ctx.event}' from '${String(adapter)}'`,
`Bot:${this.name}#${this.id}`
);
await this.process(ctx);
return new Response("OK", {
status: 200,
headers: { "Content-Type": "application/json" }
});
}
/**
* Initialize all adapters (idempotent at adapter level).
* Passes current command list so adapters can perform platform-side registration (webhooks/commands).
*/
async start() {
const commandArray = Object.values(this.commands || {});
for (const adapter of Object.values(this.adapters)) {
this.logger?.debug?.(
`Initializing adapter '${adapter.name}'`,
`Bot:${this.name}#${this.id}`
);
await adapter.init({ commands: commandArray, logger: this.logger });
this.logger?.debug?.(
`Adapter '${adapter.name}' initialized`,
`Bot:${this.name}#${this.id}`
);
}
}
};
// src/utils/try-catch.ts
async function tryCatch(input) {
try {
const value = await (typeof input === "function" ? input() : input);
return { data: value };
} catch (err) {
return { error: err };
}
}
var TelegramAdapterParams = z.object({
token: z.string().min(1, "Telegram Bot Token is required.").describe("Telegram Bot API token"),
handle: z.string().describe("Use @your_bot_username to call bot on groups."),
webhook: z.object({
url: z.string().url("Webhook URL must be a valid URL.").optional().describe("Public HTTPS endpoint for Telegram to POST updates"),
secret: z.string().min(1).max(100).optional().describe("Optional secret token to validate webhook authenticity")
}).optional().describe("Optional webhook configuration")
}).describe("Configuration parameters for the Telegram adapter");
var TelegramUpdateSchema = z.object({
update_id: z.number().describe("Unique identifier for this update"),
message: z.object({
message_id: z.number().describe("Unique message identifier inside this chat"),
from: z.object({
id: z.number().describe("Unique Telegram user identifier"),
is_bot: z.boolean().describe("Whether the sender is a bot"),
first_name: z.string().describe("Sender first name"),
last_name: z.string().optional().describe("Sender last name"),
username: z.string().optional().describe("Sender @username"),
language_code: z.string().optional().describe("IETF language code")
}).describe("Sender of the message"),
chat: z.object({
id: z.number().describe("Unique chat identifier"),
first_name: z.string().optional().describe("Private chat first name"),
last_name: z.string().optional().describe("Private chat last name"),
username: z.string().optional().describe("Private chat username"),
title: z.string().optional().describe("Group/channel title"),
type: z.enum(["private", "group", "supergroup", "channel"]).describe("Chat type discriminator")
}).describe("Chat to which the message belongs"),
date: z.number().describe("Message date (Unix time)"),
text: z.string().optional().describe("Text of the message (if text message)"),
photo: z.array(
z.object({
file_id: z.string().describe("File identifier to download or reuse"),
file_unique_id: z.string().describe("Unique file identifier"),
file_size: z.number().optional(),
width: z.number().optional(),
height: z.number().optional()
})
).optional().describe("Array of PhotoSize objects (different sizes)"),
document: z.object({
file_id: z.string(),
file_unique_id: z.string(),
file_name: z.string().optional(),
mime_type: z.string().optional(),
file_size: z.number().optional()
}).optional().describe("Document message attachment"),
audio: z.object({
file_id: z.string(),
file_unique_id: z.string(),
duration: z.number().optional(),
mime_type: z.string().optional(),
file_size: z.number().optional(),
file_name: z.string().optional()
}).optional().describe("Audio file attachment"),
voice: z.object({
file_id: z.string(),
file_unique_id: z.string(),
duration: z.number().optional(),
mime_type: z.string().optional(),
file_size: z.number().optional()
}).optional().describe("Voice message attachment"),
caption: z.string().optional().describe("Media caption")
}).optional().describe("Message data (present for message updates)")
}).describe("Telegram Update object subset leveraged by the adapter");
// src/adapters/telegram/telegram.helpers.ts
var getServiceURL = (token, url) => `https://api.telegram.org/bot${token}${url}`;
function escapeMarkdownV2(text) {
if (!text) return "";
return text.replace(/([_*[\]()~`>#+\-=|{}.!\\])/g, "\\$1");
}
function parseTelegramMessageContent(text) {
if (!text) return void 0;
if (text.startsWith("/")) {
const [commandWithSlash, ...args] = text.trim().split(" ");
const command = commandWithSlash.slice(1);
return {
type: "command",
command,
params: args,
raw: text
};
}
return {
type: "text",
content: text,
raw: text
};
}
function guessMimeType(fileName) {
if (!fileName) return "application/octet-stream";
const ext = fileName.split(".").pop()?.toLowerCase();
switch (ext) {
case "jpg":
case "jpeg":
return "image/jpeg";
case "png":
return "image/png";
case "gif":
return "image/gif";
case "webp":
return "image/webp";
case "bmp":
return "image/bmp";
case "svg":
return "image/svg+xml";
default:
return "application/octet-stream";
}
}
async function fetchTelegramFileAsFile(fileId, token, fileName, mimeType, forceJpeg = false) {
const res = await fetch(getServiceURL(token, `/getFile?file_id=${fileId}`));
const data = await res.json();
if (!res.ok || !data.ok) throw new Error("Failed to get Telegram file path");
const filePath = data.result.file_path;
const fileUrl = `https://api.telegram.org/file/bot${token}/${filePath}`;
const fileRes = await fetch(fileUrl);
if (!fileRes.ok) throw new Error("Failed to download Telegram file");
const arrayBuffer = await fileRes.arrayBuffer();
const name = fileName || filePath.split("/").pop() || "file";
let type = mimeType || fileRes.headers.get("content-type") || "";
if (forceJpeg) {
type = "image/jpeg";
} else if (!type || type === "application/octet-stream") {
type = guessMimeType(name);
}
const file = new File([arrayBuffer], name, { type });
return {
file,
base64: Buffer.from(arrayBuffer).toString("base64"),
mimeType: type,
fileName: name
};
}
// src/adapters/telegram/telegram.adapter.ts
var telegram = Bot.adapter({
name: "telegram",
parameters: TelegramAdapterParams,
/**
* Initializes the adapter: cleans previous webhook, registers commands, sets new webhook.
*/
init: async ({ config, commands, logger }) => {
if (config.webhook?.url) {
try {
const body = { url: config.webhook.url };
if (config.webhook.secret) {
body.secret_token = config.webhook.secret;
}
await fetch(getServiceURL(config.token, "/deleteWebhook"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
}).then((r) => r.json()).catch(() => {
});
const commandsPayload = {
commands: commands.map((cmd) => ({
command: cmd.name,
description: cmd.description
})),
scope: { type: "all_group_chats" }
};
await fetch(getServiceURL(config.token, "/deleteMyCommands"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({})
});
await fetch(getServiceURL(config.token, "/setMyCommands"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(commandsPayload)
}).then((r) => r.json()).catch(() => {
});
const response = await fetch(getServiceURL(config.token, "/setWebhook"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const result = await response.json();
if (!response.ok || !result.ok) {
logger?.error?.("[telegram] webhook setup failed", { result });
throw new Error(`Failed to set Telegram webhook: ${result.description || response.statusText}`);
}
logger?.info?.("[telegram] webhook configured");
} catch (error) {
logger?.error?.("[telegram] initialization error", error);
throw error;
}
} else {
logger?.info?.("[telegram] initialized without webhook (URL not provided)");
}
},
/**
* Sends a text message (MarkdownV2 escaped) to Telegram.
*/
send: async (params) => {
const { logger } = params;
const apiUrl = getServiceURL(params.config.token, "/sendMessage");
try {
const safeText = escapeMarkdownV2(params.content.content);
const response = await fetch(apiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: params.channel,
text: safeText,
parse_mode: "MarkdownV2"
})
});
const result = await response.json();
if (!response.ok || !result.ok) {
logger?.error?.("[telegram] send failed", { result });
throw new Error(`Telegram API error: ${result.description || response.statusText}`);
}
logger?.debug?.("[telegram] message sent", { channel: params.channel });
} catch (error) {
logger?.error?.("[telegram] send error", error);
throw error;
}
},
// Handle Telegram webhook: return bot context or null if unhandled
handle: async ({ request, config, logger }) => {
if (config.webhook?.secret) {
const secretTokenHeader = request.headers.get(
"X-Telegram-Bot-Api-Secret-Token"
);
if (secretTokenHeader !== config.webhook.secret) {
logger?.warn?.("[telegram] invalid secret token received");
throw new Error("Unauthorized: Invalid secret token.");
}
}
const updateData = await tryCatch(request.json());
const parsedUpdate = TelegramUpdateSchema.safeParse(updateData.data);
if (!parsedUpdate.success) {
logger?.warn?.("[telegram] invalid update structure", {
errors: parsedUpdate.error.errors
});
throw new Error(
`Invalid Telegram update structure: ${parsedUpdate.error.message}`
);
}
const update = parsedUpdate.data;
const attachments = [];
let content;
if (update.message) {
const msg = update.message;
const author = msg.from;
const chat = msg.chat;
const isGroup = ["group", "supergroup"].includes(chat.type);
let isMentioned = false;
const messageText = msg.text || msg.caption || "";
if (isGroup) {
const botUsername = config.handle?.replace("@", "") || "";
isMentioned = messageText.includes(`@${botUsername}`) || messageText.startsWith("/");
} else {
isMentioned = true;
}
if (msg.text) {
content = parseTelegramMessageContent(msg.text);
} else if (msg.photo && msg.photo.length > 0) {
const photo = msg.photo[msg.photo.length - 1];
const { file, base64, mimeType, fileName } = await fetchTelegramFileAsFile(
photo.file_id,
config.token,
void 0,
void 0,
true
);
attachments.push({ name: fileName, type: mimeType, content: base64 });
content = {
type: "image",
content: base64,
file,
caption: msg.caption
};
} else if (msg.document) {
const { file, base64, mimeType, fileName } = await fetchTelegramFileAsFile(
msg.document.file_id,
config.token,
msg.document.file_name,
msg.document.mime_type
);
attachments.push({ name: fileName, type: mimeType, content: base64 });
content = {
type: "document",
content: base64,
file
};
} else if (msg.audio) {
const { file, base64, mimeType, fileName } = await fetchTelegramFileAsFile(
msg.audio.file_id,
config.token,
msg.audio.file_name,
msg.audio.mime_type
);
attachments.push({ name: fileName, type: mimeType, content: base64 });
content = {
type: "audio",
content: base64,
file
};
} else if (msg.voice) {
const { file, base64, mimeType, fileName } = await fetchTelegramFileAsFile(
msg.voice.file_id,
config.token,
void 0,
msg.voice.mime_type
);
attachments.push({ name: fileName, type: mimeType, content: base64 });
content = {
type: "audio",
content: base64,
file
};
}
if (content) {
return {
event: "message",
provider: "telegram",
channel: {
id: String(chat.id),
name: chat.title || chat.first_name || String(chat.id),
isGroup
},
message: {
content,
attachments,
author: {
id: String(author.id),
name: `${author.first_name}${author.last_name ? ` ${author.last_name}` : ""}`,
username: author.username || "unknown"
},
isMentioned
}
};
}
}
return null;
}
});
// src/adapters/whatsapp/whatsapp.helpers.ts
var parsers = {
/**
* Parses a WhatsApp text message object and returns either:
* - BotCommandContent (if starts with '/')
* - BotTextContent (plain text)
*
* Ignores non-text messages.
*
* @param message Raw WhatsApp message object (single message entity).
* @returns BotCommandContent | BotTextContent | undefined
*/
text(message) {
function parseWhatsAppMessageContent(text) {
if (!text) return void 0;
if (text.startsWith("/")) {
const [commandWithSlash, ...args] = text.trim().split(" ");
const command = commandWithSlash.slice(1);
return {
type: "command",
command,
params: args,
raw: text
};
}
return {
type: "text",
content: text,
raw: text
};
}
if (message?.text?.body) {
return parseWhatsAppMessageContent(message.text.body);
}
return void 0;
},
/**
* Parses WhatsApp media messages (image, document, audio), downloads the
* underlying binary via the Cloud API, converts it to a base64 representation
* and returns the corresponding BotContent variant.
*
* Side Effect:
* - Pushes an attachment descriptor to the provided attachments array.
*
* @param message Raw WhatsApp message (expected to contain one media type).
* @param token WhatsApp API access token.
* @param attachments Mutable array collecting attachment metadata.
* @returns BotImageContent | BotDocumentContent | BotAudioContent | undefined
*/
async media(message, token, attachments) {
async function fetchWhatsAppMedia(mediaId2, token2) {
const mediaUrlRes = await fetch(
`https://graph.facebook.com/v17.0/${mediaId2}`,
{
headers: { Authorization: `Bearer ${token2}` }
}
);
const mediaUrlData = await mediaUrlRes.json();
if (!mediaUrlRes.ok || !mediaUrlData.url) {
throw new Error("Failed to fetch WhatsApp media URL");
}
const mediaRes = await fetch(mediaUrlData.url, {
headers: { Authorization: `Bearer ${token2}` }
});
if (!mediaRes.ok) {
throw new Error("Failed to download WhatsApp media file");
}
const arrayBuffer = await mediaRes.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const base64 = buffer.toString("base64");
const mimeType = mediaRes.headers.get("content-type") || "application/octet-stream";
const fileName = mediaId2;
let file;
try {
file = new File([arrayBuffer], fileName, { type: mimeType });
} catch {
file = { name: fileName, type: mimeType, size: buffer.length };
}
return { base64, mimeType, file };
}
let mediaType;
let mediaObj;
let caption;
if (message?.image && typeof message.image === "object" && "id" in message.image) {
mediaType = "image";
mediaObj = message.image;
caption = message.image.caption;
} else if (message?.document && typeof message.document === "object" && "id" in message.document) {
mediaType = "document";
mediaObj = message.document;
} else if (message?.audio && typeof message.audio === "object" && "id" in message.audio) {
mediaType = "audio";
mediaObj = message.audio;
}
if (!mediaType || !mediaObj) return void 0;
const mediaId = mediaObj.id;
const media = await fetchWhatsAppMedia(mediaId, token);
attachments.push({
name: mediaId,
type: media.mimeType,
content: media.base64
});
if (mediaType === "image") {
return {
type: "image",
content: media.base64,
file: media.file,
caption
};
}
if (mediaType === "document") {
return {
type: "document",
content: media.base64,
file: media.file
};
}
if (mediaType === "audio") {
return {
type: "audio",
content: media.base64,
file: media.file
};
}
return void 0;
}
};
var WhatsAppAdapterParams = z.object({
handle: z.string().describe("Telegram Bot Username for Group handlers. Use @your_bot_username to call bot on groups."),
token: z.string().min(1, "WhatsApp API Token is required.").describe("WhatsApp Cloud API access token"),
phone: z.string().min(1, "Phone is required.").describe("WhatsApp phone number ID (phone_number_id)")
}).describe("Adapter configuration for WhatsApp integration");
var WhatsAppContactSchema = z.object({
wa_id: z.string().describe("WhatsApp unique user identifier"),
profile: z.object({
name: z.string().optional().describe("Display name (if provided)")
}).optional().describe("Optional profile metadata")
}).describe("Contact metadata entry from WhatsApp webhook payload");
var WhatsAppMessageSchema = z.object({
id: z.string().describe("Unique message ID"),
from: z.string().describe("Sender WhatsApp ID (phone)"),
type: z.enum(["text", "image", "document", "audio"]).describe("Message kind"),
text: z.object({ body: z.string().describe("Text body content") }).optional().describe("Text message payload"),
image: z.custom().optional().describe("Image media file reference"),
document: z.custom().optional().describe("Document media file reference"),
audio: z.custom().optional().describe("Audio / voice media file reference"),
timestamp: z.string().describe("Message creation timestamp (epoch seconds as string)")
}).describe("Normalized WhatsApp message entity");
var WhatsAppWebhookValueSchema = z.object({
messaging_product: z.string().describe("Messaging product identifier"),
metadata: z.object({
display_phone_number: z.string().optional().describe("Human-readable display phone number"),
phone_number_id: z.string().optional().describe("Internal phone number ID reference")
}).optional().describe("Webhook metadata for the receiving business number"),
contacts: z.array(WhatsAppContactSchema).optional().describe("Contacts referenced in this webhook change"),
messages: z.array(WhatsAppMessageSchema).optional().describe("Messages included in this webhook change")
}).describe("Primary value object of a WhatsApp webhook change entry");
var WhatsAppWebhookSchema = z.object({
field: z.string().describe("Changed field (e.g., messages)"),
value: WhatsAppWebhookValueSchema.describe("Structured payload for the change")
}).describe("Single WhatsApp webhook change record upper wrapper");
// src/adapters/whatsapp/whatsapp.adapter.ts
var whatsapp = Bot.adapter({
name: "whatsapp",
parameters: WhatsAppAdapterParams,
/**
* Initialization hook (noop for now). Kept for parity with other adapters.
*/
init: async ({ logger }) => {
logger?.info?.("[whatsapp] adapter initialized (manual webhook management)");
},
/**
* Sends a plain text message to a WhatsApp destination.
* @param params.message - text content already validated by upstream caller
* @throws Error if API responds with failure
*/
send: async (params) => {
const { token, phone } = params.config;
const { logger } = params;
const apiUrl = `https://graph.facebook.com/v22.0/${phone}/messages`;
const body = {
messaging_product: "whatsapp",
recipient_type: "individual",
to: params.channel,
type: "text",
text: { body: params.content.content }
};
try {
logger?.debug?.("[whatsapp] sending message", {
channel: params.channel,
length: body.text.body.length
});
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
},
body: JSON.stringify(body)
});
const result = await response.json();
if (!response.ok || result.error) {
logger?.error?.("[whatsapp] send failed", { result });
throw new Error(result.error?.message || response.statusText);
}
logger?.debug?.("[whatsapp] message sent", { messageId: result.messages?.[0]?.id });
} catch (error) {
logger?.error?.("[whatsapp] send error", error);
throw error;
}
},
/**
* Parses an inbound WhatsApp webhook update and returns a normalized BotContext
* or null when the update does not contain a supported message event.
*
* @returns BotContext without the bot field, or null to ignore update
*/
handle: async ({ request, config, logger }) => {
const updateData = await tryCatch(request.json());
if (updateData.error) {
logger?.warn?.("[whatsapp] failed to parse JSON body", { error: updateData.error });
throw updateData.error;
}
const parsed = updateData.data.entry[0].changes[0];
const value = parsed.value;
const message = value.messages?.[0];
const attachments = [];
if (!message) {
logger?.debug?.("[whatsapp] ignoring update without message");
return null;
}
const authorId = message.from;
const authorName = value.contacts?.[0]?.profile?.name || authorId;
const channelId = parsed.value.contacts?.[0].wa_id;
const isGroup = channelId?.includes("@g.us") || false;
let isMentioned = false;
let messageText = "";
if (message.type === "text" && message.text?.body) {
messageText = message.text.body;
}
if (isGroup) {
const botKeywords = [config.handle];
isMentioned = botKeywords.some(
(keyword) => messageText.toLowerCase().includes(keyword.toLowerCase())
);
} else {
isMentioned = true;
}
let content;
switch (message.type) {
case "text":
content = parsers.text(message);
break;
case "image":
case "document":
case "audio":
content = await parsers.media(message, config.token, attachments);
break;
default:
content = void 0;
}
logger?.debug?.("[whatsapp] inbound message parsed", {
type: message.type,
hasContent: Boolean(content),
isGroup,
isMentioned
});
return {
event: "message",
provider: "whatsapp",
channel: {
id: channelId,
name: value.metadata?.display_phone_number || channelId,
isGroup
},
message: {
content,
attachments,
author: {
id: authorId,
name: authorName,
username: authorId
},
isMentioned
}
};
}
});
// src/index.ts
var adapters = {
telegram,
whatsapp
};
var VERSION = (
// @ts-expect-error - Optional global replacement hook
typeof __IGNITER_BOT_VERSION__ !== "undefined" ? (
// @ts-expect-error - Provided by build tooling if configured
__IGNITER_BOT_VERSION__
) : "0.0.0-dev"
);
export { Bot, BotError, BotErrorCodes, TelegramAdapterParams, TelegramUpdateSchema, VERSION, WhatsAppAdapterParams, WhatsAppContactSchema, WhatsAppMessageSchema, WhatsAppWebhookSchema, WhatsAppWebhookValueSchema, adapters, escapeMarkdownV2, fetchTelegramFileAsFile, getServiceURL, guessMimeType, parseTelegramMessageContent, parsers, telegram, whatsapp };
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map