Compare commits

..

No commits in common. "feature/baileys-7" and "main" have entirely different histories.

34 changed files with 900 additions and 1198 deletions

2
.gitignore vendored
View file

@ -31,5 +31,3 @@ project.org
apps/bridge-worker/scripts/* apps/bridge-worker/scripts/*
ENVIRONMENT_VARIABLES_MIGRATION.md ENVIRONMENT_VARIABLES_MIGRATION.md
local-scripts/* local-scripts/*
docs/
packages/zammad-addon-bridge/test/

2
.nvmrc
View file

@ -1 +1 @@
v24 v22.18.0

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/bridge-frontend", "name": "@link-stack/bridge-frontend",
"version": "3.4.0-beta.6", "version": "3.3.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/bridge-migrations", "name": "@link-stack/bridge-migrations",
"version": "3.4.0-beta.6", "version": "3.3.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"migrate:up:all": "tsx migrate.ts up:all", "migrate:up:all": "tsx migrate.ts up:all",

View file

@ -1,17 +1,17 @@
{ {
"name": "@link-stack/bridge-whatsapp", "name": "@link-stack/bridge-whatsapp",
"version": "3.4.0-beta.6", "version": "3.3.5",
"type": "module",
"main": "build/main/index.js", "main": "build/main/index.js",
"author": "Darren Clarke <darren@redaranj.com>", "author": "Darren Clarke <darren@redaranj.com>",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@adiwajshing/keyed-db": "0.2.4",
"@hapi/hapi": "^21.4.3", "@hapi/hapi": "^21.4.3",
"@hapipal/schmervice": "^3.0.0", "@hapipal/schmervice": "^3.0.0",
"@hapipal/toys": "^4.0.0", "@hapipal/toys": "^4.0.0",
"@link-stack/bridge-common": "workspace:*", "@link-stack/bridge-common": "workspace:*",
"@link-stack/logger": "workspace:*", "@link-stack/logger": "workspace:*",
"@whiskeysockets/baileys": "7.0.0-rc.9", "@whiskeysockets/baileys": "6.7.21",
"hapi-pino": "^13.0.0", "hapi-pino": "^13.0.0",
"link-preview-js": "^3.1.0" "link-preview-js": "^3.1.0"
}, },
@ -19,12 +19,15 @@
"@link-stack/eslint-config": "workspace:*", "@link-stack/eslint-config": "workspace:*",
"@link-stack/jest-config": "workspace:*", "@link-stack/jest-config": "workspace:*",
"@link-stack/typescript-config": "workspace:*", "@link-stack/typescript-config": "workspace:*",
"@types/long": "^5",
"@types/node": "*", "@types/node": "*",
"dotenv-cli": "^10.0.0",
"tsx": "^4.20.6",
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"dev": "node --env-file=.env --experimental-transform-types src/index.ts", "dev": "dotenv -- tsx src/index.ts",
"start": "node build/main/index.js" "start": "node build/main/index.js"
} }
} }

View file

@ -1,17 +1,17 @@
import * as Hapi from "@hapi/hapi"; import * as Hapi from "@hapi/hapi";
import hapiPino from "hapi-pino"; import hapiPino from "hapi-pino";
import Schmervice from "@hapipal/schmervice"; import Schmervice from "@hapipal/schmervice";
import WhatsappService from "./service.ts"; import WhatsappService from "./service.js";
import { import {
RegisterBotRoute, RegisterBotRoute,
UnverifyBotRoute, UnverifyBotRoute,
GetBotRoute, GetBotRoute,
SendMessageRoute, SendMessageRoute,
ReceiveMessageRoute, ReceiveMessageRoute,
} from "./routes.ts"; } from "./routes.js";
import { createLogger } from "@link-stack/logger"; import { createLogger } from "@link-stack/logger";
const logger = createLogger("bridge-whatsapp-index"); const logger = createLogger('bridge-whatsapp-index');
const server = Hapi.server({ port: 5000 }); const server = Hapi.server({ port: 5000 });

View file

@ -1,6 +1,6 @@
import * as Hapi from "@hapi/hapi"; import * as Hapi from "@hapi/hapi";
import Toys from "@hapipal/toys"; import Toys from "@hapipal/toys";
import WhatsappService from "./service.ts"; import WhatsappService from "./service";
const withDefaults = Toys.withRouteDefaults({ const withDefaults = Toys.withRouteDefaults({
options: { options: {
@ -27,9 +27,15 @@ export const SendMessageRoute = withDefaults({
description: "Send a message", description: "Send a message",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) { async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { id } = request.params; const { id } = request.params;
const { phoneNumber, message, attachments } = request.payload as MessageRequest; const { phoneNumber, message, attachments } =
request.payload as MessageRequest;
const whatsappService = getService(request); const whatsappService = getService(request);
await whatsappService.send(id, phoneNumber, message as string, attachments); await whatsappService.send(
id,
phoneNumber,
message as string,
attachments,
);
request.logger.info( request.logger.info(
{ {
id, id,

View file

@ -4,13 +4,12 @@ import makeWASocket, {
DisconnectReason, DisconnectReason,
proto, proto,
downloadContentFromMessage, downloadContentFromMessage,
MediaType,
fetchLatestBaileysVersion, fetchLatestBaileysVersion,
isJidBroadcast, isJidBroadcast,
isJidStatusBroadcast, isJidStatusBroadcast,
useMultiFileAuthState, useMultiFileAuthState,
} from "@whiskeysockets/baileys"; } from "@whiskeysockets/baileys";
type MediaType = "audio" | "document" | "image" | "video" | "sticker";
import fs from "fs"; import fs from "fs";
import { createLogger } from "@link-stack/logger"; import { createLogger } from "@link-stack/logger";
import { import {
@ -98,7 +97,6 @@ export default class WhatsappService extends Service {
...options, ...options,
auth: state, auth: state,
generateHighQualityLinkPreview: false, generateHighQualityLinkPreview: false,
syncFullHistory: true,
msgRetryCounterMap, msgRetryCounterMap,
shouldIgnoreJid: (jid) => isJidBroadcast(jid) || isJidStatusBroadcast(jid), shouldIgnoreJid: (jid) => isJidBroadcast(jid) || isJidStatusBroadcast(jid),
}); });
@ -149,17 +147,6 @@ export default class WhatsappService extends Service {
await this.queueUnreadMessages(botID, messages); await this.queueUnreadMessages(botID, messages);
} }
} }
if (events["messaging-history.set"]) {
const { messages, isLatest } = events["messaging-history.set"];
logger.info(
{ messageCount: messages.length, isLatest },
"received message history on connection",
);
if (messages.length > 0) {
await this.queueUnreadMessages(botID, messages);
}
}
}); });
this.connections[botID] = { socket, msgRetryCounterMap }; this.connections[botID] = { socket, msgRetryCounterMap };
@ -180,6 +167,7 @@ export default class WhatsappService extends Service {
await this.createConnection(botID, this.server, { await this.createConnection(botID, this.server, {
browser: WhatsappService.browserDescription, browser: WhatsappService.browserDescription,
printQRInTerminal: true,
version, version,
}); });
} }
@ -187,20 +175,18 @@ export default class WhatsappService extends Service {
} }
private async queueMessage(botID: string, webMessageInfo: proto.IWebMessageInfo) { private async queueMessage(botID: string, webMessageInfo: proto.IWebMessageInfo) {
const { key, message, messageTimestamp } = webMessageInfo; const {
if (!key) { key: { id, fromMe, remoteJid },
logger.warn("Message missing key, skipping"); message,
return; messageTimestamp,
} } = webMessageInfo;
const { id, fromMe, remoteJid } = key; logger.info("Message type debug");
// Baileys 7 uses LIDs (Linked IDs) instead of phone numbers in some cases. for (const key in message) {
// 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( logger.info(
{ remoteJid, senderPn, participantPn, fromMe }, { key, exists: !!message[key as keyof proto.IMessage] },
"Processing incoming message", "Message field",
); );
}
const isValidMessage = message && remoteJid !== "status@broadcast" && !fromMe; const isValidMessage = message && remoteJid !== "status@broadcast" && !fromMe;
if (isValidMessage) { if (isValidMessage) {
const { audioMessage, documentMessage, imageMessage, videoMessage } = message; const { audioMessage, documentMessage, imageMessage, videoMessage } = message;
@ -258,27 +244,9 @@ export default class WhatsappService extends Service {
videoMessage, videoMessage,
].find((text) => text && text !== ""); ].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 = { const payload = {
to: botID, to: botID,
from: senderPhone, from: remoteJid?.split("@")[0],
userId: senderUserId,
messageId: id, messageId: id,
sentAt: new Date((messageTimestamp as number) * 1000).toISOString(), sentAt: new Date((messageTimestamp as number) * 1000).toISOString(),
message: messageText, message: messageText,
@ -442,17 +410,12 @@ export default class WhatsappService extends Service {
} }
async receive( async receive(
_botID: string, botID: string,
_lastReceivedDate: Date, _lastReceivedDate: Date,
): Promise<proto.IWebMessageInfo[]> { ): Promise<proto.IWebMessageInfo[]> {
// loadAllUnreadMessages() was removed in Baileys 7.x const connection = this.connections[botID]?.socket;
// Messages are now delivered via events (messages.upsert, messaging-history.set) const messages = await connection.loadAllUnreadMessages();
// and forwarded to webhooks automatically.
// See: https://baileys.wiki/docs/migration/to-v7.0.0/ return messages;
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"
);
} }
} }

View file

@ -1,4 +1,4 @@
import type WhatsappService from "./service.ts"; import type WhatsappService from "./service.js";
declare module "@hapipal/schmervice" { declare module "@hapipal/schmervice" {
interface SchmerviceDecorator { interface SchmerviceDecorator {

View file

@ -1,17 +1,16 @@
{ {
"extends": "@link-stack/typescript-config/tsconfig.node.json", "extends": "@link-stack/typescript-config/tsconfig.node.json",
"compilerOptions": { "compilerOptions": {
"module": "NodeNext", "module": "commonjs",
"target": "es2022", "target": "es2018",
"esModuleInterop": true, "esModuleInterop": true,
"moduleResolution": "NodeNext", "moduleResolution": "node",
"outDir": "build/main", "outDir": "build/main",
"rootDir": "src", "rootDir": "src",
"skipLibCheck": true, "skipLibCheck": true,
"types": ["node"], "types": ["node"],
"lib": ["es2022", "DOM"], "lib": ["es2020", "DOM"],
"composite": true, "composite": true
"rewriteRelativeImportExtensions": true
}, },
"include": ["src/**/*.ts", "src/**/.*.ts"], "include": ["src/**/*.ts", "src/**/.*.ts"],
"exclude": ["node_modules/**"] "exclude": ["node_modules/**"]

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/bridge-worker", "name": "@link-stack/bridge-worker",
"version": "3.4.0-beta.6", "version": "3.3.5",
"type": "module", "type": "module",
"main": "build/main/index.js", "main": "build/main/index.js",
"author": "Darren Clarke <darren@redaranj.com>", "author": "Darren Clarke <darren@redaranj.com>",

