Move in progress apps temporarily
This commit is contained in:
parent
ba04aa108c
commit
6eaaf8e9be
360 changed files with 6171 additions and 55 deletions
1
fix/metamigo-worker/.eslintrc.js
Normal file
1
fix/metamigo-worker/.eslintrc.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
require("../.eslintrc.js");
|
||||
2
fix/metamigo-worker/.npmrc
Normal file
2
fix/metamigo-worker/.npmrc
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
@digiresilience:registry=https://gitlab.com/api/v4/packages/npm/
|
||||
@guardianproject-ops:registry=https://gitlab.com/api/v4/packages/npm/
|
||||
5
fix/metamigo-worker/babel.config.json
Normal file
5
fix/metamigo-worker/babel.config.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"presets": [
|
||||
"@digiresilience/babel-preset-metamigo"
|
||||
]
|
||||
}
|
||||
69
fix/metamigo-worker/common.ts
Normal file
69
fix/metamigo-worker/common.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/* eslint-disable camelcase */
|
||||
import { SavedVoiceProvider } from "db";
|
||||
import Twilio from "twilio";
|
||||
import { CallInstance } from "twilio/lib/rest/api/v2010/account/call";
|
||||
import { Zammad, getOrCreateUser } from "./zammad";
|
||||
|
||||
export const twilioClientFor = (
|
||||
provider: SavedVoiceProvider
|
||||
): Twilio.Twilio => {
|
||||
const { accountSid, apiKeySid, apiKeySecret } = provider.credentials;
|
||||
if (!accountSid || !apiKeySid || !apiKeySecret)
|
||||
throw new Error(
|
||||
`twilio provider ${provider.name} does not have credentials`
|
||||
);
|
||||
|
||||
return Twilio(apiKeySid, apiKeySecret, {
|
||||
accountSid,
|
||||
});
|
||||
};
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
};
|
||||
47
fix/metamigo-worker/db.ts
Normal file
47
fix/metamigo-worker/db.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import pgPromise from "pg-promise";
|
||||
import * as pgMonitor from "pg-monitor";
|
||||
import { dbInitOptions, IRepositories, AppDatabase } from "db";
|
||||
import config from "config";
|
||||
import type { IInitOptions } from "pg-promise";
|
||||
|
||||
export const initDiagnostics = (
|
||||
logSql: boolean,
|
||||
initOpts: IInitOptions<IRepositories>
|
||||
): void => {
|
||||
if (logSql) {
|
||||
pgMonitor.attach(initOpts);
|
||||
} else {
|
||||
pgMonitor.attach(initOpts, ["error"]);
|
||||
}
|
||||
};
|
||||
|
||||
export const stopDiagnostics = (): void => pgMonitor.detach();
|
||||
|
||||
let pgp: any;
|
||||
let pgpInitOptions: any;
|
||||
|
||||
export const initPgp = (): void => {
|
||||
pgpInitOptions = dbInitOptions(config);
|
||||
pgp = pgPromise(pgpInitOptions);
|
||||
};
|
||||
|
||||
const initDb = (): AppDatabase => {
|
||||
const db = pgp(config.db.connection);
|
||||
return db;
|
||||
};
|
||||
|
||||
export const stopDb = async (db: AppDatabase): Promise<void> => {
|
||||
return db.$pool.end();
|
||||
};
|
||||
|
||||
export const withDb = <T>(f: (db: AppDatabase) => Promise<T>): Promise<T> => {
|
||||
const db = initDb();
|
||||
initDiagnostics(config.logging.sql, pgpInitOptions);
|
||||
try {
|
||||
return f(db);
|
||||
} finally {
|
||||
stopDiagnostics();
|
||||
}
|
||||
};
|
||||
|
||||
export type { AppDatabase } from "db";
|
||||
53
fix/metamigo-worker/index.ts
Normal file
53
fix/metamigo-worker/index.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import * as Worker from "graphile-worker";
|
||||
import { defState } from "@digiresilience/montar";
|
||||
import config from "config";
|
||||
import { initPgp } from "./db";
|
||||
import logger from "./logger";
|
||||
import workerUtils from "./utils";
|
||||
import { assertFfmpegAvailable } from "./lib/media-convert";
|
||||
|
||||
const logFactory = (scope: any) => (level: any, message: any, meta: any) => {
|
||||
const pinoLevel = level === "warning" ? "warn" : level;
|
||||
const childLogger = logger.child({ scope });
|
||||
if (meta) childLogger[pinoLevel](meta, message);
|
||||
else childLogger[pinoLevel](message);
|
||||
};
|
||||
|
||||
export const configWorker = async (): Promise<Worker.RunnerOptions> => {
|
||||
const { connection, concurrency, pollInterval } = config.worker;
|
||||
logger.info({ concurrency, pollInterval }, "Starting worker");
|
||||
return {
|
||||
concurrency,
|
||||
pollInterval,
|
||||
logger: new Worker.Logger(logFactory),
|
||||
connectionString: connection,
|
||||
// eslint-disable-next-line unicorn/prefer-module
|
||||
taskDirectory: `${__dirname}/tasks`,
|
||||
};
|
||||
};
|
||||
|
||||
export const startWorker = async (): Promise<Worker.Runner> => {
|
||||
// ensure ffmpeg is installed and working
|
||||
await assertFfmpegAvailable();
|
||||
logger.info("ffmpeg found");
|
||||
|
||||
await workerUtils.migrate();
|
||||
logger.info("worker database migrated");
|
||||
|
||||
initPgp();
|
||||
|
||||
const workerConfig = await configWorker();
|
||||
const worker = await Worker.run(workerConfig);
|
||||
return worker;
|
||||
};
|
||||
|
||||
export const stopWorker = async (): Promise<void> => {
|
||||
await worker.stop();
|
||||
};
|
||||
|
||||
const worker = defState("worker", {
|
||||
start: startWorker,
|
||||
stop: stopWorker,
|
||||
});
|
||||
|
||||
export default worker;
|
||||
84
fix/metamigo-worker/lib/media-convert.ts
Normal file
84
fix/metamigo-worker/lib/media-convert.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { Readable } from "stream";
|
||||
import ffmpeg from "fluent-ffmpeg";
|
||||
import * as R from "remeda";
|
||||
|
||||
const requiredCodecs = ["mp3", "webm", "wav"];
|
||||
|
||||
export interface AudioConvertOpts {
|
||||
bitrate?: string;
|
||||
audioCodec?: string;
|
||||
format?: string;
|
||||
}
|
||||
|
||||
const defaultAudioConvertOpts = {
|
||||
bitrate: "32k",
|
||||
audioCodec: "libmp3lame",
|
||||
format: "mp3",
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts an audio file to a different format. defaults to converting to mp3 with a 32k bitrate using the libmp3lame codec
|
||||
*
|
||||
* @param input the buffer containing the binary data of the input file
|
||||
* @param opts options to control how the audio file is converted
|
||||
* @return resolves to a buffer containing the binary data of the converted file
|
||||
**/
|
||||
export const convert = (
|
||||
input: Buffer,
|
||||
opts?: AudioConvertOpts
|
||||
): Promise<Buffer> => {
|
||||
const settings = { ...defaultAudioConvertOpts, ...opts };
|
||||
return new Promise((resolve, reject) => {
|
||||
const stream = Readable.from(input);
|
||||
let out = Buffer.alloc(0);
|
||||
const cmd = ffmpeg(stream)
|
||||
.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);
|
||||
reject(err);
|
||||
})
|
||||
.on("end", () => {
|
||||
resolve(out);
|
||||
});
|
||||
const outstream = cmd.pipe();
|
||||
outstream.on("data", (chunk: Buffer) => {
|
||||
out = Buffer.concat([out, chunk]);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if ffmpeg is installed and usable. Checks for required codecs and a working ffmpeg installation.
|
||||
*
|
||||
* @return resolves to true if ffmpeg is installed and usable
|
||||
* */
|
||||
export const selfCheck = (): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
ffmpeg.getAvailableFormats((err, codecs) => {
|
||||
if (err) {
|
||||
console.error("FFMPEG error:", err);
|
||||
resolve(false);
|
||||
}
|
||||
|
||||
const preds = R.map(requiredCodecs, (codec) => (available: any) =>
|
||||
available[codec] && available[codec].canDemux && available[codec].canMux
|
||||
);
|
||||
|
||||
resolve(R.allPass(codecs, preds));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
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}`
|
||||
);
|
||||
};
|
||||
8
fix/metamigo-worker/logger.ts
Normal file
8
fix/metamigo-worker/logger.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defState } from "@digiresilience/montar";
|
||||
import { configureLogger } from "common";
|
||||
import config from "config";
|
||||
|
||||
export const logger = defState("workerLogger", {
|
||||
start: async () => configureLogger(config),
|
||||
});
|
||||
export default logger;
|
||||
55
fix/metamigo-worker/package.json
Normal file
55
fix/metamigo-worker/package.json
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
{
|
||||
"name": "worker",
|
||||
"version": "0.2.0",
|
||||
"main": "build/main/index.js",
|
||||
"author": "Abel Luck <abel@guardianproject.info>",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@digiresilience/montar": "^0.1.6",
|
||||
"graphile-worker": "^0.13.0",
|
||||
"remeda": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.20.12",
|
||||
"@babel/preset-env": "7.20.2",
|
||||
"@babel/preset-typescript": "7.18.6",
|
||||
"@types/fluent-ffmpeg": "^2.1.20",
|
||||
"@types/jest": "^29.2.5",
|
||||
"common": "0.2.5",
|
||||
"config": "3.3.8",
|
||||
"db": "3.4.0",
|
||||
"eslint": "^8.32.0",
|
||||
"jest": "^29.3.1",
|
||||
"jest-circus": "^29.3.1",
|
||||
"jest-junit": "^15.0.0",
|
||||
"nodemon": "^2.0.20",
|
||||
"pino-pretty": "^9.1.1",
|
||||
"prettier": "^2.8.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"typedoc": "^0.23.24",
|
||||
"typescript": "4.9.4"
|
||||
},
|
||||
"nodemonConfig": {
|
||||
"ignore": [
|
||||
"docs/*"
|
||||
],
|
||||
"ext": "ts,json,js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"build-test": "tsc -p tsconfig.json",
|
||||
"doc:html": "typedoc src/ --exclude '**/*.test.ts' --exclude '**/*.spec.ts' --name $npm_package_name --readme README.md --target es2019 --mode file --out build/docs",
|
||||
"doc": "yarn run doc:html",
|
||||
"fix:lint": "eslint src --ext .ts --fix",
|
||||
"fix:prettier": "prettier \"src/**/*.ts\" --write",
|
||||
"worker": "NODE_ENV=development yarn cli worker",
|
||||
"test:jest": "JEST_CIRCUS=1 jest --coverage --forceExit --detectOpenHandles --reporters=default --reporters=jest-junit",
|
||||
"test:jest-verbose": "yarn test:jest --verbose --silent=false",
|
||||
"test": "yarn test:jest",
|
||||
"lint": "yarn lint:lint && yarn lint:prettier",
|
||||
"lint:lint": "eslint src --ext .ts",
|
||||
"lint:prettier": "prettier \"src/**/*.ts\" --list-different",
|
||||
"watch:build": "tsc -p tsconfig.json -w",
|
||||
"watch:test": "yarn test:jest --watchAll"
|
||||
}
|
||||
}
|
||||
57
fix/metamigo-worker/tasks/notify-webhook.ts
Normal file
57
fix/metamigo-worker/tasks/notify-webhook.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import Wreck from "@hapi/wreck";
|
||||
import * as R from "remeda";
|
||||
import { withDb, AppDatabase } from "../db";
|
||||
import logger from "../logger";
|
||||
|
||||
export interface WebhookOptions {
|
||||
webhookId: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
const notifyWebhooksTask = async (options: WebhookOptions): Promise<void> =>
|
||||
withDb(async (db: AppDatabase) => {
|
||||
const { webhookId, payload } = options;
|
||||
|
||||
const webhook = await db.webhooks.findById({ id: webhookId });
|
||||
if (!webhook) {
|
||||
logger.debug(
|
||||
{ webhookId },
|
||||
"notify-webhook: no webhook registered with id"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { endpointUrl, httpMethod, headers } = webhook;
|
||||
const headersFormatted = R.reduce(
|
||||
headers || [],
|
||||
(acc: any, h: any) => {
|
||||
acc[h.header] = h.value;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
const wreck = Wreck.defaults({
|
||||
json: true,
|
||||
headers: headersFormatted,
|
||||
});
|
||||
|
||||
// http errors will bubble up and cause the job to fail and be retried
|
||||
try {
|
||||
logger.debug(
|
||||
{ webhookId, endpointUrl, httpMethod },
|
||||
"notify-webhook: notifying"
|
||||
);
|
||||
await (httpMethod === "post"
|
||||
? wreck.post(endpointUrl, { payload })
|
||||
: wreck.put(endpointUrl, { payload }));
|
||||
} catch (error: any) {
|
||||
logger.error(
|
||||
{ webhookId, error: error.output },
|
||||
"notify-webhook failed with this error"
|
||||
);
|
||||
throw new Error(`webhook failed webhookId=${webhookId}`);
|
||||
}
|
||||
});
|
||||
|
||||
export default notifyWebhooksTask;
|
||||
76
fix/metamigo-worker/tasks/signal-message.ts
Normal file
76
fix/metamigo-worker/tasks/signal-message.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/* eslint-disable camelcase */
|
||||
import { withDb, AppDatabase } from "../db";
|
||||
import workerUtils from "../utils";
|
||||
|
||||
interface WebhookPayload {
|
||||
to: string;
|
||||
from: string;
|
||||
message_id: string;
|
||||
sent_at: string;
|
||||
message: string;
|
||||
attachment: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
interface SignalMessageTaskOptions {
|
||||
id: string;
|
||||
source: string;
|
||||
timestamp: string;
|
||||
message: string;
|
||||
attachments: unknown[];
|
||||
signalBotId: string;
|
||||
}
|
||||
|
||||
const formatPayload = (
|
||||
messageInfo: SignalMessageTaskOptions
|
||||
): WebhookPayload => {
|
||||
const { id, source, message, timestamp } = messageInfo;
|
||||
|
||||
return {
|
||||
to: "16464229653",
|
||||
from: source,
|
||||
message_id: id,
|
||||
sent_at: timestamp,
|
||||
message,
|
||||
attachment: "",
|
||||
filename: "test.png",
|
||||
mime_type: "image/png",
|
||||
};
|
||||
};
|
||||
|
||||
const notifyWebhooks = async (
|
||||
db: AppDatabase,
|
||||
messageInfo: SignalMessageTaskOptions
|
||||
) => {
|
||||
const { id: messageID, signalBotId } = messageInfo;
|
||||
const webhooks = await db.webhooks.findAllByBackendId("signal", signalBotId);
|
||||
if (webhooks && webhooks.length === 0) return;
|
||||
|
||||
webhooks.forEach(({ id }) => {
|
||||
const payload = formatPayload(messageInfo);
|
||||
console.log({ payload });
|
||||
workerUtils.addJob(
|
||||
"notify-webhook",
|
||||
{
|
||||
payload,
|
||||
webhookId: id,
|
||||
},
|
||||
{
|
||||
// this de-deduplicates the job
|
||||
jobKey: `webhook-${id}-message-${messageID}`,
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const signalMessageTask = async (
|
||||
options: SignalMessageTaskOptions
|
||||
): Promise<void> => {
|
||||
console.log(options);
|
||||
withDb(async (db: AppDatabase) => {
|
||||
await notifyWebhooks(db, options);
|
||||
});
|
||||
};
|
||||
|
||||
export default signalMessageTask;
|
||||
87
fix/metamigo-worker/tasks/signald-message.ts
Normal file
87
fix/metamigo-worker/tasks/signald-message.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/* eslint-disable camelcase */
|
||||
import logger from "../logger";
|
||||
import { IncomingMessagev1 } from "@digiresilience/node-signald/dist/generated";
|
||||
import { withDb, AppDatabase } from "../db";
|
||||
import workerUtils from "../utils";
|
||||
|
||||
interface WebhookPayload {
|
||||
to: string;
|
||||
from: string;
|
||||
message_id: string;
|
||||
sent_at: string;
|
||||
message: string;
|
||||
attachment: string | null;
|
||||
filename: string | null;
|
||||
mime_type: string | null;
|
||||
}
|
||||
|
||||
interface SignaldMessageTaskOptions {
|
||||
message: IncomingMessagev1;
|
||||
botId: string;
|
||||
botPhoneNumber: string;
|
||||
}
|
||||
|
||||
const formatPayload = (opts: SignaldMessageTaskOptions): WebhookPayload => {
|
||||
const { botId, botPhoneNumber, message } = opts;
|
||||
const { source, timestamp, data_message: dataMessage } = message;
|
||||
|
||||
const { number }: any = source;
|
||||
|
||||
const { body, attachments }: any = dataMessage;
|
||||
|
||||
return {
|
||||
to: botPhoneNumber,
|
||||
from: number,
|
||||
message_id: `${botId}-${timestamp}`,
|
||||
sent_at: `${timestamp}`,
|
||||
message: body,
|
||||
attachment: null,
|
||||
filename: null,
|
||||
mime_type: null,
|
||||
};
|
||||
};
|
||||
|
||||
const notifyWebhooks = async (
|
||||
db: AppDatabase,
|
||||
messageInfo: SignaldMessageTaskOptions
|
||||
) => {
|
||||
const {
|
||||
botId,
|
||||
message: { timestamp },
|
||||
} = messageInfo;
|
||||
const webhooks = await db.webhooks.findAllByBackendId("signal", botId);
|
||||
if (webhooks && webhooks.length === 0) {
|
||||
logger.debug({ botId }, "no webhooks registered for signal bot");
|
||||
return;
|
||||
}
|
||||
|
||||
webhooks.forEach(({ id }) => {
|
||||
const payload = formatPayload(messageInfo);
|
||||
logger.debug(
|
||||
{ payload },
|
||||
"formatted signal bot payload for notify-webhook"
|
||||
);
|
||||
workerUtils.addJob(
|
||||
"notify-webhook",
|
||||
{
|
||||
payload,
|
||||
webhookId: id,
|
||||
},
|
||||
{
|
||||
// this de-deduplicates the job
|
||||
jobKey: `webhook-${id}-message-${botId}-${timestamp}`,
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const signaldMessageTask = async (
|
||||
options: SignaldMessageTaskOptions
|
||||
): Promise<void> => {
|
||||
console.log(options);
|
||||
withDb(async (db: AppDatabase) => {
|
||||
await notifyWebhooks(db, options);
|
||||
});
|
||||
};
|
||||
|
||||
export default signaldMessageTask;
|
||||
101
fix/metamigo-worker/tasks/twilio-recording.ts
Normal file
101
fix/metamigo-worker/tasks/twilio-recording.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import Wreck from "@hapi/wreck";
|
||||
import { withDb, AppDatabase } from "../db";
|
||||
import { twilioClientFor } from "../common";
|
||||
import { CallInstance } from "twilio/lib/rest/api/v2010/account/call";
|
||||
import workerUtils from "../utils";
|
||||
|
||||
interface WebhookPayload {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
to: string;
|
||||
from: string;
|
||||
duration: string;
|
||||
callSid: string;
|
||||
recording: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
const getTwilioRecording = async (url: string) => {
|
||||
try {
|
||||
const { payload } = await Wreck.get(url);
|
||||
return { recording: payload as Buffer };
|
||||
} catch (error: any) {
|
||||
console.error(error.output);
|
||||
return { error: error.output };
|
||||
}
|
||||
};
|
||||
|
||||
const formatPayload = (
|
||||
call: CallInstance,
|
||||
recording: Buffer
|
||||
): WebhookPayload => {
|
||||
return {
|
||||
startTime: call.startTime.toISOString(),
|
||||
endTime: call.endTime.toISOString(),
|
||||
to: call.toFormatted,
|
||||
from: call.fromFormatted,
|
||||
duration: call.duration,
|
||||
callSid: call.sid,
|
||||
recording: recording.toString("base64"),
|
||||
mimeType: "audio/mpeg",
|
||||
};
|
||||
};
|
||||
|
||||
const notifyWebhooks = async (
|
||||
db: AppDatabase,
|
||||
voiceLineId: string,
|
||||
call: CallInstance,
|
||||
recording: Buffer
|
||||
) => {
|
||||
const webhooks = await db.webhooks.findAllByBackendId("voice", voiceLineId);
|
||||
if (webhooks && webhooks.length === 0) return;
|
||||
|
||||
webhooks.forEach(({ id }) => {
|
||||
const payload = formatPayload(call, recording);
|
||||
workerUtils.addJob(
|
||||
"notify-webhook",
|
||||
{
|
||||
payload,
|
||||
webhookId: id,
|
||||
},
|
||||
{
|
||||
// this de-depuplicates the job
|
||||
jobKey: `webhook-${id}-call-${call.sid}`,
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
interface TwilioRecordingTaskOptions {
|
||||
accountSid: string;
|
||||
callSid: string;
|
||||
recordingSid: string;
|
||||
voiceLineId: string;
|
||||
}
|
||||
|
||||
const twilioRecordingTask = async (
|
||||
options: TwilioRecordingTaskOptions
|
||||
): Promise<void> =>
|
||||
withDb(async (db: AppDatabase) => {
|
||||
const { voiceLineId, accountSid, callSid, recordingSid } = options;
|
||||
|
||||
const voiceLine = await db.voiceLines.findById({ id: voiceLineId });
|
||||
if (!voiceLine) return;
|
||||
|
||||
const provider = await db.voiceProviders.findByTwilioAccountSid(accountSid);
|
||||
if (!provider) return;
|
||||
|
||||
const client = twilioClientFor(provider);
|
||||
const meta = await client.recordings(recordingSid).fetch();
|
||||
|
||||
const mp3Url = "https://api.twilio.com/" + meta.uri.slice(0, -4) + "mp3";
|
||||
const { recording, error } = await getTwilioRecording(mp3Url);
|
||||
if (error) {
|
||||
throw new Error(`failed to get recording for call ${callSid}`);
|
||||
}
|
||||
|
||||
const call = await client.calls(callSid).fetch();
|
||||
await notifyWebhooks(db, voiceLineId, call, recording!);
|
||||
});
|
||||
|
||||
export default twilioRecordingTask;
|
||||
48
fix/metamigo-worker/tasks/voice-line-audio-update.ts
Normal file
48
fix/metamigo-worker/tasks/voice-line-audio-update.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { createHash } from "crypto";
|
||||
import { withDb, AppDatabase } from "../db";
|
||||
import { convert } from "../lib/media-convert";
|
||||
|
||||
interface VoiceLineAudioUpdateTaskOptions {
|
||||
voiceLineId: string;
|
||||
}
|
||||
|
||||
const sha1sum = (v: any) => {
|
||||
const shasum = createHash("sha1");
|
||||
shasum.update(v);
|
||||
return shasum.digest("hex");
|
||||
};
|
||||
|
||||
const voiceLineAudioUpdateTask = async (
|
||||
payload: VoiceLineAudioUpdateTaskOptions
|
||||
): Promise<void> =>
|
||||
withDb(async (db: AppDatabase) => {
|
||||
const { voiceLineId } = payload;
|
||||
const voiceLine = await db.voiceLines.findById({ id: voiceLineId });
|
||||
if (!voiceLine) return;
|
||||
if (!voiceLine?.promptAudio?.["audio/webm"]) return;
|
||||
|
||||
const webm = Buffer.from(voiceLine.promptAudio["audio/webm"], "base64");
|
||||
const webmSha1 = sha1sum(webm);
|
||||
|
||||
if (
|
||||
voiceLine.promptAudio.checksum &&
|
||||
voiceLine.promptAudio.checksum === webmSha1
|
||||
) {
|
||||
// already converted
|
||||
return;
|
||||
}
|
||||
|
||||
const mp3 = await convert(webm);
|
||||
await db.voiceLines.updateById(
|
||||
{ id: voiceLine.id },
|
||||
{
|
||||
promptAudio: {
|
||||
...voiceLine.promptAudio,
|
||||
"audio/mpeg": mp3.toString("base64"),
|
||||
checksum: webmSha1,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
export default voiceLineAudioUpdateTask;
|
||||
41
fix/metamigo-worker/tasks/voice-line-delete.ts
Normal file
41
fix/metamigo-worker/tasks/voice-line-delete.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import Twilio from "twilio";
|
||||
import config from "config";
|
||||
import { withDb, AppDatabase } from "../db";
|
||||
|
||||
interface VoiceLineDeleteTaskOptions {
|
||||
voiceLineId: string;
|
||||
providerId: string;
|
||||
providerLineSid: string;
|
||||
}
|
||||
|
||||
const voiceLineDeleteTask = async (
|
||||
payload: VoiceLineDeleteTaskOptions
|
||||
): Promise<void> =>
|
||||
withDb(async (db: AppDatabase) => {
|
||||
const { voiceLineId, providerId, providerLineSid } = payload;
|
||||
const provider = await db.voiceProviders.findById({ id: providerId });
|
||||
if (!provider) return;
|
||||
|
||||
const { accountSid, apiKeySid, apiKeySecret } = provider.credentials;
|
||||
if (!accountSid || !apiKeySid || !apiKeySecret)
|
||||
throw new Error(
|
||||
`twilio provider ${provider.name} does not have credentials`
|
||||
);
|
||||
|
||||
const client = Twilio(apiKeySid, apiKeySecret, {
|
||||
accountSid,
|
||||
});
|
||||
|
||||
const number = await client.incomingPhoneNumbers(providerLineSid).fetch();
|
||||
if (
|
||||
number &&
|
||||
number.voiceUrl ===
|
||||
`${config.frontend.url}/api/v1/voice/twilio/record/${voiceLineId}`
|
||||
)
|
||||
await client.incomingPhoneNumbers(providerLineSid).update({
|
||||
voiceUrl: "",
|
||||
voiceMethod: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
export default voiceLineDeleteTask;
|
||||
38
fix/metamigo-worker/tasks/voice-line-provider-update.ts
Normal file
38
fix/metamigo-worker/tasks/voice-line-provider-update.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import Twilio from "twilio";
|
||||
import config from "config";
|
||||
import { withDb, AppDatabase } from "../db";
|
||||
|
||||
interface VoiceLineUpdateTaskOptions {
|
||||
voiceLineId: string;
|
||||
}
|
||||
|
||||
const voiceLineUpdateTask = async (
|
||||
payload: VoiceLineUpdateTaskOptions
|
||||
): Promise<void> =>
|
||||
withDb(async (db: AppDatabase) => {
|
||||
const { voiceLineId } = payload;
|
||||
const voiceLine = await db.voiceLines.findById({ id: voiceLineId });
|
||||
if (!voiceLine) return;
|
||||
|
||||
const provider = await db.voiceProviders.findById({
|
||||
id: voiceLine.providerId,
|
||||
});
|
||||
if (!provider) return;
|
||||
|
||||
const { accountSid, apiKeySid, apiKeySecret } = provider.credentials;
|
||||
if (!accountSid || !apiKeySid || !apiKeySecret)
|
||||
throw new Error(
|
||||
`twilio provider ${provider.name} does not have credentials`
|
||||
);
|
||||
|
||||
const client = Twilio(apiKeySid, apiKeySecret, {
|
||||
accountSid,
|
||||
});
|
||||
|
||||
await client.incomingPhoneNumbers(voiceLine.providerLineSid).update({
|
||||
voiceUrl: `${config.frontend.url}/api/v1/voice/twilio/record/${voiceLineId}`,
|
||||
voiceMethod: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
export default voiceLineUpdateTask;
|
||||
94
fix/metamigo-worker/tasks/whatsapp-message.ts
Normal file
94
fix/metamigo-worker/tasks/whatsapp-message.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
/* eslint-disable camelcase */
|
||||
import { withDb, AppDatabase } from "../db";
|
||||
import workerUtils from "../utils";
|
||||
|
||||
interface WebhookPayload {
|
||||
to: string;
|
||||
from: string;
|
||||
message_id: string;
|
||||
sent_at: string;
|
||||
message: string;
|
||||
attachment: string;
|
||||
filename: string;
|
||||
mime_type: string;
|
||||
}
|
||||
|
||||
interface WhatsappMessageTaskOptions {
|
||||
waMessageId: string;
|
||||
waMessage: string;
|
||||
waTimestamp: string;
|
||||
attachment: string;
|
||||
filename: string;
|
||||
mimetype: string;
|
||||
botPhoneNumber: string;
|
||||
whatsappBotId: string;
|
||||
}
|
||||
|
||||
const formatPayload = (
|
||||
messageInfo: WhatsappMessageTaskOptions
|
||||
): WebhookPayload => {
|
||||
const {
|
||||
waMessageId,
|
||||
waMessage,
|
||||
waTimestamp,
|
||||
attachment,
|
||||
filename,
|
||||
mimetype,
|
||||
botPhoneNumber,
|
||||
} = messageInfo;
|
||||
const parsedMessage = JSON.parse(waMessage);
|
||||
const message = parsedMessage.message?.conversation ??
|
||||
parsedMessage.message?.extendedTextMessage?.text ??
|
||||
parsedMessage.message?.imageMessage?.caption ??
|
||||
parsedMessage.message?.videoMessage?.caption;
|
||||
|
||||
return {
|
||||
to: botPhoneNumber,
|
||||
from: parsedMessage.key.remoteJid,
|
||||
message_id: waMessageId,
|
||||
sent_at: waTimestamp,
|
||||
message,
|
||||
attachment,
|
||||
filename,
|
||||
mime_type: mimetype,
|
||||
};
|
||||
};
|
||||
|
||||
const notifyWebhooks = async (
|
||||
db: AppDatabase,
|
||||
messageInfo: WhatsappMessageTaskOptions
|
||||
) => {
|
||||
const { waMessageId, whatsappBotId } = messageInfo;
|
||||
const webhooks = await db.webhooks.findAllByBackendId(
|
||||
"whatsapp",
|
||||
whatsappBotId
|
||||
);
|
||||
if (webhooks && webhooks.length === 0) return;
|
||||
|
||||
webhooks.forEach(({ id }) => {
|
||||
const payload = formatPayload(messageInfo);
|
||||
console.log({ payload });
|
||||
workerUtils.addJob(
|
||||
"notify-webhook",
|
||||
{
|
||||
payload,
|
||||
webhookId: id,
|
||||
},
|
||||
{
|
||||
// this de-deduplicates the job
|
||||
jobKey: `webhook-${id}-message-${waMessageId}`,
|
||||
}
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const whatsappMessageTask = async (
|
||||
options: WhatsappMessageTaskOptions
|
||||
): Promise<void> => {
|
||||
console.log(options);
|
||||
withDb(async (db: AppDatabase) => {
|
||||
await notifyWebhooks(db, options);
|
||||
});
|
||||
};
|
||||
|
||||
export default whatsappMessageTask;
|
||||
8
fix/metamigo-worker/tsconfig.json
Normal file
8
fix/metamigo-worker/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "build/main"
|
||||
},
|
||||
"include": ["**/*.ts", "**/.*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
21
fix/metamigo-worker/utils.ts
Normal file
21
fix/metamigo-worker/utils.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import * as Worker from "graphile-worker";
|
||||
import { defState } from "@digiresilience/montar";
|
||||
import config from "config";
|
||||
|
||||
const startWorkerUtils = async (): Promise<Worker.WorkerUtils> => {
|
||||
const workerUtils = await Worker.makeWorkerUtils({
|
||||
connectionString: config.worker.connection,
|
||||
});
|
||||
return workerUtils;
|
||||
};
|
||||
|
||||
const stopWorkerUtils = async (): Promise<void> => {
|
||||
return workerUtils.release();
|
||||
};
|
||||
|
||||
const workerUtils = defState("workerUtils", {
|
||||
start: startWorkerUtils,
|
||||
stop: stopWorkerUtils,
|
||||
});
|
||||
|
||||
export default workerUtils;
|
||||
106
fix/metamigo-worker/zammad.ts
Normal file
106
fix/metamigo-worker/zammad.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/* eslint-disable camelcase,@typescript-eslint/explicit-module-boundary-types,@typescript-eslint/no-explicit-any */
|
||||
import querystring from "querystring";
|
||||
import Wreck from "@hapi/wreck";
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
firstname?: string;
|
||||
lastname?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
export interface Ticket {
|
||||
id: number;
|
||||
title?: string;
|
||||
group_id?: number;
|
||||
customer_id?: number;
|
||||
}
|
||||
|
||||
export interface ZammadClient {
|
||||
ticket: {
|
||||
create: (data: any) => Promise<Ticket>;
|
||||
};
|
||||
user: {
|
||||
search: (data: any) => Promise<User[]>;
|
||||
create: (data: any) => Promise<User>;
|
||||
};
|
||||
}
|
||||
|
||||
export type ZammadCredentials =
|
||||
| { username: string; password: string }
|
||||
| { token: string };
|
||||
|
||||
export interface ZammadClientOpts {
|
||||
headers?: Record<string, any>;
|
||||
}
|
||||
|
||||
const formatAuth = (credentials: any) => {
|
||||
if (credentials.username) {
|
||||
return (
|
||||
"Basic " +
|
||||
Buffer.from(`${credentials.username}:${credentials.password}`).toString(
|
||||
"base64"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (credentials.token) {
|
||||
return `Token ${credentials.token}`;
|
||||
}
|
||||
|
||||
throw new Error("invalid zammad credentials type");
|
||||
};
|
||||
|
||||
export const Zammad = (
|
||||
credentials: ZammadCredentials,
|
||||
host: string,
|
||||
opts?: ZammadClientOpts
|
||||
): ZammadClient => {
|
||||
const extraHeaders = (opts && opts.headers) || {};
|
||||
|
||||
const wreck = Wreck.defaults({
|
||||
baseUrl: `${host}/api/v1/`,
|
||||
headers: {
|
||||
authorization: formatAuth(credentials),
|
||||
...extraHeaders,
|
||||
},
|
||||
json: true,
|
||||
});
|
||||
|
||||
return {
|
||||
ticket: {
|
||||
create: async (payload) => {
|
||||
const { payload: result } = await wreck.post("tickets", { payload });
|
||||
return result as Ticket;
|
||||
},
|
||||
},
|
||||
user: {
|
||||
search: async (query) => {
|
||||
const qp = querystring.stringify({ query });
|
||||
const { payload: result } = await wreck.get(`users/search?${qp}`);
|
||||
return result as User[];
|
||||
},
|
||||
create: async (payload) => {
|
||||
const { payload: result } = await wreck.post("users", { payload });
|
||||
return result as User;
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const getUser = async (zammad: ZammadClient, phoneNumber: string) => {
|
||||
const mungedNumber = phoneNumber.replace("+", "");
|
||||
const results = await zammad.user.search(`phone:${mungedNumber}`);
|
||||
if (results.length > 0) return results[0];
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getOrCreateUser = async (zammad: ZammadClient, phoneNumber: string) => {
|
||||
const customer = await getUser(zammad, phoneNumber);
|
||||
if (customer) return customer;
|
||||
|
||||
return zammad.user.create({
|
||||
phone: phoneNumber,
|
||||
note: "User created by Grabadora from incoming voice call",
|
||||
});
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue