WhatsApp/Signal/Formstack/admin updates
This commit is contained in:
parent
bcecf61a46
commit
d0cc5a21de
451 changed files with 16139 additions and 39623 deletions
|
|
@ -1,8 +1,6 @@
|
|||
/* eslint-disable camelcase */
|
||||
// import { SavedVoiceProvider } from "@digiresilience/bridge-db";
|
||||
import Twilio from "twilio";
|
||||
import { CallInstance } from "twilio/lib/rest/api/v2010/account/call";
|
||||
import { Zammad, getOrCreateUser } from "./zammad.js";
|
||||
|
||||
type SavedVoiceProvider = any;
|
||||
|
||||
|
|
@ -20,52 +18,3 @@ export const twilioClientFor = (
|
|||
});
|
||||
};
|
||||
|
||||
export const createZammadTicket = async (
|
||||
call: CallInstance,
|
||||
mp3: Buffer,
|
||||
): Promise<void> => {
|
||||
const title = `Call from ${call.fromFormatted} at ${call.startTime}`;
|
||||
const body = `<ul>
|
||||
<li>Caller: ${call.fromFormatted}</li>
|
||||
<li>Service Number: ${call.toFormatted}</li>
|
||||
<li>Call Duration: ${call.duration} seconds</li>
|
||||
<li>Start Time: ${call.startTime}</li>
|
||||
<li>End Time: ${call.endTime}</li>
|
||||
</ul>
|
||||
<p>See the attached recording.</p>`;
|
||||
const filename = `${call.sid}-${call.startTime}.mp3`;
|
||||
const zammad = Zammad(
|
||||
{
|
||||
token: "EviH_WL0p6YUlCoIER7noAZEAPsYA_fVU4FZCKdpq525Vmzzvl8d7dNuP_8d-Amb",
|
||||
},
|
||||
"https://demo.digiresilience.org",
|
||||
);
|
||||
try {
|
||||
const customer = await getOrCreateUser(zammad, call.fromFormatted);
|
||||
await zammad.ticket.create({
|
||||
title,
|
||||
group: "Finances",
|
||||
note: "This ticket was created automaticaly from a recorded phone call.",
|
||||
customer_id: customer.id,
|
||||
article: {
|
||||
body,
|
||||
subject: title,
|
||||
content_type: "text/html",
|
||||
type: "note",
|
||||
attachments: [
|
||||
{
|
||||
filename,
|
||||
data: mp3.toString("base64"),
|
||||
"mime-type": "audio/mpeg",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.log(Object.keys(error));
|
||||
if (error.isBoom) {
|
||||
console.log(error.output);
|
||||
throw new Error("Failed to create zamamd ticket");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
272
apps/bridge-worker/lib/formstack-field-mapping.ts
Normal file
272
apps/bridge-worker/lib/formstack-field-mapping.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import { createLogger } from "@link-stack/logger";
|
||||
|
||||
const logger = createLogger('formstack-field-mapping');
|
||||
|
||||
/**
|
||||
* Field mapping configuration for Formstack to Zammad integration
|
||||
*
|
||||
* This configuration is completely flexible - you define your own internal field names
|
||||
* and map them to both Formstack source fields and Zammad custom fields.
|
||||
*/
|
||||
export interface FieldMappingConfig {
|
||||
/**
|
||||
* Map internal field keys to Formstack field names
|
||||
*
|
||||
* Required keys (system):
|
||||
* - formId: The Formstack Form ID field
|
||||
* - uniqueId: The Formstack submission unique ID field
|
||||
*
|
||||
* Optional keys with special behavior:
|
||||
* - email: Used for user lookup/creation (if provided)
|
||||
* - phone: Used for user lookup/creation (if provided)
|
||||
* - signalAccount: Used for Signal-based user lookup (tried first before phone)
|
||||
* - name: User's full name (can be nested object with first/last, used in user creation)
|
||||
* - organization: Used in ticket title template placeholder {organization}
|
||||
* - typeOfSupport: Used in ticket title template placeholder {typeOfSupport}
|
||||
* - descriptionOfIssue: Used as article subject (defaults to "Support Request" if not provided)
|
||||
*
|
||||
* All other keys are completely arbitrary and defined by your form.
|
||||
*/
|
||||
sourceFields: Record<string, string>;
|
||||
|
||||
/**
|
||||
* Map Zammad custom field names to internal field keys (from sourceFields)
|
||||
*
|
||||
* Example:
|
||||
* {
|
||||
* "us_state": "state", // Zammad field "us_state" gets value from sourceFields["state"]
|
||||
* "zip_code": "zipCode", // Zammad field "zip_code" gets value from sourceFields["zipCode"]
|
||||
* "custom_field": "myField" // Any custom field mapping
|
||||
* }
|
||||
*
|
||||
* The values in this object must correspond to keys in sourceFields.
|
||||
*/
|
||||
zammadFields: Record<string, string>;
|
||||
|
||||
/**
|
||||
* Configuration for ticket creation
|
||||
*/
|
||||
ticket: {
|
||||
/** Zammad group name to assign tickets to */
|
||||
group: string;
|
||||
|
||||
/** Article type name (e.g., "note", "cdr_signal", "email") */
|
||||
defaultArticleType: string;
|
||||
|
||||
/**
|
||||
* Template for ticket title
|
||||
* Supports placeholders: {name}, {organization}, {typeOfSupport}
|
||||
* Placeholders reference internal field keys from sourceFields
|
||||
*/
|
||||
titleTemplate?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration for extracting nested field values
|
||||
*/
|
||||
nestedFields?: {
|
||||
/**
|
||||
* How to extract first/last name from a nested Name field
|
||||
* Example: { firstNamePath: "first", lastNamePath: "last" }
|
||||
* for a field like { "Name": { "first": "John", "last": "Doe" } }
|
||||
*/
|
||||
name?: {
|
||||
firstNamePath?: string;
|
||||
lastNamePath?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let cachedMapping: FieldMappingConfig | null = null;
|
||||
|
||||
/**
|
||||
* Load field mapping configuration from environment variable (REQUIRED)
|
||||
*/
|
||||
export function loadFieldMapping(): FieldMappingConfig {
|
||||
if (cachedMapping) {
|
||||
return cachedMapping;
|
||||
}
|
||||
|
||||
const configJson = process.env.FORMSTACK_FIELD_MAPPING;
|
||||
|
||||
if (!configJson) {
|
||||
throw new Error(
|
||||
'FORMSTACK_FIELD_MAPPING environment variable is required. ' +
|
||||
'Please set it to a JSON string containing your field mapping configuration.'
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('Loading Formstack field mapping from environment variable');
|
||||
|
||||
try {
|
||||
const config = JSON.parse(configJson) as FieldMappingConfig;
|
||||
|
||||
// Validate required sections exist
|
||||
if (!config.sourceFields || typeof config.sourceFields !== 'object') {
|
||||
throw new Error('Invalid field mapping configuration: sourceFields must be an object');
|
||||
}
|
||||
|
||||
if (!config.zammadFields || typeof config.zammadFields !== 'object') {
|
||||
throw new Error('Invalid field mapping configuration: zammadFields must be an object');
|
||||
}
|
||||
|
||||
if (!config.ticket || typeof config.ticket !== 'object') {
|
||||
throw new Error('Invalid field mapping configuration: ticket must be an object');
|
||||
}
|
||||
|
||||
// Validate required ticket fields
|
||||
if (!config.ticket.group) {
|
||||
throw new Error('Invalid field mapping configuration: ticket.group is required');
|
||||
}
|
||||
|
||||
if (!config.ticket.defaultArticleType) {
|
||||
throw new Error('Invalid field mapping configuration: ticket.defaultArticleType is required');
|
||||
}
|
||||
|
||||
// Validate required source fields
|
||||
const systemRequiredFields = ['formId', 'uniqueId'];
|
||||
for (const field of systemRequiredFields) {
|
||||
if (!config.sourceFields[field]) {
|
||||
throw new Error(`Invalid field mapping configuration: sourceFields.${field} is required (system field)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate zammadFields reference valid sourceFields
|
||||
for (const [zammadField, sourceKey] of Object.entries(config.zammadFields)) {
|
||||
if (!config.sourceFields[sourceKey]) {
|
||||
logger.warn(
|
||||
{ zammadField, sourceKey },
|
||||
'Zammad field maps to non-existent source field key'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Successfully loaded Formstack field mapping configuration');
|
||||
cachedMapping = config;
|
||||
return cachedMapping;
|
||||
|
||||
} catch (error) {
|
||||
logger.error({
|
||||
error: error instanceof Error ? error.message : error,
|
||||
jsonLength: configJson.length
|
||||
}, 'Failed to parse field mapping configuration');
|
||||
|
||||
throw new Error(
|
||||
`Failed to parse Formstack field mapping JSON: ${error instanceof Error ? error.message : error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a field value from formData using the source field name mapping
|
||||
*/
|
||||
export function getFieldValue(
|
||||
formData: any,
|
||||
internalFieldKey: string,
|
||||
mapping?: FieldMappingConfig
|
||||
): any {
|
||||
const config = mapping || loadFieldMapping();
|
||||
const sourceFieldName = config.sourceFields[internalFieldKey];
|
||||
if (!sourceFieldName) {
|
||||
return undefined;
|
||||
}
|
||||
return formData[sourceFieldName];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a nested field value (e.g., Name.first)
|
||||
*/
|
||||
export function getNestedFieldValue(
|
||||
fieldValue: any,
|
||||
path: string | undefined
|
||||
): any {
|
||||
if (!path || !fieldValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parts = path.split('.');
|
||||
let current = fieldValue;
|
||||
|
||||
for (const part of parts) {
|
||||
if (current && typeof current === 'object') {
|
||||
current = current[part];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format field value (handle arrays, objects, etc.)
|
||||
*/
|
||||
export function formatFieldValue(value: any): string | undefined {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.join(', ');
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ticket title from template and data
|
||||
* Replaces placeholders like {name}, {organization}, {typeOfSupport} with provided values
|
||||
*/
|
||||
export function buildTicketTitle(
|
||||
mapping: FieldMappingConfig,
|
||||
data: Record<string, string | undefined>
|
||||
): string {
|
||||
const template = mapping.ticket.titleTemplate || '{name}';
|
||||
|
||||
let title = template;
|
||||
|
||||
// Replace all placeholders in the template
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const placeholder = `{${key}}`;
|
||||
if (title.includes(placeholder)) {
|
||||
if (value) {
|
||||
title = title.replace(placeholder, value);
|
||||
} else {
|
||||
// Remove empty placeholder and surrounding separators
|
||||
title = title.replace(` - ${placeholder}`, '').replace(`${placeholder} - `, '').replace(placeholder, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return title.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all Zammad field values from form data using the mapping
|
||||
* Returns an object with Zammad field names as keys and formatted values
|
||||
*/
|
||||
export function getZammadFieldValues(
|
||||
formData: any,
|
||||
mapping?: FieldMappingConfig
|
||||
): Record<string, string> {
|
||||
const config = mapping || loadFieldMapping();
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const [zammadFieldName, sourceKey] of Object.entries(config.zammadFields)) {
|
||||
const value = getFieldValue(formData, sourceKey, config);
|
||||
const formatted = formatFieldValue(value);
|
||||
if (formatted !== undefined) {
|
||||
result[zammadFieldName] = formatted;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset cached mapping (useful for testing)
|
||||
*/
|
||||
export function resetMappingCache(): void {
|
||||
cachedMapping = null;
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
//import { defState } from "@digiresilience/montar";
|
||||
//import { configureLogger } from "@digiresilience/bridge-common";
|
||||
// import config from "@digiresilience/bridge-config";
|
||||
|
||||
//export const logger = defState("workerLogger", {
|
||||
// start: async () => configureLogger(config),
|
||||
//});
|
||||
//export default logger;
|
||||
|
||||
export const logger = {};
|
||||
export default logger;
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import { Readable } from "stream";
|
||||
import ffmpeg from "fluent-ffmpeg";
|
||||
import * as R from "remeda";
|
||||
import { createLogger } from "@link-stack/logger";
|
||||
|
||||
const logger = createLogger('bridge-worker-media-convert');
|
||||
|
||||
const requiredCodecs = ["mp3", "webm", "wav"];
|
||||
|
||||
|
|
@ -25,7 +28,7 @@ const defaultAudioConvertOpts = {
|
|||
**/
|
||||
export const convert = (
|
||||
input: Buffer,
|
||||
opts?: AudioConvertOpts
|
||||
opts?: AudioConvertOpts,
|
||||
): Promise<Buffer> => {
|
||||
const settings = { ...defaultAudioConvertOpts, ...opts };
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -35,12 +38,8 @@ export const convert = (
|
|||
.audioCodec(settings.audioCodec)
|
||||
.audioBitrate(settings.bitrate)
|
||||
.toFormat(settings.format)
|
||||
.on("error", (err, stdout, stderr) => {
|
||||
console.error(err.message);
|
||||
console.log("FFMPEG OUTPUT");
|
||||
console.log(stdout);
|
||||
console.log("FFMPEG ERROR");
|
||||
console.log(stderr);
|
||||
.on("error", (err, _stdout, _stderr) => {
|
||||
logger.error({ error: err }, 'FFmpeg conversion error');
|
||||
reject(err);
|
||||
})
|
||||
.on("end", () => {
|
||||
|
|
@ -62,12 +61,16 @@ export const selfCheck = (): Promise<boolean> => {
|
|||
return new Promise((resolve) => {
|
||||
ffmpeg.getAvailableFormats((err, codecs) => {
|
||||
if (err) {
|
||||
console.error("FFMPEG error:", err);
|
||||
logger.error({ error: err }, 'FFMPEG error');
|
||||
resolve(false);
|
||||
}
|
||||
|
||||
const preds = R.map(requiredCodecs, (codec) => (available: any) =>
|
||||
available[codec] && available[codec].canDemux && available[codec].canMux
|
||||
const preds = R.map(
|
||||
requiredCodecs,
|
||||
(codec) => (available: any) =>
|
||||
available[codec] &&
|
||||
available[codec].canDemux &&
|
||||
available[codec].canMux,
|
||||
);
|
||||
|
||||
resolve(R.allPass(codecs, preds));
|
||||
|
|
@ -79,6 +82,6 @@ export const assertFfmpegAvailable = async (): Promise<void> => {
|
|||
const r = await selfCheck();
|
||||
if (!r)
|
||||
throw new Error(
|
||||
`ffmpeg is not installed, could not be located, or does not support the required codecs: ${requiredCodecs}`
|
||||
`ffmpeg is not installed, could not be located, or does not support the required codecs: ${requiredCodecs}`,
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,11 +19,13 @@ export interface Ticket {
|
|||
export interface ZammadClient {
|
||||
ticket: {
|
||||
create: (data: any) => Promise<Ticket>;
|
||||
update: (id: number, data: any) => Promise<Ticket>;
|
||||
};
|
||||
user: {
|
||||
search: (data: any) => Promise<User[]>;
|
||||
create: (data: any) => Promise<User>;
|
||||
};
|
||||
get: (path: string) => Promise<any>;
|
||||
}
|
||||
|
||||
export type ZammadCredentials =
|
||||
|
|
@ -39,7 +41,7 @@ const formatAuth = (credentials: any) => {
|
|||
return (
|
||||
"Basic " +
|
||||
Buffer.from(`${credentials.username}:${credentials.password}`).toString(
|
||||
"base64"
|
||||
"base64",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -54,7 +56,7 @@ const formatAuth = (credentials: any) => {
|
|||
export const Zammad = (
|
||||
credentials: ZammadCredentials,
|
||||
host: string,
|
||||
opts?: ZammadClientOpts
|
||||
opts?: ZammadClientOpts,
|
||||
): ZammadClient => {
|
||||
const extraHeaders = (opts && opts.headers) || {};
|
||||
|
||||
|
|
@ -73,6 +75,12 @@ export const Zammad = (
|
|||
const { payload: result } = await wreck.post("tickets", { payload });
|
||||
return result as Ticket;
|
||||
},
|
||||
update: async (id, payload) => {
|
||||
const { payload: result } = await wreck.put(`tickets/${id}`, {
|
||||
payload,
|
||||
});
|
||||
return result as Ticket;
|
||||
},
|
||||
},
|
||||
user: {
|
||||
search: async (query) => {
|
||||
|
|
@ -85,22 +93,79 @@ export const Zammad = (
|
|||
return result as User;
|
||||
},
|
||||
},
|
||||
get: async (path) => {
|
||||
const { payload: result } = await wreck.get(path);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitizes phone number to E.164 format: +15554446666
|
||||
* Strips all non-digit characters except +, ensures + prefix
|
||||
* @param phoneNumber - Raw phone number (e.g., "(555) 444-6666", "5554446666", "+1 555 444 6666")
|
||||
* @returns E.164 formatted phone number (e.g., "+15554446666")
|
||||
* @throws Error if phone number is invalid
|
||||
*/
|
||||
export const sanitizePhoneNumber = (phoneNumber: string): string => {
|
||||
// Remove all characters except digits and +
|
||||
let cleaned = phoneNumber.replace(/[^\d+]/g, "");
|
||||
|
||||
// Ensure it starts with +
|
||||
if (!cleaned.startsWith("+")) {
|
||||
// Assume US/Canada if no country code (11 digits starting with 1, or 10 digits)
|
||||
if (cleaned.length === 10) {
|
||||
cleaned = "+1" + cleaned;
|
||||
} else if (cleaned.length === 11 && cleaned.startsWith("1")) {
|
||||
cleaned = "+" + cleaned;
|
||||
} else if (cleaned.length >= 10) {
|
||||
// International number without +, add it
|
||||
cleaned = "+" + cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate E.164 format: + followed by 10-15 digits
|
||||
if (!/^\+\d{10,15}$/.test(cleaned)) {
|
||||
throw new Error(`Invalid phone number format: ${phoneNumber}`);
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
};
|
||||
|
||||
export const getUser = async (zammad: ZammadClient, phoneNumber: string) => {
|
||||
const mungedNumber = phoneNumber.replace("+", "");
|
||||
const results = await zammad.user.search(`phone:${mungedNumber}`);
|
||||
// Sanitize to E.164 format
|
||||
const sanitized = sanitizePhoneNumber(phoneNumber);
|
||||
|
||||
// Remove + for Zammad search query
|
||||
const searchNumber = sanitized.replace("+", "");
|
||||
|
||||
// Try sanitized format first (e.g., "6464229653" for "+16464229653")
|
||||
let results = await zammad.user.search(`phone:${searchNumber}`);
|
||||
if (results.length > 0) return results[0];
|
||||
|
||||
// Fall back to searching for original input (handles legacy formatted numbers)
|
||||
// This ensures we can find users with "(646) 422-9653" format in database
|
||||
const originalCleaned = phoneNumber.replace(/[^\d+]/g, "").replace("+", "");
|
||||
if (originalCleaned !== searchNumber) {
|
||||
results = await zammad.user.search(`phone:${originalCleaned}`);
|
||||
if (results.length > 0) return results[0];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getOrCreateUser = async (zammad: ZammadClient, phoneNumber: string) => {
|
||||
export const getOrCreateUser = async (
|
||||
zammad: ZammadClient,
|
||||
phoneNumber: string,
|
||||
) => {
|
||||
const customer = await getUser(zammad, phoneNumber);
|
||||
if (customer) return customer;
|
||||
|
||||
// Sanitize phone number to E.164 format before storing
|
||||
const sanitized = sanitizePhoneNumber(phoneNumber);
|
||||
|
||||
return zammad.user.create({
|
||||
phone: phoneNumber,
|
||||
note: "User created by Grabadora from incoming voice call",
|
||||
phone: sanitized,
|
||||
note: "User created from incoming voice call",
|
||||
});
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue