diff --git a/apps/bridge-whatsapp/src/routes.ts b/apps/bridge-whatsapp/src/routes.ts index 9219bf0..2b76cb8 100644 --- a/apps/bridge-whatsapp/src/routes.ts +++ b/apps/bridge-whatsapp/src/routes.ts @@ -17,6 +17,7 @@ const getService = (request: Hapi.Request): WhatsappService => { interface MessageRequest { phoneNumber: string; message: string; + attachments?: Array<{ data: string; filename: string; mime_type: string }>; } export const SendMessageRoute = withDefaults({ @@ -26,10 +27,23 @@ export const SendMessageRoute = withDefaults({ description: "Send a message", async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) { const { id } = request.params; - const { phoneNumber, message } = request.payload as MessageRequest; + const { phoneNumber, message, attachments } = + request.payload as MessageRequest; const whatsappService = getService(request); - await whatsappService.send(id, phoneNumber, message as string); - request.logger.info({ id }, "Sent a message at %s", new Date()); + await whatsappService.send( + id, + phoneNumber, + message as string, + attachments, + ); + request.logger.info( + { + id, + attachmentCount: attachments?.length || 0, + }, + "Sent a message at %s", + new Date(), + ); return _h .response({ diff --git a/apps/bridge-whatsapp/src/service.ts b/apps/bridge-whatsapp/src/service.ts index a52fd73..2a73a41 100644 --- a/apps/bridge-whatsapp/src/service.ts +++ b/apps/bridge-whatsapp/src/service.ts @@ -13,7 +13,7 @@ import makeWASocket, { import fs from "fs"; import { createLogger } from "@link-stack/logger"; -const logger = createLogger('bridge-whatsapp-service'); +const logger = createLogger("bridge-whatsapp-service"); export type AuthCompleteCallback = (error?: string) => void; @@ -60,7 +60,7 @@ export default class WhatsappService extends Service { try { connection.end(null); } catch (error) { - logger.error({ error }, 'Connection reset error'); + logger.error({ error }, "Connection reset error"); } } this.connections = {}; @@ -95,27 +95,27 @@ export default class WhatsappService extends Service { isNewLogin, } = update; if (qr) { - logger.info('got qr code'); + logger.info("got qr code"); const botDirectory = this.getBotDirectory(botID); const qrPath = `${botDirectory}/qr.txt`; fs.writeFileSync(qrPath, qr, "utf8"); } else if (isNewLogin) { - logger.info('got new login'); + logger.info("got new login"); const botDirectory = this.getBotDirectory(botID); const verifiedFile = `${botDirectory}/verified`; fs.writeFileSync(verifiedFile, ""); } else if (connectionState === "open") { - logger.info('opened connection'); + logger.info("opened connection"); } else if (connectionState === "close") { - logger.info({ lastDisconnect }, 'connection closed'); + logger.info({ lastDisconnect }, "connection closed"); const disconnectStatusCode = (lastDisconnect?.error as any)?.output ?.statusCode; if (disconnectStatusCode === DisconnectReason.restartRequired) { - logger.info('reconnecting after got new login'); + logger.info("reconnecting after got new login"); await this.createConnection(botID, server, options); authCompleteCallback?.(); } else if (disconnectStatusCode !== DisconnectReason.loggedOut) { - logger.info('reconnecting'); + logger.info("reconnecting"); await this.sleep(pause); pause *= 2; this.createConnection(botID, server, options); @@ -124,12 +124,12 @@ export default class WhatsappService extends Service { } if (events["creds.update"]) { - logger.info('creds update'); + logger.info("creds update"); await saveCreds(); } if (events["messages.upsert"]) { - logger.info('messages upsert'); + logger.info("messages upsert"); const upsert = events["messages.upsert"]; const { messages } = upsert; if (messages) { @@ -152,7 +152,10 @@ export default class WhatsappService extends Service { const verifiedFile = `${directory}/verified`; if (fs.existsSync(verifiedFile)) { const { version, isLatest } = await fetchLatestBaileysVersion(); - logger.info({ version: version.join('.'), isLatest }, 'using WA version'); + logger.info( + { version: version.join("."), isLatest }, + "using WA version", + ); await this.createConnection(botID, this.server, { browser: WhatsappService.browserDescription, @@ -172,9 +175,12 @@ export default class WhatsappService extends Service { message, messageTimestamp, } = webMessageInfo; - logger.info('Message type debug'); + logger.info("Message type debug"); for (const key in message) { - logger.info({ key, exists: !!message[key as keyof proto.IMessage] }, 'Message field'); + logger.info( + { key, exists: !!message[key as keyof proto.IMessage] }, + "Message field", + ); } const isValidMessage = message && remoteJid !== "status@broadcast" && !fromMe; @@ -299,10 +305,45 @@ export default class WhatsappService extends Service { botID: string, phoneNumber: string, message: string, + attachments?: Array<{ data: string; filename: string; mime_type: string }>, ): Promise { const connection = this.connections[botID]?.socket; const recipient = `${phoneNumber.replace(/\D+/g, "")}@s.whatsapp.net`; - await connection.sendMessage(recipient, { text: message }); + + // Send text message if provided + if (message) { + await connection.sendMessage(recipient, { text: message }); + } + + // Send attachments if provided + if (attachments && attachments.length > 0) { + for (const attachment of attachments) { + const buffer = Buffer.from(attachment.data, "base64"); + + if (attachment.mime_type.startsWith("image/")) { + await connection.sendMessage(recipient, { + image: buffer, + caption: attachment.filename, + }); + } else if (attachment.mime_type.startsWith("video/")) { + await connection.sendMessage(recipient, { + video: buffer, + caption: attachment.filename, + }); + } else if (attachment.mime_type.startsWith("audio/")) { + await connection.sendMessage(recipient, { + audio: buffer, + mimetype: attachment.mime_type, + }); + } else { + await connection.sendMessage(recipient, { + document: buffer, + fileName: attachment.filename, + mimetype: attachment.mime_type, + }); + } + } + } } async receive( diff --git a/apps/bridge-worker/tasks/signal/send-signal-message.ts b/apps/bridge-worker/tasks/signal/send-signal-message.ts index 57a7f4d..5b00bbc 100644 --- a/apps/bridge-worker/tasks/signal/send-signal-message.ts +++ b/apps/bridge-worker/tasks/signal/send-signal-message.ts @@ -3,7 +3,7 @@ import { createLogger } from "@link-stack/logger"; import * as signalApi from "@link-stack/signal-api"; const { Configuration, MessagesApi, GroupsApi } = signalApi; -const logger = createLogger('bridge-worker-send-signal-message'); +const logger = createLogger("bridge-worker-send-signal-message"); interface SendSignalMessageTaskOptions { token: string; @@ -13,6 +13,11 @@ interface SendSignalMessageTaskOptions { quoteMessage?: string; // Optional: message text to quote quoteAuthor?: string; // Optional: author of quoted message (phone number) quoteTimestamp?: number; // Optional: timestamp of quoted message in milliseconds + attachments?: Array<{ + data: string; // base64 + filename: string; + mime_type: string; + }>; } const sendSignalMessageTask = async ({ @@ -23,13 +28,17 @@ const sendSignalMessageTask = async ({ quoteMessage, quoteAuthor, quoteTimestamp, + attachments, }: SendSignalMessageTaskOptions): Promise => { - logger.debug({ - token, - to, - conversationId, - messageLength: message?.length, - }, 'Processing outgoing message'); + logger.debug( + { + token, + to, + conversationId, + messageLength: message?.length, + }, + "Processing outgoing message", + ); const bot = await db .selectFrom("SignalBot") .selectAll() @@ -58,12 +67,16 @@ const sendSignalMessageTask = async ({ const isGroupId = isUUID || isGroupPrefix || isBase64; const enableAutoGroups = process.env.BRIDGE_SIGNAL_AUTO_GROUPS === "true"; - logger.debug({ - to, - isGroupId, - enableAutoGroups, - shouldCreateGroup: enableAutoGroups && !isGroupId && to && conversationId, - }, 'Recipient analysis'); + logger.debug( + { + to, + isGroupId, + enableAutoGroups, + shouldCreateGroup: + enableAutoGroups && !isGroupId && to && conversationId, + }, + "Recipient analysis", + ); // If sending to a phone number and auto-groups is enabled, create a group first if (enableAutoGroups && !isGroupId && to && conversationId) { @@ -93,7 +106,7 @@ const sendSignalMessageTask = async ({ const createdGroup = groups.find((g) => g.id === finalTo); if (createdGroup && createdGroup.internalId) { internalId = createdGroup.internalId; - logger.debug({ internalId }, 'Got actual internalId'); + logger.debug({ internalId }, "Got actual internalId"); } else { // Fallback: extract base64 part from ID if (finalTo.startsWith("group.")) { @@ -101,20 +114,23 @@ const sendSignalMessageTask = async ({ } } } catch (fetchError) { - logger.debug('Could not fetch group details, using ID base64 part'); + logger.debug("Could not fetch group details, using ID base64 part"); // Fallback: extract base64 part from ID if (finalTo.startsWith("group.")) { internalId = finalTo.substring(6); } } - logger.debug({ - groupId: finalTo, - internalId, - groupName, - conversationId, - originalRecipient: to, - botNumber: bot.phoneNumber, - }, 'Created new Signal group'); + logger.debug( + { + groupId: finalTo, + internalId, + groupName, + conversationId, + originalRecipient: to, + botNumber: bot.phoneNumber, + }, + "Created new Signal group", + ); // Notify Zammad about the new group ID via webhook await worker.addJob("common/notify-webhooks", { @@ -130,23 +146,30 @@ const sendSignalMessageTask = async ({ }); } } catch (groupError) { - logger.error({ - error: groupError instanceof Error ? groupError.message : groupError, - to, - conversationId, - }, 'Error creating Signal group'); + logger.error( + { + error: + groupError instanceof Error ? groupError.message : groupError, + to, + conversationId, + }, + "Error creating Signal group", + ); // Continue with original recipient if group creation fails } } - logger.debug({ - fromNumber: number, - toRecipient: finalTo, - originalTo: to, - recipientChanged: to !== finalTo, - groupCreated, - isGroupRecipient: finalTo.startsWith("group."), - }, 'Sending message via API'); + logger.debug( + { + fromNumber: number, + toRecipient: finalTo, + originalTo: to, + recipientChanged: to !== finalTo, + groupCreated, + isGroupRecipient: finalTo.startsWith("group."), + }, + "Sending message via API", + ); // Build the message data with optional quote parameters const messageData: signalApi.ApiSendMessageV2 = { @@ -155,12 +178,15 @@ const sendSignalMessageTask = async ({ message, }; - logger.debug({ - number, - recipients: [finalTo], - message: message.substring(0, 50) + "...", - hasQuoteParams: !!(quoteMessage && quoteAuthor && quoteTimestamp), - }, 'Message data being sent'); + logger.debug( + { + number, + recipients: [finalTo], + message: message.substring(0, 50) + "...", + hasQuoteParams: !!(quoteMessage && quoteAuthor && quoteTimestamp), + }, + "Message data being sent", + ); // Add quote parameters if all are provided if (quoteMessage && quoteAuthor && quoteTimestamp) { @@ -168,43 +194,64 @@ const sendSignalMessageTask = async ({ messageData.quoteAuthor = quoteAuthor; messageData.quoteMessage = quoteMessage; - logger.debug({ - quoteAuthor, - quoteMessage: quoteMessage.substring(0, 50) + "...", - quoteTimestamp, - }, 'Including quote in message'); + logger.debug( + { + quoteAuthor, + quoteMessage: quoteMessage.substring(0, 50) + "...", + quoteTimestamp, + }, + "Including quote in message", + ); + } + + // Add attachments if provided + if (attachments && attachments.length > 0) { + messageData.base64Attachments = attachments.map((att) => att.data); + logger.debug( + { + attachmentCount: attachments.length, + attachmentNames: attachments.map((att) => att.filename), + }, + "Including attachments in message", + ); } const response = await messagesClient.v2SendPost({ data: messageData, }); - logger.debug({ - to: finalTo, - groupCreated, - response: response?.timestamp || "no timestamp", - }, 'Message sent successfully'); + logger.debug( + { + to: finalTo, + groupCreated, + response: response?.timestamp || "no timestamp", + }, + "Message sent successfully", + ); } catch (error: any) { // Try to get the actual error message from the response if (error.response) { try { const errorBody = await error.response.text(); - logger.error({ - status: error.response.status, - statusText: error.response.statusText, - body: errorBody, - sentTo: finalTo, - messageDetails: { - fromNumber: number, - toRecipients: [finalTo], - hasQuote: !!quoteMessage, + logger.error( + { + status: error.response.status, + statusText: error.response.statusText, + body: errorBody, + sentTo: finalTo, + messageDetails: { + fromNumber: number, + toRecipients: [finalTo], + hasQuote: !!quoteMessage, + }, }, - }, 'Signal API error'); + "Signal API error", + ); } catch (e) { - logger.error('Could not parse error response'); + logger.error("Could not parse error response"); } } - logger.error({ error }, 'Full error details'); + logger.error({ error }, "Full error details"); throw error; } }; diff --git a/apps/bridge-worker/tasks/whatsapp/send-whatsapp-message.ts b/apps/bridge-worker/tasks/whatsapp/send-whatsapp-message.ts index 6370152..8e89d13 100644 --- a/apps/bridge-worker/tasks/whatsapp/send-whatsapp-message.ts +++ b/apps/bridge-worker/tasks/whatsapp/send-whatsapp-message.ts @@ -1,18 +1,24 @@ import { db } from "@link-stack/bridge-common"; import { createLogger } from "@link-stack/logger"; -const logger = createLogger('bridge-worker-send-whatsapp-message'); +const logger = createLogger("bridge-worker-send-whatsapp-message"); interface SendWhatsappMessageTaskOptions { token: string; to: string; message: any; + attachments?: Array<{ + data: string; + filename: string; + mime_type: string; + }>; } const sendWhatsappMessageTask = async ({ message, to, token, + attachments, }: SendWhatsappMessageTaskOptions): Promise => { const bot = await db .selectFrom("WhatsappBot") @@ -21,13 +27,38 @@ const sendWhatsappMessageTask = async ({ .executeTakeFirstOrThrow(); const url = `${process.env.BRIDGE_WHATSAPP_URL}/api/bots/${bot.id}/send`; - const params = { message, phoneNumber: to }; + const params: any = { message, phoneNumber: to }; + + if (attachments && attachments.length > 0) { + params.attachments = attachments; + logger.debug( + { + attachmentCount: attachments.length, + attachmentNames: attachments.map((att) => att.filename), + }, + "Sending WhatsApp message with attachments", + ); + } + try { const result = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params), }); + + if (!result.ok) { + const errorText = await result.text(); + logger.error( + { + status: result.status, + errorText, + url, + }, + "WhatsApp send failed", + ); + throw new Error(`Failed to send message: ${result.status}`); + } } catch (error) { logger.error({ error }); throw new Error("Failed to send message"); diff --git a/packages/zammad-addon-bridge/src/lib/cdr_signal.rb b/packages/zammad-addon-bridge/src/lib/cdr_signal.rb index c6f011f..7047bdd 100644 --- a/packages/zammad-addon-bridge/src/lib/cdr_signal.rb +++ b/packages/zammad-addon-bridge/src/lib/cdr_signal.rb @@ -334,6 +334,20 @@ class CdrSignal options = {} options[:conversationId] = ticket.number if ticket + # Get attachments from the article + attachments = Store.list(object: 'Ticket::Article', o_id: article.id) + if attachments.any? + attachment_data = attachments.map do |attachment| + { + data: Base64.strict_encode64(attachment.content), + filename: attachment.filename, + mime_type: attachment.preferences['Mime-Type'] || attachment.preferences['Content-Type'] || 'application/octet-stream' + } + end + options[:attachments] = attachment_data + Rails.logger.debug { "Sending #{attachment_data.length} attachment(s) with message" } + end + @api.send_message(recipient, article[:body], options) end end diff --git a/packages/zammad-addon-bridge/src/lib/cdr_signal_api.rb b/packages/zammad-addon-bridge/src/lib/cdr_signal_api.rb index 02ac423..d9e2f70 100644 --- a/packages/zammad-addon-bridge/src/lib/cdr_signal_api.rb +++ b/packages/zammad-addon-bridge/src/lib/cdr_signal_api.rb @@ -41,6 +41,10 @@ class CdrSignalApi params[:conversationId] = options[:conversationId] options.delete(:conversationId) end + if options[:attachments] + params[:attachments] = options[:attachments] + options.delete(:attachments) + end post('send', params.merge(parse_hash(options))) end end diff --git a/packages/zammad-addon-bridge/src/lib/cdr_whatsapp.rb b/packages/zammad-addon-bridge/src/lib/cdr_whatsapp.rb index e20303a..b066505 100644 --- a/packages/zammad-addon-bridge/src/lib/cdr_whatsapp.rb +++ b/packages/zammad-addon-bridge/src/lib/cdr_whatsapp.rb @@ -323,6 +323,22 @@ class CdrWhatsapp Rails.logger.debug { "Sending to recipient: '#{recipient}'" } - @api.send_message(recipient, article[:body]) + options = {} + + # Get attachments from the article + attachments = Store.list(object: 'Ticket::Article', o_id: article.id) + if attachments.any? + attachment_data = attachments.map do |attachment| + { + data: Base64.strict_encode64(attachment.content), + filename: attachment.filename, + mime_type: attachment.preferences['Mime-Type'] || attachment.preferences['Content-Type'] || 'application/octet-stream' + } + end + options[:attachments] = attachment_data + Rails.logger.debug { "Sending #{attachment_data.length} attachment(s) with message" } + end + + @api.send_message(recipient, article[:body], options) end end diff --git a/packages/zammad-addon-bridge/src/lib/cdr_whatsapp_api.rb b/packages/zammad-addon-bridge/src/lib/cdr_whatsapp_api.rb index d3f3ac8..16bc80b 100644 --- a/packages/zammad-addon-bridge/src/lib/cdr_whatsapp_api.rb +++ b/packages/zammad-addon-bridge/src/lib/cdr_whatsapp_api.rb @@ -35,6 +35,9 @@ class CdrWhatsappApi end def send_message(recipient, text, options = {}) - post('send', { to: recipient.to_s, message: text }.merge(parse_hash(options))) + params = { to: recipient.to_s, message: text } + params[:attachments] = options[:attachments] if options[:attachments] + options.delete(:attachments) if options[:attachments] + post('send', params.merge(parse_hash(options))) end end