UNPKG

@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
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