Merge branch 'develop' into 'main'

Refactoring and new Bridge services

See merge request digiresilience/link/link-stack!8
This commit is contained in:
Darren Clarke 2024-08-07 16:16:32 +00:00
commit bbcd4ca1d9
710 changed files with 21452 additions and 28798 deletions

6
.gitignore vendored
View file

@ -1,6 +1,7 @@
node_modules
.env
.turbo
*.tsbuildinfo
build/**
**/dist/**
.next/**
@ -19,10 +20,11 @@ docker-compose.yml
coverage
.pgpass
**/dist/**
.metamigo.local.json
.bridge.local.json
out/
signald-state/*
!./signald-state/.gitkeep
baileys-state
signald-state
project.org
project.org
**/.openapi-generator/

View file

@ -84,38 +84,49 @@ leafcutter-docker-release:
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/leafcutter
metamigo-docker-build:
bridge-frontend-docker-build:
extends: .docker-build
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/metamigo
DOCKERFILE_PATH: ./apps/metamigo-cli/Dockerfile
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/bridge-frontend
DOCKERFILE_PATH: ./apps/bridge-frontend/Dockerfile
metamigo-docker-release:
bridge-frontend-docker-release:
extends: .docker-release
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/metamigo
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/bridge-frontend
elasticsearch-docker-build:
bridge-worker-docker-build:
extends: .docker-build
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/elasticsearch
DOCKERFILE_PATH: ./docker/elasticsearch/Dockerfile
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/bridge-worker
DOCKERFILE_PATH: ./apps/bridge-worker/Dockerfile
elasticsearch-docker-release:
bridge-worker-docker-release:
extends: .docker-release
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/elasticsearch
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/bridge-worker
label-studio-docker-build:
bridge-whatsapp-docker-build:
extends: .docker-build
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/label-studio
DOCKERFILE_PATH: ./docker/label-studio/Dockerfile
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/bridge-whatsapp
DOCKERFILE_PATH: ./apps/bridge-whatsapp/Dockerfile
label-studio-docker-release:
bridge-whatsapp-docker-release:
extends: .docker-release
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/label-studio
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/bridge-whatsapp
signal-cli-rest-api-docker-build:
extends: .docker-build
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/signal-cli-rest-api
DOCKERFILE_PATH: ./docker/signal-cli-rest-api/Dockerfile
signal-cli-rest-api-docker-release:
extends: .docker-release
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/signal-cli-rest-api
memcached-docker-build:
extends: .docker-build
@ -183,17 +194,6 @@ redis-docker-release:
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/redis
signald-docker-build:
extends: .docker-build
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/signald
DOCKERFILE_PATH: ./docker/signald/Dockerfile
signald-docker-release:
extends: .docker-release
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/signald
zammad-docker-build:
extends: .docker-build
variables:
@ -206,7 +206,7 @@ zammad-docker-build:
- npm install npm@latest -g
- npm install -g turbo
- npm ci
- turbo build --force --filter zammad-addon-*
- turbo build --force --filter @link-stack/zammad-addon-*
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- DOCKER_BUILDKIT=1 docker build --build-arg EMBEDDED=true --pull --no-cache -t ${DOCKER_NS}:${DOCKER_TAG} -f ${DOCKERFILE_PATH} ${DOCKER_CONTEXT}
- docker push ${DOCKER_NS}:${DOCKER_TAG}
@ -215,7 +215,7 @@ zammad-docker-release:
extends: .docker-release
variables:
DOCKER_NS: ${CI_REGISTRY}/digiresilience/link/link-stack/zammad
zammad-standalone-docker-build:
extends: .docker-build
variables:
@ -228,7 +228,7 @@ zammad-standalone-docker-build:
- npm install npm@latest -g
- npm install -g turbo
- npm ci
- turbo build --force --filter zammad-addon-*
- turbo build --force --filter @link-stack/zammad-addon-*
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- DOCKER_BUILDKIT=1 docker build --pull --no-cache -t ${DOCKER_NS}:${DOCKER_TAG} -f ${DOCKERFILE_PATH} ${DOCKER_CONTEXT}
- docker push ${DOCKER_NS}:${DOCKER_TAG}

View file

@ -1,78 +0,0 @@
FROM gitpod/workspace-full
# install tools we need
RUN set -ex; \
pyenv global system; \
sudo add-apt-repository ppa:ansible/ansible; \
sudo add-apt-repository ppa:maxmind/ppa; \
curl -s https://helm.baltorepo.com/organization/signing.asc | sudo apt-key add - ; \
curl -L "https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh" | sudo bash; \
echo "deb https://baltocdn.com/helm/stable/debian/ all main" | sudo tee /etc/apt/sources.list.d/helm-stable-debian.list ; \
sudo apt-get update; \
sudo apt-get -y upgrade ; \
sudo apt-get install -y \
ansible \
build-essential \
httpie \
fd-find \
ffmpeg \
geoipupdate \
gitlab-runner \
helm \
htop \
iotop \
iptraf \
jq \
kitty-terminfo \
libolm-dev \
ncdu \
postgresql \
pwgen \
python3-wheel \
ripgrep \
rsync \
scdaemon \
socat \
tmux \
unrar \
unzip \
vifm \
vim \
yamllint \
zsh \
zsh-syntax-highlighting \
; sudo rm -rf /var/lib/apt/lists/*
RUN set -ex; \
brew install \
zoxide \
fzf;
# needed for tailscale
RUN sudo update-alternatives --set ip6tables /usr/sbin/ip6tables-nft
# install npm global packages we need
RUN set -ex; \
npm install -g \
standard-version \
turbo \
;
# make a place for all our warez
RUN sudo mkdir -p /usr/local/bin
# install AWS' kubectl
# from https://docs.aws.amazon.com/eks/latest/userguide/install-kubectl.html
ARG KUBECTL_URL="https://amazon-eks.s3.us-west-2.amazonaws.com/1.21.2/2021-07-05/bin/linux/amd64/kubectl"
RUN set -ex; \
curl -o kubectl "${KUBECTL_URL}"; \
chmod +x kubectl; \
sudo mv kubectl /usr/local/bin
# install cloudflared
# from https://github.com/cloudflare/cloudflared/releases
ARG CLOUDFLARED_VERSION="2023.2.1"
RUN set -ex; \
wget --progress=dot:mega https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-linux-amd64.deb; \
sudo dpkg -i cloudflared-linux-amd64.deb; \
cloudflared --version

View file

@ -1,63 +0,0 @@
---
# build the docker image for our gitpod from this dockerfile
image:
file: .gitpod.dockerfile
# all init+before are run in prebuilds, and on workspace startup
tasks:
- name: npm install
init: |
npm install
# extra extensions we share
vscode:
extensions:
- redhat.vscode-yaml
- ms-azuretools.vscode-docker
- ms-kubernetes-tools.vscode-kubernetes-tools
- ms-vscode.makefile-tools
- bungcip.better-toml
- sleistner.vscode-fileutils
- esbenp.prettier-vscode
- darkriszty.markdown-table-prettify
- VisualStudioExptTeam.vscodeintellicode
ports:
- name: Zammad
port: 8001
onOpen: notify
- name: Leafcutter Local
port: 3001
onOpen: notify
- name: Leafcutter
port: 8004
onOpen: notify
- name: Link
port: 8003
onOpen: notify
- name: Link Local
port: 3000
onOpen: notify
- name: Metamigo
port: 8002
onOpen: notify
- name: Metamigo Local
port: 2999
onOpen: notify
- name: Metamigo API
port: 8004
onOpen: notify
- name: Zammad Postgres
port: 5432
onOpen: notify
- name: Metamigo Postgres
port: 5433
onOpen: notify

View file

@ -1,3 +0,0 @@
{
"prettier.prettierPath": ""
}

View file

@ -1,83 +0,0 @@
CURRENT_UID := $(shell id -u):$(shell id -g)
PACKAGE_NAME ?= $(shell jq -r '.name' package.json)
PACKAGE_VERSION?= $(shell jq -r '.version' package.json)
BUILD_DATE ?=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
DOCKER_ARGS ?=
DOCKER_NS ?= registry.gitlab.com/digiresilience/link/${PACKAGE_NAME}
DOCKER_TAG ?= test
DOCKER_BUILD := docker build ${DOCKER_ARGS} --build-arg BUILD_DATE=${BUILD_DATE}
DOCKER_BUILD_FRESH := ${DOCKER_BUILD} --pull --no-cache
DOCKER_BUILD_ARGS := --build-arg VCS_REF=${CI_COMMIT_SHORT_SHA}
DOCKER_PUSH := docker push
DOCKER_BUILD_TAG := ${DOCKER_NS}:${DOCKER_TAG}
.PHONY: .npmrc
.EXPORT_ALL_VARIABLES:
.npmrc:
ifdef CI_JOB_TOKEN
echo '@guardianproject-ops:registry=https://gitlab.com/api/v4/packages/npm/' > .npmrc
echo '@digiresilience:registry=https://gitlab.com/api/v4/packages/npm/' >> .npmrc
echo '//gitlab.com/api/v4/packages/npm/:_authToken=${CI_JOB_TOKEN}' >> .npmrc
echo '//gitlab.com/api/v4/projects/:_authToken=${CI_JOB_TOKEN}' >> .npmrc
echo '//gitlab.com/api/v4/projects/${CI_PROJECT_ID}/packages/npm/:_authToken=${CI_JOB_TOKEN}' >> .npmrc
endif
docker/build: .npmrc
DOCKER_BUILDKIT=1 ${DOCKER_BUILD} ${DOCKER_BUILD_ARGS} -t ${DOCKER_BUILD_TAG} ${PWD}
docker/build-fresh: .npmrc
DOCKER_BUILDKIT=1 ${DOCKER_BUILD_FRESH} ${DOCKER_BUILD_ARGS} -t ${DOCKER_BUILD_TAG} ${PWD}
docker/add-tag:
docker pull ${DOCKER_NS}:${DOCKER_TAG}
docker tag ${DOCKER_NS}:${DOCKER_TAG} ${DOCKER_NS}:${DOCKER_TAG_NEW}
docker push ${DOCKER_NS}:${DOCKER_TAG_NEW}
docker/push:
${DOCKER_PUSH} ${DOCKER_BUILD_TAG}
docker/build-push: docker/build docker/push
docker/build-fresh-push: docker/build-fresh docker/push
# don't use this to generate passwords for production
generate-secrets:
ZAMMAD_DATABASE_PASSWORD=$(shell openssl rand -hex 16)
METAMIGO_DATABASE_ROOT_PASSWORD=$(shell openssl rand -hex 16)
METAMIGO_DATABASE_PASSWORD=$(shell openssl rand -hex 16)
METAMIGO_DATABASE_AUTHENTICATOR_PASSWORD=$(shell openssl rand -hex 16)
NEXTAUTH_AUDIENCE=$(shell openssl rand -hex 16)
NEXTAUTH_SECRET=$(shell openssl rand -hex 16)
generate-keys:
docker exec -i $(shell docker ps -aqf "name=metamigo-frontend") bash -c "/opt/metamigo/cli gen-jwks"
setup-signal:
mkdir -p signald
create-admin-user:
docker exec -i $(shell docker ps -aqf "name=metamigo-postgresql") bash < ./scripts/create-admin-user.sh
.env:
@test -f .env || echo "You must create .env please refer to the README" && exit 1
start: .env
CURRENT_UID=$(CURRENT_UID) docker compose -f docker-compose.link.yml up -d
start-dev: .env
CURRENT_UID=$(CURRENT_UID) docker compose up --build -d
restart: .env
CURRENT_UID=$(CURRENT_UID) docker restart $(shell docker ps -a -q)
stop:
CURRENT_UID=$(CURRENT_UID) docker compose down
destroy:
docker compose down
docker volume prune
dev-metamigo:
CURRENT_UID=$(CURRENT_UID) docker compose up -d metamigo-postgresql signald

View file

@ -1,36 +1 @@
# Dev Setup
> NOTE: When using Gitpod/Codespaces, use at least 16GB RAM
Local dev with docker-compose
* Create `link-stack/.env` from Bitwarden `.env for root of link-stack`
* Run local dev with docker-compose:
```
git clone ...
cd link-stack
make start-dev
```
Or for local dev of a single app
* Create `link-stack/apps/link/.env.local` from Bitwarden `.env.local for link-stack/apps/link`
* Create `link-stack/apps/metamigo-frontend/.metamigo.local.json` from Bitwarden `.metamigo.local.json for link-stack/apps/metamigo/frontend`
* Build locally for development:
```
npm install
make dev-metamigo # this starts the containers
npm run migrate # this migrates the db
npm run dev:metamigo # this runs metamigo frontend and api
```
# TODO
- [ ] Delete old JWT config stuff
- [ ] Consolidate config
- [ ] Complete react-admin upgrade.. make all the metamigo-frontend stuff work
* https://marmelab.com/react-admin/Upgrade.html#no-more-prop-injection-in-page-components
- [ ] Get metamigo-worker working
- [ ] Migrate off mui/styles
* https://mui.com/material-ui/migration/v5-style-changes/
* the codemods might help us?
# TK

View file

@ -1,27 +1,27 @@
FROM node:20 as base
FROM node:20-bookworm AS base
FROM base AS builder
ARG APP_DIR=/opt/metamigo-cli
ARG APP_DIR=/opt/bridge-frontend
RUN mkdir -p ${APP_DIR}/
RUN npm i -g turbo
WORKDIR ${APP_DIR}
COPY . .
RUN turbo prune --scope=@digiresilience/metamigo-cli --docker
RUN turbo prune --scope=@link-stack/bridge-frontend --scope=@link-stack/bridge-migrations --docker
FROM base AS installer
ARG APP_DIR=/opt/metamigo-cli
ARG APP_DIR=/opt/bridge-frontend
WORKDIR ${APP_DIR}
COPY .gitignore .gitignore
COPY --from=builder ${APP_DIR}/.gitignore .gitignore
COPY --from=builder ${APP_DIR}/out/json/ .
COPY --from=builder ${APP_DIR}/out/package-lock.json ./package-lock.json
RUN npm i
RUN npm ci
COPY --from=builder ${APP_DIR}/out/full/ .
RUN npm i -g turbo
RUN turbo run build --filter=metamigo-cli
RUN turbo run build --filter=@link-stack/bridge-frontend --filter=@link-stack/bridge-migrations
FROM base AS runner
ARG APP_DIR=/opt/metamigo-cli
ARG APP_DIR=/opt/bridge-frontend
WORKDIR ${APP_DIR}/
ARG BUILD_DATE
ARG VERSION
@ -33,21 +33,16 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
apt-get install -y --no-install-recommends \
dumb-init
RUN mkdir -p ${APP_DIR}
RUN chown -R node ${APP_DIR}/
USER node
WORKDIR ${APP_DIR}
COPY --from=installer ${APP_DIR}/node_modules/ ./node_modules/
COPY --from=installer ${APP_DIR}/packages/ ./packages/
COPY --from=installer ${APP_DIR}/apps/metamigo-cli/ ./apps/metamigo-cli/
COPY --from=installer ${APP_DIR}/apps/metamigo-api/ ./apps/metamigo-api/
COPY --from=installer ${APP_DIR}/apps/metamigo-worker/ ./apps/metamigo-worker/
COPY --from=installer ${APP_DIR}/node_modules/ ./node_modules/
COPY --from=installer ${APP_DIR}/apps/bridge-frontend/ ./apps/bridge-frontend/
COPY --from=installer ${APP_DIR}/apps/bridge-migrations/ ./apps/bridge-migrations/
COPY --from=installer ${APP_DIR}/package.json ./package.json
USER root
WORKDIR ${APP_DIR}/apps/metamigo-cli/
RUN chown -R node:node ${APP_DIR}/
WORKDIR ${APP_DIR}/apps/bridge-frontend/
RUN chmod +x docker-entrypoint.sh
USER node
EXPOSE 3000
ENV PORT 3000
ENV NODE_ENV production
ENTRYPOINT ["/opt/metamigo-cli/apps/metamigo-cli/docker-entrypoint.sh"]
ENTRYPOINT ["/opt/bridge-frontend/apps/bridge-frontend/docker-entrypoint.sh"]

View file

@ -0,0 +1,14 @@
import { Metadata } from "next";
import { getSession } from "next-auth/react";
import { Login } from "@/app/_components/Login";
export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Login",
};
export default async function Page() {
const session = await getSession();
return <Login session={session} />;
}

View file

@ -0,0 +1,11 @@
import { Create } from "@link-stack/bridge-ui";
type PageProps = {
params: { segment: string[] };
};
export default function Page({ params: { segment } }: PageProps) {
const service = segment[0];
return <Create service={service} />;
}

View file

@ -0,0 +1,27 @@
import { db } from "@link-stack/bridge-common";
import { serviceConfig, Detail } from "@link-stack/bridge-ui";
type Props = {
params: { segment: string[] };
};
export default async function Page({ params: { segment } }: Props) {
const service = segment[0];
const id = segment?.[1];
if (!id) return null;
const {
[service]: { table },
} = serviceConfig;
const row = await db
.selectFrom(table)
.selectAll()
.where("id", "=", id)
.executeTakeFirst();
if (!row) return null;
return <Detail service={service} row={row} />;
}

View file

@ -0,0 +1,27 @@
import { db } from "@link-stack/bridge-common";
import { serviceConfig, Edit } from "@link-stack/bridge-ui";
type PageProps = {
params: { segment: string[] };
};
export default async function Page({ params: { segment } }: PageProps) {
const service = segment[0];
const id = segment?.[1];
if (!id) return null;
const {
[service]: { table },
} = serviceConfig;
const row = await db
.selectFrom(table)
.selectAll()
.where("id", "=", id)
.executeTakeFirst();
if (!row) return null;
return <Edit service={service} row={row} />;
}

View file

@ -0,0 +1,3 @@
import { ServiceLayout } from "@link-stack/bridge-ui";
export default ServiceLayout;

View file

@ -0,0 +1,22 @@
import { db } from "@link-stack/bridge-common";
import { serviceConfig, List } from "@link-stack/bridge-ui";
type PageProps = {
params: {
segment: string[];
};
};
export default async function Page({ params: { segment } }: PageProps) {
const service = segment[0];
if (!service) return null;
const config = serviceConfig[service];
if (!config) return null;
const rows = await db.selectFrom(config.table).selectAll().execute();
return <List service={service} rows={rows} />;
}

View file

@ -0,0 +1,9 @@
import { InternalLayout } from "@/app/_components/InternalLayout";
export default function Layout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return <InternalLayout>{children}</InternalLayout>;
}

View file

@ -0,0 +1,5 @@
import { Home } from "@link-stack/bridge-ui";
export default function Page() {
return <Home />;
}

View file

@ -0,0 +1,35 @@
"use client";
import { FC, PropsWithChildren, useState } from "react";
import { Grid } from "@mui/material";
import { CssBaseline } from "@mui/material";
import { SessionProvider } from "next-auth/react";
import { css, Global } from "@emotion/react";
import { fonts } from "@link-stack/ui";
import { Sidebar } from "./Sidebar";
export const InternalLayout: FC<PropsWithChildren> = ({ children }) => {
const [open, setOpen] = useState(true);
const { roboto } = fonts;
const globalCSS = css`
* {
font-family: ${roboto.style.fontFamily};
}
`;
return (
<SessionProvider>
<Global styles={globalCSS} />
<CssBaseline />
<Grid container direction="row">
<Sidebar open={open} setOpen={setOpen} />
<Grid
item
sx={{ ml: open ? "270px" : "70px", width: "100%", height: "100vh" }}
>
{children as any}
</Grid>
</Grid>
</SessionProvider>
);
};

View file

@ -0,0 +1,185 @@
"use client";
import { FC, useState } from "react";
import {
Box,
Grid,
Container,
IconButton,
Typography,
TextField,
} from "@mui/material";
import {
Apple as AppleIcon,
Google as GoogleIcon,
Key as KeyIcon,
} from "@mui/icons-material";
import { signIn } from "next-auth/react";
import Image from "next/image";
import LinkLogo from "@/app/_images/link-logo-small.png";
import { colors, fonts } from "@link-stack/ui";
import { useSearchParams } from "next/navigation";
type LoginProps = {
session: any;
};
export const Login: FC<LoginProps> = ({ session }) => {
const origin =
typeof window !== "undefined" && window.location.origin
? window.location.origin
: "";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const params = useSearchParams();
const error = params.get("error");
const { darkGray, cdrLinkOrange, white } = colors;
const { poppins } = fonts;
const buttonStyles = {
borderRadius: 500,
width: "100%",
fontSize: "16px",
fontWeight: "bold",
backgroundColor: white,
"&:hover": {
color: white,
backgroundColor: cdrLinkOrange,
},
};
const fieldStyles = {
"& label.Mui-focused": {
color: cdrLinkOrange,
},
"& .MuiInput-underline:after": {
borderBottomColor: cdrLinkOrange,
},
"& .MuiFilledInput-underline:after": {
borderBottomColor: cdrLinkOrange,
},
"& .MuiOutlinedInput-root": {
"&.Mui-focused fieldset": {
borderColor: cdrLinkOrange,
},
},
};
return (
<Box sx={{ backgroundColor: darkGray, height: "100vh" }}>
<Container maxWidth="md" sx={{ p: 10 }}>
<Grid container spacing={2} direction="column" alignItems="center">
<Grid
item
container
direction="row"
justifyContent="center"
alignItems="center"
>
<Grid item>
<Box
sx={{
width: "70px",
height: "70px",
margin: "0 auto",
}}
>
<Image
src={LinkLogo}
alt="Link logo"
width={70}
height={70}
style={{
objectFit: "cover",
filter: "grayscale(100) brightness(100)",
}}
/>
</Box>
</Grid>
<Grid item>
<Typography
variant="h2"
sx={{
fontSize: 36,
color: "white",
fontWeight: 700,
mt: 1,
ml: 0.5,
fontFamily: poppins.style.fontFamily,
}}
>
CDR Bridge
</Typography>
</Grid>
</Grid>
<Grid item sx={{ width: "100%" }}>
{!session ? (
<Container
maxWidth="xs"
sx={{
p: 3,
mt: 3,
}}
>
<Grid
container
spacing={3}
direction="column"
alignItems="center"
>
{error ? (
<Grid item sx={{ width: "100%" }}>
<Box sx={{ backgroundColor: "red", p: 3 }}>
<Typography
variant="body1"
sx={{
fontSize: 18,
color: "white",
textAlign: "center",
}}
>
{`${error} error`}
</Typography>
</Box>
</Grid>
) : null}
<Grid item sx={{ width: "100%" }}>
<IconButton
sx={buttonStyles}
onClick={() =>
signIn("google", {
callbackUrl: `${origin}`,
})
}
>
<GoogleIcon sx={{ mr: 1 }} />
Sign in with Google
</IconButton>
</Grid>
<Grid item sx={{ width: "100%" }}>
<IconButton
aria-label="Sign in with Apple"
sx={buttonStyles}
onClick={() =>
signIn("apple", {
callbackUrl: `${window.location.origin}`,
})
}
>
<AppleIcon sx={{ mr: 1 }} />
Sign in with Apple
</IconButton>
</Grid>
</Grid>
</Container>
) : null}
{session ? (
<Box component="h4">
{` ${session.user.name ?? session.user.email}.`}
</Box>
) : null}
</Grid>
</Grid>
</Container>
</Box>
);
};

View file

@ -0,0 +1,399 @@
"use client";
import { FC } from "react";
import {
Box,
Grid,
Typography,
List,
ListItemButton,
ListItemIcon,
ListItemText,
ListItemSecondaryAction,
Drawer,
} from "@mui/material";
import {
ExpandCircleDown as ExpandCircleDownIcon,
AccountCircle as AccountCircleIcon,
Chat as ChatIcon,
PermPhoneMsg as PhoneIcon,
WhatsApp as WhatsAppIcon,
Facebook as FacebookIcon,
AirlineStops as AirlineStopsIcon,
Logout as LogoutIcon,
} from "@mui/icons-material";
import { usePathname } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { typography, fonts, Button } from "@link-stack/ui";
import LinkLogo from "@/app/_images/link-logo-small.png";
import { useSession, signOut } from "next-auth/react";
const openWidth = 270;
const closedWidth = 70;
const MenuItem = ({
name,
href,
Icon,
iconSize,
inset = false,
selected = false,
open = true,
badge,
target = "_self",
}: any) => (
<Link href={href} target={target}>
<ListItemButton
sx={{
p: 0,
mb: 1,
bl: iconSize === 0 ? "1px solid white" : "inherit",
}}
selected={selected}
>
{iconSize > 0 ? (
<ListItemIcon
sx={{
color: `white`,
minWidth: 0,
mr: 2,
textAlign: "center",
margin: open ? "0 8 0 0" : "0 auto",
}}
>
<Box
sx={{
width: iconSize,
height: iconSize,
mr: 0.5,
mt: "-4px",
}}
>
<Icon />
</Box>
</ListItemIcon>
) : (
<Box
sx={{
width: 30,
height: "28px",
position: "relative",
ml: "9px",
mr: "1px",
}}
>
<Box
sx={{
width: "1px",
height: "56px",
backgroundColor: "white",
position: "absolute",
left: "3px",
top: "-10px",
}}
/>
<Box
sx={{
width: "42px",
height: "42px",
position: "absolute",
top: "-27px",
left: "3px",
border: "1px solid #fff",
borderColor: "transparent transparent transparent #fff",
borderRadius: "60px",
rotate: "-35deg",
}}
/>
</Box>
)}
{open && (
<ListItemText
inset={inset}
primary={
<Typography
variant="body1"
sx={{
fontSize: 16,
fontWeight: "bold",
border: 0,
textAlign: "left",
color: "white",
}}
>
{name}
</Typography>
}
/>
)}
{badge && badge > 0 ? (
<ListItemSecondaryAction>
<Typography
color="textSecondary"
variant="body1"
className="badge"
sx={{
backgroundColor: "#FFB620",
color: "black !important",
borderRadius: 10,
px: 1,
fontSize: 12,
fontWeight: "bold",
}}
>
{badge}
</Typography>
</ListItemSecondaryAction>
) : null}
</ListItemButton>
</Link>
);
interface SidebarProps {
open: boolean;
setOpen: (open: boolean) => void;
}
export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
const pathname = usePathname();
const { poppins } = fonts;
const { bodyLarge } = typography;
const { data: session } = useSession();
const user = session?.user;
const logout = () => {
signOut({ callbackUrl: "/login" });
};
return (
<Drawer
sx={{ width: open ? openWidth : closedWidth, flexShrink: 0 }}
variant="permanent"
anchor="left"
open={open}
PaperProps={{
sx: {
width: open ? openWidth : closedWidth,
border: 0,
overflow: "visible",
},
}}
>
<Box
sx={{
position: "absolute",
top: 24,
right: open ? -8 : -16,
color: "#1C75FD",
rotate: open ? "90deg" : "-90deg",
}}
onClick={() => {
setOpen!(!open);
}}
>
<ExpandCircleDownIcon
sx={{
width: 24,
height: 24,
background: "white",
borderRadius: 500,
}}
/>
</Box>
<Grid
container
direction="column"
justifyContent="space-between"
wrap="nowrap"
spacing={0}
sx={{ backgroundColor: "#25272A", height: "100%", p: 2 }}
>
<Grid item container>
<Grid item sx={{ width: open ? "40px" : "100%" }}>
<Box
sx={{
width: "40px",
height: "40px",
margin: open ? "0" : "0 auto",
}}
>
<Image
src={LinkLogo}
alt="Link logo"
width={40}
height={40}
style={{
objectFit: "cover",
filter: "grayscale(100) brightness(100)",
}}
/>
</Box>
.
</Grid>
{open && (
<Grid item>
<Typography
variant="h2"
sx={{
fontSize: 26,
color: "white",
fontWeight: 700,
mt: 1,
ml: 0.5,
fontFamily: poppins.style.fontFamily,
}}
>
CDR Bridge
</Typography>
</Grid>
)}
</Grid>
<Grid item>
<Box
sx={{
height: "0.5px",
width: "100%",
backgroundColor: "#666",
mb: 1,
}}
/>
</Grid>
<Grid
item
container
direction="column"
sx={{
mt: "6px",
overflow: "scroll",
scrollbarWidth: "none",
msOverflowStyle: "none",
"&::-webkit-scrollbar": { display: "none" },
}}
flexGrow={1}
>
<List
component="nav"
sx={{
a: {
textDecoration: "none",
".MuiListItemButton-root": {
p: 1,
borderRadius: 2,
"&:hover": {
background: "#555",
},
".MuiTypography-root": {
p: {
color: "#999 !important",
fontSize: 16,
},
},
".badge": {
p: { fontSize: 12, color: "black !important" },
},
},
".Mui-selected": {
background: "#444",
color: "#fff !important",
".MuiTypography-root": {
p: {
color: "#fff !important",
fontSize: 16,
},
},
".badge": {
p: { fontSize: 12, color: "black !important" },
},
},
},
}}
>
<MenuItem
name="WhatsApp"
href="/whatsapp"
selected={pathname.endsWith("/whatsapp")}
Icon={WhatsAppIcon}
iconSize={20}
/>
<MenuItem
name="Signal"
href="/signal"
selected={pathname.startsWith("/signal")}
Icon={ChatIcon}
iconSize={20}
/>
<MenuItem
name="Facebook"
href="/facebook"
selected={pathname.startsWith("/facebook")}
Icon={FacebookIcon}
iconSize={20}
/>
<MenuItem
name="Voice"
href="/voice"
selected={pathname.startsWith("/voice")}
Icon={PhoneIcon}
iconSize={20}
/>
<MenuItem
name="Webhooks"
href="/webhooks"
selected={pathname.startsWith("/webhooks")}
Icon={AirlineStopsIcon}
iconSize={20}
/>
<MenuItem
name="Users"
href="/users"
selected={pathname.startsWith("/users")}
Icon={AccountCircleIcon}
iconSize={20}
/>
</List>
</Grid>
<Grid
item
container
direction="row"
alignItems="center"
spacing={1}
sx={{
borderTop: "1px solid #ffffff33",
pt: 0.5,
}}
>
{user?.image && (
<Grid item>
<Box sx={{ width: 20, height: 20 }}>
<Image
src={user?.image ?? ""}
alt="Profile image"
width={20}
height={20}
unoptimized
/>
</Box>
</Grid>
)}
<Grid item>
<Box
sx={{
...bodyLarge,
color: "white",
}}
>
{user?.email}
</Box>
</Grid>
<Grid item>
<Button text="Logout" kind="secondary" onClick={logout} />
</Grid>
</Grid>
</Grid>
</Drawer>
);
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

View file

@ -0,0 +1,17 @@
import GoogleProvider from "next-auth/providers/google";
import { KyselyAdapter } from "@auth/kysely-adapter";
import { db } from "@link-stack/bridge-common";
export const authOptions = {
// @ts-ignore
adapter: KyselyAdapter(db),
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
session: {
strategy: "jwt" as any,
},
};

View file

@ -0,0 +1 @@
export { receiveMessage as POST } from "@link-stack/bridge-ui";

View file

@ -0,0 +1 @@
export { getBot as GET } from "@link-stack/bridge-ui";

View file

@ -0,0 +1 @@
export { sendMessage as POST } from "@link-stack/bridge-ui";

View file

@ -0,0 +1,3 @@
import { handleWebhook } from "@link-stack/bridge-ui";
export { handleWebhook as GET, handleWebhook as POST };

View file

@ -0,0 +1,7 @@
import NextAuth from "next-auth";
import { authOptions } from "@/app/_lib/authentication";
// @ts-expect-error
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View file

@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { LicenseInfo } from "@mui/x-license";
LicenseInfo.setLicenseKey(
"c787ac6613c5f2aa0494c4285fe3e9f2Tz04OTY1NyxFPTE3NDYzNDE0ODkwMDAsUz1wcm8sTE09c3Vic2NyaXB0aW9uLEtWPTI=",
);
export const metadata: Metadata = {
title: "CDR Bridge",
description: "",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}

View file

@ -0,0 +1,7 @@
#!/bin/bash
set -e
echo "running migrations"
(cd ../bridge-migrations/ && npm run migrate:up:all)
echo "starting bridge-frontend"
exec dumb-init npm run start

View file

@ -0,0 +1,24 @@
import { withAuth } from "next-auth/middleware";
export default withAuth({
pages: {
signIn: `/login`,
},
callbacks: {
authorized: ({ token }) => {
if (process.env.SETUP_MODE === "true") {
return true;
}
if (token?.email) {
return true;
}
return false;
},
},
});
export const config = {
matcher: ["/((?!ws|wss|api|_next/static|_next/image|favicon.ico).*)"],
};

View file

@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@link-stack/ui", "@link-stack/bridge-common", "@link-stack/bridge-ui"],
};
export default nextConfig;

View file

@ -0,0 +1,58 @@
{
"name": "@link-stack/bridge-frontend",
"version": "2.1.0",
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"migrate:up:all": "tsx database/migrate.ts up:all",
"migrate:up:one": "tsx database/migrate.ts up:one",
"migrate:down:all": "tsx database/migrate.ts down:all",
"migrate:down:one": "tsx database/migrate.ts down:one"
},
"dependencies": {
"@auth/kysely-adapter": "^1.4.2",
"@emotion/cache": "^11.13.1",
"@emotion/react": "^11.13.0",
"@emotion/styled": "^11.13.0",
"@mui/icons-material": "^5",
"@mui/material": "^5",
"@mui/material-nextjs": "^5.16.6",
"@mui/x-data-grid-pro": "^7.12.0",
"@mui/x-date-pickers-pro": "^7.12.0",
"@mui/x-license": "^7.12.0",
"@link-stack/bridge-common": "*",
"@link-stack/bridge-ui": "*",
"@link-stack/signal-api": "*",
"date-fns": "^3.6.0",
"dotenv": "^16.4.5",
"graphile-worker": "^0.16.6",
"kysely": "0.26.1",
"material-ui-popup-state": "^5.1.2",
"mui-chips-input": "^2.1.5",
"next": "14.2.5",
"next-auth": "^4.24.7",
"pg": "^8.12.0",
"react": "18.3.1",
"react-cookie": "^7.2.0",
"react-digit-input": "^2.1.0",
"react-dom": "18.3.1",
"react-qr-code": "^2.0.15",
"react-timer-hook": "^3.0.7",
"sharp": "^0.33.4",
"tss-react": "^4.9.12",
"tsx": "^4.16.5",
"@link-stack/ui": "*"
},
"devDependencies": {
"@types/node": "^22",
"@types/pg": "^8.11.6",
"@types/react": "^18",
"@types/react-dom": "^18",
"@link-stack/eslint-config": "*",
"@link-stack/typescript-config": "*",
"typescript": "^5"
}
}

View file

@ -0,0 +1,27 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"paths": {
"@/*": ["./*"]
},
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,93 @@
import * as path from "path";
import { fileURLToPath } from "url";
import { promises as fs } from "fs";
import {
Kysely,
Migrator,
MigrationResult,
FileMigrationProvider,
PostgresDialect,
CamelCasePlugin,
} from "kysely";
import pkg from "pg";
const { Pool } = pkg;
import * as dotenv from "dotenv";
interface Database {}
export const migrate = async (arg: string) => {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
if (process.env.NODE_ENV !== "production") {
dotenv.config({ path: path.join(__dirname, "../.env.local") });
}
const db = new Kysely<Database>({
dialect: new PostgresDialect({
pool: new Pool({
host: process.env.DATABASE_HOST,
database: process.env.DATABASE_NAME,
port: parseInt(process.env.DATABASE_PORT!),
user: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
}),
}),
plugins: [new CamelCasePlugin()],
});
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder: path.join(__dirname, "migrations"),
}),
});
let error: any = null;
let results: MigrationResult[] = [];
if (arg === "up:all") {
const out = await migrator.migrateToLatest();
results = out.results ?? [];
error = out.error;
} else if (arg === "up:one") {
const out = await migrator.migrateUp();
results = out.results ?? [];
error = out.error;
} else if (arg === "down:all") {
const migrations = await migrator.getMigrations();
for (const _ of migrations) {
const out = await migrator.migrateDown();
if (out.results) {
results = results.concat(out.results);
error = out.error;
}
}
} else if (arg === "down:one") {
const out = await migrator.migrateDown();
if (out.results) {
results = out.results ?? [];
error = out.error;
}
}
results?.forEach((it) => {
if (it.status === "Success") {
console.log(
`Migration "${it.migrationName} ${it.direction.toLowerCase()}" was executed successfully`,
);
} else if (it.status === "Error") {
console.error(`Failed to execute migration "${it.migrationName}"`);
}
});
if (error) {
console.error("Failed to migrate");
console.error(error);
process.exit(1);
}
await db.destroy();
};
const arg = process.argv.slice(2).pop();
migrate(arg as string);

View file

@ -0,0 +1,72 @@
import { Kysely, sql } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable("User")
.addColumn("id", "uuid", (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn("name", "text")
.addColumn("email", "text", (col) => col.unique().notNull())
.addColumn("emailVerified", "timestamptz")
.addColumn("image", "text")
.execute();
await db.schema
.createTable("Account")
.addColumn("id", "uuid", (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn("userId", "uuid", (col) =>
col.references("User.id").onDelete("cascade").notNull(),
)
.addColumn("type", "text", (col) => col.notNull())
.addColumn("provider", "text", (col) => col.notNull())
.addColumn("providerAccountId", "text", (col) => col.notNull())
.addColumn("refresh_token", "text")
.addColumn("access_token", "text")
.addColumn("expires_at", "bigint")
.addColumn("token_type", "text")
.addColumn("scope", "text")
.addColumn("id_token", "text")
.addColumn("session_state", "text")
.execute();
await db.schema
.createTable("Session")
.addColumn("id", "uuid", (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn("userId", "uuid", (col) =>
col.references("User.id").onDelete("cascade").notNull(),
)
.addColumn("sessionToken", "text", (col) => col.notNull().unique())
.addColumn("expires", "timestamptz", (col) => col.notNull())
.execute();
await db.schema
.createTable("VerificationToken")
.addColumn("identifier", "text", (col) => col.notNull())
.addColumn("token", "text", (col) => col.notNull().unique())
.addColumn("expires", "timestamptz", (col) => col.notNull())
.execute();
await db.schema
.createIndex("Account_userId_index")
.on("Account")
.column("userId")
.execute();
await db.schema
.createIndex("Session_userId_index")
.on("Session")
.column("userId")
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable("Account").ifExists().execute();
await db.schema.dropTable("Session").ifExists().execute();
await db.schema.dropTable("User").ifExists().execute();
await db.schema.dropTable("VerificationToken").ifExists().execute();
}

View file

@ -0,0 +1,33 @@
import { Kysely, sql } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable("SignalBot")
.addColumn("id", "uuid", (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn("phone_number", "text")
.addColumn("token", "text", (col) => col.unique().notNull())
.addColumn("user_id", "uuid")
.addColumn("name", "text")
.addColumn("description", "text")
.addColumn("qr_code", "text")
.addColumn("verified", "boolean", (col) => col.notNull().defaultTo(false))
.addColumn("created_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn("updated_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex("SignalBotToken")
.on("SignalBot")
.column("token")
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable("SignalBot").ifExists().execute();
}

View file

@ -0,0 +1,33 @@
import { Kysely, sql } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable("WhatsappBot")
.addColumn("id", "uuid", (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn("phone_number", "text")
.addColumn("token", "text", (col) => col.unique().notNull())
.addColumn("user_id", "uuid")
.addColumn("name", "text")
.addColumn("description", "text")
.addColumn("qr_code", "text")
.addColumn("verified", "boolean", (col) => col.notNull().defaultTo(false))
.addColumn("created_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn("updated_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex("WhatsappBotToken")
.on("WhatsappBot")
.column("token")
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable("WhatsappBot").ifExists().execute();
}

View file

@ -0,0 +1,77 @@
import { Kysely, sql } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable("VoiceProvider")
.addColumn("id", "uuid", (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn("kind", "text", (col) => col.notNull())
.addColumn("name", "text", (col) => col.notNull())
.addColumn("description", "text")
.addColumn("credentials", "jsonb", (col) => col.notNull())
.addColumn("created_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn("updated_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex("VoiceProviderName")
.on("VoiceProvider")
.column("name")
.execute();
await db.schema
.createTable("VoiceLine")
.addColumn("id", "uuid", (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn("provider_id", "uuid", (col) =>
col.notNull().references("VoiceProvider.id").onDelete("cascade"),
)
.addColumn("provider_line_sid", "text", (col) => col.notNull())
.addColumn("number", "text", (col) => col.notNull())
.addColumn("name", "text", (col) => col.notNull())
.addColumn("description", "text")
.addColumn("language", "text", (col) => col.notNull())
.addColumn("voice", "text", (col) => col.notNull())
.addColumn("prompt_text", "text")
.addColumn("prompt_audio", "jsonb")
.addColumn("audio_prompt_enabled", "boolean", (col) =>
col.notNull().defaultTo(false),
)
.addColumn("audio_converted_at", "timestamptz")
.addColumn("created_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn("updated_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex("VoiceLineProviderId")
.on("VoiceLine")
.column("provider_id")
.execute();
await db.schema
.createIndex("VoiceLineProviderLineSid")
.on("VoiceLine")
.column("provider_line_sid")
.execute();
await db.schema
.createIndex("VoiceLineNumber")
.on("VoiceLine")
.column("number")
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable("VoiceLine").ifExists().execute();
await db.schema.dropTable("VoiceProvider").ifExists().execute();
}

View file

@ -0,0 +1,36 @@
import { Kysely, sql } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable("FacebookBot")
.addColumn("id", "uuid", (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn("name", "text")
.addColumn("description", "text")
.addColumn("token", "text")
.addColumn("page_access_token", "text")
.addColumn("app_secret", "text")
.addColumn("verify_token", "text")
.addColumn("page_id", "text")
.addColumn("app_id", "text")
.addColumn("user_id", "uuid")
.addColumn("verified", "boolean", (col) => col.notNull().defaultTo(false))
.addColumn("created_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn("updated_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex("FacebookBotToken")
.on("FacebookBot")
.column("token")
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable("FacebookBot").ifExists().execute();
}

View file

@ -0,0 +1,41 @@
import { Kysely, sql } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable("Webhook")
.addColumn("id", "uuid", (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn("name", "text", (col) => col.notNull())
.addColumn("description", "text")
.addColumn("backend_type", "text", (col) => col.notNull())
.addColumn("backend_id", "uuid", (col) => col.notNull())
.addColumn("endpoint_url", "text", (col) =>
col.notNull().check(sql`endpoint_url ~ '^https?://[^/]+'`),
)
.addColumn("http_method", "text", (col) =>
col
.notNull()
.defaultTo("post")
.check(sql`http_method in ('post', 'put')`),
)
.addColumn("headers", "jsonb")
.addColumn("created_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn("updated_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex("WebhookBackendTypeBackendId")
.on("Webhook")
.column("backend_type")
.column("backend_id")
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable("Webhook").ifExists().execute();
}

View file

@ -0,0 +1,28 @@
import { Kysely, sql } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable("Setting")
.addColumn("id", "uuid", (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn("name", "text")
.addColumn("value", "jsonb")
.addColumn("created_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.addColumn("updated_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex("SettingName")
.on("Setting")
.column("name")
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable("Setting").ifExists().execute();
}

View file

@ -0,0 +1,9 @@
import { Kysely } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.schema.alterTable("User").addColumn("role", "text").execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.alterTable("User").dropColumn("role").execute();
}

View file

@ -0,0 +1,24 @@
{
"name": "@link-stack/bridge-migrations",
"version": "2.1.0",
"type": "module",
"scripts": {
"migrate:up:all": "tsx migrate.ts up:all",
"migrate:up:one": "tsx migrate.ts up:one",
"migrate:down:all": "tsx migrate.ts down:all",
"migrate:down:one": "tsx migrate.ts down:one"
},
"dependencies": {
"dotenv": "^16.4.5",
"kysely": "0.26.1",
"pg": "^8.12.0",
"tsx": "^4.16.5"
},
"devDependencies": {
"@types/node": "^22",
"@types/pg": "^8.11.6",
"@link-stack/eslint-config": "*",
"@link-stack/typescript-config": "*",
"typescript": "^5"
}
}

View file

@ -0,0 +1,39 @@
FROM node:20-bookworm AS base
FROM base AS builder
ARG APP_DIR=/opt/bridge-whatsapp
RUN mkdir -p ${APP_DIR}/
RUN npm i -g turbo
WORKDIR ${APP_DIR}
COPY . .
RUN turbo prune --scope=@link-stack/bridge-whatsapp --docker
FROM base AS installer
ARG APP_DIR=/opt/bridge-whatsapp
WORKDIR ${APP_DIR}
COPY --from=builder ${APP_DIR}/out/json/ .
COPY --from=builder ${APP_DIR}/out/full/ .
COPY --from=builder ${APP_DIR}/out/package-lock.json ./package-lock.json
RUN npm ci
RUN npm i -g turbo
RUN turbo run build --filter=@link-stack/bridge-whatsapp
FROM base as runner
ARG BUILD_DATE
ARG VERSION
ARG APP_DIR=/opt/bridge-whatsapp
RUN mkdir -p ${APP_DIR}/
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
apt-get install -y --no-install-recommends \
dumb-init
WORKDIR ${APP_DIR}
COPY --from=installer ${APP_DIR} ./
RUN chown -R node:node ${APP_DIR}
WORKDIR ${APP_DIR}/apps/bridge-whatsapp/
RUN chmod +x docker-entrypoint.sh
USER node
RUN mkdir /home/node/baileys
EXPOSE 5000
ENV PORT 5000
ENV NODE_ENV production
ENTRYPOINT ["/opt/bridge-whatsapp/apps/bridge-whatsapp/docker-entrypoint.sh"]

View file

@ -0,0 +1,5 @@
#!/bin/bash
set -e
echo "starting bridge-whatsapp"
exec dumb-init npm run start

View file

@ -1,4 +1,4 @@
{
"preset": "jest-config-link",
"preset": "jest-config",
"setupFiles": ["<rootDir>/src/setup.test.ts"]
}
}

View file

@ -0,0 +1,31 @@
{
"name": "@link-stack/bridge-whatsapp",
"version": "2.1.0",
"main": "build/main/index.js",
"author": "Darren Clarke <darren@redaranj.com>",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@adiwajshing/keyed-db": "0.2.4",
"@hapi/boom": "^10.0.1",
"@hapi/hapi": "^21.3.10",
"@hapipal/schmervice": "^3.0.0",
"@hapipal/toys": "^4.0.0",
"@whiskeysockets/baileys": "^6.7.5",
"hapi-pino": "^12.1.0",
"link-preview-js": "^3.0.5"
},
"devDependencies": {
"@link-stack/eslint-config": "*",
"@link-stack/jest-config": "*",
"@link-stack/typescript-config": "*",
"@types/node": "*",
"dotenv-cli": "^7.4.2",
"tsx": "^4.16.5",
"typescript": "^5.5.4"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "dotenv -- tsx src/index.ts",
"start": "node build/main/index.js"
}
}

View file

@ -0,0 +1,39 @@
import * as Hapi from "@hapi/hapi";
import hapiPino from "hapi-pino";
import Schmervice from "@hapipal/schmervice";
import WhatsappService from "./service.js";
import {
RegisterBotRoute,
UnverifyBotRoute,
GetBotRoute,
SendMessageRoute,
ReceiveMessageRoute,
} from "./routes.js";
const server = Hapi.server({ port: 5000 });
const startServer = async () => {
await server.register({ plugin: hapiPino });
server.route(RegisterBotRoute);
server.route(UnverifyBotRoute);
server.route(GetBotRoute);
server.route(SendMessageRoute);
server.route(ReceiveMessageRoute);
await server.register(Schmervice);
server.registerService(WhatsappService);
await server.start();
return server;
};
const main = async () => {
await startServer();
};
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -0,0 +1,118 @@
import * as Hapi from "@hapi/hapi";
import Toys from "@hapipal/toys";
import WhatsappService from "./service";
const withDefaults = Toys.withRouteDefaults({
options: {
cors: true,
},
});
const getService = (request: Hapi.Request): WhatsappService => {
const { whatsappService } = request.services();
return whatsappService as WhatsappService;
};
interface MessageRequest {
phoneNumber: string;
message: string;
}
export const SendMessageRoute = withDefaults({
method: "post",
path: "/api/bots/{id}/send",
options: {
description: "Send a message",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { id } = request.params;
console.log({ payload: request.payload });
const { phoneNumber, message } = request.payload as MessageRequest;
const whatsappService = getService(request);
await whatsappService.send(id, phoneNumber, message as string);
request.logger.info({ id }, "Sent a message at %s", new Date());
return _h
.response({
result: {
recipient: phoneNumber,
timestamp: new Date().toISOString(),
source: id,
},
})
.code(200);
},
},
});
export const ReceiveMessageRoute = withDefaults({
method: "get",
path: "/api/bots/{id}/receive",
options: {
description: "Receive messages",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { id } = request.params;
const whatsappService = getService(request);
const date = new Date();
const twoDaysAgo = new Date(date.getTime());
twoDaysAgo.setDate(date.getDate() - 2);
request.logger.info({ id }, "Received messages at %s", new Date());
return whatsappService.receive(id, twoDaysAgo);
},
},
});
export const RegisterBotRoute = withDefaults({
method: "post",
path: "/api/bots/{id}/register",
options: {
description: "Register a bot",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { id } = request.params;
const whatsappService = getService(request);
await whatsappService.register(id);
/*
, (error: string) => {
if (error) {
return _h.response(error).code(500);
}
request.logger.info({ id }, "Register bot at %s", new Date());
return _h.response().code(200);
});
*/
return _h.response().code(200);
},
},
});
export const UnverifyBotRoute = withDefaults({
method: "post",
path: "/api/bots/{id}/unverify",
options: {
description: "Unverify bot",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { id } = request.params;
const whatsappService = getService(request);
return whatsappService.unverify(id);
},
},
});
export const GetBotRoute = withDefaults({
method: "get",
path: "/api/bots/{id}",
options: {
description: "Get bot info",
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
const { id } = request.params;
const whatsappService = getService(request);
return whatsappService.getBot(id);
},
},
});

View file

@ -1,8 +1,5 @@
/* eslint-disable unicorn/no-abusive-eslint-disable */
/* eslint-disable */
import { Server } from "@hapi/hapi";
import { Service } from "@hapipal/schmervice";
import { SavedWhatsappBot as Bot } from "@digiresilience/metamigo-db";
import makeWASocket, {
DisconnectReason,
proto,
@ -14,16 +11,15 @@ import makeWASocket, {
useMultiFileAuthState,
} from "@whiskeysockets/baileys";
import fs from "fs";
import workerUtils from "../../worker-utils.js";
export type AuthCompleteCallback = (error?: string) => void;
export default class WhatsappService extends Service {
connections: { [key: string]: any; } = {};
loginConnections: { [key: string]: any; } = {};
connections: { [key: string]: any } = {};
loginConnections: { [key: string]: any } = {};
static browserDescription: [string, string, string] = [
"Metamigo",
"Bridge",
"Chrome",
"2.0",
];
@ -32,8 +28,16 @@ export default class WhatsappService extends Service {
super(server, options);
}
getAuthDirectory(bot: Bot): string {
return `/baileys/${bot.id}`;
getBaseDirectory(): string {
return `/home/node/baileys`;
}
getBotDirectory(id: string): string {
return `${this.getBaseDirectory()}/${id}`;
}
getAuthDirectory(id: string): string {
return `${this.getBotDirectory(id)}/auth`;
}
async initialize(): Promise<void> {
@ -45,7 +49,6 @@ export default class WhatsappService extends Service {
}
private async sleep(ms: number): Promise<void> {
console.log(`pausing ${ms}`);
return new Promise((resolve) => setTimeout(resolve, ms));
}
@ -61,17 +64,18 @@ export default class WhatsappService extends Service {
}
private async createConnection(
bot: Bot,
botID: string,
server: Server,
options: any,
authCompleteCallback?: any
authCompleteCallback?: any,
) {
const directory = this.getAuthDirectory(bot);
const { state, saveCreds } = await useMultiFileAuthState(directory);
const authDirectory = this.getAuthDirectory(botID);
const { state, saveCreds } = await useMultiFileAuthState(authDirectory);
const msgRetryCounterMap: any = {};
const socket = makeWASocket({
...options,
auth: state,
generateHighQualityLinkPreview: false,
msgRetryCounterMap,
shouldIgnoreJid: (jid) =>
isJidBroadcast(jid) || isJidStatusBroadcast(jid),
@ -89,27 +93,29 @@ export default class WhatsappService extends Service {
} = update;
if (qr) {
console.log("got qr code");
await this.server.db().whatsappBots.updateQR(bot, qr);
const botDirectory = this.getBotDirectory(botID);
const qrPath = `${botDirectory}/qr.txt`;
fs.writeFileSync(qrPath, qr, "utf8");
} else if (isNewLogin) {
console.log("got new login");
await this.server.db().whatsappBots.updateVerified(bot, true);
const botDirectory = this.getBotDirectory(botID);
const verifiedFile = `${botDirectory}/verified`;
fs.writeFileSync(verifiedFile, "");
} else if (connectionState === "open") {
console.log("opened connection");
} else if (connectionState === "close") {
console.log("connection closed due to ", lastDisconnect.error);
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);
await this.createConnection(updatedBot, server, options);
await this.createConnection(botID, server, options);
authCompleteCallback?.();
} else if (disconnectStatusCode !== DisconnectReason.loggedOut) {
console.log("reconnecting");
await this.sleep(pause);
pause *= 2;
this.createConnection(bot, server, options);
this.createConnection(botID, server, options);
}
}
}
@ -124,39 +130,49 @@ export default class WhatsappService extends Service {
const upsert = events["messages.upsert"];
const { messages } = upsert;
if (messages) {
await this.queueUnreadMessages(bot, messages);
await this.queueUnreadMessages(botID, messages);
}
}
});
this.connections[bot.id] = { socket, msgRetryCounterMap };
this.connections[botID] = { socket, msgRetryCounterMap };
}
private async updateConnections() {
this.resetConnections();
const bots = await this.server.db().whatsappBots.findAll();
for await (const bot of bots) {
if (bot.isVerified) {
const baseDirectory = this.getBaseDirectory();
const botIDs = fs.readdirSync(baseDirectory);
console.log({ botIDs });
for await (const botID of botIDs) {
const directory = this.getBotDirectory(botID);
const verifiedFile = `${directory}/verified`;
if (fs.existsSync(verifiedFile)) {
const { version, isLatest } = await fetchLatestBaileysVersion();
console.log(`using WA v${version.join(".")}, isLatest: ${isLatest}`);
await this.createConnection(bot, this.server, {
await this.createConnection(botID, this.server, {
browser: WhatsappService.browserDescription,
printQRInTerminal: false,
printQRInTerminal: true,
version,
});
}
}
}
private async queueMessage(bot: Bot, webMessageInfo: proto.IWebMessageInfo) {
private async queueMessage(
botID: string,
webMessageInfo: proto.IWebMessageInfo,
) {
const {
key: { id, fromMe, remoteJid },
message,
messageTimestamp,
} = webMessageInfo;
if (!fromMe && message && remoteJid !== "status@broadcast") {
console.log(webMessageInfo);
const isValidMessage =
message && remoteJid !== "status@broadcast" && !fromMe;
if (isValidMessage) {
const { audioMessage, documentMessage, imageMessage, videoMessage } =
message;
const isMediaMessage =
@ -164,31 +180,32 @@ export default class WhatsappService extends Service {
const messageContent = Object.values(message)[0];
let messageType: MediaType;
let attachment: string;
let filename: string;
let mimetype: string;
let attachment: string | null | undefined;
let filename: string | null | undefined;
let mimeType: string | null | undefined;
if (isMediaMessage) {
if (audioMessage) {
messageType = "audio";
filename = id + "." + audioMessage.mimetype.split("/").pop();
mimetype = audioMessage.mimetype;
filename = id + "." + audioMessage.mimetype?.split("/").pop();
mimeType = audioMessage.mimetype;
} else if (documentMessage) {
messageType = "document";
filename = documentMessage.fileName;
mimetype = documentMessage.mimetype;
mimeType = documentMessage.mimetype;
} else if (imageMessage) {
messageType = "image";
filename = id + "." + imageMessage.mimetype.split("/").pop();
mimetype = imageMessage.mimetype;
filename = id + "." + imageMessage.mimetype?.split("/").pop();
mimeType = imageMessage.mimetype;
} else if (videoMessage) {
messageType = "video";
filename = id + "." + videoMessage.mimetype.split("/").pop();
mimetype = videoMessage.mimetype;
filename = id + "." + videoMessage.mimetype?.split("/").pop();
mimeType = videoMessage.mimetype;
}
const stream = await downloadContentFromMessage(
messageContent,
messageType
// @ts-ignore
messageType,
);
let buffer = Buffer.from([]);
for await (const chunk of stream) {
@ -198,100 +215,97 @@ export default class WhatsappService extends Service {
}
if (messageContent || attachment) {
const receivedMessage = {
waMessageId: id,
waMessage: JSON.stringify(webMessageInfo),
waTimestamp: new Date((messageTimestamp as number) * 1000),
const conversation = message?.conversation;
const extendedTextMessage = message?.extendedTextMessage?.text;
const imageMessage = message?.imageMessage?.caption;
const videoMessage = message?.videoMessage?.caption;
const messageText = [
conversation,
extendedTextMessage,
imageMessage,
videoMessage,
].find((text) => text && text !== "");
const payload = {
to: botID,
from: remoteJid?.split("@")[0],
messageId: id,
sentAt: new Date((messageTimestamp as number) * 1000).toISOString(),
message: messageText,
attachment,
filename,
mimetype,
whatsappBotId: bot.id,
botPhoneNumber: bot.phoneNumber,
mimeType,
};
workerUtils.addJob("whatsapp-message", receivedMessage, {
jobKey: id,
});
await fetch(
`${process.env.BRIDGE_FRONTEND_URL}/api/whatsapp/bots/${botID}/receive`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
},
);
}
}
}
private async queueUnreadMessages(
bot: Bot,
messages: proto.IWebMessageInfo[]
botID: string,
messages: proto.IWebMessageInfo[],
) {
for await (const message of messages) {
await this.queueMessage(bot, message);
await this.queueMessage(botID, 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;
getBot(botID: string): Record<string, any> {
const botDirectory = this.getBotDirectory(botID);
const qrPath = `${botDirectory}/qr.txt`;
const verifiedFile = `${botDirectory}/verified`;
const qr = fs.existsSync(qrPath) ? fs.readFileSync(qrPath, "utf8") : null;
const verified = fs.existsSync(verifiedFile);
return { qr, verified };
}
async unverify(bot: Bot): Promise<Bot> {
const directory = this.getAuthDirectory(bot);
fs.rmSync(directory, { recursive: true, force: true });
return this.server.db().whatsappBots.updateVerified(bot, false);
async unverify(botID: string): Promise<void> {
const botDirectory = this.getBotDirectory(botID);
fs.rmSync(botDirectory, { recursive: true, force: true });
}
async remove(bot: Bot): Promise<number> {
const directory = this.getAuthDirectory(bot);
fs.rmSync(directory, { recursive: true, force: true });
return this.server.db().whatsappBots.remove(bot);
}
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> {
async register(
botID: string,
callback?: AuthCompleteCallback,
): Promise<void> {
const { version } = await fetchLatestBaileysVersion();
await this.createConnection(bot, this.server, { version }, callback);
await this.createConnection(
botID,
this.server,
{ version, browser: WhatsappService.browserDescription },
callback,
);
callback?.();
}
async send(bot: Bot, phoneNumber: string, message: string): Promise<void> {
const connection = this.connections[bot.id]?.socket;
async send(
botID: string,
phoneNumber: string,
message: string,
): Promise<void> {
const connection = this.connections[botID]?.socket;
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]?.socket;
const messages = await connection.messagesReceivedAfter(
lastReceivedDate,
false
);
for (const message of messages) {
this.queueMessage(bot, message);
}
}
async receive(
bot: Bot,
_lastReceivedDate: Date
botID: string,
_lastReceivedDate: Date,
): Promise<proto.IWebMessageInfo[]> {
const connection = this.connections[bot.id]?.socket;
const connection = this.connections[botID]?.socket;
const messages = await connection.loadAllUnreadMessages();
return messages;
}
}

View file

@ -0,0 +1,8 @@
import type WhatsappService from "./service.js";
declare module "@hapipal/schmervice" {
interface SchmerviceDecorator {
(namespace: "whatsapp"): WhatsappService;
}
type ServiceFunctionalInterface = { name: string };
}

View file

@ -0,0 +1,17 @@
{
"extends": "@link-stack/typescript-config/tsconfig.node.json",
"compilerOptions": {
"module": "commonjs",
"target": "es2018",
"esModuleInterop": true,
"moduleResolution": "node",
"outDir": "build/main",
"rootDir": "src",
"skipLibCheck": true,
"types": ["jest", "node", "long"],
"lib": ["es2020", "DOM"],
"composite": true
},
"include": ["src/**/*.ts", "src/**/.*.ts"],
"exclude": ["node_modules/**"]
}

View file

@ -8,6 +8,5 @@
**/.env*
**/coverage
**/.next
**/amigo.*.json
**/cypress/videos
**/cypress/screenshots

View file

@ -0,0 +1,36 @@
FROM node:20-bookworm AS base
FROM base AS builder
ARG APP_DIR=/opt/bridge-worker
RUN mkdir -p ${APP_DIR}/
RUN npm i -g turbo
WORKDIR ${APP_DIR}
COPY . .
RUN turbo prune --scope=@link-stack/bridge-worker --docker
FROM base AS installer
ARG APP_DIR=/opt/bridge-worker
WORKDIR ${APP_DIR}
COPY --from=builder ${APP_DIR}/out/json/ .
COPY --from=builder ${APP_DIR}/out/full/ .
COPY --from=builder ${APP_DIR}/out/package-lock.json ./package-lock.json
RUN npm ci
RUN npm i -g turbo
RUN turbo run build --filter=@link-stack/bridge-worker
FROM base as runner
ARG BUILD_DATE
ARG VERSION
ARG APP_DIR=/opt/bridge-worker
RUN mkdir -p ${APP_DIR}/
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
apt-get install -y --no-install-recommends \
dumb-init
WORKDIR ${APP_DIR}
COPY --from=installer ${APP_DIR} ./
RUN chown -R node:node ${APP_DIR}
WORKDIR ${APP_DIR}/apps/bridge-worker/
RUN chmod +x docker-entrypoint.sh
USER node
ENV NODE_ENV production
ENTRYPOINT ["/opt/bridge-worker/apps/bridge-worker/docker-entrypoint.sh"]

View file

@ -0,0 +1 @@
*/1 * * * * fetch-signal-messages ?max=1

View file

@ -0,0 +1,5 @@
#!/bin/bash
set -e
echo "starting bridge-worker"
exec dumb-init npm run start

View file

@ -0,0 +1,13 @@
import type {} from "graphile-config";
import type {} from "graphile-worker";
const preset: GraphileConfig.Preset = {
worker: {
connectionString: process.env.DATABASE_URL,
maxPoolSize: 10,
pollInterval: 2000,
fileExtensions: [".ts"],
},
};
export default preset;

View file

@ -0,0 +1,28 @@
import { run } from "graphile-worker";
import * as path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const startWorker = async () => {
console.log("Starting worker...");
console.log(process.env);
await run({
connectionString: process.env.DATABASE_URL,
concurrency: 10,
noHandleSignals: false,
pollInterval: 1000,
taskDirectory: `${__dirname}/tasks`,
crontabFile: `${__dirname}/crontab`,
});
};
const main = async () => {
await startWorker();
};
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -1,16 +1,18 @@
/* eslint-disable camelcase */
import { SavedVoiceProvider } from "@digiresilience/metamigo-db";
// import { SavedVoiceProvider } from "@digiresilience/bridge-db";
import Twilio from "twilio";
import { CallInstance } from "twilio/lib/rest/api/v2010/account/call";
import { Zammad, getOrCreateUser } from "./zammad";
import { Zammad, getOrCreateUser } from "./zammad.js";
type SavedVoiceProvider = any;
export const twilioClientFor = (
provider: SavedVoiceProvider
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`
`twilio provider ${provider.name} does not have credentials`,
);
return Twilio(apiKeySid, apiKeySecret, {
@ -20,7 +22,7 @@ export const twilioClientFor = (
export const createZammadTicket = async (
call: CallInstance,
mp3: Buffer
mp3: Buffer,
): Promise<void> => {
const title = `Call from ${call.fromFormatted} at ${call.startTime}`;
const body = `<ul>
@ -36,7 +38,7 @@ export const createZammadTicket = async (
{
token: "EviH_WL0p6YUlCoIER7noAZEAPsYA_fVU4FZCKdpq525Vmzzvl8d7dNuP_8d-Amb",
},
"https://demo.digiresilience.org"
"https://demo.digiresilience.org",
);
try {
const customer = await getOrCreateUser(zammad, call.fromFormatted);

View file

@ -1,12 +1,17 @@
/*
import pgPromise from "pg-promise";
import * as pgMonitor from "pg-monitor";
import { dbInitOptions, IRepositories, AppDatabase } from "@digiresilience/metamigo-db";
import config from "@digiresilience/metamigo-config";
import {
dbInitOptions,
IRepositories,
AppDatabase,
} from "@digiresilience/bridge-db";
import config from "@digiresilience/bridge-config";
import type { IInitOptions } from "pg-promise";
export const initDiagnostics = (
logSql: boolean,
initOpts: IInitOptions<IRepositories>
initOpts: IInitOptions<IRepositories>,
): void => {
if (logSql) {
pgMonitor.attach(initOpts);
@ -33,15 +38,19 @@ const initDb = (): AppDatabase => {
export const stopDb = async (db: AppDatabase): Promise<void> => {
return db.$pool.end();
};
*/
export type AppDatabase = any;
export const withDb = <T>(f: (db: AppDatabase) => Promise<T>): Promise<T> => {
const db = initDb();
/*
const db = initDb();
initDiagnostics(config.logging.sql, pgpInitOptions);
try {
return f(db);
} finally {
stopDiagnostics();
}
*/
return f(null);
};
export type { AppDatabase } from "@digiresilience/metamigo-db";

View file

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

View file

@ -1,7 +1,8 @@
import * as Worker from "graphile-worker";
import { defState } from "@digiresilience/montar";
import config from "@digiresilience/metamigo-config";
// import { defState } from "@digiresilience/montar";
//import config from "@digiresilience/bridge-config";
/*
const startWorkerUtils = async (): Promise<Worker.WorkerUtils> => {
const workerUtils = await Worker.makeWorkerUtils({
connectionString: config.worker.connection,
@ -18,4 +19,8 @@ const workerUtils = defState("workerUtils", {
stop: stopWorkerUtils,
});
*/
export const workerUtils: any = {};
export default workerUtils;

View file

@ -0,0 +1,39 @@
{
"name": "@link-stack/bridge-worker",
"version": "2.1.0",
"type": "module",
"main": "build/main/index.js",
"author": "Darren Clarke <darren@redaranj.com>",
"license": "AGPL-3.0-or-later",
"scripts": {
"build": "tsc -p tsconfig.json && cp crontab build/main/crontab",
"dev": "dotenv -- graphile-worker",
"start": "node build/main/index.js"
},
"dependencies": {
"@hapi/wreck": "^18.1.0",
"@link-stack/bridge-common": "*",
"@link-stack/signal-api": "*",
"fluent-ffmpeg": "^2.1.3",
"graphile-worker": "^0.16.6",
"html-to-text": "^9.0.5",
"jest": "^29.7.0",
"kysely": "^0.27.3",
"pg": "^8.12.0",
"remeda": "^2.10.0",
"twilio": "^5.2.2"
},
"devDependencies": {
"@babel/core": "7.25.2",
"@babel/preset-env": "7.25.3",
"@babel/preset-typescript": "7.24.7",
"@types/fluent-ffmpeg": "^2.1.25",
"dotenv-cli": "^7.4.2",
"@link-stack/eslint-config": "*",
"prettier": "^3.3.3",
"@link-stack/typescript-config": "*",
"ts-node": "^10.9.2",
"typedoc": "^0.26.5",
"typescript": "^5.5.4"
}
}

View file

@ -0,0 +1,32 @@
import { db } from "@link-stack/bridge-common";
export interface NotifyWebhooksOptions {
backendId: string;
payload: any;
}
const notifyWebhooksTask = async (
options: NotifyWebhooksOptions,
): Promise<void> => {
const { backendId, payload } = options;
const webhooks = await db
.selectFrom("Webhook")
.selectAll()
.where("backendId", "=", backendId)
.execute();
for (const webhook of webhooks) {
const { endpointUrl, httpMethod, headers } = webhook;
const finalHeaders = { "Content-Type": "application/json", ...headers };
console.log({ endpointUrl, httpMethod, headers, finalHeaders });
const result = await fetch(endpointUrl, {
method: httpMethod,
headers: finalHeaders,
body: JSON.stringify(payload),
});
console.log(result);
}
};
export default notifyWebhooksTask;

View file

@ -0,0 +1,34 @@
import { db, getWorkerUtils } from "@link-stack/bridge-common";
interface ReceiveFacebookMessageTaskOptions {
message: any;
}
const receiveFacebookMessageTask = async ({
message,
}: ReceiveFacebookMessageTaskOptions): Promise<void> => {
const worker = await getWorkerUtils();
for (const entry of message.entry) {
for (const messaging of entry.messaging) {
const pageId = messaging.recipient.id;
const row = await db
.selectFrom("FacebookBot")
.selectAll()
.where("pageId", "=", pageId)
.executeTakeFirstOrThrow();
const backendId = row.id;
const payload = {
to: pageId,
from: messaging.sender.id,
sent_at: new Date(messaging.timestamp).toISOString(),
message: messaging.message.text,
message_id: messaging.message.mid,
};
await worker.addJob("common/notify-webhooks", { backendId, payload });
}
}
};
export default receiveFacebookMessageTask;

View file

@ -0,0 +1,41 @@
import { db } from "@link-stack/bridge-common";
interface SendFacebookMessageTaskOptions {
token: string;
to: string;
message: string;
}
const sendFacebookMessageTask = async (
options: SendFacebookMessageTaskOptions,
): Promise<void> => {
const { token, to, message } = options;
const { pageId, pageAccessToken } = await db
.selectFrom("FacebookBot")
.selectAll()
.where("token", "=", token)
.executeTakeFirstOrThrow();
const endpoint = `https://graph.facebook.com/v19.0/${pageId}/messages`;
const outgoingMessage = {
recipient: { id: to },
message: { text: message },
messaging_type: "RESPONSE",
access_token: pageAccessToken,
};
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(outgoingMessage),
});
console.log({ response });
} catch (error) {
console.error({ error });
throw error;
}
};
export default sendFacebookMessageTask;

View file

@ -0,0 +1,44 @@
import { db, getWorkerUtils } from "@link-stack/bridge-common";
import * as signalApi from "@link-stack/signal-api";
const { Configuration, MessagesApi } = signalApi;
const fetchSignalMessagesTask = async (): Promise<void> => {
const worker = await getWorkerUtils();
const rows = await db.selectFrom("SignalBot").selectAll().execute();
const config = new Configuration({
basePath: process.env.BRIDGE_SIGNAL_URL,
});
const messagesClient = new MessagesApi(config);
for (const row of rows) {
const { id, phoneNumber: number } = row;
const messages = await messagesClient.v1ReceiveNumberGet({ number });
for (const msg of messages) {
const { envelope } = msg as any;
const { source, sourceUuid, dataMessage } = envelope;
const message = dataMessage?.message;
const rawTimestamp = dataMessage?.timestamp;
const timestamp = new Date(rawTimestamp);
const messageId = `${sourceUuid}-${rawTimestamp}`;
const attachment = undefined;
const mimeType = undefined;
const filename = undefined;
if (source !== number && message) {
await worker.addJob("signal/receive-signal-message", {
token: id,
to: number,
from: source,
messageId,
message,
sentAt: timestamp.toISOString(),
attachment,
filename,
mimeType,
});
}
}
}
};
export default fetchSignalMessagesTask;

View file

@ -1,31 +1,39 @@
/* eslint-disable camelcase */
/*
import { convert } from "html-to-text";
import fetch from "node-fetch";
import { URLSearchParams } from "url";
import { withDb, AppDatabase } from "../db";
import { loadConfig } from "@digiresilience/metamigo-config";
import { tagMap } from "../lib/tag-map";
import { withDb, AppDatabase } from "../../lib/db.js";
// import { loadConfig } from "@digiresilience/bridge-config";
import { tagMap } from "../../lib/tag-map.js";
const config: any = {};
type FormattedZammadTicket = {
data: Record<string, unknown>,
data: Record<string, unknown>;
predictions: Record<string, unknown>[];
};
const getZammadTickets = async (page: number, minUpdatedTimestamp: Date): Promise<[boolean, FormattedZammadTicket[]]> => {
const { leafcutter: { zammadApiUrl, zammadApiKey, contributorName, contributorId } } = await loadConfig();
const getZammadTickets = async (
page: number,
minUpdatedTimestamp: Date,
): Promise<[boolean, FormattedZammadTicket[]]> => {
const {
leafcutter: { zammadApiUrl, zammadApiKey, contributorName, contributorId },
} = config;
const headers = { Authorization: `Token ${zammadApiKey}` };
let shouldContinue = false;
const docs = [];
const ticketsQuery = new URLSearchParams({
"expand": "true",
"sort_by": "updated_at",
"order_by": "asc",
"query": "state.name: closed",
"per_page": "25",
"page": `${page}`,
expand: "true",
sort_by: "updated_at",
order_by: "asc",
query: "state.name: closed",
per_page: "25",
page: `${page}`,
});
const rawTickets = await fetch(`${zammadApiUrl}/tickets/search?${ticketsQuery}`,
{ headers }
const rawTickets = await fetch(
`${zammadApiUrl}/tickets/search?${ticketsQuery}`,
{ headers },
);
const tickets: any = await rawTickets.json();
console.log({ tickets });
@ -41,14 +49,25 @@ const getZammadTickets = async (page: number, minUpdatedTimestamp: Date): Promis
shouldContinue = true;
if (source_closed_at <= minUpdatedTimestamp) {
console.log(`Skipping ticket`, { source_id, source_updated_at, source_closed_at, minUpdatedTimestamp });
console.log(`Skipping ticket`, {
source_id,
source_updated_at,
source_closed_at,
minUpdatedTimestamp,
});
continue;
}
console.log(`Processing ticket`, { source_id, source_updated_at, source_closed_at, minUpdatedTimestamp });
console.log(`Processing ticket`, {
source_id,
source_updated_at,
source_closed_at,
minUpdatedTimestamp,
});
const rawArticles = await fetch(`${zammadApiUrl}/ticket_articles/by_ticket/${source_id}`,
{ headers }
const rawArticles = await fetch(
`${zammadApiUrl}/ticket_articles/by_ticket/${source_id}`,
{ headers },
);
const articles: any = await rawArticles.json();
let articleText = "";
@ -69,7 +88,9 @@ const getZammadTickets = async (page: number, minUpdatedTimestamp: Date): Promis
o_id: source_id,
});
const rawTags = await fetch(`${zammadApiUrl}/tags?${tagsQuery}`, { headers });
const rawTags = await fetch(`${zammadApiUrl}/tags?${tagsQuery}`, {
headers,
});
const { tags }: any = await rawTags.json();
const transformedTags = [];
for (const tag of tags) {
@ -88,7 +109,7 @@ const getZammadTickets = async (page: number, minUpdatedTimestamp: Date): Promis
source_created_at,
source_updated_at,
},
predictions: []
predictions: [],
};
const result = transformedTags.map((tag) => {
@ -115,12 +136,17 @@ const getZammadTickets = async (page: number, minUpdatedTimestamp: Date): Promis
return [shouldContinue, docs];
};
const fetchFromZammad = async (minUpdatedTimestamp: Date): Promise<FormattedZammadTicket[]> => {
const fetchFromZammad = async (
minUpdatedTimestamp: Date,
): Promise<FormattedZammadTicket[]> => {
const pages = [...Array.from({ length: 10000 }).keys()];
const allTickets: FormattedZammadTicket[] = [];
for await (const page of pages) {
const [shouldContinue, tickets] = await getZammadTickets(page + 1, minUpdatedTimestamp);
const [shouldContinue, tickets] = await getZammadTickets(
page + 1,
minUpdatedTimestamp,
);
if (!shouldContinue) {
break;
@ -135,7 +161,9 @@ const fetchFromZammad = async (minUpdatedTimestamp: Date): Promise<FormattedZamm
};
const sendToLabelStudio = async (tickets: FormattedZammadTicket[]) => {
const { leafcutter: { labelStudioApiUrl, labelStudioApiKey } } = await loadConfig();
const {
leafcutter: { labelStudioApiUrl, labelStudioApiKey },
} = config;
const headers = {
Authorization: `Token ${labelStudioApiKey}`,
@ -154,13 +182,19 @@ const sendToLabelStudio = async (tickets: FormattedZammadTicket[]) => {
console.log(JSON.stringify(importResult, undefined, 2));
}
};
*/
const importLabelStudioTask = async (): Promise<void> => {
/*
withDb(async (db: AppDatabase) => {
const { leafcutter: { contributorName } } = await loadConfig();
const {
leafcutter: { contributorName },
} = config;
const settingName = `${contributorName}ImportLabelStudioTask`;
const res: any = await db.settings.findByName(settingName);
const startTimestamp = res?.value?.minUpdatedTimestamp ? new Date(res.value.minUpdatedTimestamp as string) : new Date("2023-03-01");
const startTimestamp = res?.value?.minUpdatedTimestamp
? new Date(res.value.minUpdatedTimestamp as string)
: new Date("2023-03-01");
const tickets = await fetchFromZammad(startTimestamp);
if (tickets.length > 0) {
@ -168,9 +202,12 @@ const importLabelStudioTask = async (): Promise<void> => {
const lastTicket = tickets.pop();
const newLastTimestamp = lastTicket.data.source_closed_at;
console.log({ newLastTimestamp });
await db.settings.upsert(settingName, { minUpdatedTimestamp: newLastTimestamp });
await db.settings.upsert(settingName, {
minUpdatedTimestamp: newLastTimestamp,
});
}
});
*/
};
export default importLabelStudioTask;

View file

@ -1,8 +1,10 @@
/* eslint-disable camelcase */
import fetch from "node-fetch";
/*
import { URLSearchParams } from "url";
import { withDb, AppDatabase } from "../db";
import { loadConfig } from "@digiresilience/metamigo-config";
import { withDb, AppDatabase } from "../../lib/db.js";
// import { loadConfig } from "@digiresilience/bridge-config";
const config: any = {};
type LabelStudioTicket = {
id: string;
@ -27,14 +29,12 @@ type LeafcutterTicket = {
source_updated_at: string;
};
const getLabelStudioTickets = async (page: number): Promise<LabelStudioTicket[]> => {
const getLabelStudioTickets = async (
page: number,
): Promise<LabelStudioTicket[]> => {
const {
leafcutter: {
labelStudioApiUrl,
labelStudioApiKey,
}
} = await loadConfig();
leafcutter: { labelStudioApiUrl, labelStudioApiKey },
} = config;
const headers = {
Authorization: `Token ${labelStudioApiKey}`,
Accept: "application/json",
@ -44,8 +44,10 @@ const getLabelStudioTickets = async (page: number): Promise<LabelStudioTicket[]>
page: `${page}`,
});
console.log({ url: `${labelStudioApiUrl}/projects/1/tasks?${ticketsQuery}` });
const res = await fetch(`${labelStudioApiUrl}/projects/1/tasks?${ticketsQuery}`,
{ headers });
const res = await fetch(
`${labelStudioApiUrl}/projects/1/tasks?${ticketsQuery}`,
{ headers },
);
console.log({ res });
const tasksResult: any = await res.json();
console.log({ tasksResult });
@ -53,7 +55,9 @@ const getLabelStudioTickets = async (page: number): Promise<LabelStudioTicket[]>
return tasksResult;
};
const fetchFromLabelStudio = async (minUpdatedTimestamp: Date): Promise<LabelStudioTicket[]> => {
const fetchFromLabelStudio = async (
minUpdatedTimestamp: Date,
): Promise<LabelStudioTicket[]> => {
const pages = [...Array.from({ length: 10000 }).keys()];
const allDocs: LabelStudioTicket[] = [];
@ -85,9 +89,9 @@ const sendToLeafcutter = async (tickets: LabelStudioTicket[]) => {
contributorId,
opensearchApiUrl,
opensearchUsername,
opensearchPassword
}
} = await loadConfig();
opensearchPassword,
},
} = config;
console.log({ tickets });
const filteredTickets = tickets.filter((ticket) => ticket.is_labeled);
@ -96,11 +100,7 @@ const sendToLeafcutter = async (tickets: LabelStudioTicket[]) => {
const {
id,
annotations,
data: {
source_id,
source_created_at,
source_updated_at
}
data: { source_id, source_created_at, source_updated_at },
} = ticket;
const getTags = (tags: Record<string, any>[], name: string) =>
@ -127,7 +127,7 @@ const sendToLeafcutter = async (tickets: LabelStudioTicket[]) => {
origin: contributorId,
origin_id: source_id as string,
source_created_at: source_created_at as string,
source_updated_at: source_updated_at as string
source_updated_at: source_updated_at as string,
};
});
@ -144,21 +144,34 @@ const sendToLeafcutter = async (tickets: LabelStudioTicket[]) => {
});
console.log({ result });
};
*/
const importLeafcutterTask = async (): Promise<void> => {
/*
withDb(async (db: AppDatabase) => {
const { leafcutter: { contributorName } } = await loadConfig();
const {
leafcutter: { contributorName },
} = config;
const settingName = `${contributorName}ImportLeafcutterTask`;
const res: any = await db.settings.findByName(settingName);
const startTimestamp = res?.value?.minUpdatedTimestamp ? new Date(res.value.minUpdatedTimestamp as string) : new Date("2023-03-01");
const startTimestamp = res?.value?.minUpdatedTimestamp
? new Date(res.value.minUpdatedTimestamp as string)
: new Date("2023-03-01");
const newLastTimestamp = new Date();
console.log({ contributorName, settingName, res, startTimestamp, newLastTimestamp });
console.log({
contributorName,
settingName,
res,
startTimestamp,
newLastTimestamp,
});
const tickets = await fetchFromLabelStudio(startTimestamp);
console.log({ tickets });
await sendToLeafcutter(tickets);
await db.settings.upsert(settingName, { minUpdatedTimestamp: newLastTimestamp });
await db.settings.upsert(settingName, {
minUpdatedTimestamp: newLastTimestamp,
});
});
*/
};
export default importLeafcutterTask;

View file

@ -0,0 +1,49 @@
import { db, getWorkerUtils } from "@link-stack/bridge-common";
interface ReceiveSignalMessageTaskOptions {
token: string;
to: string;
from: string;
messageId: string;
sentAt: string;
message: string;
attachment?: string;
filename?: string;
mimeType?: string;
}
const receiveSignalMessageTask = async ({
token,
to,
from,
messageId,
sentAt,
message,
attachment,
filename,
mimeType,
}: ReceiveSignalMessageTaskOptions): Promise<void> => {
console.log({ token, to, from });
const worker = await getWorkerUtils();
const row = await db
.selectFrom("SignalBot")
.selectAll()
.where("id", "=", token)
.executeTakeFirstOrThrow();
const backendId = row.id;
const payload = {
to,
from,
message_id: messageId,
sent_at: sentAt,
message,
attachment,
filename,
mime_type: mimeType,
};
await worker.addJob("common/notify-webhooks", { backendId, payload });
};
export default receiveSignalMessageTask;

View file

@ -0,0 +1,44 @@
import { db } from "@link-stack/bridge-common";
import * as signalApi from "@link-stack/signal-api";
const { Configuration, MessagesApi } = signalApi;
interface SendSignalMessageTaskOptions {
token: string;
to: string;
message: any;
}
const sendSignalMessageTask = async ({
token,
to,
message,
}: SendSignalMessageTaskOptions): Promise<void> => {
console.log({ token, to });
const bot = await db
.selectFrom("SignalBot")
.selectAll()
.where("token", "=", token)
.executeTakeFirstOrThrow();
const { phoneNumber: number } = bot;
const config = new Configuration({
basePath: process.env.BRIDGE_SIGNAL_URL,
});
const messagesClient = new MessagesApi(config);
try {
const response = await messagesClient.v2SendPost({
data: {
number,
recipients: [to],
message,
},
});
console.log({ response });
} catch (error) {
console.error({ error });
throw error;
}
};
export default sendSignalMessageTask;

View file

@ -0,0 +1,11 @@
// import { db, getWorkerUtils } from "@link-stack/bridge-common";
interface ReceiveVoiceMessageTaskOptions {
message: any;
}
const receiveVoiceMessageTask = async ({
message,
}: ReceiveVoiceMessageTaskOptions): Promise<void> => {};
export default receiveVoiceMessageTask;

View file

@ -0,0 +1,11 @@
// import { db, getWorkerUtils } from "@link-stack/bridge-common";
interface SendVoiceMessageTaskOptions {
message: any;
}
const sendVoiceMessageTask = async ({
message,
}: SendVoiceMessageTaskOptions): Promise<void> => {};
export default sendVoiceMessageTask;

View file

@ -1,8 +1,8 @@
import Wreck from "@hapi/wreck";
import { withDb, AppDatabase } from "../db";
import { twilioClientFor } from "../common";
import { withDb, AppDatabase } from "../../lib/db.js";
import { twilioClientFor } from "../../lib/common.js";
import { CallInstance } from "twilio/lib/rest/api/v2010/account/call";
import workerUtils from "../utils";
import workerUtils from "../../lib/utils.js";
interface WebhookPayload {
startTime: string;
@ -27,7 +27,7 @@ const getTwilioRecording = async (url: string) => {
const formatPayload = (
call: CallInstance,
recording: Buffer
recording: Buffer,
): WebhookPayload => {
return {
startTime: call.startTime.toISOString(),
@ -45,12 +45,12 @@ const notifyWebhooks = async (
db: AppDatabase,
voiceLineId: string,
call: CallInstance,
recording: Buffer
recording: Buffer,
) => {
const webhooks = await db.webhooks.findAllByBackendId("voice", voiceLineId);
if (webhooks && webhooks.length === 0) return;
webhooks.forEach(({ id }) => {
webhooks.forEach(({ id }: any) => {
const payload = formatPayload(call, recording);
workerUtils.addJob(
"notify-webhook",
@ -61,7 +61,7 @@ const notifyWebhooks = async (
{
// this de-depuplicates the job
jobKey: `webhook-${id}-call-${call.sid}`,
}
},
);
});
};
@ -74,7 +74,7 @@ interface TwilioRecordingTaskOptions {
}
const twilioRecordingTask = async (
options: TwilioRecordingTaskOptions
options: TwilioRecordingTaskOptions,
): Promise<void> =>
withDb(async (db: AppDatabase) => {
const { voiceLineId, accountSid, callSid, recordingSid } = options;

View file

@ -1,6 +1,6 @@
import { createHash } from "crypto";
import { withDb, AppDatabase } from "../db";
import { convert } from "../lib/media-convert";
import { withDb, AppDatabase } from "../../lib/db.js";
import { convert } from "../../lib/media-convert.js";
interface VoiceLineAudioUpdateTaskOptions {
voiceLineId: string;
@ -13,7 +13,7 @@ const sha1sum = (v: any) => {
};
const voiceLineAudioUpdateTask = async (
payload: VoiceLineAudioUpdateTaskOptions
payload: VoiceLineAudioUpdateTaskOptions,
): Promise<void> =>
withDb(async (db: AppDatabase) => {
const { voiceLineId } = payload;
@ -41,7 +41,7 @@ const voiceLineAudioUpdateTask = async (
"audio/mpeg": mp3.toString("base64"),
checksum: webmSha1,
},
}
},
);
});

View file

@ -1,6 +1,8 @@
import Twilio from "twilio";
import config from "@digiresilience/metamigo-config";
import { withDb, AppDatabase } from "../db";
// import config from "@digiresilience/bridge-config";
import { withDb, AppDatabase } from "../../lib/db.js";
const config: any = {};
interface VoiceLineDeleteTaskOptions {
voiceLineId: string;
@ -9,7 +11,7 @@ interface VoiceLineDeleteTaskOptions {
}
const voiceLineDeleteTask = async (
payload: VoiceLineDeleteTaskOptions
payload: VoiceLineDeleteTaskOptions,
): Promise<void> =>
withDb(async (db: AppDatabase) => {
const { voiceLineId, providerId, providerLineSid } = payload;
@ -19,7 +21,7 @@ const voiceLineDeleteTask = async (
const { accountSid, apiKeySid, apiKeySecret } = provider.credentials;
if (!accountSid || !apiKeySid || !apiKeySecret)
throw new Error(
`twilio provider ${provider.name} does not have credentials`
`twilio provider ${provider.name} does not have credentials`,
);
const client = Twilio(apiKeySid, apiKeySecret, {
@ -30,7 +32,7 @@ const voiceLineDeleteTask = async (
if (
number &&
number.voiceUrl ===
`${config.frontend.url}/api/v1/voice/twilio/record/${voiceLineId}`
`${config.frontend.url}/api/v1/voice/twilio/record/${voiceLineId}`
)
await client.incomingPhoneNumbers(providerLineSid).update({
voiceUrl: "",

View file

@ -1,13 +1,15 @@
import Twilio from "twilio";
import config from "@digiresilience/metamigo-config";
import { withDb, AppDatabase } from "../db";
// import config from "@digiresilience/bridge-config";
import { withDb, AppDatabase } from "../../lib/db.js";
const config: any = {};
interface VoiceLineUpdateTaskOptions {
voiceLineId: string;
}
const voiceLineUpdateTask = async (
payload: VoiceLineUpdateTaskOptions
payload: VoiceLineUpdateTaskOptions,
): Promise<void> =>
withDb(async (db: AppDatabase) => {
const { voiceLineId } = payload;
@ -22,7 +24,7 @@ const voiceLineUpdateTask = async (
const { accountSid, apiKeySid, apiKeySecret } = provider.credentials;
if (!accountSid || !apiKeySid || !apiKeySecret)
throw new Error(
`twilio provider ${provider.name} does not have credentials`
`twilio provider ${provider.name} does not have credentials`,
);
const client = Twilio(apiKeySid, apiKeySecret, {

View file

@ -0,0 +1,49 @@
import { db, getWorkerUtils } from "@link-stack/bridge-common";
interface ReceiveWhatsappMessageTaskOptions {
token: string;
to: string;
from: string;
messageId: string;
sentAt: string;
message: string;
attachment?: string;
filename?: string;
mimeType?: string;
}
const receiveWhatsappMessageTask = async ({
token,
to,
from,
messageId,
sentAt,
message,
attachment,
filename,
mimeType,
}: ReceiveWhatsappMessageTaskOptions): Promise<void> => {
console.log({ token, to, from });
const worker = await getWorkerUtils();
const row = await db
.selectFrom("WhatsappBot")
.selectAll()
.where("id", "=", token)
.executeTakeFirstOrThrow();
const backendId = row.id;
const payload = {
to,
from,
message_id: messageId,
sent_at: sentAt,
message,
attachment,
filename,
mime_type: mimeType,
};
await worker.addJob("common/notify-webhooks", { backendId, payload });
};
export default receiveWhatsappMessageTask;

View file

@ -0,0 +1,35 @@
import { db } from "@link-stack/bridge-common";
interface SendWhatsappMessageTaskOptions {
token: string;
to: string;
message: any;
}
const sendWhatsappMessageTask = async ({
message,
to,
token,
}: SendWhatsappMessageTaskOptions): Promise<void> => {
const bot = await db
.selectFrom("WhatsappBot")
.selectAll()
.where("token", "=", token)
.executeTakeFirstOrThrow();
const url = `${process.env.BRIDGE_WHATSAPP_URL}/api/bots/${bot.id}/send`;
const params = { message, phoneNumber: to };
try {
const result = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
console.log({ result });
} catch (error) {
console.error({ error });
throw new Error("Failed to send message");
}
};
export default sendWhatsappMessageTask;

View file

@ -0,0 +1,8 @@
{
"extends": "@link-stack/typescript-config/tsconfig.node.json",
"compilerOptions": {
"outDir": "build/main"
},
"include": ["**/*.ts", "**/.*.ts"],
"exclude": ["node_modules", "build"]
}

View file

@ -1,4 +1,3 @@
FROM node:20 AS base
FROM base AS builder
@ -7,12 +6,12 @@ RUN mkdir -p ${APP_DIR}/
RUN npm i -g turbo
WORKDIR ${APP_DIR}
COPY . .
RUN turbo prune --scope=leafcutter --docker
RUN turbo prune --scope=@link-stack/leafcutter --docker
FROM base AS installer
ARG APP_DIR=/opt/leafcutter
WORKDIR ${APP_DIR}
COPY .gitignore .gitignore
COPY --from=builder ${APP_DIR}/.gitignore .gitignore
COPY --from=builder ${APP_DIR}/out/json/ .
COPY --from=builder ${APP_DIR}/out/package-lock.json ./package-lock.json
RUN npm ci
@ -20,7 +19,7 @@ RUN npm ci
COPY --from=builder ${APP_DIR}/out/full/ .
ARG LINK_EMBEDDED=true
RUN npm i -g turbo
RUN turbo run build --filter=leafcutter
RUN turbo run build --filter=@link-stack/leafcutter
FROM base AS runner
ARG APP_DIR=/opt/leafcutter

View file

@ -9,7 +9,7 @@ import { useTranslate } from "react-polyglot";
import { LanguageSelect } from "app/_components/LanguageSelect";
import LeafcutterLogoLarge from "images/leafcutter-logo-large.png";
import { signIn } from "next-auth/react";
import { useAppContext } from "app/_components/AppProvider";
import { useLeafcutterContext } from "@link-stack/leafcutter-ui";
type LoginProps = {
session: any;
@ -20,7 +20,7 @@ export const Login: FC<LoginProps> = ({ session }) => {
const {
colors: { leafcutterElectricBlue, lightGray },
typography: { h1, h4 },
} = useAppContext();
} = useLeafcutterContext();
const buttonStyles = {
backgroundColor: lightGray,
borderRadius: 500,

View file

@ -1,4 +1,4 @@
import { About } from "leafcutter-common";
import { About } from "@link-stack/leafcutter-ui";
export default function Page() {
return <About />;

View file

@ -1,5 +1,5 @@
import { getTemplates } from "app/_lib/opensearch";
import { Create } from "leafcutter-common";
import { Create } from "@link-stack/leafcutter-ui";
export default async function Page() {
const templates = await getTemplates(100);

View file

@ -1,4 +1,4 @@
import { FAQ } from "leafcutter-common";
import { FAQ } from "@link-stack/leafcutter-ui";
export default function Page() {
return <FAQ />;

View file

@ -1,12 +1,5 @@
import { ReactNode } from "react";
import "app/_styles/global.css";
import "@fontsource/poppins/400.css";
import "@fontsource/poppins/700.css";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/700.css";
import "@fontsource/playfair-display/900.css";
// import getConfig from "next/config";
// import { LicenseInfo } from "@mui/x-data-grid-pro";
import { InternalLayout } from "../_components/InternalLayout";
type LayoutProps = {

View file

@ -1,7 +1,7 @@
import { getServerSession } from "next-auth";
import { authOptions } from "app/_lib/auth";
import { getUserVisualizations } from "app/_lib/opensearch";
import { Home } from "leafcutter-common";
import { Home } from "@link-stack/leafcutter-ui";
export default async function Page() {
const session = await getServerSession(authOptions);

View file

@ -1,6 +1,6 @@
/* eslint-disable no-underscore-dangle */
// import { Client } from "@opensearch-project/opensearch";
import { Preview } from "leafcutter-common";
import { Preview } from "@link-stack/leafcutter-ui";
// import { createVisualization } from "lib/opensearch";
export default function Page() {

View file

@ -5,12 +5,12 @@ import { useLayoutEffect } from "react";
import { useRouter } from "next/navigation";
import { Grid, CircularProgress } from "@mui/material";
import Iframe from "react-iframe";
import { useAppContext } from "leafcutter-common/components/AppProvider";
import { useLeafcutterContext } from "@link-stack/leafcutter-ui/components/LeafcutterProvider";
export const Setup: FC = () => {
const {
colors: { leafcutterElectricBlue },
} = useAppContext();
} = useLeafcutterContext();
const router = useRouter();
useLayoutEffect(() => {
setTimeout(() => router.push("/"), 4000);

View file

@ -1,5 +1,5 @@
import { getTrends } from "app/_lib/opensearch";
import { Trends } from "leafcutter-common";
import { Trends } from "@link-stack/leafcutter-ui";
export default async function Page() {
const visualizations = await getTrends(25);

View file

@ -1,6 +1,6 @@
/* eslint-disable no-underscore-dangle */
import { Client } from "@opensearch-project/opensearch";
import { VisualizationDetail } from "leafcutter-common";
import { VisualizationDetail } from "@link-stack/leafcutter-ui";
const getVisualization = async (visualizationID: string) => {
const node = `https://${process.env.OPENSEARCH_USERNAME}:${process.env.OPENSEARCH_PASSWORD}@${process.env.OPENSEARCH_URL}`;

View file

@ -11,13 +11,13 @@ import {
bindTrigger,
bindMenu,
} from "material-ui-popup-state/hooks";
import { useAppContext } from "leafcutter-common/components/AppProvider";
import { useLeafcutterContext } from "@link-stack/leafcutter-ui/components/LeafcutterProvider";
export const AccountButton: FC = () => {
const t = useTranslate();
const {
colors: { leafcutterElectricBlue },
} = useAppContext();
} = useLeafcutterContext();
const popupState = usePopupState({ variant: "popover", popupId: "account" });
return (

Some files were not shown because too many files have changed in this diff Show more