import { db, getWorkerUtils } from "@link-stack/bridge-common"; import { createLogger } from "@link-stack/logger"; import * as signalApi from "@link-stack/signal-api"; const { Configuration, GroupsApi } = signalApi; const logger = createLogger('bridge-worker-receive-signal-message'); interface ReceiveSignalMessageTaskOptions { token: string; to: string; from: string; userId?: string; // Signal user UUID for user identification messageId: string; sentAt: string; message: string; attachment?: string; filename?: string; mimeType?: string; isGroup?: boolean; } const receiveSignalMessageTask = async ({ token, to, from, userId, messageId, sentAt, message, attachment, filename, mimeType, isGroup, }: ReceiveSignalMessageTaskOptions): Promise => { logger.debug({ messageId, from, to, isGroup, hasMessage: !!message, hasAttachment: !!attachment, token, }, 'Processing incoming message'); const worker = await getWorkerUtils(); const row = await db .selectFrom("SignalBot") .selectAll() .where("id", "=", token) .executeTakeFirstOrThrow(); const backendId = row.id; let finalTo = to; let createdInternalId: string | undefined; // Check if auto-group creation is enabled and this is NOT already a group message const enableAutoGroups = process.env.BRIDGE_SIGNAL_AUTO_GROUPS === "true"; logger.debug({ enableAutoGroups, isGroup, shouldCreateGroup: enableAutoGroups && !isGroup && from && to, }, 'Auto-groups config'); // If this is already a group message and auto-groups is enabled, // use group provided in 'to' if (enableAutoGroups && isGroup && to) { // Signal sends the internal ID (base64) in group messages // We should NOT add "group." prefix - that's for sending messages, not receiving logger.debug('Message is from existing group with internal ID'); finalTo = to; } else if (enableAutoGroups && !isGroup && from && to) { try { const config = new Configuration({ basePath: process.env.BRIDGE_SIGNAL_URL, }); const groupsClient = new GroupsApi(config); // Always create a new group for direct messages to the helpdesk // This ensures each conversation gets its own group/ticket logger.info({ from }, 'Creating new group for user'); // Include timestamp to make each group unique const timestamp = new Date() .toISOString() .replace(/[:.]/g, "-") .substring(0, 19); const groupName = `Support: ${from} (${timestamp})`; // Create new group for this conversation const createGroupResponse = await groupsClient.v1GroupsNumberPost({ number: row.phoneNumber, data: { name: groupName, members: [from], description: "Private support conversation", }, }); logger.debug({ createGroupResponse }, 'Group creation response from Signal API'); if (createGroupResponse.id) { // The createGroupResponse.id already contains the full group identifier (group.BASE64) finalTo = createGroupResponse.id; // Fetch the group details to get the actual internalId // The base64 part of the ID is NOT the same as the internalId! try { logger.debug('Fetching group details to get internalId'); const groups = await groupsClient.v1GroupsNumberGet({ number: row.phoneNumber, }); logger.debug({ groupsSample: groups.slice(0, 3) }, 'Groups for bot'); const createdGroup = groups.find((g) => g.id === finalTo); if (createdGroup) { logger.debug({ createdGroup }, 'Found created group details'); } if (createdGroup && createdGroup.internalId) { createdInternalId = createdGroup.internalId; logger.debug({ createdInternalId }, 'Got actual internalId'); } else { // Fallback: extract base64 part from ID if (finalTo.startsWith("group.")) { createdInternalId = finalTo.substring(6); } } } catch (fetchError) { logger.debug('Could not fetch group details, using ID base64 part'); // Fallback: extract base64 part from ID if (finalTo.startsWith("group.")) { createdInternalId = finalTo.substring(6); } } logger.debug({ fullGroupId: finalTo, internalId: createdInternalId, }, 'Group created successfully'); logger.debug({ groupId: finalTo, internalId: createdInternalId, groupName, forPhoneNumber: from, botNumber: row.phoneNumber, response: createGroupResponse, }, 'Created new Signal group'); } // Now handle notifications and message forwarding for both new and existing groups if (finalTo && finalTo.startsWith("group.")) { // Forward the user's initial message to the group using quote feature try { logger.debug('Forwarding initial message to group using quote feature'); const attributionMessage = `Message from ${from}:\n"${message}"\n\n---\nSupport team: Your request has been received. An agent will respond shortly.`; await worker.addJob("signal/send-signal-message", { token: row.token, to: finalTo, message: attributionMessage, conversationId: null, quoteMessage: message, quoteAuthor: from, quoteTimestamp: Date.parse(sentAt), }); logger.debug({ finalTo }, 'Successfully forwarded initial message to group'); } catch (forwardError) { logger.error({ error: forwardError }, 'Error forwarding message to group'); } // Send a response to the original DM informing about the group try { logger.debug('Sending group notification to original DM'); const dmNotification = `Hello! A private support group has been created for your conversation.\n\nGroup name: ${groupName}\n\nPlease look for the new group in your Signal app to continue the conversation. Our support team will respond there shortly.\n\nThank you for contacting support!`; await worker.addJob("signal/send-signal-message", { token: row.token, to: from, message: dmNotification, conversationId: null, }); logger.debug('Successfully sent group notification to user DM'); } catch (dmError) { logger.error({ error: dmError }, 'Error sending DM notification'); } } } catch (error: any) { // Check if error is because group already exists const errorMessage = error?.response?.data?.error || error?.message || error; const isAlreadyExists = errorMessage?.toString().toLowerCase().includes("already") || errorMessage?.toString().toLowerCase().includes("exists"); if (isAlreadyExists) { logger.debug({ from }, 'Group might already exist, continuing with original recipient'); } else { logger.error({ error: errorMessage, from, to, botNumber: row.phoneNumber, }, 'Error creating Signal group'); } } } const payload = { to: finalTo, from, user_id: userId, // Signal user UUID for user identification message_id: messageId, sent_at: sentAt, message, attachment, filename, mime_type: mimeType, is_group: finalTo.startsWith("group"), }; await worker.addJob("common/notify-webhooks", { backendId, payload }); }; export default receiveSignalMessageTask;