diff --git a/.gitignore b/.gitignore
index cc597ab..eb15c89 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
\ No newline at end of file
+project.org
+**/.openapi-generator/
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index a8a2974..1907598 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -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}
diff --git a/.gitpod.dockerfile b/.gitpod.dockerfile
deleted file mode 100644
index e2b4a7b..0000000
--- a/.gitpod.dockerfile
+++ /dev/null
@@ -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
diff --git a/.gitpod.yml b/.gitpod.yml
deleted file mode 100644
index b7144e3..0000000
--- a/.gitpod.yml
+++ /dev/null
@@ -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
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
deleted file mode 100644
index 4183c4a..0000000
--- a/.vscode/settings.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "prettier.prettierPath": ""
-}
diff --git a/Makefile b/Makefile
deleted file mode 100644
index dfd3742..0000000
--- a/Makefile
+++ /dev/null
@@ -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
diff --git a/README.md b/README.md
index 5b2659e..964bee8 100644
--- a/README.md
+++ b/README.md
@@ -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?
\ No newline at end of file
+# TK
diff --git a/apps/metamigo-frontend/.eslintrc.json b/apps/bridge-frontend/.eslintrc.json
similarity index 100%
rename from apps/metamigo-frontend/.eslintrc.json
rename to apps/bridge-frontend/.eslintrc.json
diff --git a/apps/metamigo-frontend/.gitignore b/apps/bridge-frontend/.gitignore
similarity index 100%
rename from apps/metamigo-frontend/.gitignore
rename to apps/bridge-frontend/.gitignore
diff --git a/apps/metamigo-cli/Dockerfile b/apps/bridge-frontend/Dockerfile
similarity index 52%
rename from apps/metamigo-cli/Dockerfile
rename to apps/bridge-frontend/Dockerfile
index 211caf5..0d0d1fa 100644
--- a/apps/metamigo-cli/Dockerfile
+++ b/apps/bridge-frontend/Dockerfile
@@ -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"]
diff --git a/apps/metamigo-frontend/README.md b/apps/bridge-frontend/README.md
similarity index 100%
rename from apps/metamigo-frontend/README.md
rename to apps/bridge-frontend/README.md
diff --git a/apps/bridge-frontend/app/(login)/login/page.tsx b/apps/bridge-frontend/app/(login)/login/page.tsx
new file mode 100644
index 0000000..8f79c27
--- /dev/null
+++ b/apps/bridge-frontend/app/(login)/login/page.tsx
@@ -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 ;
+}
diff --git a/apps/bridge-frontend/app/(main)/[...segment]/@create/page.tsx b/apps/bridge-frontend/app/(main)/[...segment]/@create/page.tsx
new file mode 100644
index 0000000..3973926
--- /dev/null
+++ b/apps/bridge-frontend/app/(main)/[...segment]/@create/page.tsx
@@ -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 ;
+}
diff --git a/apps/bridge-frontend/app/(main)/[...segment]/@detail/page.tsx b/apps/bridge-frontend/app/(main)/[...segment]/@detail/page.tsx
new file mode 100644
index 0000000..0b1f49c
--- /dev/null
+++ b/apps/bridge-frontend/app/(main)/[...segment]/@detail/page.tsx
@@ -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 ;
+}
diff --git a/apps/bridge-frontend/app/(main)/[...segment]/@edit/page.tsx b/apps/bridge-frontend/app/(main)/[...segment]/@edit/page.tsx
new file mode 100644
index 0000000..59977eb
--- /dev/null
+++ b/apps/bridge-frontend/app/(main)/[...segment]/@edit/page.tsx
@@ -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 ;
+}
diff --git a/apps/bridge-frontend/app/(main)/[...segment]/layout.tsx b/apps/bridge-frontend/app/(main)/[...segment]/layout.tsx
new file mode 100644
index 0000000..c360a57
--- /dev/null
+++ b/apps/bridge-frontend/app/(main)/[...segment]/layout.tsx
@@ -0,0 +1,3 @@
+import { ServiceLayout } from "@link-stack/bridge-ui";
+
+export default ServiceLayout;
diff --git a/apps/bridge-frontend/app/(main)/[...segment]/page.tsx b/apps/bridge-frontend/app/(main)/[...segment]/page.tsx
new file mode 100644
index 0000000..e248a86
--- /dev/null
+++ b/apps/bridge-frontend/app/(main)/[...segment]/page.tsx
@@ -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
;
+}
diff --git a/apps/bridge-frontend/app/(main)/layout.tsx b/apps/bridge-frontend/app/(main)/layout.tsx
new file mode 100644
index 0000000..32203a2
--- /dev/null
+++ b/apps/bridge-frontend/app/(main)/layout.tsx
@@ -0,0 +1,9 @@
+import { InternalLayout } from "@/app/_components/InternalLayout";
+
+export default function Layout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return {children};
+}
diff --git a/apps/bridge-frontend/app/(main)/page.tsx b/apps/bridge-frontend/app/(main)/page.tsx
new file mode 100644
index 0000000..e01be47
--- /dev/null
+++ b/apps/bridge-frontend/app/(main)/page.tsx
@@ -0,0 +1,5 @@
+import { Home } from "@link-stack/bridge-ui";
+
+export default function Page() {
+ return ;
+}
diff --git a/apps/bridge-frontend/app/_components/InternalLayout.tsx b/apps/bridge-frontend/app/_components/InternalLayout.tsx
new file mode 100644
index 0000000..fcce6f2
--- /dev/null
+++ b/apps/bridge-frontend/app/_components/InternalLayout.tsx
@@ -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 = ({ children }) => {
+ const [open, setOpen] = useState(true);
+ const { roboto } = fonts;
+ const globalCSS = css`
+ * {
+ font-family: ${roboto.style.fontFamily};
+ }
+ `;
+
+ return (
+
+
+
+
+
+
+ {children as any}
+
+
+
+ );
+};
diff --git a/apps/bridge-frontend/app/_components/Login.tsx b/apps/bridge-frontend/app/_components/Login.tsx
new file mode 100644
index 0000000..8e40129
--- /dev/null
+++ b/apps/bridge-frontend/app/_components/Login.tsx
@@ -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 = ({ 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 (
+
+
+
+
+
+
+
+
+
+
+
+ CDR Bridge
+
+
+
+
+
+ {!session ? (
+
+
+ {error ? (
+
+
+
+ {`${error} error`}
+
+
+
+ ) : null}
+
+
+ signIn("google", {
+ callbackUrl: `${origin}`,
+ })
+ }
+ >
+
+ Sign in with Google
+
+
+
+
+ signIn("apple", {
+ callbackUrl: `${window.location.origin}`,
+ })
+ }
+ >
+
+ Sign in with Apple
+
+
+
+
+ ) : null}
+ {session ? (
+
+ {` ${session.user.name ?? session.user.email}.`}
+
+ ) : null}
+
+
+
+
+ );
+};
diff --git a/apps/bridge-frontend/app/_components/Sidebar.tsx b/apps/bridge-frontend/app/_components/Sidebar.tsx
new file mode 100644
index 0000000..31aaaaa
--- /dev/null
+++ b/apps/bridge-frontend/app/_components/Sidebar.tsx
@@ -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) => (
+
+
+ {iconSize > 0 ? (
+
+
+
+
+
+ ) : (
+
+
+
+
+ )}
+ {open && (
+
+ {name}
+
+ }
+ />
+ )}
+ {badge && badge > 0 ? (
+
+
+ {badge}
+
+
+ ) : null}
+
+
+);
+
+interface SidebarProps {
+ open: boolean;
+ setOpen: (open: boolean) => void;
+}
+
+export const Sidebar: FC = ({ 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 (
+
+ {
+ setOpen!(!open);
+ }}
+ >
+
+
+
+
+
+
+
+
+ .
+
+ {open && (
+
+
+ CDR Bridge
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {user?.image && (
+
+
+
+
+
+ )}
+
+
+
+ {user?.email}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/bridge-frontend/app/_images/link-logo-small.png b/apps/bridge-frontend/app/_images/link-logo-small.png
new file mode 100644
index 0000000..7de12ef
Binary files /dev/null and b/apps/bridge-frontend/app/_images/link-logo-small.png differ
diff --git a/apps/bridge-frontend/app/_lib/authentication.ts b/apps/bridge-frontend/app/_lib/authentication.ts
new file mode 100644
index 0000000..2ea1461
--- /dev/null
+++ b/apps/bridge-frontend/app/_lib/authentication.ts
@@ -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,
+ },
+};
diff --git a/apps/bridge-frontend/app/api/[service]/bots/[token]/receive/route.ts b/apps/bridge-frontend/app/api/[service]/bots/[token]/receive/route.ts
new file mode 100644
index 0000000..a9874ea
--- /dev/null
+++ b/apps/bridge-frontend/app/api/[service]/bots/[token]/receive/route.ts
@@ -0,0 +1 @@
+export { receiveMessage as POST } from "@link-stack/bridge-ui";
diff --git a/apps/bridge-frontend/app/api/[service]/bots/[token]/route.ts b/apps/bridge-frontend/app/api/[service]/bots/[token]/route.ts
new file mode 100644
index 0000000..b641c90
--- /dev/null
+++ b/apps/bridge-frontend/app/api/[service]/bots/[token]/route.ts
@@ -0,0 +1 @@
+export { getBot as GET } from "@link-stack/bridge-ui";
diff --git a/apps/bridge-frontend/app/api/[service]/bots/[token]/send/route.ts b/apps/bridge-frontend/app/api/[service]/bots/[token]/send/route.ts
new file mode 100644
index 0000000..ab8e383
--- /dev/null
+++ b/apps/bridge-frontend/app/api/[service]/bots/[token]/send/route.ts
@@ -0,0 +1 @@
+export { sendMessage as POST } from "@link-stack/bridge-ui";
diff --git a/apps/bridge-frontend/app/api/[service]/webhooks/route.ts b/apps/bridge-frontend/app/api/[service]/webhooks/route.ts
new file mode 100644
index 0000000..650b718
--- /dev/null
+++ b/apps/bridge-frontend/app/api/[service]/webhooks/route.ts
@@ -0,0 +1,3 @@
+import { handleWebhook } from "@link-stack/bridge-ui";
+
+export { handleWebhook as GET, handleWebhook as POST };
diff --git a/apps/bridge-frontend/app/api/auth/[...nextauth]/route.ts b/apps/bridge-frontend/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 0000000..ad23efa
--- /dev/null
+++ b/apps/bridge-frontend/app/api/auth/[...nextauth]/route.ts
@@ -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 };
diff --git a/apps/bridge-frontend/app/layout.tsx b/apps/bridge-frontend/app/layout.tsx
new file mode 100644
index 0000000..08546c3
--- /dev/null
+++ b/apps/bridge-frontend/app/layout.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
diff --git a/apps/bridge-frontend/docker-entrypoint.sh b/apps/bridge-frontend/docker-entrypoint.sh
new file mode 100644
index 0000000..92f4841
--- /dev/null
+++ b/apps/bridge-frontend/docker-entrypoint.sh
@@ -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
diff --git a/apps/bridge-frontend/middleware.ts b/apps/bridge-frontend/middleware.ts
new file mode 100644
index 0000000..a00c650
--- /dev/null
+++ b/apps/bridge-frontend/middleware.ts
@@ -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).*)"],
+};
diff --git a/apps/bridge-frontend/next.config.js b/apps/bridge-frontend/next.config.js
new file mode 100644
index 0000000..73f0796
--- /dev/null
+++ b/apps/bridge-frontend/next.config.js
@@ -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;
diff --git a/apps/bridge-frontend/package.json b/apps/bridge-frontend/package.json
new file mode 100644
index 0000000..5a56a05
--- /dev/null
+++ b/apps/bridge-frontend/package.json
@@ -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"
+ }
+}
diff --git a/apps/bridge-frontend/tsconfig.json b/apps/bridge-frontend/tsconfig.json
new file mode 100644
index 0000000..e700859
--- /dev/null
+++ b/apps/bridge-frontend/tsconfig.json
@@ -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"]
+}
diff --git a/apps/bridge-migrations/migrate.ts b/apps/bridge-migrations/migrate.ts
new file mode 100644
index 0000000..2e6281b
--- /dev/null
+++ b/apps/bridge-migrations/migrate.ts
@@ -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({
+ 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);
diff --git a/apps/bridge-migrations/migrations/0001-add-next-auth.ts b/apps/bridge-migrations/migrations/0001-add-next-auth.ts
new file mode 100644
index 0000000..b57cb7b
--- /dev/null
+++ b/apps/bridge-migrations/migrations/0001-add-next-auth.ts
@@ -0,0 +1,72 @@
+import { Kysely, sql } from "kysely";
+
+export async function up(db: Kysely): Promise {
+ 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): Promise {
+ 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();
+}
diff --git a/apps/bridge-migrations/migrations/0002-add-signal.ts b/apps/bridge-migrations/migrations/0002-add-signal.ts
new file mode 100644
index 0000000..4f7cbd1
--- /dev/null
+++ b/apps/bridge-migrations/migrations/0002-add-signal.ts
@@ -0,0 +1,33 @@
+import { Kysely, sql } from "kysely";
+
+export async function up(db: Kysely): Promise {
+ 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): Promise {
+ await db.schema.dropTable("SignalBot").ifExists().execute();
+}
diff --git a/apps/bridge-migrations/migrations/0003-add-whatsapp.ts b/apps/bridge-migrations/migrations/0003-add-whatsapp.ts
new file mode 100644
index 0000000..6c3a490
--- /dev/null
+++ b/apps/bridge-migrations/migrations/0003-add-whatsapp.ts
@@ -0,0 +1,33 @@
+import { Kysely, sql } from "kysely";
+
+export async function up(db: Kysely): Promise {
+ 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): Promise {
+ await db.schema.dropTable("WhatsappBot").ifExists().execute();
+}
diff --git a/apps/bridge-migrations/migrations/0004-add-voice.ts b/apps/bridge-migrations/migrations/0004-add-voice.ts
new file mode 100644
index 0000000..01f4b58
--- /dev/null
+++ b/apps/bridge-migrations/migrations/0004-add-voice.ts
@@ -0,0 +1,77 @@
+import { Kysely, sql } from "kysely";
+
+export async function up(db: Kysely): Promise {
+ 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): Promise {
+ await db.schema.dropTable("VoiceLine").ifExists().execute();
+ await db.schema.dropTable("VoiceProvider").ifExists().execute();
+}
diff --git a/apps/bridge-migrations/migrations/0005-add-facebook.ts b/apps/bridge-migrations/migrations/0005-add-facebook.ts
new file mode 100644
index 0000000..fb08b11
--- /dev/null
+++ b/apps/bridge-migrations/migrations/0005-add-facebook.ts
@@ -0,0 +1,36 @@
+import { Kysely, sql } from "kysely";
+
+export async function up(db: Kysely): Promise {
+ 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): Promise {
+ await db.schema.dropTable("FacebookBot").ifExists().execute();
+}
diff --git a/apps/bridge-migrations/migrations/0006-add-webhooks.ts b/apps/bridge-migrations/migrations/0006-add-webhooks.ts
new file mode 100644
index 0000000..257db6e
--- /dev/null
+++ b/apps/bridge-migrations/migrations/0006-add-webhooks.ts
@@ -0,0 +1,41 @@
+import { Kysely, sql } from "kysely";
+
+export async function up(db: Kysely): Promise {
+ 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): Promise {
+ await db.schema.dropTable("Webhook").ifExists().execute();
+}
diff --git a/apps/bridge-migrations/migrations/0007-add-settings.ts b/apps/bridge-migrations/migrations/0007-add-settings.ts
new file mode 100644
index 0000000..9d539c3
--- /dev/null
+++ b/apps/bridge-migrations/migrations/0007-add-settings.ts
@@ -0,0 +1,28 @@
+import { Kysely, sql } from "kysely";
+
+export async function up(db: Kysely): Promise {
+ 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): Promise {
+ await db.schema.dropTable("Setting").ifExists().execute();
+}
diff --git a/apps/bridge-migrations/migrations/0008-add-user-role.ts b/apps/bridge-migrations/migrations/0008-add-user-role.ts
new file mode 100644
index 0000000..84fb230
--- /dev/null
+++ b/apps/bridge-migrations/migrations/0008-add-user-role.ts
@@ -0,0 +1,9 @@
+import { Kysely } from "kysely";
+
+export async function up(db: Kysely): Promise {
+ await db.schema.alterTable("User").addColumn("role", "text").execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema.alterTable("User").dropColumn("role").execute();
+}
diff --git a/apps/bridge-migrations/package.json b/apps/bridge-migrations/package.json
new file mode 100644
index 0000000..528ad95
--- /dev/null
+++ b/apps/bridge-migrations/package.json
@@ -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"
+ }
+}
diff --git a/apps/bridge-whatsapp/Dockerfile b/apps/bridge-whatsapp/Dockerfile
new file mode 100644
index 0000000..c1a34c7
--- /dev/null
+++ b/apps/bridge-whatsapp/Dockerfile
@@ -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"]
diff --git a/apps/bridge-whatsapp/docker-entrypoint.sh b/apps/bridge-whatsapp/docker-entrypoint.sh
new file mode 100644
index 0000000..866302b
--- /dev/null
+++ b/apps/bridge-whatsapp/docker-entrypoint.sh
@@ -0,0 +1,5 @@
+#!/bin/bash
+
+set -e
+echo "starting bridge-whatsapp"
+exec dumb-init npm run start
diff --git a/apps/metamigo-api/jest.config.json b/apps/bridge-whatsapp/jest.config.json
similarity index 60%
rename from apps/metamigo-api/jest.config.json
rename to apps/bridge-whatsapp/jest.config.json
index bd6efbc..9a87c8d 100644
--- a/apps/metamigo-api/jest.config.json
+++ b/apps/bridge-whatsapp/jest.config.json
@@ -1,4 +1,4 @@
{
- "preset": "jest-config-link",
+ "preset": "jest-config",
"setupFiles": ["/src/setup.test.ts"]
-}
\ No newline at end of file
+}
diff --git a/apps/bridge-whatsapp/package.json b/apps/bridge-whatsapp/package.json
new file mode 100644
index 0000000..227abd4
--- /dev/null
+++ b/apps/bridge-whatsapp/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@link-stack/bridge-whatsapp",
+ "version": "2.1.0",
+ "main": "build/main/index.js",
+ "author": "Darren Clarke ",
+ "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"
+ }
+}
diff --git a/apps/bridge-whatsapp/src/index.ts b/apps/bridge-whatsapp/src/index.ts
new file mode 100644
index 0000000..21814f4
--- /dev/null
+++ b/apps/bridge-whatsapp/src/index.ts
@@ -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);
+});
diff --git a/apps/bridge-whatsapp/src/routes.ts b/apps/bridge-whatsapp/src/routes.ts
new file mode 100644
index 0000000..10c9757
--- /dev/null
+++ b/apps/bridge-whatsapp/src/routes.ts
@@ -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);
+ },
+ },
+});
diff --git a/apps/metamigo-api/src/app/services/whatsapp.ts b/apps/bridge-whatsapp/src/service.ts
similarity index 52%
rename from apps/metamigo-api/src/app/services/whatsapp.ts
rename to apps/bridge-whatsapp/src/service.ts
index 6f26ca7..4f6eafa 100644
--- a/apps/metamigo-api/src/app/services/whatsapp.ts
+++ b/apps/bridge-whatsapp/src/service.ts
@@ -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 {
@@ -45,7 +49,6 @@ export default class WhatsappService extends Service {
}
private async sleep(ms: number): Promise {
- 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 {
- 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 {
+ 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 {
- 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 {
+ const botDirectory = this.getBotDirectory(botID);
+ fs.rmSync(botDirectory, { recursive: true, force: true });
}
- async remove(bot: Bot): Promise {
- const directory = this.getAuthDirectory(bot);
- fs.rmSync(directory, { recursive: true, force: true });
- return this.server.db().whatsappBots.remove(bot);
- }
-
- async findAll(): Promise {
- return this.server.db().whatsappBots.findAll();
- }
-
- async findById(id: string): Promise {
- return this.server.db().whatsappBots.findById({ id });
- }
-
- async findByToken(token: string): Promise {
- return this.server.db().whatsappBots.findBy({ token });
- }
-
- async register(bot: Bot, callback: AuthCompleteCallback): Promise {
+ async register(
+ botID: string,
+ callback?: AuthCompleteCallback,
+ ): Promise {
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 {
- const connection = this.connections[bot.id]?.socket;
+ async send(
+ botID: string,
+ phoneNumber: string,
+ message: string,
+ ): Promise {
+ 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 {
- 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 {
- const connection = this.connections[bot.id]?.socket;
+ const connection = this.connections[botID]?.socket;
const messages = await connection.loadAllUnreadMessages();
+
return messages;
}
}
diff --git a/apps/bridge-whatsapp/src/types.ts b/apps/bridge-whatsapp/src/types.ts
new file mode 100644
index 0000000..231a804
--- /dev/null
+++ b/apps/bridge-whatsapp/src/types.ts
@@ -0,0 +1,8 @@
+import type WhatsappService from "./service.js";
+
+declare module "@hapipal/schmervice" {
+ interface SchmerviceDecorator {
+ (namespace: "whatsapp"): WhatsappService;
+ }
+ type ServiceFunctionalInterface = { name: string };
+}
diff --git a/apps/bridge-whatsapp/tsconfig.json b/apps/bridge-whatsapp/tsconfig.json
new file mode 100644
index 0000000..afdb4f6
--- /dev/null
+++ b/apps/bridge-whatsapp/tsconfig.json
@@ -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/**"]
+}
diff --git a/apps/metamigo-api/.dockerignore b/apps/bridge-worker/.dockerignore
similarity index 89%
rename from apps/metamigo-api/.dockerignore
rename to apps/bridge-worker/.dockerignore
index 9a1b830..8161443 100644
--- a/apps/metamigo-api/.dockerignore
+++ b/apps/bridge-worker/.dockerignore
@@ -8,6 +8,5 @@
**/.env*
**/coverage
**/.next
-**/amigo.*.json
**/cypress/videos
**/cypress/screenshots
diff --git a/apps/bridge-worker/Dockerfile b/apps/bridge-worker/Dockerfile
new file mode 100644
index 0000000..345ba66
--- /dev/null
+++ b/apps/bridge-worker/Dockerfile
@@ -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"]
diff --git a/apps/bridge-worker/crontab b/apps/bridge-worker/crontab
new file mode 100644
index 0000000..0db6c6b
--- /dev/null
+++ b/apps/bridge-worker/crontab
@@ -0,0 +1 @@
+*/1 * * * * fetch-signal-messages ?max=1
diff --git a/apps/bridge-worker/docker-entrypoint.sh b/apps/bridge-worker/docker-entrypoint.sh
new file mode 100644
index 0000000..5a72275
--- /dev/null
+++ b/apps/bridge-worker/docker-entrypoint.sh
@@ -0,0 +1,5 @@
+#!/bin/bash
+
+set -e
+echo "starting bridge-worker"
+exec dumb-init npm run start
diff --git a/apps/bridge-worker/graphile.config.ts b/apps/bridge-worker/graphile.config.ts
new file mode 100644
index 0000000..8b550c7
--- /dev/null
+++ b/apps/bridge-worker/graphile.config.ts
@@ -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;
diff --git a/apps/bridge-worker/index.ts b/apps/bridge-worker/index.ts
new file mode 100644
index 0000000..5a7dbd5
--- /dev/null
+++ b/apps/bridge-worker/index.ts
@@ -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);
+});
diff --git a/apps/metamigo-worker/common.ts b/apps/bridge-worker/lib/common.ts
similarity index 88%
rename from apps/metamigo-worker/common.ts
rename to apps/bridge-worker/lib/common.ts
index 3a9dd89..26e4aeb 100644
--- a/apps/metamigo-worker/common.ts
+++ b/apps/bridge-worker/lib/common.ts
@@ -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 => {
const title = `Call from ${call.fromFormatted} at ${call.startTime}`;
const body = `
@@ -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);
diff --git a/apps/metamigo-worker/db.ts b/apps/bridge-worker/lib/db.ts
similarity index 76%
rename from apps/metamigo-worker/db.ts
rename to apps/bridge-worker/lib/db.ts
index 16aeed3..6ec16c4 100644
--- a/apps/metamigo-worker/db.ts
+++ b/apps/bridge-worker/lib/db.ts
@@ -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
+ initOpts: IInitOptions,
): void => {
if (logSql) {
pgMonitor.attach(initOpts);
@@ -33,15 +38,19 @@ const initDb = (): AppDatabase => {
export const stopDb = async (db: AppDatabase): Promise => {
return db.$pool.end();
};
+ */
+
+export type AppDatabase = any;
export const withDb = (f: (db: AppDatabase) => Promise): Promise => {
- 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";
diff --git a/apps/metamigo-frontend/app/_lib/facebook.ts b/apps/bridge-worker/lib/facebook.ts
similarity index 100%
rename from apps/metamigo-frontend/app/_lib/facebook.ts
rename to apps/bridge-worker/lib/facebook.ts
diff --git a/apps/bridge-worker/lib/logger.ts b/apps/bridge-worker/lib/logger.ts
new file mode 100644
index 0000000..89d34aa
--- /dev/null
+++ b/apps/bridge-worker/lib/logger.ts
@@ -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;
diff --git a/apps/metamigo-worker/lib/media-convert.ts b/apps/bridge-worker/lib/media-convert.ts
similarity index 100%
rename from apps/metamigo-worker/lib/media-convert.ts
rename to apps/bridge-worker/lib/media-convert.ts
diff --git a/apps/metamigo-worker/lib/tag-map.ts b/apps/bridge-worker/lib/tag-map.ts
similarity index 100%
rename from apps/metamigo-worker/lib/tag-map.ts
rename to apps/bridge-worker/lib/tag-map.ts
diff --git a/apps/metamigo-worker/utils.ts b/apps/bridge-worker/lib/utils.ts
similarity index 75%
rename from apps/metamigo-worker/utils.ts
rename to apps/bridge-worker/lib/utils.ts
index b0dfb29..db3cce8 100644
--- a/apps/metamigo-worker/utils.ts
+++ b/apps/bridge-worker/lib/utils.ts
@@ -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 => {
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;
diff --git a/apps/metamigo-frontend/app/_lib/voice.ts b/apps/bridge-worker/lib/voice.ts
similarity index 100%
rename from apps/metamigo-frontend/app/_lib/voice.ts
rename to apps/bridge-worker/lib/voice.ts
diff --git a/apps/metamigo-frontend/app/_lib/whatsapp.ts b/apps/bridge-worker/lib/whatsapp.ts
similarity index 100%
rename from apps/metamigo-frontend/app/_lib/whatsapp.ts
rename to apps/bridge-worker/lib/whatsapp.ts
diff --git a/apps/metamigo-worker/zammad.ts b/apps/bridge-worker/lib/zammad.ts
similarity index 100%
rename from apps/metamigo-worker/zammad.ts
rename to apps/bridge-worker/lib/zammad.ts
diff --git a/apps/bridge-worker/package.json b/apps/bridge-worker/package.json
new file mode 100644
index 0000000..8b754d0
--- /dev/null
+++ b/apps/bridge-worker/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "@link-stack/bridge-worker",
+ "version": "2.1.0",
+ "type": "module",
+ "main": "build/main/index.js",
+ "author": "Darren Clarke ",
+ "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"
+ }
+}
diff --git a/apps/bridge-worker/tasks/common/notify-webhooks.ts b/apps/bridge-worker/tasks/common/notify-webhooks.ts
new file mode 100644
index 0000000..0ed2c17
--- /dev/null
+++ b/apps/bridge-worker/tasks/common/notify-webhooks.ts
@@ -0,0 +1,32 @@
+import { db } from "@link-stack/bridge-common";
+
+export interface NotifyWebhooksOptions {
+ backendId: string;
+ payload: any;
+}
+
+const notifyWebhooksTask = async (
+ options: NotifyWebhooksOptions,
+): Promise => {
+ 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;
diff --git a/apps/bridge-worker/tasks/facebook/receive-facebook-message.ts b/apps/bridge-worker/tasks/facebook/receive-facebook-message.ts
new file mode 100644
index 0000000..58e4c42
--- /dev/null
+++ b/apps/bridge-worker/tasks/facebook/receive-facebook-message.ts
@@ -0,0 +1,34 @@
+import { db, getWorkerUtils } from "@link-stack/bridge-common";
+
+interface ReceiveFacebookMessageTaskOptions {
+ message: any;
+}
+
+const receiveFacebookMessageTask = async ({
+ message,
+}: ReceiveFacebookMessageTaskOptions): Promise => {
+ 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;
diff --git a/apps/bridge-worker/tasks/facebook/send-facebook-message.ts b/apps/bridge-worker/tasks/facebook/send-facebook-message.ts
new file mode 100644
index 0000000..3f28fc8
--- /dev/null
+++ b/apps/bridge-worker/tasks/facebook/send-facebook-message.ts
@@ -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 => {
+ 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;
diff --git a/apps/bridge-worker/tasks/fetch-signal-messages.ts b/apps/bridge-worker/tasks/fetch-signal-messages.ts
new file mode 100644
index 0000000..4b1df6a
--- /dev/null
+++ b/apps/bridge-worker/tasks/fetch-signal-messages.ts
@@ -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 => {
+ 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;
diff --git a/apps/metamigo-worker/tasks/import-label-studio.ts b/apps/bridge-worker/tasks/leafcutter/import-label-studio.ts
similarity index 67%
rename from apps/metamigo-worker/tasks/import-label-studio.ts
rename to apps/bridge-worker/tasks/leafcutter/import-label-studio.ts
index a2909ff..cb1857e 100644
--- a/apps/metamigo-worker/tasks/import-label-studio.ts
+++ b/apps/bridge-worker/tasks/leafcutter/import-label-studio.ts
@@ -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,
+ data: Record;
predictions: Record[];
};
-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 => {
+const fetchFromZammad = async (
+ minUpdatedTimestamp: Date,
+): Promise => {
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 {
- 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 => {
+ /*
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 => {
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;
diff --git a/apps/metamigo-worker/tasks/import-leafcutter.ts b/apps/bridge-worker/tasks/leafcutter/import-leafcutter.ts
similarity index 76%
rename from apps/metamigo-worker/tasks/import-leafcutter.ts
rename to apps/bridge-worker/tasks/leafcutter/import-leafcutter.ts
index f7cf113..0170262 100644
--- a/apps/metamigo-worker/tasks/import-leafcutter.ts
+++ b/apps/bridge-worker/tasks/leafcutter/import-leafcutter.ts
@@ -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 => {
+const getLabelStudioTickets = async (
+ page: number,
+): Promise => {
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
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
return tasksResult;
};
-const fetchFromLabelStudio = async (minUpdatedTimestamp: Date): Promise => {
+const fetchFromLabelStudio = async (
+ minUpdatedTimestamp: Date,
+): Promise => {
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[], 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 => {
+ /*
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;
diff --git a/apps/bridge-worker/tasks/signal/receive-signal-message.ts b/apps/bridge-worker/tasks/signal/receive-signal-message.ts
new file mode 100644
index 0000000..b1688ea
--- /dev/null
+++ b/apps/bridge-worker/tasks/signal/receive-signal-message.ts
@@ -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 => {
+ 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;
diff --git a/apps/bridge-worker/tasks/signal/send-signal-message.ts b/apps/bridge-worker/tasks/signal/send-signal-message.ts
new file mode 100644
index 0000000..4150300
--- /dev/null
+++ b/apps/bridge-worker/tasks/signal/send-signal-message.ts
@@ -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 => {
+ 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;
diff --git a/apps/bridge-worker/tasks/voice/receive-voice-message.ts b/apps/bridge-worker/tasks/voice/receive-voice-message.ts
new file mode 100644
index 0000000..19832b3
--- /dev/null
+++ b/apps/bridge-worker/tasks/voice/receive-voice-message.ts
@@ -0,0 +1,11 @@
+// import { db, getWorkerUtils } from "@link-stack/bridge-common";
+
+interface ReceiveVoiceMessageTaskOptions {
+ message: any;
+}
+
+const receiveVoiceMessageTask = async ({
+ message,
+}: ReceiveVoiceMessageTaskOptions): Promise => {};
+
+export default receiveVoiceMessageTask;
diff --git a/apps/bridge-worker/tasks/voice/send-voice-message.ts b/apps/bridge-worker/tasks/voice/send-voice-message.ts
new file mode 100644
index 0000000..da073e0
--- /dev/null
+++ b/apps/bridge-worker/tasks/voice/send-voice-message.ts
@@ -0,0 +1,11 @@
+// import { db, getWorkerUtils } from "@link-stack/bridge-common";
+
+interface SendVoiceMessageTaskOptions {
+ message: any;
+}
+
+const sendVoiceMessageTask = async ({
+ message,
+}: SendVoiceMessageTaskOptions): Promise => {};
+
+export default sendVoiceMessageTask;
diff --git a/apps/metamigo-worker/tasks/twilio-recording.ts b/apps/bridge-worker/tasks/voice/twilio-recording.ts
similarity index 89%
rename from apps/metamigo-worker/tasks/twilio-recording.ts
rename to apps/bridge-worker/tasks/voice/twilio-recording.ts
index cfb0e41..9372b61 100644
--- a/apps/metamigo-worker/tasks/twilio-recording.ts
+++ b/apps/bridge-worker/tasks/voice/twilio-recording.ts
@@ -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 =>
withDb(async (db: AppDatabase) => {
const { voiceLineId, accountSid, callSid, recordingSid } = options;
diff --git a/apps/metamigo-worker/tasks/voice-line-audio-update.ts b/apps/bridge-worker/tasks/voice/voice-line-audio-update.ts
similarity index 87%
rename from apps/metamigo-worker/tasks/voice-line-audio-update.ts
rename to apps/bridge-worker/tasks/voice/voice-line-audio-update.ts
index 8a4628a..cee33b1 100644
--- a/apps/metamigo-worker/tasks/voice-line-audio-update.ts
+++ b/apps/bridge-worker/tasks/voice/voice-line-audio-update.ts
@@ -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 =>
withDb(async (db: AppDatabase) => {
const { voiceLineId } = payload;
@@ -41,7 +41,7 @@ const voiceLineAudioUpdateTask = async (
"audio/mpeg": mp3.toString("base64"),
checksum: webmSha1,
},
- }
+ },
);
});
diff --git a/apps/metamigo-worker/tasks/voice-line-delete.ts b/apps/bridge-worker/tasks/voice/voice-line-delete.ts
similarity index 79%
rename from apps/metamigo-worker/tasks/voice-line-delete.ts
rename to apps/bridge-worker/tasks/voice/voice-line-delete.ts
index 44834cf..d591028 100644
--- a/apps/metamigo-worker/tasks/voice-line-delete.ts
+++ b/apps/bridge-worker/tasks/voice/voice-line-delete.ts
@@ -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 =>
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: "",
diff --git a/apps/metamigo-worker/tasks/voice-line-provider-update.ts b/apps/bridge-worker/tasks/voice/voice-line-provider-update.ts
similarity index 84%
rename from apps/metamigo-worker/tasks/voice-line-provider-update.ts
rename to apps/bridge-worker/tasks/voice/voice-line-provider-update.ts
index 13a6295..ce2af3e 100644
--- a/apps/metamigo-worker/tasks/voice-line-provider-update.ts
+++ b/apps/bridge-worker/tasks/voice/voice-line-provider-update.ts
@@ -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 =>
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, {
diff --git a/apps/bridge-worker/tasks/whatsapp/receive-whatsapp-message.ts b/apps/bridge-worker/tasks/whatsapp/receive-whatsapp-message.ts
new file mode 100644
index 0000000..024c70e
--- /dev/null
+++ b/apps/bridge-worker/tasks/whatsapp/receive-whatsapp-message.ts
@@ -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 => {
+ 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;
diff --git a/apps/bridge-worker/tasks/whatsapp/send-whatsapp-message.ts b/apps/bridge-worker/tasks/whatsapp/send-whatsapp-message.ts
new file mode 100644
index 0000000..509371f
--- /dev/null
+++ b/apps/bridge-worker/tasks/whatsapp/send-whatsapp-message.ts
@@ -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 => {
+ 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;
diff --git a/apps/bridge-worker/tsconfig.json b/apps/bridge-worker/tsconfig.json
new file mode 100644
index 0000000..0347b1a
--- /dev/null
+++ b/apps/bridge-worker/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@link-stack/typescript-config/tsconfig.node.json",
+ "compilerOptions": {
+ "outDir": "build/main"
+ },
+ "include": ["**/*.ts", "**/.*.ts"],
+ "exclude": ["node_modules", "build"]
+}
diff --git a/apps/leafcutter/Dockerfile b/apps/leafcutter/Dockerfile
index a682ef0..3a6cb58 100644
--- a/apps/leafcutter/Dockerfile
+++ b/apps/leafcutter/Dockerfile
@@ -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
diff --git a/apps/leafcutter/app/(login)/login/_components/Login.tsx b/apps/leafcutter/app/(login)/login/_components/Login.tsx
index fe47b5d..b584387 100644
--- a/apps/leafcutter/app/(login)/login/_components/Login.tsx
+++ b/apps/leafcutter/app/(login)/login/_components/Login.tsx
@@ -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 = ({ session }) => {
const {
colors: { leafcutterElectricBlue, lightGray },
typography: { h1, h4 },
- } = useAppContext();
+ } = useLeafcutterContext();
const buttonStyles = {
backgroundColor: lightGray,
borderRadius: 500,
diff --git a/apps/leafcutter/app/(main)/about/page.tsx b/apps/leafcutter/app/(main)/about/page.tsx
index c92ba88..b6958b2 100644
--- a/apps/leafcutter/app/(main)/about/page.tsx
+++ b/apps/leafcutter/app/(main)/about/page.tsx
@@ -1,4 +1,4 @@
-import { About } from "leafcutter-common";
+import { About } from "@link-stack/leafcutter-ui";
export default function Page() {
return ;
diff --git a/apps/leafcutter/app/(main)/create/page.tsx b/apps/leafcutter/app/(main)/create/page.tsx
index ece4c15..53826ca 100644
--- a/apps/leafcutter/app/(main)/create/page.tsx
+++ b/apps/leafcutter/app/(main)/create/page.tsx
@@ -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);
diff --git a/apps/leafcutter/app/(main)/faq/page.tsx b/apps/leafcutter/app/(main)/faq/page.tsx
index a908dfb..396a9b5 100644
--- a/apps/leafcutter/app/(main)/faq/page.tsx
+++ b/apps/leafcutter/app/(main)/faq/page.tsx
@@ -1,4 +1,4 @@
-import { FAQ } from "leafcutter-common";
+import { FAQ } from "@link-stack/leafcutter-ui";
export default function Page() {
return ;
diff --git a/apps/leafcutter/app/(main)/layout.tsx b/apps/leafcutter/app/(main)/layout.tsx
index 9d6b1bc..5b9e9f1 100644
--- a/apps/leafcutter/app/(main)/layout.tsx
+++ b/apps/leafcutter/app/(main)/layout.tsx
@@ -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 = {
diff --git a/apps/leafcutter/app/(main)/page.tsx b/apps/leafcutter/app/(main)/page.tsx
index 41ec7df..8893dae 100644
--- a/apps/leafcutter/app/(main)/page.tsx
+++ b/apps/leafcutter/app/(main)/page.tsx
@@ -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);
diff --git a/apps/leafcutter/app/(main)/preview/[...visualizationID]/page.tsx b/apps/leafcutter/app/(main)/preview/[...visualizationID]/page.tsx
index 88991c5..8daaaba 100644
--- a/apps/leafcutter/app/(main)/preview/[...visualizationID]/page.tsx
+++ b/apps/leafcutter/app/(main)/preview/[...visualizationID]/page.tsx
@@ -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() {
diff --git a/apps/leafcutter/app/(main)/setup/_components/Setup.tsx b/apps/leafcutter/app/(main)/setup/_components/Setup.tsx
index eb98fbe..1a079ec 100644
--- a/apps/leafcutter/app/(main)/setup/_components/Setup.tsx
+++ b/apps/leafcutter/app/(main)/setup/_components/Setup.tsx
@@ -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);
diff --git a/apps/leafcutter/app/(main)/trends/page.tsx b/apps/leafcutter/app/(main)/trends/page.tsx
index 221c6e3..3f58b1a 100644
--- a/apps/leafcutter/app/(main)/trends/page.tsx
+++ b/apps/leafcutter/app/(main)/trends/page.tsx
@@ -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);
diff --git a/apps/leafcutter/app/(main)/visualizations/[...visualizationID]/page.tsx b/apps/leafcutter/app/(main)/visualizations/[...visualizationID]/page.tsx
index 0787645..5ef382b 100644
--- a/apps/leafcutter/app/(main)/visualizations/[...visualizationID]/page.tsx
+++ b/apps/leafcutter/app/(main)/visualizations/[...visualizationID]/page.tsx
@@ -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}`;
diff --git a/apps/leafcutter/app/_components/AccountButton.tsx b/apps/leafcutter/app/_components/AccountButton.tsx
index 2158b82..be8a9e7 100644
--- a/apps/leafcutter/app/_components/AccountButton.tsx
+++ b/apps/leafcutter/app/_components/AccountButton.tsx
@@ -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 (
diff --git a/apps/leafcutter/app/_components/AppProvider.tsx b/apps/leafcutter/app/_components/AppProvider.tsx
index 5fb065c..7e3c316 100644
--- a/apps/leafcutter/app/_components/AppProvider.tsx
+++ b/apps/leafcutter/app/_components/AppProvider.tsx
@@ -8,7 +8,7 @@ import {
useState,
PropsWithChildren,
} from "react";
-import { colors, typography } from "leafcutter-common/styles/theme";
+import { colors, typography } from "@link-stack/leafcutter-ui/styles/theme";
const basePath = process.env.GITLAB_CI
? "/link/link-stack/apps/leafcutter"
@@ -29,7 +29,7 @@ const AppContext = createContext({
setFoundCount: null as any,
});
-export const AppProvider: FC = ({ children }) => {
+export const LeafcutterProvider: FC = ({ children }) => {
const initialState = {
incidentType: {
display: "Incident Type",
@@ -156,6 +156,6 @@ export const AppProvider: FC = ({ children }) => {
);
};
-export function useAppContext() {
+export function useLeafcutterContext() {
return useContext(AppContext);
}
diff --git a/apps/leafcutter/app/_components/HelpButton.tsx b/apps/leafcutter/app/_components/HelpButton.tsx
index 48df6e1..f6893b5 100644
--- a/apps/leafcutter/app/_components/HelpButton.tsx
+++ b/apps/leafcutter/app/_components/HelpButton.tsx
@@ -4,7 +4,7 @@ import { FC, useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import { Button } from "@mui/material";
import { QuestionMark as QuestionMarkIcon } from "@mui/icons-material";
-import { useAppContext } from "leafcutter-common/components/AppProvider";
+import { useLeafcutterContext } from "@link-stack/leafcutter-ui/components/LeafcutterProvider";
export const HelpButton: FC = () => {
const router = useRouter();
@@ -12,7 +12,7 @@ export const HelpButton: FC = () => {
const [helpActive, setHelpActive] = useState(false);
const {
colors: { leafcutterElectricBlue },
- } = useAppContext();
+ } = useLeafcutterContext();
const onClick = () => {
if (helpActive) {
router.push(pathname);
diff --git a/apps/leafcutter/app/_components/InternalLayout.tsx b/apps/leafcutter/app/_components/InternalLayout.tsx
index bd859ca..c8fd251 100644
--- a/apps/leafcutter/app/_components/InternalLayout.tsx
+++ b/apps/leafcutter/app/_components/InternalLayout.tsx
@@ -7,8 +7,8 @@ import CookieConsent from "react-cookie-consent";
import { useCookies } from "react-cookie";
import { TopNav } from "./TopNav";
import { Sidebar } from "./Sidebar";
-import { GettingStartedDialog } from "leafcutter-common";
-import { useAppContext } from "leafcutter-common/components/AppProvider";
+import { GettingStartedDialog } from "@link-stack/leafcutter-ui";
+import { useLeafcutterContext } from "@link-stack/leafcutter-ui/components/LeafcutterProvider";
// import { Footer } from "./Footer";
type LayoutProps = PropsWithChildren<{
@@ -29,7 +29,7 @@ export const InternalLayout: FC = ({
cdrLinkOrange,
helpYellow,
},
- } = useAppContext();
+ } = useLeafcutterContext();
return (
<>
diff --git a/apps/leafcutter/app/_components/LanguageSelect.tsx b/apps/leafcutter/app/_components/LanguageSelect.tsx
index 43cf363..908ef21 100644
--- a/apps/leafcutter/app/_components/LanguageSelect.tsx
+++ b/apps/leafcutter/app/_components/LanguageSelect.tsx
@@ -8,13 +8,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";
// import { Tooltip } from "./Tooltip";
export const LanguageSelect = () => {
const {
colors: { white, leafcutterElectricBlue },
- } = useAppContext();
+ } = useLeafcutterContext();
const router = useRouter();
const locales: any = { en: "English", fr: "Français" };
const locale = "en";
diff --git a/apps/leafcutter/app/_components/MultiProvider.tsx b/apps/leafcutter/app/_components/MultiProvider.tsx
index bc441ff..67e12b5 100644
--- a/apps/leafcutter/app/_components/MultiProvider.tsx
+++ b/apps/leafcutter/app/_components/MultiProvider.tsx
@@ -8,20 +8,14 @@ import { CookiesProvider } from "react-cookie";
import { I18n } from "react-polyglot";
import { AdapterDateFns } from "@mui/x-date-pickers-pro/AdapterDateFnsV3";
import { LocalizationProvider } from "@mui/x-date-pickers-pro";
-import { AppProvider } from "leafcutter-common/components/AppProvider";
-import { NextAppDirEmotionCacheProvider } from "tss-react/next/appDir";
-import en from "leafcutter-common/locales/en.json";
-import fr from "leafcutter-common/locales/fr.json";
-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 "app/_styles/global.css";
-import { LicenseInfo } from "@mui/x-date-pickers-pro";
+import { LeafcutterProvider } from "@link-stack/leafcutter-ui/components/LeafcutterProvider";
+import { AppRouterCacheProvider } from "@mui/material-nextjs/v14-appRouter";
+import en from "@link-stack/leafcutter-ui/locales/en.json";
+import fr from "@link-stack/leafcutter-ui/locales/fr.json";
+import { LicenseInfo } from "@mui/x-license";
LicenseInfo.setLicenseKey(
- "7c9bf25d9e240f76e77cbf7d2ba58a23Tz02NjU4OCxFPTE3MTU4NjIzMzQ2ODgsUz1wcm8sTE09c3Vic2NyaXB0aW9uLEtWPTI=",
+ "c787ac6613c5f2aa0494c4285fe3e9f2Tz04OTY1NyxFPTE3NDYzNDE0ODkwMDAsUz1wcm8sTE09c3Vic2NyaXB0aW9uLEtWPTI=",
);
const messages: any = { en, fr };
@@ -31,19 +25,19 @@ export const MultiProvider: FC = ({ children }: any) => {
const locale = "en";
return (
-
+
-
+
{children}
-
+
-
+
);
};
diff --git a/apps/leafcutter/app/_components/Sidebar.tsx b/apps/leafcutter/app/_components/Sidebar.tsx
index 4a8acaf..acdcef3 100644
--- a/apps/leafcutter/app/_components/Sidebar.tsx
+++ b/apps/leafcutter/app/_components/Sidebar.tsx
@@ -20,8 +20,8 @@ import {
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslate } from "react-polyglot";
-import { useAppContext } from "leafcutter-common/components/AppProvider";
-import { Tooltip } from "leafcutter-common";
+import { useLeafcutterContext } from "@link-stack/leafcutter-ui/components/LeafcutterProvider";
+import { Tooltip } from "@link-stack/leafcutter-ui";
// import { ArrowCircleRight as ArrowCircleRightIcon } from "@mui/icons-material";
const MenuItem = ({
@@ -43,7 +43,7 @@ const MenuItem = ({
}) => {
const {
colors: { leafcutterLightBlue, black },
- } = useAppContext();
+ } = useLeafcutterContext();
return (
@@ -105,7 +105,7 @@ export const Sidebar: FC = ({ open }) => {
const section = pathname?.split("/")[1];
const {
colors: { white }, // leafcutterElectricBlue, leafcutterLightBlue,
- } = useAppContext();
+ } = useLeafcutterContext();
// const [recentUpdates, setRecentUpdates] = useState([]);
diff --git a/apps/leafcutter/app/_components/TopNav.tsx b/apps/leafcutter/app/_components/TopNav.tsx
index 650f174..90c9b72 100644
--- a/apps/leafcutter/app/_components/TopNav.tsx
+++ b/apps/leafcutter/app/_components/TopNav.tsx
@@ -8,8 +8,8 @@ import { useTranslate } from "react-polyglot";
import LeafcutterLogo from "images/leafcutter-logo.png";
import { AccountButton } from "./AccountButton";
import { HelpButton } from "./HelpButton";
-import { Tooltip } from "leafcutter-common";
-import { useAppContext } from "leafcutter-common/components/AppProvider";
+import { Tooltip } from "@link-stack/leafcutter-ui";
+import { useLeafcutterContext } from "@link-stack/leafcutter-ui/components/LeafcutterProvider";
// import { LanguageSelect } from "./LanguageSelect";
export const TopNav: FC = () => {
@@ -17,7 +17,7 @@ export const TopNav: FC = () => {
const {
colors: { white, leafcutterElectricBlue, cdrLinkOrange },
typography: { h5, h6 },
- } = useAppContext();
+ } = useLeafcutterContext();
return (
{
- const {
- url,
- } = req;
- const parsedURL = new URL(url);
-
- console.log({ url });
- console.log({ pathname: parsedURL.pathname });
- console.log({ allowed: parsedURL.pathname.startsWith("/app") });
- const allowed = parsedURL.pathname.startsWith('/login') || parsedURL.pathname.startsWith('/api' || parsedURL.pathname.startsWith("/app"));
- if (allowed) {
- return true;
- }
-
+ authorized: ({ token }) => {
+ // check for existence in opensearch user list
if (token?.email) {
return true;
}
return false;
-
},
}
}
@@ -33,6 +21,6 @@ export default withAuth(
export const config = {
matcher: [
- '/((?!api|app|bootstrap|3961|ui|translations|internal|login|node_modules|_next/static|_next/image|favicon.ico).*)',
+ '/((?!api|app|login|_next/static|_next/image|favicon.ico).*)',
],
};
diff --git a/apps/leafcutter/next.config.js b/apps/leafcutter/next.config.js
index 066c69a..7b953ae 100644
--- a/apps/leafcutter/next.config.js
+++ b/apps/leafcutter/next.config.js
@@ -7,7 +7,7 @@ const ContentSecurityPolicy = `
`;
module.exports = {
- transpilePackages: ["leafcutter-common"],
+ transpilePackages: ["@link-stack/leafcutter-ui", "@link-stack/opensearch-common"],
experimental: {
missingSuspenseWithCSRBailout: false,
},
diff --git a/apps/leafcutter/package.json b/apps/leafcutter/package.json
index 85df496..e578e22 100644
--- a/apps/leafcutter/package.json
+++ b/apps/leafcutter/package.json
@@ -1,6 +1,6 @@
{
- "name": "leafcutter",
- "version": "0.2.0",
+ "name": "@link-stack/leafcutter",
+ "version": "2.1.0",
"scripts": {
"dev": "next dev -p 3001",
"login": "aws sso login --sso-session cdr",
@@ -13,54 +13,47 @@
"lint": "next lint"
},
"dependencies": {
- "@emotion/cache": "^11.11.0",
- "@emotion/react": "^11.11.4",
+ "@emotion/cache": "^11.13.1",
+ "@emotion/react": "^11.13.0",
"@emotion/server": "^11.11.0",
- "@emotion/styled": "^11.11.0",
- "@fontsource/playfair-display": "^5.0.21",
- "@fontsource/poppins": "^5.0.12",
- "@fontsource/roboto": "^5.0.12",
+ "@emotion/styled": "^11.13.0",
+ "@link-stack/leafcutter-ui": "*",
+ "@link-stack/opensearch-common": "*",
"@mui/icons-material": "^5",
- "@mui/lab": "^5.0.0-alpha.167",
"@mui/material": "^5",
- "@mui/x-data-grid-pro": "^6.19.6",
- "@mui/x-date-pickers-pro": "^6.19.6",
- "@opensearch-project/opensearch": "^2.5.0",
+ "@mui/material-nextjs": "^5.16.6",
+ "@mui/x-data-grid-pro": "^7.12.0",
+ "@mui/x-date-pickers-pro": "^7.12.0",
+ "@opensearch-project/opensearch": "^2.11.0",
"cryptr": "^6.3.0",
- "date-fns": "^3.3.1",
- "http-proxy-middleware": "^2.0.6",
- "leafcutter-common": "*",
- "material-ui-popup-state": "^5.0.10",
- "next": "14.1.2",
- "next-auth": "^4.24.6",
- "next-http-proxy-middleware": "^1.2.6",
- "nodemailer": "^6.9.11",
- "react": "18.2.0",
- "react-cookie": "^7.1.0",
+ "date-fns": "^3.6.0",
+ "http-proxy-middleware": "^3.0.0",
+ "material-ui-popup-state": "^5.1.2",
+ "next": "14.2.5",
+ "next-auth": "^4.24.7",
+ "react": "18.3.1",
+ "react-cookie": "^7.2.0",
"react-cookie-consent": "^9.0.0",
- "react-dom": "18.2.0",
+ "react-dom": "18.3.1",
"react-iframe": "^1.8.5",
"react-markdown": "^9.0.1",
"react-polyglot": "^0.7.2",
- "sharp": "^0.33.2",
- "swr": "^2.2.5",
- "tss-react": "^4.9.4",
- "uuid": "^9.0.1"
+ "sharp": "^0.33.4",
+ "uuid": "^10.0.0"
},
"devDependencies": {
- "@babel/core": "^7.24.0",
- "@types/node": "^20.11.24",
- "@types/react": "18.2.63",
- "@types/uuid": "^9.0.8",
+ "@babel/core": "^7.25.2",
+ "@types/node": "^22.1.0",
+ "@types/react": "18.3.3",
+ "@types/uuid": "^10.0.0",
"babel-loader": "^9.1.3",
- "eslint": "^8.57.0",
- "eslint-config-airbnb": "^19.0.4",
- "eslint-config-next": "^14.1.2",
+ "eslint": "^8.0.0",
+ "eslint-config-next": "^14.2.5",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
- "eslint-plugin-jsx-a11y": "^6.8.0",
- "eslint-plugin-prettier": "^5.1.3",
- "eslint-plugin-react": "^7.34.0",
- "typescript": "5.3.3"
+ "eslint-plugin-jsx-a11y": "^6.9.0",
+ "eslint-plugin-prettier": "^5.2.1",
+ "eslint-plugin-react": "^7.35.0",
+ "typescript": "5.5.4"
}
}
diff --git a/apps/link/Dockerfile b/apps/link/Dockerfile
index d9c2e9f..de7bb3b 100644
--- a/apps/link/Dockerfile
+++ b/apps/link/Dockerfile
@@ -1,4 +1,3 @@
-
FROM node:20-bookworm AS base
FROM base AS builder
@@ -7,19 +6,19 @@ RUN mkdir -p ${APP_DIR}/
RUN npm i -g turbo
WORKDIR ${APP_DIR}
COPY . .
-RUN turbo prune --scope=link --docker
+RUN turbo prune --scope=@link-stack/link --scope=@link-stack/bridge-migrations --docker
FROM base AS installer
ARG APP_DIR=/opt/link
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
COPY --from=builder ${APP_DIR}/out/full/ .
RUN npm i -g turbo
-RUN turbo run build --filter=link
+RUN turbo run build --filter=@link-stack/link --filter=@link-stack/bridge-migrations
FROM base AS runner
ARG APP_DIR=/opt/link
@@ -40,6 +39,7 @@ USER node
WORKDIR ${APP_DIR}
COPY --from=installer ${APP_DIR}/node_modules/ ./node_modules/
COPY --from=installer ${APP_DIR}/apps/link/ ./apps/link/
+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/link/
diff --git a/apps/link/app/(login)/login/_components/Login.tsx b/apps/link/app/(login)/login/_components/Login.tsx
index 16331e3..0a7f083 100644
--- a/apps/link/app/(login)/login/_components/Login.tsx
+++ b/apps/link/app/(login)/login/_components/Login.tsx
@@ -17,7 +17,7 @@ import {
import { signIn } from "next-auth/react";
import Image from "next/image";
import LinkLogo from "public/link-logo-small.png";
-import { colors } from "app/_styles/theme";
+import { colors, fonts } from "@link-stack/ui";
import { useSearchParams } from "next/navigation";
type LoginProps = {
@@ -34,6 +34,7 @@ export const Login: FC = ({ session }) => {
const params = useSearchParams();
const error = params.get("error");
const { darkGray, cdrLinkOrange, white } = colors;
+ const { poppins } = fonts;
const buttonStyles = {
borderRadius: 500,
width: "100%",
@@ -102,7 +103,7 @@ export const Login: FC = ({ session }) => {
fontWeight: 700,
mt: 1,
ml: 0.5,
- fontFamily: "Poppins",
+ fontFamily: poppins.style.fontFamily,
}}
>
CDR Link
diff --git a/apps/link/app/(login)/login/page.tsx b/apps/link/app/(login)/login/page.tsx
index 0367882..7bafdbb 100644
--- a/apps/link/app/(login)/login/page.tsx
+++ b/apps/link/app/(login)/login/page.tsx
@@ -1,12 +1,18 @@
+import { Suspense } from "react";
import { Metadata } from "next";
import { getSession } from "next-auth/react";
import { Login } from "./_components/Login";
export const metadata: Metadata = {
- title: "Login",
+ title: "CDR Link - Login",
};
export default async function Page() {
const session = await getSession();
- return ;
+
+ return (
+ Loading...}>
+
+
+ );
}
diff --git a/apps/link/app/(main)/_components/Home.tsx b/apps/link/app/(main)/_components/Home.tsx
index 3196091..1a404ae 100644
--- a/apps/link/app/(main)/_components/Home.tsx
+++ b/apps/link/app/(main)/_components/Home.tsx
@@ -1,6 +1,11 @@
"use client";
import { FC } from "react";
-import { ZammadWrapper } from "./ZammadWrapper";
+import { OpenSearchWrapper } from "@link-stack/leafcutter-ui";
-export const Home: FC = () => ;
+export const Home: FC = () => (
+
+);
diff --git a/apps/link/app/(main)/_components/InternalLayout.tsx b/apps/link/app/(main)/_components/InternalLayout.tsx
index 3eea55f..e80b52a 100644
--- a/apps/link/app/(main)/_components/InternalLayout.tsx
+++ b/apps/link/app/(main)/_components/InternalLayout.tsx
@@ -1,21 +1,38 @@
"use client";
import { FC, PropsWithChildren, useState } from "react";
-import { Grid } from "@mui/material";
+import { Grid, Box } from "@mui/material";
import { Sidebar } from "./Sidebar";
+import { SetupModeWarning } from "./SetupModeWarning";
-export const InternalLayout: FC = ({ children }) => {
+interface InternalLayoutProps extends PropsWithChildren {
+ setupModeActive: boolean;
+ leafcutterEnabled: boolean;
+}
+
+export const InternalLayout: FC = ({
+ children,
+ setupModeActive,
+ leafcutterEnabled,
+}) => {
const [open, setOpen] = useState(true);
return (
-
-
-
- {children as any}
+
+
+
+
+
+ {children as any}
+
-
+
);
};
diff --git a/apps/link/app/(main)/_components/SearchBox.tsx b/apps/link/app/(main)/_components/SearchBox.tsx
index 03f0f37..9f31c48 100644
--- a/apps/link/app/(main)/_components/SearchBox.tsx
+++ b/apps/link/app/(main)/_components/SearchBox.tsx
@@ -1,9 +1,8 @@
import { FC, useState, useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
-import useSWR from "swr";
import { Grid, Box, TextField, Autocomplete } from "@mui/material";
-import { searchQuery } from "@/app/_graphql/searchQuery";
-import { colors } from "@/app/_styles/theme";
+import { searchAllAction } from "@/app/_actions/search";
+import { colors } from "@link-stack/ui";
type SearchResultProps = {
props: any;
@@ -42,8 +41,6 @@ const SearchInput = (params: any) => (
);
const SearchResult: FC = ({ props, option }) => {
- console.log({ option });
-
const { lightGrey, mediumGray, black, white } = colors;
return (
@@ -95,22 +92,20 @@ const SearchResult: FC = ({ props, option }) => {
export const SearchBox: FC = () => {
const [open, setOpen] = useState(false);
+ const [searchResults, setSearchResults] = useState([]);
const [selectedValue, setSelectedValue] = useState(null);
const [searchTerms, setSearchTerms] = useState("");
const pathname = usePathname();
const router = useRouter();
- const { data, error }: any = useSWR({
- document: searchQuery,
- variables: {
- search: searchTerms ?? "",
- limit: 50,
- },
- refreshInterval: 10000,
- });
useEffect(() => {
- setOpen(false);
- }, [pathname]);
+ const fetchSearchResults = async () => {
+ const results = await searchAllAction(searchTerms ?? "", 50);
+ setSearchResults(results);
+ };
+
+ fetchSearchResults();
+ }, [searchTerms]);
return (
{
open={open}
onOpen={() => setOpen(true)}
noOptionsText="No results"
- options={data?.search ?? []}
+ options={searchResults ?? []}
getOptionLabel={(option: any) => {
if (option) {
return option.title;
diff --git a/apps/link/app/(main)/_components/SetupModeWarning.tsx b/apps/link/app/(main)/_components/SetupModeWarning.tsx
new file mode 100644
index 0000000..0411916
--- /dev/null
+++ b/apps/link/app/(main)/_components/SetupModeWarning.tsx
@@ -0,0 +1,27 @@
+import { FC } from "react";
+import { Box } from "@mui/material";
+
+interface SetupModeWarningProps {
+ setupModeActive: boolean;
+}
+
+export const SetupModeWarning: FC = ({
+ setupModeActive,
+}) =>
+ setupModeActive ? (
+
+
+ Setup Mode Active
+
+
+ ) : null;
diff --git a/apps/link/app/(main)/_components/Sidebar.tsx b/apps/link/app/(main)/_components/Sidebar.tsx
index 9576fee..c8c546e 100644
--- a/apps/link/app/(main)/_components/Sidebar.tsx
+++ b/apps/link/app/(main)/_components/Sidebar.tsx
@@ -1,7 +1,6 @@
"use client";
import { FC, useEffect, useState } from "react";
-import useSWR from "swr";
import {
Box,
Grid,
@@ -26,18 +25,18 @@ import {
Assessment as AssessmentIcon,
LibraryBooks as LibraryBooksIcon,
School as SchoolIcon,
- Search as SearchIcon,
} from "@mui/icons-material";
import { usePathname } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import LinkLogo from "public/link-logo-small.png";
import { useSession, signOut } from "next-auth/react";
-import { getTicketOverviewCountsQuery } from "app/_graphql/getTicketOverviewCountsQuery";
+import { getOverviewTicketCountsAction } from "app/_actions/overviews";
import { SearchBox } from "./SearchBox";
+import { fonts } from "@link-stack/ui";
const openWidth = 270;
-const closedWidth = 100;
+const closedWidth = 70;
const MenuItem = ({
name,
@@ -48,150 +47,165 @@ const MenuItem = ({
selected = false,
open = true,
badge,
+ depth = 0,
target = "_self",
-}: any) => (
-
-
- {iconSize > 0 ? (
-
- {
+ const { roboto } = fonts;
+
+ return (
+
+
+ {iconSize > 0 ? (
+
-
-
-
- ) : (
-
-
-
-
- )}
- {open && (
-
- {name}
-
- }
- />
- )}
- {badge && badge > 0 ? (
-
-
+
+
+ ) : (
+
- {badge}
-
-
- ) : null}
-
-
-);
+
+
+ {depth > 0 && (
+
+ )}
+
+ )}
+ {open && (
+
+ {name}
+
+ }
+ />
+ )}
+ {open && badge && badge > 0 ? (
+
+
+ {badge}
+
+
+ ) : null}
+
+
+ );
+};
interface SidebarProps {
open: boolean;
setOpen: (open: boolean) => void;
+ leafcutterEnabled?: boolean;
}
-export const Sidebar: FC = ({ open, setOpen }) => {
+export const Sidebar: FC = ({
+ open,
+ setOpen,
+ leafcutterEnabled = false,
+}) => {
const pathname = usePathname();
const { data: session } = useSession();
- const username = session?.user?.name || "User";
+ const [overviewCounts, setOverviewCounts] = useState(null);
+ const { poppins } = fonts;
+ const username = session?.user?.name || "";
// @ts-ignore
const roles = session?.user?.roles || [];
- const { data: overviewData, error: overviewError }: any = useSWR(
- {
- document: getTicketOverviewCountsQuery,
- },
- { refreshInterval: 10000 },
- );
- const findOverviewByName = (name: string) =>
- overviewData?.ticketOverviews?.edges?.find(
- (overview: any) => overview.node.name === name,
- )?.node?.id;
- const findOverviewCountByID = (id: string) =>
- overviewData?.ticketOverviews?.edges?.find(
- (overview: any) => overview.node.id === id,
- )?.node?.ticketCount ?? 0;
- const recentCount = 0;
- const assignedID = findOverviewByName("My Assigned Tickets");
- const assignedCount = findOverviewCountByID(assignedID);
- const openID = findOverviewByName("Open Tickets");
- const openCount = findOverviewCountByID(openID);
- const urgentID = findOverviewByName("Escalated Tickets");
- const urgentCount = findOverviewCountByID(urgentID);
- const unassignedID = findOverviewByName("Unassigned & Open Tickets");
- const unassignedCount = findOverviewCountByID(unassignedID);
+
+ useEffect(() => {
+ const fetchCounts = async () => {
+ const counts = await getOverviewTicketCountsAction();
+ setOverviewCounts(counts);
+ };
+
+ fetchCounts();
+
+ const interval = setInterval(fetchCounts, 10000);
+
+ return () => clearInterval(interval);
+ }, []);
const logout = () => {
signOut({ callbackUrl: "/login" });
@@ -214,7 +228,7 @@ export const Sidebar: FC = ({ open, setOpen }) => {
= ({ open, setOpen }) => {
>
= ({ open, setOpen }) => {
fontWeight: 700,
mt: 1,
ml: 0.5,
- fontFamily: "Poppins",
+ fontFamily: poppins.style.fontFamily,
}}
>
CDR Link
@@ -332,9 +346,7 @@ export const Sidebar: FC = ({ open, setOpen }) => {
}}
/>
-
-
-
+ {open && }
= ({ open, setOpen }) => {
},
}}
>
-
+ {leafcutterEnabled && (
+
+ )}
diff --git a/packages/leafcutter-common/components/Tooltip.tsx b/packages/leafcutter-ui/components/Tooltip.tsx
similarity index 98%
rename from packages/leafcutter-common/components/Tooltip.tsx
rename to packages/leafcutter-ui/components/Tooltip.tsx
index 898ac9c..364f6e6 100644
--- a/packages/leafcutter-common/components/Tooltip.tsx
+++ b/packages/leafcutter-ui/components/Tooltip.tsx
@@ -12,7 +12,7 @@ import {
} from "@mui/material";
import { Close as CloseIcon } from "@mui/icons-material";
import { useTranslate } from "react-polyglot";
-import { useAppContext } from "./AppProvider";
+import { useLeafcutterContext } from "./LeafcutterProvider";
interface TooltipProps {
title: string;
@@ -38,7 +38,7 @@ export const Tooltip: FC = ({
const {
typography: { p, small },
colors: { white, leafcutterElectricBlue, almostBlack },
- } = useAppContext();
+ } = useLeafcutterContext();
const router = useRouter();
const pathname = usePathname() ?? "";
const searchParams = useSearchParams();
diff --git a/packages/leafcutter-common/components/Trends.tsx b/packages/leafcutter-ui/components/Trends.tsx
similarity index 95%
rename from packages/leafcutter-common/components/Trends.tsx
rename to packages/leafcutter-ui/components/Trends.tsx
index c0c9f84..970bb23 100644
--- a/packages/leafcutter-common/components/Trends.tsx
+++ b/packages/leafcutter-ui/components/Trends.tsx
@@ -5,7 +5,7 @@ import { Grid, Box } from "@mui/material";
import { useTranslate } from "react-polyglot";
import { PageHeader } from "./PageHeader";
import { VisualizationCard } from "./VisualizationCard";
-import { useAppContext } from "./AppProvider";
+import { useLeafcutterContext } from "./LeafcutterProvider";
type TrendsProps = {
visualizations: any;
@@ -16,7 +16,7 @@ export const Trends: FC = ({ visualizations }) => {
const {
colors: { cdrLinkOrange },
typography: { h1, h4, p },
- } = useAppContext();
+ } = useLeafcutterContext();
return (
<>
diff --git a/packages/leafcutter-common/components/VisualizationBuilder.tsx b/packages/leafcutter-ui/components/VisualizationBuilder.tsx
similarity index 94%
rename from packages/leafcutter-common/components/VisualizationBuilder.tsx
rename to packages/leafcutter-ui/components/VisualizationBuilder.tsx
index 1e66169..29fa5ea 100644
--- a/packages/leafcutter-common/components/VisualizationBuilder.tsx
+++ b/packages/leafcutter-ui/components/VisualizationBuilder.tsx
@@ -32,7 +32,7 @@ import { Tooltip } from "./Tooltip";
import visualizationMap from "../config/visualizationMap.json";
import { VisualizationSelectCard } from "./VisualizationSelectCard";
import { MetricSelectCard } from "./MetricSelectCard";
-import { useAppContext } from "./AppProvider";
+import { useLeafcutterContext } from "./LeafcutterProvider";
interface VisualizationBuilderProps {
templates: any[];
@@ -49,7 +49,9 @@ export const VisualizationBuilder: FC = ({
query,
replaceQuery,
clearQuery,
- } = useAppContext();
+ datasource,
+ setDatasource,
+ } = useLeafcutterContext();
const { visualizations } = visualizationMap;
const [selectedVisualizationType, setSelectedVisualizationType] = useState(
null as any,
@@ -194,6 +196,24 @@ export const VisualizationBuilder: FC = ({
+
+