From 3d8f794cabf1c02676b52d272a05f709b37b953c Mon Sep 17 00:00:00 2001 From: Darren Clarke Date: Thu, 15 Jan 2026 10:01:15 +0100 Subject: [PATCH] Add user ID support for Baileys 7 LIDs and Signal UUIDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Baileys 7 uses LIDs (Linked IDs) instead of phone numbers in remoteJid for some messages. This caused messages to be matched to wrong tickets because the LID was used as the sender identifier. This commit adds proper support for both phone numbers and user IDs across WhatsApp and Signal channels. Changes: Database: - Add migration for whatsapp_user_id and signal_user_id fields on users table Zammad controllers: - Update user lookup with 3-step fallback: phone → dedicated user_id field → user_id in phone field (legacy) - Store user IDs in dedicated fields when available - Update phone field when we receive actual phone number for legacy records - Fix redundant condition in Signal controller Bridge services: - Extract both phone (from senderPn/participantPn) and LID (from remoteJid) - Send both identifiers to Zammad via webhooks - Use camelCase (userId) in bridge-whatsapp, convert to snake_case (user_id) in bridge-worker for Zammad compatibility Baileys 7 compliance: - Remove broken loadAllUnreadMessages() call (removed in Baileys 7) - Return descriptive error directing users to use webhooks instead Misc: - Add docs/ to .gitignore --- .gitignore | 1 + apps/bridge-whatsapp/src/service.ts | 50 ++++++++++++----- .../tasks/fetch-signal-messages.ts | 1 + .../tasks/signal/receive-signal-message.ts | 3 ++ .../whatsapp/receive-whatsapp-message.ts | 5 +- .../channels_cdr_signal_controller.rb | 52 +++++++++++++----- .../channels_cdr_whatsapp_controller.rb | 53 +++++++++++++++---- .../20260115000001_add_messaging_user_ids.rb | 25 +++++++++ 8 files changed, 154 insertions(+), 36 deletions(-) create mode 100644 packages/zammad-addon-bridge/src/db/addon/bridge/20260115000001_add_messaging_user_ids.rb diff --git a/.gitignore b/.gitignore index 4011990..9b270af 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ project.org apps/bridge-worker/scripts/* ENVIRONMENT_VARIABLES_MIGRATION.md local-scripts/* +docs/ diff --git a/apps/bridge-whatsapp/src/service.ts b/apps/bridge-whatsapp/src/service.ts index 41450cc..43bc205 100644 --- a/apps/bridge-whatsapp/src/service.ts +++ b/apps/bridge-whatsapp/src/service.ts @@ -193,13 +193,14 @@ export default class WhatsappService extends Service { return; } const { id, fromMe, remoteJid } = key; - logger.info("Message type debug"); - for (const key in message) { - logger.info( - { key, exists: !!message[key as keyof proto.IMessage] }, - "Message field", - ); - } + // Baileys 7 uses LIDs (Linked IDs) instead of phone numbers in some cases. + // senderPn contains the actual phone number when available. + const senderPn = (key as any).senderPn as string | undefined; + const participantPn = (key as any).participantPn as string | undefined; + logger.info( + { remoteJid, senderPn, participantPn, fromMe }, + "Processing incoming message", + ); const isValidMessage = message && remoteJid !== "status@broadcast" && !fromMe; if (isValidMessage) { const { audioMessage, documentMessage, imageMessage, videoMessage } = message; @@ -257,9 +258,27 @@ export default class WhatsappService extends Service { videoMessage, ].find((text) => text && text !== ""); + // Extract phone number and user ID (LID) separately + // remoteJid may contain LIDs (Baileys 7+) which are not phone numbers + const jidValue = remoteJid?.split("@")[0]; + const isLidJid = remoteJid?.endsWith("@lid"); + + // Phone number: prefer senderPn/participantPn, fall back to remoteJid only if it's not a LID + const senderPhone = senderPn?.split("@")[0] || participantPn?.split("@")[0] || (!isLidJid ? jidValue : undefined); + + // User ID (LID): extract from remoteJid if it's a LID format + const senderUserId = isLidJid ? jidValue : undefined; + + // Must have at least one identifier + if (!senderPhone && !senderUserId) { + logger.warn({ remoteJid, senderPn, participantPn }, "Could not determine sender identity, skipping message"); + return; + } + const payload = { to: botID, - from: remoteJid?.split("@")[0], + from: senderPhone, + userId: senderUserId, messageId: id, sentAt: new Date((messageTimestamp as number) * 1000).toISOString(), message: messageText, @@ -423,12 +442,17 @@ export default class WhatsappService extends Service { } async receive( - botID: string, + _botID: string, _lastReceivedDate: Date, ): Promise { - const connection = this.connections[botID]?.socket; - const messages = await connection.loadAllUnreadMessages(); - - return messages; + // loadAllUnreadMessages() was removed in Baileys 7.x + // Messages are now delivered via events (messages.upsert, messaging-history.set) + // and forwarded to webhooks automatically. + // See: https://baileys.wiki/docs/migration/to-v7.0.0/ + throw new Error( + "Message polling is no longer supported in Baileys 7.x. " + + "Please configure a webhook to receive messages instead. " + + "Messages are automatically forwarded to BRIDGE_FRONTEND_URL/api/whatsapp/bots/{id}/receive" + ); } } diff --git a/apps/bridge-worker/tasks/fetch-signal-messages.ts b/apps/bridge-worker/tasks/fetch-signal-messages.ts index f4bac04..6769a81 100644 --- a/apps/bridge-worker/tasks/fetch-signal-messages.ts +++ b/apps/bridge-worker/tasks/fetch-signal-messages.ts @@ -168,6 +168,7 @@ const processMessage = async ({ token: id, to: toRecipient, from: source, + userId: sourceUuid, // Signal user UUID for user identification messageId: `${sourceUuid}-${rawTimestamp}`, message: dataMessage?.message, sentAt: timestamp.toISOString(), diff --git a/apps/bridge-worker/tasks/signal/receive-signal-message.ts b/apps/bridge-worker/tasks/signal/receive-signal-message.ts index a504b3c..331f41a 100644 --- a/apps/bridge-worker/tasks/signal/receive-signal-message.ts +++ b/apps/bridge-worker/tasks/signal/receive-signal-message.ts @@ -9,6 +9,7 @@ interface ReceiveSignalMessageTaskOptions { token: string; to: string; from: string; + userId?: string; // Signal user UUID for user identification messageId: string; sentAt: string; message: string; @@ -22,6 +23,7 @@ const receiveSignalMessageTask = async ({ token, to, from, + userId, messageId, sentAt, message, @@ -212,6 +214,7 @@ const receiveSignalMessageTask = async ({ const payload = { to: finalTo, from, + user_id: userId, // Signal user UUID for user identification message_id: messageId, sent_at: sentAt, message, diff --git a/apps/bridge-worker/tasks/whatsapp/receive-whatsapp-message.ts b/apps/bridge-worker/tasks/whatsapp/receive-whatsapp-message.ts index 48adc6f..94cee82 100644 --- a/apps/bridge-worker/tasks/whatsapp/receive-whatsapp-message.ts +++ b/apps/bridge-worker/tasks/whatsapp/receive-whatsapp-message.ts @@ -3,7 +3,8 @@ import { db, getWorkerUtils } from "@link-stack/bridge-common"; interface ReceiveWhatsappMessageTaskOptions { token: string; to: string; - from: string; + from?: string; + userId?: string; messageId: string; sentAt: string; message: string; @@ -16,6 +17,7 @@ const receiveWhatsappMessageTask = async ({ token, to, from, + userId, messageId, sentAt, message, @@ -33,6 +35,7 @@ const receiveWhatsappMessageTask = async ({ const payload = { to, from, + user_id: userId, message_id: messageId, sent_at: sentAt, message, diff --git a/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb b/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb index f09c458..bd22b09 100644 --- a/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb +++ b/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_signal_controller.rb @@ -154,16 +154,31 @@ class ChannelsCdrSignalController < ApplicationController return if Ticket::Article.exists?(message_id: "cdr_signal.#{message_id}") receiver_phone_number = params[:to].strip - sender_phone_number = params[:from].strip + sender_phone_number = params[:from].present? ? params[:from].strip : nil + sender_user_id = params[:user_id].present? ? params[:user_id].strip : nil # Check if this is a group message using the is_group flag from bridge-worker # This flag is set when: # 1. The original message came from a Signal group # 2. Bridge-worker created a new group for the conversation - is_group_message = params[:is_group].to_s == 'true' || params[:is_group].to_s == 'true' + is_group_message = params[:is_group].to_s == 'true' + + # Lookup customer with fallback chain: + # 1. Phone number in phone/mobile fields (preferred) + # 2. Signal user ID in signal_user_id field + # 3. User ID in phone/mobile fields (legacy - we used to store UUIDs there) + customer = nil + if sender_phone_number.present? + customer = User.find_by(phone: sender_phone_number) + customer ||= User.find_by(mobile: sender_phone_number) + end + if customer.nil? && sender_user_id.present? + customer = User.find_by(signal_user_id: sender_user_id) + # Legacy fallback: user ID might be stored in phone field + customer ||= User.find_by(phone: sender_user_id) + customer ||= User.find_by(mobile: sender_user_id) + end - customer = User.find_by(phone: sender_phone_number) - customer ||= User.find_by(mobile: sender_phone_number) unless customer role_ids = Role.signup_role_ids customer = User.create( @@ -171,7 +186,8 @@ class ChannelsCdrSignalController < ApplicationController lastname: '', email: '', password: '', - phone: sender_phone_number, + phone: sender_phone_number.presence || sender_user_id, + signal_user_id: sender_user_id, note: 'CDR Signal', active: true, role_ids: role_ids, @@ -180,6 +196,15 @@ class ChannelsCdrSignalController < ApplicationController ) end + # Update signal_user_id if we have it and customer doesn't + if sender_user_id.present? && customer.signal_user_id.blank? + customer.update(signal_user_id: sender_user_id) + end + # Update phone if we have it and customer only has user_id in phone field + if sender_phone_number.present? && customer.phone == sender_user_id + customer.update(phone: sender_phone_number) + end + # set current user UserInfo.current_user_id = customer.id current_user_set(customer, 'token_auth') @@ -208,7 +233,8 @@ class ChannelsCdrSignalController < ApplicationController attachment_data_base64 = params[:attachment] attachment_filename = params[:filename] attachment_mimetype = params[:mime_type] - title = "Message from #{sender_phone_number} at #{sent_at}" + sender_display = sender_phone_number.presence || sender_user_id + title = "Message from #{sender_display} at #{sent_at}" body = message # find ticket or create one @@ -218,7 +244,7 @@ class ChannelsCdrSignalController < ApplicationController Rails.logger.info "=== SIGNAL GROUP TICKET LOOKUP ===" Rails.logger.info "Looking for ticket with group_id: #{receiver_phone_number}" Rails.logger.info "Customer ID: #{customer.id}" - Rails.logger.info "Customer Phone: #{sender_phone_number}" + Rails.logger.info "Customer Phone: #{sender_display}" Rails.logger.info "Channel ID: #{channel.id}" begin @@ -256,12 +282,13 @@ class ChannelsCdrSignalController < ApplicationController ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id else # Set up chat_id based on whether this is a group message - chat_id = is_group_message ? receiver_phone_number : sender_phone_number + chat_id = is_group_message ? receiver_phone_number : (sender_phone_number.presence || sender_user_id) # Build preferences with group_id included if needed cdr_signal_prefs = { - bot_token: channel.options[:bot_token], # change to bot id - chat_id: chat_id + bot_token: channel.options[:bot_token], + chat_id: chat_id, + user_id: sender_user_id } Rails.logger.info "=== CREATING NEW TICKET ===" @@ -283,7 +310,7 @@ class ChannelsCdrSignalController < ApplicationController ticket.save! article_params = { - from: sender_phone_number, + from: sender_display, to: receiver_phone_number, sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id, subject: title, @@ -296,7 +323,8 @@ class ChannelsCdrSignalController < ApplicationController cdr_signal: { timestamp: sent_at, message_id: message_id, - from: sender_phone_number + from: sender_phone_number, + user_id: sender_user_id } } } diff --git a/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_whatsapp_controller.rb b/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_whatsapp_controller.rb index c30d5ee..bc91676 100644 --- a/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_whatsapp_controller.rb +++ b/packages/zammad-addon-bridge/src/app/controllers/channels_cdr_whatsapp_controller.rb @@ -123,12 +123,16 @@ class ChannelsCdrWhatsappController < ApplicationController errors = {} %i[to - from message_id sent_at].each do |field| errors[field] = 'required' if params[field].blank? end + # At least one of from (phone) or user_id must be present + if params[:from].blank? && params[:user_id].blank? + errors[:from] = 'required (or user_id)' + end + if errors.present? render json: { errors: errors @@ -141,9 +145,25 @@ class ChannelsCdrWhatsappController < ApplicationController return if Ticket::Article.exists?(message_id: "cdr_whatsapp.#{message_id}") receiver_phone_number = params[:to].strip - sender_phone_number = params[:from].strip - customer = User.find_by(phone: sender_phone_number) - customer ||= User.find_by(mobile: sender_phone_number) + sender_phone_number = params[:from].present? ? params[:from].strip : nil + sender_user_id = params[:user_id].present? ? params[:user_id].strip : nil + + # Lookup customer with fallback chain: + # 1. Phone number in phone/mobile fields (preferred) + # 2. WhatsApp user ID in whatsapp_user_id field + # 3. User ID in phone/mobile fields (legacy - we used to store LIDs there) + customer = nil + if sender_phone_number.present? + customer = User.find_by(phone: sender_phone_number) + customer ||= User.find_by(mobile: sender_phone_number) + end + if customer.nil? && sender_user_id.present? + customer = User.find_by(whatsapp_user_id: sender_user_id) + # Legacy fallback: user ID might be stored in phone field + customer ||= User.find_by(phone: sender_user_id) + customer ||= User.find_by(mobile: sender_user_id) + end + unless customer role_ids = Role.signup_role_ids customer = User.create( @@ -151,7 +171,8 @@ class ChannelsCdrWhatsappController < ApplicationController lastname: '', email: '', password: '', - phone: sender_phone_number, + phone: sender_phone_number.presence || sender_user_id, + whatsapp_user_id: sender_user_id, note: 'CDR Whatsapp', active: true, role_ids: role_ids, @@ -160,6 +181,15 @@ class ChannelsCdrWhatsappController < ApplicationController ) end + # Update whatsapp_user_id if we have it and customer doesn't + if sender_user_id.present? && customer.whatsapp_user_id.blank? + customer.update(whatsapp_user_id: sender_user_id) + end + # Update phone if we have it and customer only has user_id in phone field + if sender_phone_number.present? && customer.phone == sender_user_id + customer.update(phone: sender_phone_number) + end + # set current user UserInfo.current_user_id = customer.id current_user_set(customer, 'token_auth') @@ -188,7 +218,8 @@ class ChannelsCdrWhatsappController < ApplicationController attachment_data_base64 = params[:attachment] attachment_filename = params[:filename] attachment_mimetype = params[:mime_type] - title = "Message from #{sender_phone_number} at #{sent_at}" + sender_display = sender_phone_number.presence || sender_user_id + title = "Message from #{sender_display} at #{sent_at}" body = message # find ticket or create one @@ -207,8 +238,9 @@ class ChannelsCdrWhatsappController < ApplicationController preferences: { channel_id: channel.id, cdr_whatsapp: { - bot_token: channel.options[:bot_token], # change to bot id - chat_id: sender_phone_number + bot_token: channel.options[:bot_token], + chat_id: sender_phone_number.presence || sender_user_id, + user_id: sender_user_id } } ) @@ -217,7 +249,7 @@ class ChannelsCdrWhatsappController < ApplicationController ticket.save! article_params = { - from: sender_phone_number, + from: sender_display, to: receiver_phone_number, sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id, subject: title, @@ -230,7 +262,8 @@ class ChannelsCdrWhatsappController < ApplicationController cdr_whatsapp: { timestamp: sent_at, message_id: message_id, - from: sender_phone_number + from: sender_phone_number, + user_id: sender_user_id } } } diff --git a/packages/zammad-addon-bridge/src/db/addon/bridge/20260115000001_add_messaging_user_ids.rb b/packages/zammad-addon-bridge/src/db/addon/bridge/20260115000001_add_messaging_user_ids.rb new file mode 100644 index 0000000..817f9c2 --- /dev/null +++ b/packages/zammad-addon-bridge/src/db/addon/bridge/20260115000001_add_messaging_user_ids.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class AddMessagingUserIds < ActiveRecord::Migration[5.2] + def self.up + # Add WhatsApp user ID field (LID - Linked ID in Baileys 7+) + unless column_exists?(:users, :whatsapp_user_id) + add_column :users, :whatsapp_user_id, :string, limit: 50 + add_index :users, :whatsapp_user_id + end + + # Add Signal user ID field (UUID) + unless column_exists?(:users, :signal_user_id) + add_column :users, :signal_user_id, :string, limit: 50 + add_index :users, :signal_user_id + end + end + + def self.down + remove_index :users, :whatsapp_user_id if index_exists?(:users, :whatsapp_user_id) + remove_column :users, :whatsapp_user_id if column_exists?(:users, :whatsapp_user_id) + + remove_index :users, :signal_user_id if index_exists?(:users, :signal_user_id) + remove_column :users, :signal_user_id if column_exists?(:users, :signal_user_id) + end +end