Add all repos

This commit is contained in:
Darren Clarke 2023-02-13 12:41:30 +00:00
parent faa12c60bc
commit 8a91c9b89b
369 changed files with 29047 additions and 28 deletions

View file

@ -0,0 +1 @@
require("../.eslintrc.js");

26
metamigo-api/app/index.ts Normal file
View file

@ -0,0 +1,26 @@
import type * as Hapi from "@hapi/hapi";
import * as Joi from "joi";
import type { IAppConfig } from "../config";
import * as Services from "./services";
import * as Routes from "./routes";
import * as Plugins from "./plugins";
const AppPlugin = {
name: "App",
register: async (
server: Hapi.Server,
options: { config: IAppConfig }
): Promise<void> => {
// declare our **run-time** plugin dependencies
// these are runtime only deps, not registration time
// ref: https://hapipal.com/best-practices/handling-plugin-dependencies
server.dependency(["config", "hapi-pino"]);
server.validator(Joi);
await Plugins.register(server, options.config);
await Services.register(server);
await Routes.register(server);
},
};
export default AppPlugin;

View file

@ -0,0 +1,198 @@
import { Boom } from "@hapi/boom";
import { Server } from "@hapi/hapi";
import { randomBytes } from "crypto";
import type { Logger } from "pino";
import {
proto,
BufferJSON,
generateRegistrationId,
Curve,
signedKeyPair,
AuthenticationCreds,
AuthenticationState,
AccountSettings,
SignalDataSet,
SignalDataTypeMap,
SignalKeyStore,
SignalKeyStoreWithTransaction,
} from "@adiwajshing/baileys";
import { SavedWhatsappBot as Bot } from "db";
const KEY_MAP: { [T in keyof SignalDataTypeMap]: string } = {
"pre-key": "preKeys",
session: "sessions",
"sender-key": "senderKeys",
"app-state-sync-key": "appStateSyncKeys",
"app-state-sync-version": "appStateVersions",
"sender-key-memory": "senderKeyMemory",
};
export const addTransactionCapability = (
state: SignalKeyStore,
logger: Logger
): SignalKeyStoreWithTransaction => {
let inTransaction = false;
let transactionCache: SignalDataSet = {};
let mutations: SignalDataSet = {};
const prefetch = async (type: keyof SignalDataTypeMap, ids: string[]) => {
if (!inTransaction) {
throw new Boom("Cannot prefetch without transaction");
}
const dict = transactionCache[type];
const idsRequiringFetch = dict
? ids.filter((item) => !(item in dict))
: ids;
// only fetch if there are any items to fetch
if (idsRequiringFetch.length) {
const result = await state.get(type, idsRequiringFetch);
transactionCache[type] = transactionCache[type] || {};
// @ts-expect-error
Object.assign(transactionCache[type], result);
}
};
return {
get: async (type, ids) => {
if (inTransaction) {
await prefetch(type, ids);
return ids.reduce((dict, id) => {
const value = transactionCache[type]?.[id];
if (value) {
// @ts-expect-error
dict[id] = value;
}
return dict;
}, {});
} else {
return state.get(type, ids);
}
},
set: (data) => {
if (inTransaction) {
logger.trace({ types: Object.keys(data) }, "caching in transaction");
for (const key in data) {
// @ts-expect-error
transactionCache[key] = transactionCache[key] || {};
// @ts-expect-error
Object.assign(transactionCache[key], data[key]);
// @ts-expect-error
mutations[key] = mutations[key] || {};
// @ts-expect-error
Object.assign(mutations[key], data[key]);
}
} else {
return state.set(data);
}
},
isInTransaction: () => inTransaction,
// @ts-expect-error
prefetch: (type, ids) => {
logger.trace({ type, ids }, "prefetching");
return prefetch(type, ids);
},
transaction: async (work) => {
if (inTransaction) {
await work();
} else {
logger.debug("entering transaction");
inTransaction = true;
try {
await work();
if (Object.keys(mutations).length) {
logger.debug("committing transaction");
await state.set(mutations);
} else {
logger.debug("no mutations in transaction");
}
} finally {
inTransaction = false;
transactionCache = {};
mutations = {};
}
}
},
};
};
export const initAuthCreds = (): AuthenticationCreds => {
const identityKey = Curve.generateKeyPair();
return {
noiseKey: Curve.generateKeyPair(),
signedIdentityKey: identityKey,
signedPreKey: signedKeyPair(identityKey, 1),
registrationId: generateRegistrationId(),
advSecretKey: randomBytes(32).toString("base64"),
nextPreKeyId: 1,
firstUnuploadedPreKeyId: 1,
processedHistoryMessages: [],
accountSettings: {
unarchiveChats: false,
},
} as any;
};
export const useDatabaseAuthState = (
bot: Bot,
server: Server
): { state: AuthenticationState; saveState: () => void } => {
let { logger }: any = server;
let creds: AuthenticationCreds;
let keys: any = {};
const saveState = async () => {
logger && logger.trace("saving auth state");
const authInfo = JSON.stringify({ creds, keys }, BufferJSON.replacer, 2);
await server.db().whatsappBots.updateAuthInfo(bot, authInfo);
};
if (bot.authInfo) {
console.log("Auth info exists");
const result = JSON.parse(bot.authInfo, BufferJSON.reviver);
creds = result.creds;
keys = result.keys;
} else {
console.log("Auth info does not exist");
creds = initAuthCreds();
keys = {};
}
return {
state: {
creds,
keys: {
get: (type, ids) => {
const key = KEY_MAP[type];
return ids.reduce((dict, id) => {
let value = keys[key]?.[id];
if (value) {
if (type === "app-state-sync-key") {
// @ts-expect-error
value = proto.AppStateSyncKeyData.fromObject(value);
}
// @ts-expect-error
dict[id] = value;
}
return dict;
}, {});
},
set: (data) => {
for (const _key in data) {
const key = KEY_MAP[_key as keyof SignalDataTypeMap];
keys[key] = keys[key] || {};
// @ts-expect-error
Object.assign(keys[key], data[_key]);
}
saveState();
},
},
},
saveState,
};
};

View file

@ -0,0 +1,114 @@
import * as Boom from "@hapi/boom";
import * as Hoek from "@hapi/hoek";
import * as Hapi from "@hapi/hapi";
import { promisify } from "util";
import jwt from "jsonwebtoken";
import jwksClient, { hapiJwt2KeyAsync } from "jwks-rsa";
import type { IAppConfig } from "../../config";
const CF_JWT_HEADER_NAME = "cf-access-jwt-assertion";
const CF_JWT_ALGOS = ["RS256"];
const verifyToken = (settings: any) => {
const { audience, issuer } = settings;
const client = jwksClient({
jwksUri: `${issuer}/cdn-cgi/access/certs`,
});
return async (token: any) => {
const getKey = (header: any, callback: any) => {
client.getSigningKey(header.kid, (err, key) => {
if (err)
throw Boom.serverUnavailable(
"failed to fetch cloudflare access jwks"
);
callback(undefined, key?.getPublicKey());
});
};
const opts = {
algorithms: CF_JWT_ALGOS,
audience,
issuer,
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (promisify(jwt.verify) as any)(token, getKey, opts);
};
};
const handleCfJwt = (verify: any) => async (
request: Hapi.Request,
h: Hapi.ResponseToolkit
) => {
const token = request.headers[CF_JWT_HEADER_NAME];
if (token) {
try {
await verify(token);
} catch (error) {
console.error(error);
return Boom.unauthorized("invalid cloudflare access token");
}
}
return h.continue;
};
const defaultOpts = {
issuer: undefined,
audience: undefined,
strategyName: "clouflareaccess",
validate: undefined,
};
const cfJwtRegister = async (server: Hapi.Server, options: any): Promise<void> => {
server.dependency(["hapi-auth-jwt2"]);
const settings = Hoek.applyToDefaults(defaultOpts, options);
const verify = verifyToken(settings);
const { validate, strategyName, audience, issuer } = settings;
server.ext("onPreAuth", handleCfJwt(verify));
server.auth.strategy(strategyName!, "jwt", {
key: hapiJwt2KeyAsync({
jwksUri: `${issuer}/cdn-cgi/access/certs`,
}),
cookieKey: false,
urlKey: false,
headerKey: CF_JWT_HEADER_NAME,
validate,
verifyOptions: {
audience,
issuer,
algorithms: ["RS256"],
},
});
};
export const registerCloudflareAccessJwt = async (
server: Hapi.Server,
config: IAppConfig
): Promise<void> => {
const { audience, domain } = config.cfaccess;
// only enable this plugin if cloudflare access config is configured
if (audience && domain) {
server.log(["auth"], "cloudflare access authorization enabled");
await server.register({
plugin: {
name: "cloudflare-jwt",
version: "0.0.1",
register: cfJwtRegister,
},
options: {
issuer: `https://${domain}`,
audience,
validate: (decoded: any, _request: any) => {
const { email, name } = decoded;
return {
isValid: true,
credentials: { user: { email, name } },
};
},
},
});
}
};

View file

@ -0,0 +1,26 @@
import type * as Hapi from "@hapi/hapi";
import NextAuthPlugin, { AdapterFactory } from "@digiresilience/hapi-nextauth";
import { NextAuthAdapter } from "common";
import type { SavedUser, UnsavedUser, SavedSession } from "common";
import { IAppConfig } from "config";
export const registerNextAuth = async (
server: Hapi.Server,
config: IAppConfig
): Promise<void> => {
// I'm not sure why I need to be so explicit with the generic types here
// I thought ts could figure out the generics based on the concrete params, but apparently not
const nextAuthAdapterFactory: AdapterFactory<
SavedUser,
UnsavedUser,
SavedSession
> = (request: Hapi.Request) => new NextAuthAdapter(request.db());
await server.register({
plugin: NextAuthPlugin,
options: {
nextAuthAdapterFactory,
sharedSecret: config.nextAuth.secret,
},
});
};

View file

@ -0,0 +1,32 @@
import type * as Hapi from "@hapi/hapi";
import Schmervice from "@hapipal/schmervice";
import PgPromisePlugin from "@digiresilience/hapi-pg-promise";
import type { IAppConfig } from "../../config";
import { dbInitOptions } from "db";
import { registerNextAuth } from "./hapi-nextauth";
import { registerSwagger } from "./swagger";
import { registerNextAuthJwt } from "./nextauth-jwt";
import { registerCloudflareAccessJwt } from "./cloudflare-jwt";
export const register = async (
server: Hapi.Server,
config: IAppConfig
): Promise<void> => {
await server.register(Schmervice);
await server.register({
plugin: PgPromisePlugin,
options: {
// the only required parameter is the connection string
connection: config.db.connection,
// ... and the pg-promise initialization options
pgpInit: dbInitOptions(config),
},
});
await registerNextAuth(server, config);
await registerSwagger(server);
await registerNextAuthJwt(server, config);
await registerCloudflareAccessJwt(server, config);
};

View file

@ -0,0 +1,100 @@
import * as Hoek from "@hapi/hoek";
import * as Hapi from "@hapi/hapi";
import type { IAppConfig } from "../../config";
// hapi-auth-jwt2 expects the key to be a raw key
const jwkToHapiAuthJwt2 = (jwkString) => {
try {
const jwk = JSON.parse(jwkString);
return Buffer.from(jwk.k, "base64");
} catch {
throw new Error(
"Failed to parse key for JWT verification. This is probably an application configuration error."
);
}
};
const jwtDefaults = {
jwkeysB64: undefined,
validate: undefined,
strategyName: "nextauth-jwt",
};
const jwtRegister = async (server: Hapi.Server, options): Promise<void> => {
server.dependency(["hapi-auth-jwt2"]);
const settings: any = Hoek.applyToDefaults(jwtDefaults, options);
const key = settings.jwkeysB64.map((k) => jwkToHapiAuthJwt2(k));
server.auth.strategy(settings.strategyName!, "jwt", {
key,
cookieKey: false,
urlKey: false,
validate: settings.validate,
});
};
export const registerNextAuthJwt = async (
server: Hapi.Server,
config: IAppConfig
): Promise<void> => {
if (config.nextAuth.signingKey) {
await server.register({
plugin: {
name: "nextauth-jwt",
version: "0.0.2",
register: jwtRegister,
},
options: {
jwkeysB64: [config.nextAuth.signingKey],
validate: async (decoded, request: Hapi.Request) => {
const { email, name, role } = decoded;
const user = await request.db().users.findBy({ email });
if (!config.isProd) {
server.logger.info(
{
email,
name,
role,
},
"nextauth-jwt authorizing request"
);
// server.logger.info({ user }, "nextauth-jwt user result");
}
return {
isValid: Boolean(user && user.isActive),
// this credentials object is made available in every request
// at `request.auth.credentials`
credentials: { email, name, role },
};
},
},
});
} else if (config.isProd) {
throw new Error("Missing nextauth.signingKey configuration value.");
} else {
server.log(
["warn"],
"Missing nextauth.signingKey configuration value. Authentication of nextauth endpoints disabled!"
);
}
};
// @hapi/jwt expects the key in its own format
/* UNUSED
const _jwkToHapiJwt = (jwkString) => {
try {
const jwk = JSON.parse(jwkString);
const rawKey = Buffer.from(jwk.k, "base64");
return {
key: rawKey,
algorithms: [jwk.alg],
kid: jwk.kid,
};
} catch {
throw new Error(
"Failed to parse key for JWT verification. This is probably an application configuration error."
);
}
};
*/

View file

@ -0,0 +1,32 @@
import * as Inert from "@hapi/inert";
import * as Vision from "@hapi/vision";
import type * as Hapi from "@hapi/hapi";
import * as HapiSwagger from "hapi-swagger";
export const registerSwagger = async (server: Hapi.Server): Promise<void> => {
const swaggerOptions: HapiSwagger.RegisterOptions = {
info: {
title: "Metamigo API Docs",
description: "part of CDR Link",
version: "0.1",
},
// group sets of endpoints by tag
tags: [
{
name: "users",
description: "API for Users",
},
],
documentationRouteTags: ["swagger"],
documentationPath: "/api-docs",
};
await server.register([
{ plugin: Inert },
{ plugin: Vision },
{
plugin: HapiSwagger,
options: swaggerOptions,
},
]);
};

View file

@ -0,0 +1,21 @@
import * as Metamigo from "common";
import Toys from "@hapipal/toys";
export const withDefaults = Toys.withRouteDefaults({
options: {
cors: true,
auth: "nextauth-jwt",
validate: {
failAction: Metamigo.validatingFailAction,
},
},
});
export const noAuth = Toys.withRouteDefaults({
options: {
cors: true,
validate: {
failAction: Metamigo.validatingFailAction,
},
},
});

View file

@ -0,0 +1,33 @@
import isFunction from "lodash/isFunction";
import type * as Hapi from "@hapi/hapi";
import * as RandomRoutes from "./random";
import * as UserRoutes from "./users";
import * as VoiceRoutes from "./voice";
import * as WhatsappRoutes from "./whatsapp";
import * as SignalRoutes from "./signal";
const loadRouteIndex = async (server, index) => {
const routes = [];
for (const exported in index) {
if (Object.prototype.hasOwnProperty.call(index, exported)) {
const route = index[exported];
routes.push(route);
}
}
routes.forEach(async (route) => {
if (isFunction(route)) server.route(await route(server));
else server.route(route);
});
};
export const register = async (server: Hapi.Server): Promise<void> => {
// Load your routes here.
// routes are loaded from the list of exported vars
// a route file should export routes directly or an async function that returns the routes.
loadRouteIndex(server, RandomRoutes);
loadRouteIndex(server, UserRoutes);
loadRouteIndex(server, VoiceRoutes);
loadRouteIndex(server, WhatsappRoutes);
loadRouteIndex(server, SignalRoutes);
};

View file

@ -0,0 +1,249 @@
import * as Hapi from "@hapi/hapi";
import * as Joi from "joi";
import * as Helpers from "../helpers";
import Boom from "boom";
const getSignalService = (request) => {
return request.services().signaldService;
};
export const GetAllSignalBotsRoute = Helpers.withDefaults({
method: "get",
path: "/api/signal/bots",
options: {
description: "Get all bots",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
const signalService = getSignalService(request);
const bots = await signalService.findAll();
if (bots) {
// with the pino logger the first arg is an object of data to log
// the second arg is a message
// all other args are formated args for the msg
request.logger.info({ bots }, "Retrieved bot(s) at %s", new Date());
return { bots };
}
return _h.response().code(204);
},
},
});
export const GetBotsRoute = Helpers.noAuth({
method: "get",
path: "/api/signal/bots/{token}",
options: {
description: "Get one bot",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
const { token } = request.params;
const signalService = getSignalService(request);
const bot = await signalService.findByToken(token);
if (bot) {
// with the pino logger the first arg is an object of data to log
// the second arg is a message
// all other args are formated args for the msg
request.logger.info({ bot }, "Retrieved bot(s) at %s", new Date());
return bot;
}
throw Boom.notFound("Bot not found");
},
},
});
interface MessageRequest {
phoneNumber: string;
message: string;
}
export const SendBotRoute = Helpers.noAuth({
method: "post",
path: "/api/signal/bots/{token}/send",
options: {
description: "Send a message",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
const { token } = request.params;
const { phoneNumber, message } = request.payload as MessageRequest;
const signalService = getSignalService(request);
const bot = await signalService.findByToken(token);
if (bot) {
request.logger.info({ bot }, "Sent a message at %s", new Date());
await signalService.send(bot, phoneNumber, message as string);
return _h
.response({
result: {
recipient: phoneNumber,
timestamp: new Date().toISOString(),
source: bot.phoneNumber,
},
})
.code(200); // temp
}
throw Boom.notFound("Bot not found");
},
},
});
interface ResetSessionRequest {
phoneNumber: string;
}
export const ResetSessionBotRoute = Helpers.noAuth({
method: "post",
path: "/api/signal/bots/{token}/resetSession",
options: {
description: "Reset a session with another user",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
const { token } = request.params;
const { phoneNumber } = request.payload as ResetSessionRequest;
const signalService = getSignalService(request);
const bot = await signalService.findByToken(token);
if (bot) {
await signalService.resetSession(bot, phoneNumber);
return _h
.response({
result: {
recipient: phoneNumber,
timestamp: new Date().toISOString(),
source: bot.phoneNumber,
},
})
.code(200); // temp
}
throw Boom.notFound("Bot not found");
},
},
});
export const ReceiveBotRoute = Helpers.withDefaults({
method: "get",
path: "/api/signal/bots/{token}/receive",
options: {
description: "Receive messages",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
const { token } = request.params;
const signalService = getSignalService(request);
const bot = await signalService.findByToken(token);
if (bot) {
request.logger.info({ bot }, "Received messages at %s", new Date());
return signalService.receive(bot);
}
throw Boom.notFound("Bot not found");
},
},
});
export const RegisterBotRoute = Helpers.withDefaults({
method: "get",
path: "/api/signal/bots/{id}/register",
options: {
description: "Register a bot",
handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
const { id } = request.params;
const signalService = getSignalService(request);
const { code } = request.query;
const bot = await signalService.findById(id);
if (!bot) throw Boom.notFound("Bot not found");
try {
request.logger.info({ bot }, "Create bot at %s", new Date());
await signalService.register(bot, code);
return h.response(bot).code(200);
} catch (error) {
return h.response().code(error.code);
}
},
},
});
interface BotRequest {
phoneNumber: string;
description: string;
}
export const CreateBotRoute = Helpers.withDefaults({
method: "post",
path: "/api/signal/bots",
options: {
description: "Register a bot",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
const { phoneNumber, description } = request.payload as BotRequest;
const signalService = getSignalService(request);
console.log("request.auth.credentials:", request.auth.credentials);
const bot = await signalService.create(
phoneNumber,
description,
request.auth.credentials.email as string
);
if (bot) {
request.logger.info({ bot }, "Create bot at %s", new Date());
return bot;
}
throw Boom.notFound("Bot not found");
},
},
});
export const RequestCodeRoute = Helpers.withDefaults({
method: "get",
path: "/api/signal/bots/{id}/requestCode",
options: {
description: "Register a bot",
validate: {
params: Joi.object({
id: Joi.string().uuid().required(),
}),
query: Joi.object({
mode: Joi.string().valid("sms", "voice").required(),
captcha: Joi.string(),
}),
},
handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
const { id } = request.params;
const { mode, captcha } = request.query;
const signalService = getSignalService(request);
const bot = await signalService.findById(id);
if (!bot) {
throw Boom.notFound("Bot not found");
}
try {
if (mode === "sms") {
await signalService.requestSMSVerification(bot, captcha);
} else if (mode === "voice") {
await signalService.requestVoiceVerification(bot, captcha);
}
return h.response().code(200);
} catch (error) {
console.log(error);
if (error.name === "CaptchaRequiredException") {
return h.response().code(402);
} else if (error.code) {
return h.response().code(error.code);
} else {
return h.response().code(500);
}
}
},
},
});

View file

@ -0,0 +1,59 @@
import * as Joi from "joi";
import * as Hapi from "@hapi/hapi";
import { UserRecord, crudRoutesFor, CrudControllerBase } from "common";
import * as RouteHelpers from "../helpers";
class UserRecordController extends CrudControllerBase(UserRecord) { }
const validator = (): Record<string, Hapi.RouteOptionsValidate> => ({
create: {
payload: Joi.object({
name: Joi.string().required(),
email: Joi.string().email().required(),
emailVerified: Joi.string().isoDate().required(),
createdBy: Joi.string().required(),
avatar: Joi.string()
.uri({ scheme: ["http", "https"] })
.optional(),
userRole: Joi.string().optional(),
isActive: Joi.boolean().optional(),
}).label("UserCreate"),
},
updateById: {
params: {
userId: Joi.string().uuid().required(),
},
payload: Joi.object({
name: Joi.string().optional(),
email: Joi.string().email().optional(),
emailVerified: Joi.string().isoDate().optional(),
createdBy: Joi.boolean().optional(),
avatar: Joi.string()
.uri({ scheme: ["http", "https"] })
.optional(),
userRole: Joi.string().optional(),
isActive: Joi.boolean().optional(),
createdAt: Joi.string().isoDate().optional(),
updatedAt: Joi.string().isoDate().optional(),
}).label("UserUpdate"),
},
deleteById: {
params: {
userId: Joi.string().uuid().required(),
},
},
getById: {
params: {
userId: Joi.string().uuid().required(),
},
},
});
export const UserRoutes = async (
_server: Hapi.Server
): Promise<Hapi.ServerRoute[]> => {
const controller = new UserRecordController("users", "userId");
return RouteHelpers.withDefaults(
crudRoutesFor("user", "/api/users", controller, "userId", validator())
);
};

View file

@ -0,0 +1,124 @@
import * as Hapi from "@hapi/hapi";
import * as Joi from "joi";
import * as Boom from "@hapi/boom";
import * as R from "remeda";
import * as Helpers from "../helpers";
import Twilio from "twilio";
import { crudRoutesFor, CrudControllerBase } from "common";
import { VoiceLineRecord, SavedVoiceLine } from "db";
const TwilioHandlers = {
freeNumbers: async (provider, request: Hapi.Request) => {
const { accountSid, apiKeySid, apiKeySecret } = provider.credentials;
const client = Twilio(apiKeySid, apiKeySecret, {
accountSid,
});
const numbers = R.pipe(
await client.incomingPhoneNumbers.list({ limit: 100 }),
R.filter((n) => n.capabilities.voice),
R.map(R.pick(["sid", "phoneNumber"]))
);
const numberSids = R.map(numbers, R.prop("sid"));
const voiceLineRepo = request.db().voiceLines;
const voiceLines: SavedVoiceLine[] =
await voiceLineRepo.findAllByProviderLineSids(numberSids);
const voiceLineSids = new Set(R.map(voiceLines, R.prop("providerLineSid")));
return R.pipe(
numbers,
R.reject((n) => voiceLineSids.has(n.sid)),
R.map((n) => ({ id: n.sid, name: n.phoneNumber }))
);
},
};
export const VoiceProviderRoutes = Helpers.withDefaults([
{
method: "GET",
path: "/api/voice/providers/{providerId}/freeNumbers",
options: {
description:
"get a list of the incoming numbers for a provider account that aren't assigned to a voice line",
validate: {
params: {
providerId: Joi.string().uuid().required(),
},
},
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
const { providerId } = request.params;
const voiceProvidersRepo = request.db().voiceProviders;
const provider = await voiceProvidersRepo.findById(providerId);
if (!provider) return Boom.notFound();
switch (provider.kind) {
case "TWILIO":
return TwilioHandlers.freeNumbers(provider, request);
default:
return Boom.badImplementation();
}
},
},
},
]);
class VoiceLineRecordController extends CrudControllerBase(VoiceLineRecord) { }
const validator = (): Record<string, Hapi.RouteOptionsValidate> => ({
create: {
payload: Joi.object({
providerType: Joi.string().required(),
providerId: Joi.string().required(),
number: Joi.string().required(),
language: Joi.string().required(),
voice: Joi.string().required(),
promptText: Joi.string().optional(),
promptRecording: Joi.binary()
.encoding("base64")
.max(50 * 1000 * 1000)
.optional(),
}).label("VoiceLineCreate"),
},
updateById: {
params: {
id: Joi.string().uuid().required(),
},
payload: Joi.object({
providerType: Joi.string().optional(),
providerId: Joi.string().optional(),
number: Joi.string().optional(),
language: Joi.string().optional(),
voice: Joi.string().optional(),
promptText: Joi.string().optional(),
promptRecording: Joi.binary()
.encoding("base64")
.max(50 * 1000 * 1000)
.optional(),
}).label("VoiceLineUpdate"),
},
deleteById: {
params: {
id: Joi.string().uuid().required(),
},
},
getById: {
params: {
id: Joi.string().uuid().required(),
},
},
});
export const VoiceLineRoutes = async (
_server: Hapi.Server
): Promise<Hapi.ServerRoute[]> => {
const controller = new VoiceLineRecordController("voiceLines", "id");
return Helpers.withDefaults(
crudRoutesFor(
"voice-line",
"/api/voice/voice-line",
controller,
"id",
validator()
)
);
};
export * from "./twilio";

View file

@ -0,0 +1,230 @@
import * as Hapi from "@hapi/hapi";
import * as Joi from "joi";
import * as Boom from "@hapi/boom";
import Twilio from "twilio";
import { SavedVoiceProvider } from "db";
import pMemoize from "p-memoize";
import ms from "ms";
import * as Helpers from "../../helpers";
import workerUtils from "../../../../worker-utils";
import { SayLanguage, SayVoice } from "twilio/lib/twiml/VoiceResponse";
const queueRecording = async (meta) => {
return workerUtils.addJob("twilio-recording", meta, { jobKey: meta.callSid });
};
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,
});
};
const _getOrCreateTTSTestApplication = async (
url,
name,
client: Twilio.Twilio
) => {
const application = await client.applications.list({ friendlyName: name });
if (application[0] && application[0].voiceUrl === url) {
return application[0];
}
return client.applications.create({
voiceMethod: "POST",
voiceUrl: url,
friendlyName: name,
});
};
const getOrCreateTTSTestApplication = pMemoize(_getOrCreateTTSTestApplication, {
maxAge: ms("1h"),
});
export const TwilioRoutes = Helpers.noAuth([
{
method: "get",
path: "/api/voice/twilio/prompt/{voiceLineId}",
options: {
description: "download the mp3 file to play as a prompt for the user",
validate: {
params: {
voiceLineId: Joi.string().uuid().required(),
},
},
handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
const { voiceLineId } = request.params;
const voiceLine = await request
.db()
.voiceLines.findById({ id: voiceLineId });
if (!voiceLine) return Boom.notFound();
if (!voiceLine.audioPromptEnabled) return Boom.badRequest();
const mp3 = voiceLine.promptAudio["audio/mpeg"];
if (!mp3) {
return Boom.serverUnavailable();
}
return h
.response(Buffer.from(mp3, "base64"))
.header("Content-Type", "audio/mpeg")
.header("Content-Disposition", "attachment; filename=prompt.mp3");
},
},
},
{
method: "post",
path: "/api/voice/twilio/record/{voiceLineId}",
options: {
description: "webhook for twilio to handle an incoming call",
validate: {
params: {
voiceLineId: Joi.string().uuid().required(),
},
},
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
const { voiceLineId } = request.params;
const { To } = request.payload as { To: string };
const voiceLine = await request.db().voiceLines.findBy({ number: To });
if (!voiceLine) return Boom.notFound();
if (voiceLine.id !== voiceLineId) return Boom.badRequest();
const frontendUrl = request.server.config().frontend.url;
const useTextPrompt = !voiceLine.audioPromptEnabled;
const twiml = new Twilio.twiml.VoiceResponse();
if (useTextPrompt) {
let prompt = voiceLine.promptText;
if (!prompt || prompt.length === 0)
prompt =
"The grabadora text prompt is unconfigured. Please set a prompt in the administration screen.";
twiml.say(
{
language: voiceLine.language as SayLanguage,
voice: voiceLine.voice as SayVoice,
},
prompt
);
} else {
const promptUrl = `${frontendUrl}/api/v1/voice/twilio/prompt/${voiceLineId}`;
twiml.play({ loop: 1 }, promptUrl);
}
twiml.record({
playBeep: true,
finishOnKey: "1",
recordingStatusCallback: `${frontendUrl}/api/v1/voice/twilio/recording-ready/${voiceLineId}`,
});
return twiml.toString();
},
},
},
{
method: "post",
path: "/api/voice/twilio/recording-ready/{voiceLineId}",
options: {
description: "webhook for twilio to handle a recording",
validate: {
params: {
voiceLineId: Joi.string().uuid().required(),
},
},
handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
const { voiceLineId } = request.params;
const voiceLine = await request
.db()
.voiceLines.findById({ id: voiceLineId });
if (!voiceLine) return Boom.notFound();
const { AccountSid, RecordingSid, CallSid } = request.payload as {
AccountSid: string;
RecordingSid: string;
CallSid: string;
};
await queueRecording({
voiceLineId,
accountSid: AccountSid,
callSid: CallSid,
recordingSid: RecordingSid,
});
return h.response().code(203);
},
},
},
{
method: "post",
path: "/api/voice/twilio/text-to-speech/{providerId}",
options: {
description: "webook for twilio to test the twilio text-to-speech",
validate: {
params: {
providerId: Joi.string().uuid().required(),
},
},
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
const { language, voice, prompt } = request.payload as {
language: SayLanguage;
voice: SayVoice;
prompt: string;
};
const twiml = new Twilio.twiml.VoiceResponse();
twiml.say({ language, voice }, prompt);
return twiml.toString();
},
},
},
{
method: "get",
path: "/api/voice/twilio/text-to-speech-token/{providerId}",
options: {
description:
"generates a one time token to test the twilio text-to-speech",
validate: {
params: {
providerId: Joi.string().uuid().required(),
},
},
handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
const { providerId } = request.params as { providerId: string };
const provider: SavedVoiceProvider = await request
.db()
.voiceProviders.findById({ id: providerId });
if (!provider) return Boom.notFound();
const frontendUrl = request.server.config().frontend.url;
const url = `${frontendUrl}/api/v1/voice/twilio/text-to-speech/${providerId}`;
const name = `Grabadora text-to-speech tester: ${providerId}`;
const app = await getOrCreateTTSTestApplication(
url,
name,
twilioClientFor(provider)
);
const { accountSid, apiKeySecret, apiKeySid } = provider.credentials;
const token = new Twilio.jwt.AccessToken(
accountSid,
apiKeySid,
apiKeySecret,
{ identity: "tts-test" }
);
const grant = new Twilio.jwt.AccessToken.VoiceGrant({
outgoingApplicationSid: app.sid,
incomingAllow: true,
});
token.addGrant(grant);
return h.response({
token: token.toJwt(),
});
},
},
},
]);

View file

@ -0,0 +1,195 @@
import * as Hapi from "@hapi/hapi";
import * as Helpers from "../helpers";
import Boom from "boom";
export const GetAllWhatsappBotsRoute = Helpers.withDefaults({
method: "get",
path: "/api/whatsapp/bots",
options: {
description: "Get all bots",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
const { whatsappService } = request.services();
const bots = await whatsappService.findAll();
if (bots) {
// with the pino logger the first arg is an object of data to log
// the second arg is a message
// all other args are formated args for the msg
request.logger.info({ bots }, "Retrieved bot(s) at %s", new Date());
return { bots };
}
return _h.response().code(204);
},
},
});
export const GetBotsRoute = Helpers.noAuth({
method: "get",
path: "/api/whatsapp/bots/{token}",
options: {
description: "Get one bot",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
const { token } = request.params;
const { whatsappService } = request.services();
const bot = await whatsappService.findByToken(token);
if (bot) {
// with the pino logger the first arg is an object of data to log
// the second arg is a message
// all other args are formated args for the msg
request.logger.info({ bot }, "Retrieved bot(s) at %s", new Date());
return bot;
}
throw Boom.notFound("Bot not found");
},
},
});
interface MessageRequest {
phoneNumber: string;
message: string;
}
export const SendBotRoute = Helpers.noAuth({
method: "post",
path: "/api/whatsapp/bots/{token}/send",
options: {
description: "Send a message",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
const { token } = request.params;
const { phoneNumber, message } = request.payload as MessageRequest;
const { whatsappService } = request.services();
const bot = await whatsappService.findByToken(token);
if (bot) {
request.logger.info({ bot }, "Sent a message at %s", new Date());
await whatsappService.send(bot, phoneNumber, message as string);
return _h
.response({
result: {
recipient: phoneNumber,
timestamp: new Date().toISOString(),
source: bot.phoneNumber,
},
})
.code(200); // temp
}
throw Boom.notFound("Bot not found");
},
},
});
export const ReceiveBotRoute = Helpers.withDefaults({
method: "get",
path: "/api/whatsapp/bots/{token}/receive",
options: {
description: "Receive messages",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
const { token } = request.params;
const { whatsappService } = request.services();
const bot = await whatsappService.findByToken(token);
if (bot) {
request.logger.info({ bot }, "Received messages at %s", new Date());
// temp
const date = new Date();
const twoDaysAgo = new Date(date.getTime());
twoDaysAgo.setDate(date.getDate() - 2);
return whatsappService.receive(bot, twoDaysAgo);
}
throw Boom.notFound("Bot not found");
},
},
});
export const RegisterBotRoute = Helpers.withDefaults({
method: "get",
path: "/api/whatsapp/bots/{id}/register",
options: {
description: "Register a bot",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
const { id } = request.params;
const { whatsappService } = request.services();
const bot = await whatsappService.findById(id);
if (bot) {
await whatsappService.register(bot, (error: string) => {
if (error) {
return _h.response(error).code(500);
}
request.logger.info({ bot }, "Register bot at %s", new Date());
return _h.response().code(200);
});
}
throw Boom.notFound("Bot not found");
},
},
});
export const RefreshBotRoute = Helpers.withDefaults({
method: "get",
path: "/api/whatsapp/bots/{id}/refresh",
options: {
description: "Refresh messages",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
const { id } = request.params;
const { whatsappService } = request.services();
const bot = await whatsappService.findById(id);
if (bot) {
request.logger.info({ bot }, "Refreshed messages at %s", new Date());
// await whatsappService.refresh(bot);
return;
}
throw Boom.notFound("Bot not found");
},
},
});
interface BotRequest {
phoneNumber: string;
description: string;
}
export const CreateBotRoute = Helpers.withDefaults({
method: "post",
path: "/api/whatsapp/bots",
options: {
description: "Register a bot",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
const { phoneNumber, description } = request.payload as BotRequest;
const { whatsappService } = request.services();
console.log("request.auth.credentials:", request.auth.credentials);
const bot = await whatsappService.create(
phoneNumber,
description,
request.auth.credentials.email as string
);
if (bot) {
request.logger.info({ bot }, "Register bot at %s", new Date());
return bot;
}
throw Boom.notFound("Bot not found");
},
},
});

View file

@ -0,0 +1,14 @@
import type * as Hapi from "@hapi/hapi";
import SettingsService from "./settings";
import RandomService from "./random";
import WhatsappService from "./whatsapp";
import SignaldService from "./signald";
export const register = async (server: Hapi.Server): Promise<void> => {
// register your services here
// don't forget to add them to the AppServices interface in ../types/index.ts
server.registerService(RandomService);
server.registerService(SettingsService);
server.registerService(WhatsappService);
server.registerService(SignaldService);
};

View file

@ -0,0 +1,16 @@
import * as Hapi from "@hapi/hapi";
import * as Schmervice from "@hapipal/schmervice";
import { settingInfo, SettingsService } from "db";
export const VoicemailPrompt = settingInfo<string>("voicemail-prompt");
export const VoicemailMinLength = settingInfo<number>("voicemail-min-length");
export const VoicemailUseTextPrompt = settingInfo<boolean>(
"voicemail-use-text-prompt"
);
export { ISettingsService } from "db";
// @ts-expect-error
const service = (server: Hapi.Server): Schmervice.ServiceFunctionalInterface =>
SettingsService(server.db().settings);
export default service;

View file

@ -0,0 +1,200 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Server } from "@hapi/hapi";
import { Service } from "@hapipal/schmervice";
import {
SignaldAPI,
IncomingMessagev1,
ClientMessageWrapperv1
} from "@digiresilience/node-signald";
import { SavedSignalBot as Bot } from "db";
import workerUtils from "../../worker-utils";
export default class SignaldService extends Service {
signald: SignaldAPI;
subscriptions: Set<string>;
constructor(server: Server, options: never) {
super(server, options);
if (this.server.config().signald.enabled) {
this.signald = new SignaldAPI();
this.signald.setLogger((level, msg, extra?) => {
this.server.logger[level]({ extra }, msg);
});
this.subscriptions = new Set();
}
}
async initialize(): Promise<void> {
if (this.server.config().signald.enabled && this.signald) {
this.setupListeners();
this.connect();
}
}
async teardown(): Promise<void> {
if (this.server.config().signald.enabled && this.signald)
this.signald.disconnect();
}
private connect() {
const { enabled, socket } = this.server.config().signald;
if (!enabled) return;
this.signald.connectWithBackoff(socket);
}
private async onConnected() {
await this.subscribeAll();
}
private setupListeners() {
this.signald.on("transport_error", async (error) => {
this.server.logger.info({ error }, "signald transport error");
});
this.signald.on("transport_connected", async () => {
this.onConnected();
});
this.signald.on("transport_received_payload", async (payload: ClientMessageWrapperv1) => {
this.server.logger.debug({ payload }, "signald payload received");
if (payload.type === "IncomingMessage") {
this.receiveMessage(payload.data)
}
});
this.signald.on("transport_sent_payload", async (payload) => {
this.server.logger.debug({ payload }, "signald payload sent");
});
}
private async subscribeAll() {
const result = await this.signald.listAccounts();
const accounts = result.accounts.map((account) => account.address.number);
await Promise.all(
accounts.map(async (account) => {
await this.signald.subscribe(account);
this.subscriptions.add(account);
})
);
}
private async unsubscribeAll() {
await Promise.all(
[...this.subscriptions].map(async (account) => {
await this.signald.unsubscribe(account);
this.subscriptions.delete(account);
})
);
}
async create(
phoneNumber: string,
description: string,
email: string
): Promise<Bot> {
const db = this.server.db();
const user = await db.users.findBy({ email });
const row = await db.signalBots.insert({
phoneNumber,
description,
userId: user.id,
});
return row;
}
async findAll(): Promise<Bot[]> {
const db = this.server.db();
return db.signalBots.findAll();
}
async findById(id: string): Promise<Bot> {
const db = this.server.db();
return db.signalBots.findById({ id });
}
async findByToken(token: string): Promise<Bot> {
const db = this.server.db();
return db.signalBots.findBy({ token });
}
async register(bot: Bot, code: string): Promise<any> {
const address = await this.signald.verify(bot.phoneNumber, code);
this.server.db().signalBots.updateAuthInfo(bot, address.address.uuid);
}
async send(bot: Bot, phoneNumber: string, message: string): Promise<any> {
this.server.logger.debug(
{ us: bot.phoneNumber, then: phoneNumber, message },
"signald send"
);
return await this.signald.send(
bot.phoneNumber,
{ number: phoneNumber },
undefined,
message
);
}
async resetSession(bot: Bot, phoneNumber: string): Promise<any> {
return await this.signald.resetSession(bot.phoneNumber, {
number: phoneNumber,
});
}
async requestVoiceVerification(bot: Bot, captcha?: string): Promise<void> {
this.server.logger.debug(
{ number: bot.phoneNumber, captcha },
"requesting voice verification for"
);
await this.signald.register(bot.phoneNumber, true, captcha);
}
async requestSMSVerification(bot: Bot, captcha?: string): Promise<void> {
this.server.logger.debug(
{ number: bot.phoneNumber, captcha },
"requesting sms verification for"
);
await this.signald.register(bot.phoneNumber, false, captcha);
}
private async receiveMessage(message: IncomingMessagev1) {
const { account } = message;
if (!account) {
this.server.logger.debug({ message }, "invalid message received");
this.server.logger.error("invalid message received");
}
const bot = await this.server.db().signalBots.findBy({ phoneNumber: account });
if (!bot) {
this.server.logger.info("message received for unknown bot", {
account,
message,
});
return;
}
await this.queueMessage(bot, message);
}
private async queueMessage(bot: Bot, message: IncomingMessagev1) {
const { timestamp, account, data_message: dataMessage } = message;
if (!dataMessage?.body && !dataMessage?.attachments) {
this.server.logger.info({ message }, "message received with no content");
return;
}
if (!timestamp || !account) {
this.server.logger.debug({ message }, "invalid message received");
}
const receivedMessage = {
message,
botId: bot.id,
botPhoneNumber: bot.phoneNumber,
};
workerUtils.addJob("signald-message", receivedMessage, {
jobKey: `signal-bot-${bot.id}-${timestamp}`,
queueName: `signal-bot-${bot.id}`,
});
}
}

View file

@ -0,0 +1,247 @@
import { Server } from "@hapi/hapi";
import { Service } from "@hapipal/schmervice";
import { SavedWhatsappBot as Bot } from "db";
import makeWASocket, { DisconnectReason, proto, downloadContentFromMessage, MediaType } from "@adiwajshing/baileys";
import workerUtils from "../../worker-utils";
import { useDatabaseAuthState } from "../lib/whatsapp-key-store";
import { connect } from "pg-monitor";
export type AuthCompleteCallback = (error?: string) => void;
export default class WhatsappService extends Service {
connections: { [key: string]: any } = {};
loginConnections: { [key: string]: any } = {};
static browserDescription: [string, string, string] = [
"Metamigo",
"Chrome",
"2.0",
];
constructor(server: Server, options: never) {
super(server, options);
}
async initialize(): Promise<void> {
this.updateConnections();
}
async teardown(): Promise<void> {
this.resetConnections();
}
private async sleep(ms: number): Promise<void> {
console.log(`pausing ${ms}`)
return new Promise(resolve => setTimeout(resolve, ms));
}
private async resetConnections() {
for (const connection of Object.values(this.connections)) {
try {
connection.end(null)
} catch (error) {
console.log(error);
}
}
this.connections = {};
}
private createConnection(bot: Bot, server: Server, options: any, authCompleteCallback?: any) {
const { state, saveState } = useDatabaseAuthState(bot, server)
const connection = makeWASocket({ ...options, auth: state });
let pause = 5000;
connection.ev.on('connection.update', async (update) => {
console.log(`Connection updated ${JSON.stringify(update, null, 2)}`)
const { connection: connectionState, lastDisconnect, qr, isNewLogin } = update
if (qr) {
console.log('got qr code')
await this.server.db().whatsappBots.updateQR(bot, qr);
} else if (isNewLogin) {
console.log("got new login")
} else if (connectionState === 'open') {
console.log('opened connection')
} else if (connectionState === "close") {
console.log('connection closed due to ', lastDisconnect.error)
const disconnectStatusCode = (lastDisconnect?.error as any)?.output?.statusCode
if (disconnectStatusCode === DisconnectReason.restartRequired) {
console.log('reconnecting after got new login')
const updatedBot = await this.findById(bot.id);
this.createConnection(updatedBot, server, options)
authCompleteCallback()
} else if (disconnectStatusCode !== DisconnectReason.loggedOut) {
console.log('reconnecting')
await this.sleep(pause)
pause = pause * 2
this.createConnection(bot, server, options)
}
}
})
connection.ev.on('chats.set', item => console.log(`recv ${item.chats.length} chats (is latest: ${item.isLatest})`))
connection.ev.on('messages.set', item => console.log(`recv ${item.messages.length} messages (is latest: ${item.isLatest})`))
connection.ev.on('contacts.set', item => console.log(`recv ${item.contacts.length} contacts`))
connection.ev.on('messages.upsert', async m => {
console.log("messages upsert")
const { messages } = m;
if (messages) {
await this.queueUnreadMessages(bot, messages);
}
})
connection.ev.on('messages.update', m => console.log(m))
connection.ev.on('message-receipt.update', m => console.log(m))
connection.ev.on('presence.update', m => console.log(m))
connection.ev.on('chats.update', m => console.log(m))
connection.ev.on('contacts.upsert', m => console.log(m))
connection.ev.on('creds.update', saveState)
this.connections[bot.id] = connection;
}
private async updateConnections() {
this.resetConnections();
const bots = await this.server.db().whatsappBots.findAll();
for await (const bot of bots) {
if (bot.isVerified) {
this.createConnection(
bot,
this.server,
{
browser: WhatsappService.browserDescription,
printQRInTerminal: false,
version: [2, 2204, 13],
})
}
}
}
private async queueMessage(bot: Bot, webMessageInfo: proto.WebMessageInfo) {
const { key, message, messageTimestamp } = webMessageInfo;
const { remoteJid } = key;
if (!key.fromMe && message && remoteJid !== "status@broadcast") {
const isMediaMessage =
message.audioMessage ||
message.documentMessage ||
message.imageMessage ||
message.videoMessage;
let messageContent = Object.values(message)[0]
let messageType: MediaType;
let attachment: string;
let filename: string;
let mimetype: string;
if (isMediaMessage) {
if (message.audioMessage) {
messageType = "audio";
filename =
key.id + "." + message.audioMessage.mimetype.split("/").pop();
mimetype = message.audioMessage.mimetype;
} else if (message.documentMessage) {
messageType = "document";
filename = message.documentMessage.fileName;
mimetype = message.documentMessage.mimetype;
} else if (message.imageMessage) {
messageType = "image";
filename =
key.id + "." + message.imageMessage.mimetype.split("/").pop();
mimetype = message.imageMessage.mimetype;
} else if (message.videoMessage) {
messageType = "video"
filename =
key.id + "." + message.videoMessage.mimetype.split("/").pop();
mimetype = message.videoMessage.mimetype;
}
const stream = await downloadContentFromMessage(messageContent, messageType)
let buffer = Buffer.from([])
for await (const chunk of stream) {
buffer = Buffer.concat([buffer, chunk])
}
attachment = buffer.toString("base64");
}
if (messageContent || attachment) {
const receivedMessage = {
waMessageId: key.id,
waMessage: JSON.stringify(webMessageInfo),
waTimestamp: new Date((messageTimestamp as number) * 1000),
attachment,
filename,
mimetype,
whatsappBotId: bot.id,
botPhoneNumber: bot.phoneNumber,
};
workerUtils.addJob("whatsapp-message", receivedMessage, {
jobKey: key.id,
});
}
}
}
private async queueUnreadMessages(bot: Bot, messages: any[]) {
for await (const message of messages) {
await this.queueMessage(bot, message);
}
}
async create(
phoneNumber: string,
description: string,
email: string
): Promise<Bot> {
const db = this.server.db();
const user = await db.users.findBy({ email });
const row = await db.whatsappBots.insert({
phoneNumber,
description,
userId: user.id,
});
return row;
}
async findAll(): Promise<Bot[]> {
return this.server.db().whatsappBots.findAll();
}
async findById(id: string): Promise<Bot> {
return this.server.db().whatsappBots.findById({ id });
}
async findByToken(token: string): Promise<Bot> {
return this.server.db().whatsappBots.findBy({ token });
}
async register(bot: Bot, callback: AuthCompleteCallback): Promise<void> {
await this.createConnection(bot, this.server, { version: [2, 2204, 13] }, callback);
}
async send(bot: Bot, phoneNumber: string, message: string): Promise<void> {
const connection = this.connections[bot.id];
const recipient = `${phoneNumber.replace(/\D+/g, "")}@s.whatsapp.net`;
await connection.sendMessage(recipient, { text: message });
}
async receiveSince(bot: Bot, lastReceivedDate: Date): Promise<void> {
const connection = this.connections[bot.id];
const messages = await connection.messagesReceivedAfter(
lastReceivedDate,
false
);
for (const message of messages) {
this.queueMessage(bot, message);
}
}
async receive(bot: Bot, lastReceivedDate: Date): Promise<any> {
const connection = this.connections[bot.id];
// const messages = await connection.messagesReceivedAfter(
// lastReceivedDate,
// false
// );
const messages = await connection.loadAllUnreadMessages();
return messages;
}
}

View file

@ -0,0 +1,27 @@
import type { IMain } from "pg-promise";
import type { ISettingsService } from "../services/settings";
import type WhatsappService from "../services/whatsapp";
import type SignaldService from "../services/signald";
import type { IAppConfig } from "../../config";
import type { AppDatabase } from "db";
// add your service interfaces here
interface AppServices {
settingsService: ISettingsService;
whatsappService: WhatsappService;
signaldService: SignaldService;
}
// extend the hapi types with our services and config
declare module "@hapi/hapi" {
export interface Request {
services(): AppServices;
db(): AppDatabase;
pgp: IMain;
}
export interface Server {
config(): IAppConfig;
db(): AppDatabase;
pgp: IMain;
}
}

View file

@ -0,0 +1,6 @@
{
"presets": [
"@babel/preset-env",
"@babel/preset-typescript"
]
}

10
metamigo-api/config.ts Normal file
View file

@ -0,0 +1,10 @@
import config, {
loadConfig,
loadConfigRaw,
IAppConfig,
IAppConvict,
} from "config";
export { IAppConvict, IAppConfig, loadConfig, loadConfigRaw };
export default config;

8
metamigo-api/logger.ts Normal file
View file

@ -0,0 +1,8 @@
import { defState } from "@digiresilience/montar";
import { configureLogger } from "common";
import config from "config";
export const logger = defState("apiLogger", {
start: async () => configureLogger(config),
});
export default logger;

77
metamigo-api/package.json Normal file
View file

@ -0,0 +1,77 @@
{
"name": "api",
"version": "0.2.0",
"main": "build/main/cli/index.js",
"author": "Abel Luck <abel@guardianproject.info>",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@adiwajshing/baileys": "5.0.0",
"@adiwajshing/keyed-db": "0.2.4",
"@digiresilience/hapi-nextauth": "0.2.1",
"@digiresilience/hapi-pg-promise": "^0.0.3",
"@digiresilience/montar": "^0.1.6",
"@digiresilience/node-signald": "0.0.3",
"@graphile-contrib/pg-simplify-inflector": "^6.1.0",
"@hapi/basic": "^7.0.0",
"@hapi/boom": "^10.0.0",
"@hapi/wreck": "^18.0.0",
"@hapipal/schmervice": "^2.1.0",
"@hapipal/toys": "^3.2.0",
"blipp": "^4.0.2",
"camelcase-keys": "^8.0.2",
"fluent-ffmpeg": "^2.1.2",
"graphile-migrate": "^1.4.1",
"graphile-worker": "^0.13.0",
"hapi-auth-jwt2": "^10.4.0",
"hapi-postgraphile": "^0.11.0",
"hapi-swagger": "^15.0.0",
"joi": "^17.7.0",
"jsonwebtoken": "^9.0.0",
"jwks-rsa": "^3.0.1",
"long": "^5.2.1",
"p-memoize": "^7.1.1",
"pg-monitor": "^2.0.0",
"pg-promise": "^11.0.2",
"postgraphile-plugin-connection-filter": "^2.3.0",
"remeda": "^1.6.0",
"twilio": "^3.84.1"
},
"devDependencies": {
"@babel/core": "7.20.12",
"@babel/preset-env": "7.20.2",
"@babel/preset-typescript": "7.18.6",
"@types/jest": "^29.2.5",
"eslint": "^8.32.0",
"pino-pretty": "^9.1.1",
"prettier": "^2.8.3",
"ts-node": "^10.9.1",
"typescript": "4.9.4",
"@types/hapi__wreck": "^17.0.1",
"@types/long": "^4.0.2",
"nodemon": "^2.0.20",
"@types/node": "*",
"camelcase-keys": "^8.0.2",
"pg-monitor": "^2.0.0",
"typedoc": "^0.23.24"
},
"nodemonConfig": {
"ignore": [
"docs/*"
],
"ext": "ts,json,js"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"fix:lint": "eslint src --ext .ts --fix",
"fix:prettier": "prettier \"src/**/*.ts\" --write",
"cli": "NODE_ENV=development nodemon --unhandled-rejections=strict build/main/cli/index.js",
"serve": "NODE_ENV=development npm run cli server",
"serve:prod": "NODE_ENV=production npm run cli server",
"worker": "NODE_ENV=development npm run cli worker",
"worker:prod": "NODE_ENV=production npm run cli worker",
"lint:lint": "eslint src --ext .ts",
"lint:prettier": "prettier \"src/**/*.ts\" --list-different",
"lint": "npm run lint:lint && npm run lint:prettier",
"watch:build": "tsc -p tsconfig.json -w"
}
}

View file

@ -0,0 +1,28 @@
import * as Metamigo from "common";
import { defState } from "@digiresilience/montar";
import Manifest from "./manifest";
import config, { IAppConfig } from "../config";
export const deployment = async (
config: IAppConfig,
start = false
): Promise<Metamigo.Server> => {
// Build the manifest, which describes all the plugins needed for our application server
const manifest = await Manifest.build(config);
// Create the server and optionally start it
const server = Metamigo.deployment(manifest, config, start);
return server;
};
export const stopDeployment = async (server: Metamigo.Server): Promise<void> => {
return Metamigo.stopDeployment(server);
};
const server = defState("server", {
start: () => deployment(config, true),
stop: () => stopDeployment(server),
});
export default server;

View file

@ -0,0 +1,79 @@
import * as Glue from "@hapi/glue";
import * as Metamigo from "common";
import * as Blipp from "blipp";
import HapiBasic from "@hapi/basic";
import HapiJwt from "hapi-auth-jwt2";
import HapiPostgraphile from "hapi-postgraphile";
import { getPostGraphileOptions } from "db";
import AppPlugin from "../app";
import type { IAppConfig } from "../config";
const build = async (config: IAppConfig): Promise<Glue.Manifest> => {
const { port, address } = config.server;
const metamigoPlugins = Metamigo.defaultPlugins(config);
return {
server: {
port,
address,
debug: false, // We use pino not the built-in hapi logger
routes: {
validate: {
failAction: Metamigo.validatingFailAction,
},
},
},
register: {
plugins: [
// jwt plugin, required for our jwt auth plugin
{ plugin: HapiJwt },
// Blipp prints the nicely formatted list of endpoints at app boot
{ plugin: Blipp },
// load the metamigo base plugins
...metamigoPlugins,
// basic authentication, required by hapi-nextauth
{ plugin: HapiBasic },
// load our main app
{
plugin: AppPlugin,
options: {
config,
},
},
// load Postgraphile
{
plugin: HapiPostgraphile,
options: {
route: {
path: "/graphql",
options: {
auth: {
strategies: ["nextauth-jwt"],
mode: "optional",
},
},
},
pgConfig: config.postgraphile.authConnection,
schemaName: "app_public",
schemaOptions: {
...getPostGraphileOptions(),
jwtAudiences: [config.nextAuth.audience],
jwtSecret: "",
// unauthenticated users will hit the database with this role
pgDefaultRole: "app_anonymous",
},
},
},
],
},
};
};
const Manifest = {
build,
};
export default Manifest;

View file

@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "build/main",
"types": ["long", "jest", "node"],
"lib": ["es2020", "DOM"]
},
"include": ["**/*.ts", "**/.*.ts"],
"exclude": ["node_modules"]
}

View 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("apiWorkerUtils", {
start: startWorkerUtils,
stop: stopWorkerUtils,
});
export default workerUtils;