View file

@ -168,7 +168,6 @@ const processMessage = async ({
token: id, token: id,
to: toRecipient, to: toRecipient,
from: source, from: source,
userId: sourceUuid, // Signal user UUID for user identification
messageId: `${sourceUuid}-${rawTimestamp}`, messageId: `${sourceUuid}-${rawTimestamp}`,
message: dataMessage?.message, message: dataMessage?.message,
sentAt: timestamp.toISOString(), sentAt: timestamp.toISOString(),

View file

@ -9,7 +9,6 @@ interface ReceiveSignalMessageTaskOptions {
token: string; token: string;
to: string; to: string;
from: string; from: string;
userId?: string; // Signal user UUID for user identification
messageId: string; messageId: string;
sentAt: string; sentAt: string;
message: string; message: string;
@ -23,7 +22,6 @@ const receiveSignalMessageTask = async ({
token, token,
to, to,
from, from,
userId,
messageId, messageId,
sentAt, sentAt,
message, message,
@ -214,7 +212,6 @@ const receiveSignalMessageTask = async ({
const payload = { const payload = {
to: finalTo, to: finalTo,
from, from,
user_id: userId, // Signal user UUID for user identification
message_id: messageId, message_id: messageId,
sent_at: sentAt, sent_at: sentAt,
message, message,

View file

@ -64,14 +64,13 @@ const sendSignalMessageTask = async ({
let groupCreated = false; let groupCreated = false;
try { try {
// Check if 'to' is a group ID (group.base64 format or base64 internal ID) vs individual recipient // Check if 'to' is a group ID (UUID format, group.base64 format, or base64) vs phone number
// Signal group IDs are 32 bytes = 44 chars base64 (or 43 without padding) const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
// Signal user UUIDs (ACIs) are 36 chars with hyphens: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx to,
// Phone numbers start with +, usernames with u:, PNIs with PNI: );
const isGroupPrefix = to.startsWith("group."); const isGroupPrefix = to.startsWith("group.");
const isBase64GroupId = const isBase64 = /^[A-Za-z0-9+/]+=*$/.test(to) && to.length > 20; // Base64 internal_id
/^[A-Za-z0-9+/]+=*$/.test(to) && to.length >= 43 && to.length <= 44; const isGroupId = isUUID || isGroupPrefix || isBase64;
const isGroupId = isGroupPrefix || isBase64GroupId;
const enableAutoGroups = process.env.BRIDGE_SIGNAL_AUTO_GROUPS === "true"; const enableAutoGroups = process.env.BRIDGE_SIGNAL_AUTO_GROUPS === "true";
logger.debug( logger.debug(

View file

@ -3,8 +3,7 @@ import { db, getWorkerUtils } from "@link-stack/bridge-common";
interface ReceiveWhatsappMessageTaskOptions { interface ReceiveWhatsappMessageTaskOptions {
token: string; token: string;
to: string; to: string;
from?: string; from: string;
userId?: string;
messageId: string; messageId: string;
sentAt: string; sentAt: string;
message: string; message: string;
@ -17,7 +16,6 @@ const receiveWhatsappMessageTask = async ({
token, token,
to, to,
from, from,
userId,
messageId, messageId,
sentAt, sentAt,
message, message,
@ -35,7 +33,6 @@ const receiveWhatsappMessageTask = async ({
const payload = { const payload = {
to, to,
from, from,
user_id: userId,
message_id: messageId, message_id: messageId,
sent_at: sentAt, sent_at: sentAt,
message, message,

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/link", "name": "@link-stack/link",
"version": "3.4.0-beta.6", "version": "3.3.5",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev -H 0.0.0.0", "dev": "next dev -H 0.0.0.0",

View file

@ -15,7 +15,7 @@ COPY --from=node /usr/local/lib /usr/local/lib
COPY --from=node /usr/local/include /usr/local/include COPY --from=node /usr/local/include /usr/local/include
COPY --from=node /usr/local/bin /usr/local/bin COPY --from=node /usr/local/bin /usr/local/bin
# Prepare pnpm (corepack symlinks already copied from node image) # Prepare pnpm (corepack is already enabled in node:22-alpine)
RUN corepack prepare pnpm@9.15.4 --activate RUN corepack prepare pnpm@9.15.4 --activate
# Set up pnpm home # Set up pnpm home

View file

@ -5,13 +5,13 @@ const app = process.argv[2];
const command = process.argv[3]; const command = process.argv[3];
const files = { const files = {
all: ["zammad", "postgresql", "bridge", "opensearch", "link", "signal-cli-rest-api", "bridge-whatsapp"], all: ["zammad", "postgresql", "bridge", "opensearch", "link", "signal-cli-rest-api"],
linkDev: ["zammad", "postgresql", "opensearch"], linkDev: ["zammad", "postgresql", "opensearch"],
link: ["zammad", "postgresql", "opensearch", "link"], link: ["zammad", "postgresql", "opensearch", "link"],
linkOnly: ["link"], linkOnly: ["link"],
opensearch: ["opensearch"], opensearch: ["opensearch"],
bridgeDev: ["zammad", "postgresql", "signal-cli-rest-api", "bridge-whatsapp"], bridgeDev: ["zammad", "postgresql", "signal-cli-rest-api"],
bridge: ["zammad", "postgresql", "bridge", "signal-cli-rest-api", "bridge-whatsapp"], bridge: ["zammad", "postgresql", "bridge", "signal-cli-rest-api"],
zammad: ["zammad", "postgresql", "opensearch"], zammad: ["zammad", "postgresql", "opensearch"],
}; };
@ -21,7 +21,7 @@ const finalFiles = files[app]
.map((file) => ['-f', `docker/compose/${file}.yml`]).flat(); .map((file) => ['-f', `docker/compose/${file}.yml`]).flat();
// Add bridge-dev.yml for dev commands that include zammad // Add bridge-dev.yml for dev commands that include zammad
const devAppsWithZammad = ['linkDev', 'bridgeDev']; const devAppsWithZammad = ['linkDev', 'bridgeDev', 'all'];
if (devAppsWithZammad.includes(app) && files[app].includes('zammad')) { if (devAppsWithZammad.includes(app) && files[app].includes('zammad')) {
finalFiles.push('-f', 'docker-compose.bridge-dev.yml'); finalFiles.push('-f', 'docker-compose.bridge-dev.yml');
} }

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack", "name": "@link-stack",
"version": "3.4.0-beta.6", "version": "3.3.5",
"description": "Link from the Center for Digital Resilience", "description": "Link from the Center for Digital Resilience",
"scripts": { "scripts": {
"dev": "dotenv -- turbo dev", "dev": "dotenv -- turbo dev",
@ -49,7 +49,7 @@
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"turbo": "^2.6.0", "turbo": "^2.5.8",
"typescript": "latest" "typescript": "latest"
}, },
"pnpm": { "pnpm": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/bridge-common", "name": "@link-stack/bridge-common",
"version": "3.4.0-beta.6", "version": "3.3.5",
"main": "build/main/index.js", "main": "build/main/index.js",
"type": "module", "type": "module",
"author": "Darren Clarke <darren@redaranj.com>", "author": "Darren Clarke <darren@redaranj.com>",

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/bridge-ui", "name": "@link-stack/bridge-ui",
"version": "3.4.0-beta.6", "version": "3.3.5",
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json" "build": "tsc -p tsconfig.json"
}, },

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/eslint-config", "name": "@link-stack/eslint-config",
"version": "3.4.0-beta.6", "version": "3.3.5",
"description": "amigo's eslint config", "description": "amigo's eslint config",
"main": "index.js", "main": "index.js",
"author": "Abel Luck <abel@guardianproject.info>", "author": "Abel Luck <abel@guardianproject.info>",

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/jest-config", "name": "@link-stack/jest-config",
"version": "3.4.0-beta.6", "version": "3.3.5",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"author": "Abel Luck <abel@guardianproject.info>", "author": "Abel Luck <abel@guardianproject.info>",

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/logger", "name": "@link-stack/logger",
"version": "3.4.0-beta.6", "version": "3.3.5",
"description": "Shared logging utility for Link Stack monorepo", "description": "Shared logging utility for Link Stack monorepo",
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.mjs", "module": "./dist/index.mjs",

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/signal-api", "name": "@link-stack/signal-api",
"version": "3.4.0-beta.6", "version": "3.3.5",
"type": "module", "type": "module",
"main": "build/index.js", "main": "build/index.js",
"exports": { "exports": {

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/typescript-config", "name": "@link-stack/typescript-config",
"version": "3.4.0-beta.6", "version": "3.3.5",
"description": "Shared TypeScript config", "description": "Shared TypeScript config",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"author": "Abel Luck <abel@guardianproject.info>", "author": "Abel Luck <abel@guardianproject.info>",

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/ui", "name": "@link-stack/ui",
"version": "3.4.0-beta.6", "version": "3.3.5",
"description": "", "description": "",
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json" "build": "tsc -p tsconfig.json"

View file

@ -1,7 +1,7 @@
{ {
"name": "@link-stack/zammad-addon-bridge", "name": "@link-stack/zammad-addon-bridge",
"displayName": "Bridge", "displayName": "Bridge",
"version": "3.4.0-beta.6", "version": "3.3.5",
"description": "An addon that adds CDR Bridge channels to Zammad.", "description": "An addon that adds CDR Bridge channels to Zammad.",
"scripts": { "scripts": {
"build": "node '../zammad-addon-common/dist/build.js'", "build": "node '../zammad-addon-common/dist/build.js'",

View file

@ -154,31 +154,16 @@ class ChannelsCdrSignalController < ApplicationController
return if Ticket::Article.exists?(message_id: "cdr_signal.#{message_id}") return if Ticket::Article.exists?(message_id: "cdr_signal.#{message_id}")
receiver_phone_number = params[:to].strip receiver_phone_number = params[:to].strip
sender_phone_number = params[:from].present? ? params[:from].strip : nil sender_phone_number = params[:from].strip
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 # Check if this is a group message using the is_group flag from bridge-worker
# This flag is set when: # This flag is set when:
# 1. The original message came from a Signal group # 1. The original message came from a Signal group
# 2. Bridge-worker created a new group for the conversation # 2. Bridge-worker created a new group for the conversation
is_group_message = params[:is_group].to_s == 'true' is_group_message = params[:is_group].to_s == 'true' || 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_uid 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(phone: sender_phone_number)
customer ||= User.find_by(mobile: sender_phone_number) customer ||= User.find_by(mobile: sender_phone_number)
end
if customer.nil? && sender_user_id.present?
customer = User.find_by(signal_uid: 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 unless customer
role_ids = Role.signup_role_ids role_ids = Role.signup_role_ids
customer = User.create( customer = User.create(
@ -186,8 +171,7 @@ class ChannelsCdrSignalController < ApplicationController
lastname: '', lastname: '',
email: '', email: '',
password: '', password: '',
phone: sender_phone_number.presence || sender_user_id, phone: sender_phone_number,
signal_uid: sender_user_id,
note: 'CDR Signal', note: 'CDR Signal',
active: true, active: true,
role_ids: role_ids, role_ids: role_ids,
@ -196,15 +180,6 @@ class ChannelsCdrSignalController < ApplicationController
) )
end end
# Update signal_uid if we have it and customer doesn't
if sender_user_id.present? && customer.signal_uid.blank?
customer.update(signal_uid: 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 # set current user
UserInfo.current_user_id = customer.id UserInfo.current_user_id = customer.id
current_user_set(customer, 'token_auth') current_user_set(customer, 'token_auth')
@ -233,8 +208,7 @@ class ChannelsCdrSignalController < ApplicationController
attachment_data_base64 = params[:attachment] attachment_data_base64 = params[:attachment]
attachment_filename = params[:filename] attachment_filename = params[:filename]
attachment_mimetype = params[:mime_type] attachment_mimetype = params[:mime_type]
sender_display = sender_phone_number.presence || sender_user_id title = "Message from #{sender_phone_number} at #{sent_at}"
title = "Message from #{sender_display} at #{sent_at}"
body = message body = message
# find ticket or create one # find ticket or create one
@ -244,7 +218,7 @@ class ChannelsCdrSignalController < ApplicationController
Rails.logger.info "=== SIGNAL GROUP TICKET LOOKUP ===" Rails.logger.info "=== SIGNAL GROUP TICKET LOOKUP ==="
Rails.logger.info "Looking for ticket with group_id: #{receiver_phone_number}" Rails.logger.info "Looking for ticket with group_id: #{receiver_phone_number}"
Rails.logger.info "Customer ID: #{customer.id}" Rails.logger.info "Customer ID: #{customer.id}"
Rails.logger.info "Customer Phone: #{sender_display}" Rails.logger.info "Customer Phone: #{sender_phone_number}"
Rails.logger.info "Channel ID: #{channel.id}" Rails.logger.info "Channel ID: #{channel.id}"
begin begin
@ -282,14 +256,12 @@ class ChannelsCdrSignalController < ApplicationController
ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id
else else
# Set up chat_id based on whether this is a group message # Set up chat_id based on whether this is a group message
# For direct messages, prefer UUID (more stable than phone numbers which can change) chat_id = is_group_message ? receiver_phone_number : sender_phone_number
chat_id = is_group_message ? receiver_phone_number : (sender_user_id.presence || sender_phone_number)
# Build preferences with group_id included if needed # Build preferences with group_id included if needed
cdr_signal_prefs = { cdr_signal_prefs = {
bot_token: channel.options[:bot_token], bot_token: channel.options[:bot_token], # change to bot id
chat_id: chat_id, chat_id: chat_id
user_id: sender_user_id
} }
Rails.logger.info "=== CREATING NEW TICKET ===" Rails.logger.info "=== CREATING NEW TICKET ==="
@ -311,7 +283,7 @@ class ChannelsCdrSignalController < ApplicationController
ticket.save! ticket.save!
article_params = { article_params = {
from: sender_display, from: sender_phone_number,
to: receiver_phone_number, to: receiver_phone_number,
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id, sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
subject: title, subject: title,
@ -324,8 +296,7 @@ class ChannelsCdrSignalController < ApplicationController
cdr_signal: { cdr_signal: {
timestamp: sent_at, timestamp: sent_at,
message_id: message_id, message_id: message_id,
from: sender_phone_number, from: sender_phone_number
user_id: sender_user_id
} }
} }
} }

View file

@ -123,16 +123,12 @@ class ChannelsCdrWhatsappController < ApplicationController
errors = {} errors = {}
%i[to %i[to
from
message_id message_id
sent_at].each do |field| sent_at].each do |field|
errors[field] = 'required' if params[field].blank? errors[field] = 'required' if params[field].blank?
end 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? if errors.present?
render json: { render json: {
errors: errors errors: errors
@ -145,25 +141,9 @@ class ChannelsCdrWhatsappController < ApplicationController
return if Ticket::Article.exists?(message_id: "cdr_whatsapp.#{message_id}") return if Ticket::Article.exists?(message_id: "cdr_whatsapp.#{message_id}")
receiver_phone_number = params[:to].strip receiver_phone_number = params[:to].strip
sender_phone_number = params[:from].present? ? params[:from].strip : nil sender_phone_number = params[:from].strip
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_uid 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(phone: sender_phone_number)
customer ||= User.find_by(mobile: sender_phone_number) customer ||= User.find_by(mobile: sender_phone_number)
end
if customer.nil? && sender_user_id.present?
customer = User.find_by(whatsapp_uid: 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 unless customer
role_ids = Role.signup_role_ids role_ids = Role.signup_role_ids
customer = User.create( customer = User.create(
@ -171,8 +151,7 @@ class ChannelsCdrWhatsappController < ApplicationController
lastname: '', lastname: '',
email: '', email: '',
password: '', password: '',
phone: sender_phone_number.presence || sender_user_id, phone: sender_phone_number,
whatsapp_uid: sender_user_id,
note: 'CDR Whatsapp', note: 'CDR Whatsapp',
active: true, active: true,
role_ids: role_ids, role_ids: role_ids,
@ -181,15 +160,6 @@ class ChannelsCdrWhatsappController < ApplicationController
) )
end end
# Update whatsapp_uid if we have it and customer doesn't
if sender_user_id.present? && customer.whatsapp_uid.blank?
customer.update(whatsapp_uid: 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 # set current user
UserInfo.current_user_id = customer.id UserInfo.current_user_id = customer.id
current_user_set(customer, 'token_auth') current_user_set(customer, 'token_auth')
@ -218,8 +188,7 @@ class ChannelsCdrWhatsappController < ApplicationController
attachment_data_base64 = params[:attachment] attachment_data_base64 = params[:attachment]
attachment_filename = params[:filename] attachment_filename = params[:filename]
attachment_mimetype = params[:mime_type] attachment_mimetype = params[:mime_type]
sender_display = sender_phone_number.presence || sender_user_id title = "Message from #{sender_phone_number} at #{sent_at}"
title = "Message from #{sender_display} at #{sent_at}"
body = message body = message
# find ticket or create one # find ticket or create one
@ -238,9 +207,8 @@ class ChannelsCdrWhatsappController < ApplicationController
preferences: { preferences: {
channel_id: channel.id, channel_id: channel.id,
cdr_whatsapp: { cdr_whatsapp: {
bot_token: channel.options[:bot_token], bot_token: channel.options[:bot_token], # change to bot id
chat_id: sender_phone_number.presence || sender_user_id, chat_id: sender_phone_number
user_id: sender_user_id
} }
} }
) )
@ -249,7 +217,7 @@ class ChannelsCdrWhatsappController < ApplicationController
ticket.save! ticket.save!
article_params = { article_params = {
from: sender_display, from: sender_phone_number,
to: receiver_phone_number, to: receiver_phone_number,
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id, sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
subject: title, subject: title,
@ -262,8 +230,7 @@ class ChannelsCdrWhatsappController < ApplicationController
cdr_whatsapp: { cdr_whatsapp: {
timestamp: sent_at, timestamp: sent_at,
message_id: message_id, message_id: message_id,
from: sender_phone_number, from: sender_phone_number
user_id: sender_user_id
} }
} }
} }

View file

@ -1,123 +0,0 @@
class AddMessagingUserIds < ActiveRecord::Migration[5.2]
def self.up
# Add WhatsApp UID column
unless column_exists?(:users, :whatsapp_uid)
add_column :users, :whatsapp_uid, :string, limit: 50
add_index :users, :whatsapp_uid
end
User.reset_column_information
# Add Signal UID column
unless column_exists?(:users, :signal_uid)
add_column :users, :signal_uid, :string, limit: 50
add_index :users, :signal_uid
end
User.reset_column_information
# Register WhatsApp UID with ObjectManager for UI
# Column name: whatsapp_uid, Display name: "WhatsApp User ID"
ObjectManager::Attribute.add(
force: true,
object: 'User',
name: 'whatsapp_uid',
display: 'WhatsApp User ID',
data_type: 'input',
data_option: {
type: 'text',
maxlength: 50,
null: true,
item_class: 'formGroup--halfSize',
},
editable: false,
active: true,
screens: {
signup: {},
invite_agent: {},
invite_customer: {},
edit: {
'-all-' => {
null: true,
},
},
create: {
'-all-' => {
null: true,
},
},
view: {
'-all-' => {
shown: true,
},
},
},
to_create: false,
to_migrate: false,
to_delete: false,
position: 710,
created_by_id: 1,
updated_by_id: 1,
)
# Register Signal UID with ObjectManager for UI
# Column name: signal_uid, Display name: "Signal User ID"
ObjectManager::Attribute.add(
force: true,
object: 'User',
name: 'signal_uid',
display: 'Signal User ID',
data_type: 'input',
data_option: {
type: 'text',
maxlength: 50,
null: true,
item_class: 'formGroup--halfSize',
},
editable: false,
active: true,
screens: {
signup: {},
invite_agent: {},
invite_customer: {},
edit: {
'-all-' => {
null: true,
},
},
create: {
'-all-' => {
null: true,
},
},
view: {
'-all-' => {
shown: true,
},
},
},
to_create: false,
to_migrate: false,
to_delete: false,
position: 720,
created_by_id: 1,
updated_by_id: 1,
)
end
def self.down
ObjectManager::Attribute.remove(
object: 'User',
name: 'whatsapp_uid',
)
ObjectManager::Attribute.remove(
object: 'User',
name: 'signal_uid',
)
remove_index :users, :whatsapp_uid if index_exists?(:users, :whatsapp_uid)
remove_column :users, :whatsapp_uid if column_exists?(:users, :whatsapp_uid)
remove_index :users, :signal_uid if index_exists?(:users, :signal_uid)
remove_column :users, :signal_uid if column_exists?(:users, :signal_uid)
end
end

View file

@ -1,6 +1,6 @@
{ {
"name": "@link-stack/zammad-addon-common", "name": "@link-stack/zammad-addon-common",
"version": "3.4.0-beta.6", "version": "3.3.5",
"description": "", "description": "",
"bin": { "bin": {
"zpm-build": "./dist/build.js", "zpm-build": "./dist/build.js",

View file

@ -1,7 +1,7 @@
{ {
"name": "@link-stack/zammad-addon-hardening", "name": "@link-stack/zammad-addon-hardening",
"displayName": "Hardening", "displayName": "Hardening",
"version": "3.4.0-beta.6", "version": "3.3.5",
"description": "A Zammad addon that hardens a Zammad instance according to CDR's needs.", "description": "A Zammad addon that hardens a Zammad instance according to CDR's needs.",
"scripts": { "scripts": {
"build": "node '../zammad-addon-common/dist/build.js'", "build": "node '../zammad-addon-common/dist/build.js'",

1682
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff