metamigo-api: work on making it build

This commit is contained in:
Abel Luck 2023-03-13 14:42:49 +00:00
parent 38e68852d9
commit ef216f7b1c
35 changed files with 407 additions and 322 deletions

View file

@ -2,25 +2,11 @@ require("eslint-config-link/patch/modern-module-resolution");
module.exports = {
extends: [
"eslint-config-link/profile/node",
"eslint-config-link/eslint-config-amigo/profile/typescript",
"eslint-config-link/eslint-config-amigo/profile/jest",
"eslint-config-link/profile/typescript",
"eslint-config-link/profile/jest",
],
parserOptions: { tsconfigRootDir: __dirname },
rules: {
"new-cap": "off",
"import/no-extraneous-dependencies": [
// enable this when this is fixed
// https://github.com/benmosher/eslint-plugin-import/pull/1696
"off",
{
packageDir: [
".",
"node_modules/@digiresilience/amigo",
"node_modules/@digiresilience/amigo-dev",
],
},
],
// TODO: enable this after jest fixes this issue https://github.com/nodejs/node/issues/38343
"unicorn/prefer-node-protocol": "off"
"new-cap": "off"
},
};

View file

@ -1,32 +0,0 @@
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 "@digiresilience/metamigo-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

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

View file

@ -37,7 +37,8 @@
"pg-promise": "^11.0.2",
"postgraphile-plugin-connection-filter": "^2.3.0",
"remeda": "^1.6.0",
"twilio": "^3.84.1"
"twilio": "^3.84.1",
"expiry-map": "^2.0.0"
},
"devDependencies": {
"pino-pretty": "^9.1.1",

View file

@ -7,10 +7,10 @@ import * as Plugins from "./plugins";
const AppPlugin = {
name: "App",
register: async (
async register(
server: Hapi.Server,
options: { config: IAppConfig }
): Promise<void> => {
): 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

View file

@ -1,6 +1,6 @@
import { Boom } from "@hapi/boom";
import { Server } from "@hapi/hapi";
import { randomBytes } from "crypto";
import { randomBytes } from "node:crypto";
import type { Logger } from "pino";
import {
proto,
@ -45,43 +45,38 @@ export const addTransactionCapability = (
? ids.filter((item) => !(item in dict))
: ids;
// only fetch if there are any items to fetch
if (idsRequiringFetch.length) {
if (idsRequiringFetch.length > 0) {
const result = await state.get(type, idsRequiringFetch);
transactionCache[type] = transactionCache[type] || {};
// @ts-expect-error
Object.assign(transactionCache[type], result);
}
};
return {
get: async (type, ids) => {
async get(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);
}
return state.get(type, ids);
},
set: (data) => {
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 {
@ -90,11 +85,11 @@ export const addTransactionCapability = (
},
isInTransaction: () => inTransaction,
// @ts-expect-error
prefetch: (type, ids) => {
prefetch(type, ids) {
logger.trace({ type, ids }, "prefetching");
return prefetch(type, ids);
},
transaction: async (work) => {
async transaction(work) {
if (inTransaction) {
await work();
} else {
@ -102,7 +97,7 @@ export const addTransactionCapability = (
inTransaction = true;
try {
await work();
if (Object.keys(mutations).length) {
if (Object.keys(mutations).length > 0) {
logger.debug("committing transaction");
await state.set(mutations);
} else {
@ -140,7 +135,7 @@ export const useDatabaseAuthState = (
bot: Bot,
server: Server
): { state: AuthenticationState; saveState: () => void } => {
let { logger }: any = server;
const { logger }: any = server;
let creds: AuthenticationCreds;
let keys: any = {};
@ -165,7 +160,7 @@ export const useDatabaseAuthState = (
state: {
creds,
keys: {
get: (type, ids) => {
get(type, ids) {
const key = KEY_MAP[type];
return ids.reduce((dict, id) => {
let value = keys[key]?.[id];
@ -174,18 +169,17 @@ export const useDatabaseAuthState = (
// @ts-expect-error
value = proto.AppStateSyncKeyData.fromObject(value);
}
// @ts-expect-error
dict[id] = value;
}
return dict;
}, {});
},
set: (data) => {
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]);
}

View file

@ -1,7 +1,7 @@
import * as Boom from "@hapi/boom";
import * as Hoek from "@hapi/hoek";
import * as Hapi from "@hapi/hapi";
import { promisify } from "util";
import { promisify } from "node:util";
import jwt from "jsonwebtoken";
import jwksClient, { hapiJwt2KeyAsync } from "jwks-rsa";
import type { IAppConfig } from "../../config";
@ -36,10 +36,8 @@ const verifyToken = (settings: any) => {
};
};
const handleCfJwt = (verify: any) => async (
request: Hapi.Request,
h: Hapi.ResponseToolkit
) => {
const handleCfJwt =
(verify: any) => async (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
const token = request.headers[CF_JWT_HEADER_NAME];
if (token) {
try {
@ -51,7 +49,7 @@ const handleCfJwt = (verify: any) => async (
}
return h.continue;
};
};
const defaultOpts = {
issuer: undefined,
@ -60,7 +58,10 @@ const defaultOpts = {
validate: undefined,
};
const cfJwtRegister = async (server: Hapi.Server, options: any): Promise<void> => {
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);
@ -101,7 +102,7 @@ export const registerCloudflareAccessJwt = async (
options: {
issuer: `https://${domain}`,
audience,
validate: (decoded: any, _request: any) => {
validate(decoded: any, _request: any) {
const { email, name } = decoded;
return {
isValid: true,

View file

@ -1,8 +1,12 @@
import type * as Hapi from "@hapi/hapi";
import NextAuthPlugin, { AdapterFactory } from "@digiresilience/hapi-nextauth";
import { NextAuthAdapter } from "@digiresilience/metamigo-common";
import type { SavedUser, UnsavedUser, SavedSession } from "@digiresilience/metamigo-common";
import { IAppConfig } from "config";
import type {
SavedUser,
UnsavedUser,
SavedSession,
} from "@digiresilience/metamigo-common";
import { IAppConfig } from "@digiresilience/metamigo-config";
export const registerNextAuth = async (
server: Hapi.Server,

View file

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

View file

@ -46,7 +46,7 @@ export const registerNextAuthJwt = async (
},
options: {
jwkeysB64: [config.nextAuth.signingKey],
validate: async (decoded, request: Hapi.Request) => {
async validate(decoded, request: Hapi.Request) {
const { email, name, role } = decoded;
const user = await request.db().users.findBy({ email });
if (!config.isProd) {

View file

@ -1,6 +1,5 @@
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";
@ -25,7 +24,6 @@ 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);

View file

@ -3,16 +3,14 @@ import * as Joi from "joi";
import * as Helpers from "../helpers";
import Boom from "boom";
const getSignalService = (request) => {
return request.services().signaldService;
};
const getSignalService = (request) => request.services("app").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) => {
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const signalService = getSignalService(request);
const bots = await signalService.findAll();
@ -35,7 +33,7 @@ export const GetBotsRoute = Helpers.noAuth({
path: "/api/signal/bots/{token}",
options: {
description: "Get one bot",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { token } = request.params;
const signalService = getSignalService(request);
@ -65,7 +63,7 @@ export const SendBotRoute = Helpers.noAuth({
path: "/api/signal/bots/{token}/send",
options: {
description: "Send a message",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { token } = request.params;
const { phoneNumber, message } = request.payload as MessageRequest;
const signalService = getSignalService(request);
@ -101,7 +99,7 @@ export const ResetSessionBotRoute = Helpers.noAuth({
path: "/api/signal/bots/{token}/resetSession",
options: {
description: "Reset a session with another user",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { token } = request.params;
const { phoneNumber } = request.payload as ResetSessionRequest;
const signalService = getSignalService(request);
@ -131,7 +129,7 @@ export const ReceiveBotRoute = Helpers.withDefaults({
path: "/api/signal/bots/{token}/receive",
options: {
description: "Receive messages",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { token } = request.params;
const signalService = getSignalService(request);
@ -153,7 +151,7 @@ export const RegisterBotRoute = Helpers.withDefaults({
path: "/api/signal/bots/{id}/register",
options: {
description: "Register a bot",
handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
async handler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
const { id } = request.params;
const signalService = getSignalService(request);
const { code } = request.query;
@ -182,7 +180,7 @@ export const CreateBotRoute = Helpers.withDefaults({
path: "/api/signal/bots",
options: {
description: "Register a bot",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
async handler(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);
@ -216,7 +214,7 @@ export const RequestCodeRoute = Helpers.withDefaults({
captcha: Joi.string(),
}),
},
handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
async handler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
const { id } = request.params;
const { mode, captcha } = request.query;
const signalService = getSignalService(request);
@ -233,16 +231,20 @@ export const RequestCodeRoute = Helpers.withDefaults({
} 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);
}
if (error.code) {
return h.response().code(error.code);
}
return h.response().code(500);
}
},
},

View file

@ -1,9 +1,13 @@
import * as Joi from "joi";
import * as Hapi from "@hapi/hapi";
import { UserRecord, crudRoutesFor, CrudControllerBase } from "@digiresilience/metamigo-common";
import {
UserRecord,
crudRoutesFor,
CrudControllerBase,
} from "@digiresilience/metamigo-common";
import * as RouteHelpers from "../helpers";
class UserRecordController extends CrudControllerBase(UserRecord) { }
class UserRecordController extends CrudControllerBase(UserRecord) {}
const validator = (): Record<string, Hapi.RouteOptionsValidate> => ({
create: {
@ -49,11 +53,12 @@ const validator = (): Record<string, Hapi.RouteOptionsValidate> => ({
},
});
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())
);
};
export const UserRoutes = RouteHelpers.withDefaults(
crudRoutesFor(
"user",
"/api/users",
new UserRecordController("users", "userId"),
"userId",
validator()
)
);

View file

@ -4,11 +4,14 @@ import * as Boom from "@hapi/boom";
import * as R from "remeda";
import * as Helpers from "../helpers";
import Twilio from "twilio";
import { crudRoutesFor, CrudControllerBase } from "@digiresilience/metamigo-common";
import {
crudRoutesFor,
CrudControllerBase,
} from "@digiresilience/metamigo-common";
import { VoiceLineRecord, SavedVoiceLine } from "@digiresilience/metamigo-db";
const TwilioHandlers = {
freeNumbers: async (provider, request: Hapi.Request) => {
async freeNumbers(provider, request: Hapi.Request) {
const { accountSid, apiKeySid, apiKeySecret } = provider.credentials;
const client = Twilio(apiKeySid, apiKeySecret, {
accountSid,
@ -44,23 +47,26 @@ export const VoiceProviderRoutes = Helpers.withDefaults([
providerId: Joi.string().uuid().required(),
},
},
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
async handler(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":
case "TWILIO": {
return TwilioHandlers.freeNumbers(provider, request);
default:
}
default: {
return Boom.badImplementation();
}
}
},
},
},
]);
class VoiceLineRecordController extends CrudControllerBase(VoiceLineRecord) { }
class VoiceLineRecordController extends CrudControllerBase(VoiceLineRecord) {}
const validator = (): Record<string, Hapi.RouteOptionsValidate> => ({
create: {
@ -106,19 +112,14 @@ const validator = (): Record<string, Hapi.RouteOptionsValidate> => ({
},
});
export const VoiceLineRoutes = async (
_server: Hapi.Server
): Promise<Hapi.ServerRoute[]> => {
const controller = new VoiceLineRecordController("voiceLines", "id");
return Helpers.withDefaults(
export const VoiceLineRoutes = Helpers.withDefaults(
crudRoutesFor(
"voice-line",
"/api/voice/voice-line",
controller,
new VoiceLineRecordController("voiceLines", "id"),
"id",
validator()
)
);
};
);
export * from "./twilio";

View file

@ -4,14 +4,13 @@ import * as Boom from "@hapi/boom";
import Twilio from "twilio";
import { SavedVoiceProvider } from "@digiresilience/metamigo-db";
import pMemoize from "p-memoize";
import ExpiryMap from "expiry-map";
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 queueRecording = async (meta) => workerUtils.addJob("twilio-recording", meta, { jobKey: meta.callSid });
const twilioClientFor = (provider: SavedVoiceProvider): Twilio.Twilio => {
const { accountSid, apiKeySid, apiKeySecret } = provider.credentials;
@ -43,8 +42,9 @@ const _getOrCreateTTSTestApplication = async (
});
};
const cache = new ExpiryMap(ms("1h"));
const getOrCreateTTSTestApplication = pMemoize(_getOrCreateTTSTestApplication, {
maxAge: ms("1h"),
cache,
});
export const TwilioRoutes = Helpers.noAuth([
@ -58,7 +58,7 @@ export const TwilioRoutes = Helpers.noAuth([
voiceLineId: Joi.string().uuid().required(),
},
},
handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
async handler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
const { voiceLineId } = request.params;
const voiceLine = await request
.db()
@ -89,7 +89,7 @@ export const TwilioRoutes = Helpers.noAuth([
voiceLineId: Joi.string().uuid().required(),
},
},
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
async handler(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 });
@ -136,7 +136,7 @@ export const TwilioRoutes = Helpers.noAuth([
voiceLineId: Joi.string().uuid().required(),
},
},
handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
async handler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
const { voiceLineId } = request.params;
const voiceLine = await request
.db()
@ -169,7 +169,7 @@ export const TwilioRoutes = Helpers.noAuth([
providerId: Joi.string().uuid().required(),
},
},
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { language, voice, prompt } = request.payload as {
language: SayLanguage;
voice: SayVoice;
@ -192,7 +192,7 @@ export const TwilioRoutes = Helpers.noAuth([
providerId: Joi.string().uuid().required(),
},
},
handler: async (request: Hapi.Request, h: Hapi.ResponseToolkit) => {
async handler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
const { providerId } = request.params as { providerId: string };
const provider: SavedVoiceProvider = await request
.db()

View file

@ -7,8 +7,8 @@ export const GetAllWhatsappBotsRoute = Helpers.withDefaults({
path: "/api/whatsapp/bots",
options: {
description: "Get all bots",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
const { whatsappService } = request.services();
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { whatsappService } = request.services("app");
const bots = await whatsappService.findAll();
@ -31,9 +31,9 @@ export const GetBotsRoute = Helpers.noAuth({
path: "/api/whatsapp/bots/{token}",
options: {
description: "Get one bot",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { token } = request.params;
const { whatsappService } = request.services();
const { whatsappService } = request.services("app");
const bot = await whatsappService.findByToken(token);
@ -61,10 +61,10 @@ export const SendBotRoute = Helpers.noAuth({
path: "/api/whatsapp/bots/{token}/send",
options: {
description: "Send a message",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { token } = request.params;
const { phoneNumber, message } = request.payload as MessageRequest;
const { whatsappService } = request.services();
const { whatsappService } = request.services("app");
const bot = await whatsappService.findByToken(token);
@ -93,9 +93,9 @@ export const ReceiveBotRoute = Helpers.withDefaults({
path: "/api/whatsapp/bots/{token}/receive",
options: {
description: "Receive messages",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { token } = request.params;
const { whatsappService } = request.services();
const { whatsappService } = request.services("app");
const bot = await whatsappService.findByToken(token);
@ -119,9 +119,9 @@ export const RegisterBotRoute = Helpers.withDefaults({
path: "/api/whatsapp/bots/{id}/register",
options: {
description: "Register a bot",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { id } = request.params;
const { whatsappService } = request.services();
const { whatsappService } = request.services("app");
const bot = await whatsappService.findById(id);
@ -146,9 +146,9 @@ export const RefreshBotRoute = Helpers.withDefaults({
path: "/api/whatsapp/bots/{id}/refresh",
options: {
description: "Refresh messages",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { id } = request.params;
const { whatsappService } = request.services();
const { whatsappService } = request.services("app");
const bot = await whatsappService.findById(id);
@ -174,9 +174,9 @@ export const CreateBotRoute = Helpers.withDefaults({
path: "/api/whatsapp/bots",
options: {
description: "Register a bot",
handler: async (request: Hapi.Request, _h: Hapi.ResponseToolkit) => {
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { phoneNumber, description } = request.payload as BotRequest;
const { whatsappService } = request.services();
const { whatsappService } = request.services("app");
console.log("request.auth.credentials:", request.auth.credentials);
const bot = await whatsappService.create(

View file

@ -1,13 +1,11 @@
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

@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Server } from "@hapi/hapi";
import { Service } from "@hapipal/schmervice";
import {
SignaldAPI,
IncomingMessagev1,
ClientMessageWrapperv1
ClientMessageWrapperv1,
} from "@digiresilience/node-signald";
import { SavedSignalBot as Bot } from "@digiresilience/metamigo-db";
import workerUtils from "../../worker-utils";
@ -54,12 +54,15 @@ export default class SignaldService extends Service {
this.signald.on("transport_connected", async () => {
this.onConnected();
});
this.signald.on("transport_received_payload", async (payload: ClientMessageWrapperv1) => {
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.receiveMessage(payload.data);
}
});
}
);
this.signald.on("transport_sent_payload", async (payload) => {
this.server.logger.debug({ payload }, "signald payload sent");
});
@ -163,7 +166,9 @@ export default class SignaldService extends Service {
this.server.logger.error("invalid message received");
}
const bot = await this.server.db().signalBots.findBy({ phoneNumber: account });
const bot = await this.server
.db()
.signalBots.findBy({ phoneNumber: account });
if (!bot) {
this.server.logger.info("message received for unknown bot", {
account,

View file

@ -1,16 +1,31 @@
import { Server } from "@hapi/hapi";
import { Service } from "@hapipal/schmervice";
import { SavedWhatsappBot as Bot } from "@digiresilience/metamigo-db";
import makeWASocket, { DisconnectReason, proto, downloadContentFromMessage, MediaType } from "@adiwajshing/baileys";
import makeWASocket, {
DisconnectReason,
proto,
downloadContentFromMessage,
MediaType,
AnyMessageContent,
WAProto,
MiscMessageGenerationOptions,
} 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 type Connection = {
end: (error: Error | undefined) => void;
sendMessage: (
jid: string,
content: AnyMessageContent,
options?: MiscMessageGenerationOptions
) => Promise<WAProto.WebMessageInfo | undefined>;
};
export default class WhatsappService extends Service {
connections: { [key: string]: any } = {};
loginConnections: { [key: string]: any } = {};
connections: { [key: string]: Connection } = {};
static browserDescription: [string, string, string] = [
"Metamigo",
@ -31,68 +46,87 @@ export default class WhatsappService extends Service {
}
private async sleep(ms: number): Promise<void> {
console.log(`pausing ${ms}`)
return new Promise(resolve => setTimeout(resolve, ms));
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)
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)
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
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')
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')
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
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')
console.log("reconnecting after got new login");
const updatedBot = await this.findById(bot.id);
this.createConnection(updatedBot, server, options)
authCompleteCallback()
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)
console.log("reconnecting");
await this.sleep(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")
connection.ev.process(async (events) => {
if (events["messaging-history.set"]) {
const { chats, contacts, messages, isLatest } =
events["messaging-history.set"];
console.log(
`recv ${chats.length} chats, ${contacts.length} contacts, ${messages.length} msgs (is latest: ${isLatest})`
);
}
});
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)
});
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;
}
@ -103,14 +137,11 @@ export default class WhatsappService extends Service {
const bots = await this.server.db().whatsappBots.findAll();
for await (const bot of bots) {
if (bot.isVerified) {
this.createConnection(
bot,
this.server,
{
this.createConnection(bot, this.server, {
browser: WhatsappService.browserDescription,
printQRInTerminal: false,
version: [2, 2204, 13],
})
});
}
}
}
@ -126,7 +157,7 @@ export default class WhatsappService extends Service {
message.imageMessage ||
message.videoMessage;
let messageContent = Object.values(message)[0]
const messageContent = Object.values(message)[0];
let messageType: MediaType;
let attachment: string;
let filename: string;
@ -147,17 +178,21 @@ export default class WhatsappService extends Service {
key.id + "." + message.imageMessage.mimetype.split("/").pop();
mimetype = message.imageMessage.mimetype;
} else if (message.videoMessage) {
messageType = "video"
messageType = "video";
filename =
key.id + "." + message.videoMessage.mimetype.split("/").pop();
mimetype = message.videoMessage.mimetype;
}
const stream = await downloadContentFromMessage(messageContent, messageType)
let buffer = Buffer.from([])
const stream = await downloadContentFromMessage(
messageContent,
messageType
);
let buffer = Buffer.from([]);
for await (const chunk of stream) {
buffer = Buffer.concat([buffer, chunk])
buffer = Buffer.concat([buffer, chunk]);
}
attachment = buffer.toString("base64");
}
@ -214,7 +249,12 @@ export default class WhatsappService extends Service {
}
async register(bot: Bot, callback: AuthCompleteCallback): Promise<void> {
await this.createConnection(bot, this.server, { version: [2, 2204, 13] }, callback);
await this.createConnection(
bot,
this.server,
{ version: [2, 2204, 13] },
callback
);
}
async send(bot: Bot, phoneNumber: string, message: string): Promise<void> {

View file

@ -6,16 +6,10 @@ import type { IAppConfig } from "../../config";
import type { AppDatabase } from "@digiresilience/metamigo-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;
}
@ -25,3 +19,15 @@ declare module "@hapi/hapi" {
pgp: IMain;
}
}
declare module "@hapipal/schmervice" {
interface AppServices {
settingsService: ISettingsService;
whatsappService: WhatsappService;
signaldService: SignaldService;
}
interface SchmerviceDecorator {
(namespace: "app"): AppServices;
}
}

View file

@ -0,0 +1,7 @@
export {default, loadConfig, loadConfigRaw, IAppConfig, IAppConvict} from "@digiresilience/metamigo-config";

View file

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

View file

@ -16,9 +16,9 @@ export const deployment = async (
return server;
};
export const stopDeployment = async (server: Metamigo.Server): Promise<void> => {
return Metamigo.stopDeployment(server);
};
export const stopDeployment = async (
server: Metamigo.Server
): Promise<void> => Metamigo.stopDeployment(server);
const server = defState("server", {
start: () => deployment(config, true),

View file

@ -9,9 +9,7 @@ const startWorkerUtils = async (): Promise<Worker.WorkerUtils> => {
return workerUtils;
};
const stopWorkerUtils = async (): Promise<void> => {
return workerUtils.release();
};
const stopWorkerUtils = async (): Promise<void> => workerUtils.release();
const workerUtils = defState("apiWorkerUtils", {
start: startWorkerUtils,

View file

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

55
package-lock.json generated
View file

@ -447,6 +447,7 @@
"@hapipal/toys": "^4.0.0",
"blipp": "^4.0.2",
"camelcase-keys": "^8.0.2",
"expiry-map": "^2.0.0",
"fluent-ffmpeg": "^2.1.2",
"graphile-migrate": "^1.4.1",
"graphile-worker": "^0.13.0",
@ -9921,6 +9922,17 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/expiry-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/expiry-map/-/expiry-map-2.0.0.tgz",
"integrity": "sha512-K1I5wJe2fiqjyUZf/xhxwTpaopw3F+19DsO7Oggl20+3SVTXDIevVRJav0aBMfposQdkl2E4+gnuOKd3j2X0sA==",
"dependencies": {
"map-age-cleaner": "^0.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ext": {
"version": "1.7.0",
"license": "ISC",
@ -14975,6 +14987,17 @@
"tmpl": "1.0.5"
}
},
"node_modules/map-age-cleaner": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.2.0.tgz",
"integrity": "sha512-AvxTC6id0fzSf6OyNBTp1syyCuKO7nOJvHgYlhT0Qkkjvk40zZo+av3ayVgXlxnF/DxEzEfY9mMdd7FHsd+wKQ==",
"dependencies": {
"p-defer": "^1.0.0"
},
"engines": {
"node": ">=7.6"
}
},
"node_modules/map-obj": {
"version": "4.3.0",
"license": "MIT",
@ -16380,6 +16403,14 @@
"version": "2.1.0",
"license": "MIT"
},
"node_modules/p-defer": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz",
"integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==",
"engines": {
"node": ">=4"
}
},
"node_modules/p-finally": {
"version": "1.0.0",
"license": "MIT",
@ -21824,7 +21855,6 @@
"license": "AGPL-3.0-or-later",
"dependencies": {
"@hapi/hapi": "^21.3.0",
"@hapi/hoek": "^11.0.1",
"pg-monitor": "^1.4.1",
"pg-promise": "^10.11.0"
},
@ -24902,7 +24932,6 @@
"version": "file:packages/hapi-pg-promise",
"requires": {
"@hapi/hapi": "^21.3.0",
"@hapi/hoek": "^11.0.1",
"pg-monitor": "^1.4.1",
"pg-promise": "^10.11.0"
},
@ -30367,6 +30396,14 @@
"jest-util": "^29.5.0"
}
},
"expiry-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/expiry-map/-/expiry-map-2.0.0.tgz",
"integrity": "sha512-K1I5wJe2fiqjyUZf/xhxwTpaopw3F+19DsO7Oggl20+3SVTXDIevVRJav0aBMfposQdkl2E4+gnuOKd3j2X0sA==",
"requires": {
"map-age-cleaner": "^0.2.0"
}
},
"ext": {
"version": "1.7.0",
"requires": {
@ -34672,6 +34709,14 @@
"tmpl": "1.0.5"
}
},
"map-age-cleaner": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.2.0.tgz",
"integrity": "sha512-AvxTC6id0fzSf6OyNBTp1syyCuKO7nOJvHgYlhT0Qkkjvk40zZo+av3ayVgXlxnF/DxEzEfY9mMdd7FHsd+wKQ==",
"requires": {
"p-defer": "^1.0.0"
}
},
"map-obj": {
"version": "4.3.0"
},
@ -34823,6 +34868,7 @@
"blipp": "^4.0.2",
"camelcase-keys": "^8.0.2",
"eslint-config-link": "*",
"expiry-map": "^2.0.0",
"fluent-ffmpeg": "^2.1.2",
"graphile-migrate": "^1.4.1",
"graphile-worker": "^0.13.0",
@ -35731,6 +35777,11 @@
"orderedmap": {
"version": "2.1.0"
},
"p-defer": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz",
"integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw=="
},
"p-finally": {
"version": "1.0.0"
},

View file

@ -10,7 +10,6 @@
},
"dependencies": {
"@hapi/hapi": "^21.3.0",
"@hapi/hoek": "^11.0.1",
"pg-monitor": "^1.4.1",
"pg-promise": "^10.11.0"
},
@ -19,8 +18,7 @@
"fix:lint": "eslint src --ext .ts --fix",
"fmt": "prettier \"src/**/*.ts\" --write",
"test": "jest --coverage --forceExit --detectOpenHandles --reporters=default --reporters=jest-junit",
"lint": "eslint src --ext .ts",
"lint-fmt": "prettier \"src/**/*.ts\" --list-different",
"lint": "eslint src --ext .ts && prettier \"src/**/*.ts\" --list-different",
"doc": "typedoc src/ --exclude '**/*.test.ts' --exclude '**/*.spec.ts' --name $npm_package_name --readme README.md --target es2019 --mode file --out build/docs",
"watch:build": "tsc -p tsconfig.json -w"
}

View file

@ -1,5 +1,7 @@
import * as Hapi from "@hapi/hapi";
import PgPromisePlugin from ".";
import { makePlugin } from ".";
const plugin = makePlugin();
describe("plugin option validation", () => {
let server;
@ -8,7 +10,7 @@ describe("plugin option validation", () => {
});
it("should throw when no connection details defined", async () => {
expect(server.register(PgPromisePlugin)).rejects.toThrow();
expect(server.register(plugin)).rejects.toThrow();
});
});
@ -20,7 +22,7 @@ describe("basic plugin runtime", () => {
beforeEach(async () => {
server = new Hapi.Server({ port: 0 });
await server.register({
plugin: PgPromisePlugin,
plugin,
options: defaultOpts,
});
await server.start();
@ -63,7 +65,7 @@ describe("plugin runtime", () => {
expect.assertions(5);
await server.register({
plugin: PgPromisePlugin,
plugin,
options: {
...defaultOpts,
logSql: true,

View file

@ -1,22 +1,12 @@
import * as Hapi from "@hapi/hapi";
import * as Hoek from "@hapi/hoek";
import pgPromise from "pg-promise";
import * as pgMonitor from "pg-monitor";
import type { IConnectionParameters } from "pg-promise/typescript/pg-subset";
import type { IMain, IInitOptions } from "pg-promise";
import { IPGPPluginOptions, ExtendedProtocol } from "./types";
import { Plugin } from "@hapi/hapi/lib/types/plugin";
export * from "./types";
const defaultOptions: IPGPPluginOptions<unknown> = {
connection: undefined,
pgpInit: {},
logSql: false,
decorateAs: {
pgp: "pgp",
db: "db",
},
};
export const startDiagnostics = <T>(
logSql: boolean,
@ -45,14 +35,18 @@ const startDb = async <T>(
return db;
};
const register = async <T>(
export function makePlugin<T>(): Plugin<IPGPPluginOptions<T>, void> {
return {
version: "1.0.0",
name: "pg-promise",
async register(
server: Hapi.Server,
userOpts?: IPGPPluginOptions<T>
): Promise<void> => {
const options: IPGPPluginOptions<T> = Hoek.applyToDefaults(
defaultOptions,
userOpts
) as IPGPPluginOptions<T>;
): Promise<void> {
if (userOpts.logSql === undefined) userOpts.logSql = false;
if (!userOpts.decorateAs) userOpts.decorateAs = { pgp: "pgp", db: "db" };
const options = userOpts;
if (!options.connection) {
throw new Error(
@ -70,8 +64,8 @@ const register = async <T>(
if ("pgp" in options) {
pgp = options.pgp;
} else {
pgp = await startPgp(options.pgpInit);
startDiagnostics(options.logSql, options.pgpInit);
pgp = await startPgp(options.pgpInit || {});
startDiagnostics(options.logSql, options.pgpInit || {});
}
const db = await startDb(pgp, options.connection);
@ -92,12 +86,6 @@ const register = async <T>(
stopDiagnostics();
await db.$pool.end();
});
};
const pgPromisePlugin = {
register,
name: "pg-promise",
version: "0.0.1",
};
export default pgPromisePlugin;
},
};
}

View file

@ -5,8 +5,8 @@
"author": "Abel Luck <abel@guardianproject.info>",
"license": "AGPL-3.0-only",
"private": false,
"main": "dist/index.js",
"types": "dist/index.d.ts",
"main": "build/main/index.js",
"types": "types/main/index.d.ts",
"files": [
"/dist"
],
@ -16,7 +16,7 @@
"scripts": {
"build": "tsc --build --verbose",
"watch": "tsc --build --verbose --watch",
"generate": "node util/generate.js && prettier src/generated.ts -w --loglevel error && yarn build",
"generate": "node util/generate.js && prettier src/generated.ts -w --loglevel error && npm run build",
"doc": "typedoc src/ --exclude '**/*.test.ts' --exclude '**/*.spec.ts' --name $npm_package_name --readme README.md --out dist/docs",
"fix": "echo n/a",
"lint": "echo n/a",

View file

@ -14,6 +14,7 @@
"inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts", "test/**/*.tsx"]
},
"lint": {},
"fix:lint": {},
"fmt": {},
"deploy": {
"dependsOn": ["build", "test", "lint"]