Merge branch 'develop' into 'main'
Refactoring and new Bridge services See merge request digiresilience/link/link-stack!8
This commit is contained in:
commit
bbcd4ca1d9
710 changed files with 21452 additions and 28798 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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
|
||||
**/.openapi-generator/
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
63
.gitpod.yml
63
.gitpod.yml
|
|
@ -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
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"prettier.prettierPath": ""
|
||||
}
|
||||
83
Makefile
83
Makefile
|
|
@ -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
|
||||
37
README.md
37
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?
|
||||
# TK
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
14
apps/bridge-frontend/app/(login)/login/page.tsx
Normal file
14
apps/bridge-frontend/app/(login)/login/page.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Metadata } from "next";
|
||||
import { getSession } from "next-auth/react";
|
||||
import { Login } from "@/app/_components/Login";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Login",
|
||||
};
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getSession();
|
||||
return <Login session={session} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import { Create } from "@link-stack/bridge-ui";
|
||||
|
||||
type PageProps = {
|
||||
params: { segment: string[] };
|
||||
};
|
||||
|
||||
export default function Page({ params: { segment } }: PageProps) {
|
||||
const service = segment[0];
|
||||
|
||||
return <Create service={service} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { db } from "@link-stack/bridge-common";
|
||||
import { serviceConfig, Detail } from "@link-stack/bridge-ui";
|
||||
|
||||
type Props = {
|
||||
params: { segment: string[] };
|
||||
};
|
||||
|
||||
export default async function Page({ params: { segment } }: Props) {
|
||||
const service = segment[0];
|
||||
const id = segment?.[1];
|
||||
|
||||
if (!id) return null;
|
||||
|
||||
const {
|
||||
[service]: { table },
|
||||
} = serviceConfig;
|
||||
|
||||
const row = await db
|
||||
.selectFrom(table)
|
||||
.selectAll()
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return <Detail service={service} row={row} />;
|
||||
}
|
||||
27
apps/bridge-frontend/app/(main)/[...segment]/@edit/page.tsx
Normal file
27
apps/bridge-frontend/app/(main)/[...segment]/@edit/page.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { db } from "@link-stack/bridge-common";
|
||||
import { serviceConfig, Edit } from "@link-stack/bridge-ui";
|
||||
|
||||
type PageProps = {
|
||||
params: { segment: string[] };
|
||||
};
|
||||
|
||||
export default async function Page({ params: { segment } }: PageProps) {
|
||||
const service = segment[0];
|
||||
const id = segment?.[1];
|
||||
|
||||
if (!id) return null;
|
||||
|
||||
const {
|
||||
[service]: { table },
|
||||
} = serviceConfig;
|
||||
|
||||
const row = await db
|
||||
.selectFrom(table)
|
||||
.selectAll()
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return <Edit service={service} row={row} />;
|
||||
}
|
||||
3
apps/bridge-frontend/app/(main)/[...segment]/layout.tsx
Normal file
3
apps/bridge-frontend/app/(main)/[...segment]/layout.tsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { ServiceLayout } from "@link-stack/bridge-ui";
|
||||
|
||||
export default ServiceLayout;
|
||||
22
apps/bridge-frontend/app/(main)/[...segment]/page.tsx
Normal file
22
apps/bridge-frontend/app/(main)/[...segment]/page.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { db } from "@link-stack/bridge-common";
|
||||
import { serviceConfig, List } from "@link-stack/bridge-ui";
|
||||
|
||||
type PageProps = {
|
||||
params: {
|
||||
segment: string[];
|
||||
};
|
||||
};
|
||||
|
||||
export default async function Page({ params: { segment } }: PageProps) {
|
||||
const service = segment[0];
|
||||
|
||||
if (!service) return null;
|
||||
|
||||
const config = serviceConfig[service];
|
||||
|
||||
if (!config) return null;
|
||||
|
||||
const rows = await db.selectFrom(config.table).selectAll().execute();
|
||||
|
||||
return <List service={service} rows={rows} />;
|
||||
}
|
||||
9
apps/bridge-frontend/app/(main)/layout.tsx
Normal file
9
apps/bridge-frontend/app/(main)/layout.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { InternalLayout } from "@/app/_components/InternalLayout";
|
||||
|
||||
export default function Layout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return <InternalLayout>{children}</InternalLayout>;
|
||||
}
|
||||
5
apps/bridge-frontend/app/(main)/page.tsx
Normal file
5
apps/bridge-frontend/app/(main)/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { Home } from "@link-stack/bridge-ui";
|
||||
|
||||
export default function Page() {
|
||||
return <Home />;
|
||||
}
|
||||
35
apps/bridge-frontend/app/_components/InternalLayout.tsx
Normal file
35
apps/bridge-frontend/app/_components/InternalLayout.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"use client";
|
||||
|
||||
import { FC, PropsWithChildren, useState } from "react";
|
||||
import { Grid } from "@mui/material";
|
||||
import { CssBaseline } from "@mui/material";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { css, Global } from "@emotion/react";
|
||||
import { fonts } from "@link-stack/ui";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
|
||||
export const InternalLayout: FC<PropsWithChildren> = ({ children }) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
const { roboto } = fonts;
|
||||
const globalCSS = css`
|
||||
* {
|
||||
font-family: ${roboto.style.fontFamily};
|
||||
}
|
||||
`;
|
||||
|
||||
return (
|
||||
<SessionProvider>
|
||||
<Global styles={globalCSS} />
|
||||
<CssBaseline />
|
||||
<Grid container direction="row">
|
||||
<Sidebar open={open} setOpen={setOpen} />
|
||||
<Grid
|
||||
item
|
||||
sx={{ ml: open ? "270px" : "70px", width: "100%", height: "100vh" }}
|
||||
>
|
||||
{children as any}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SessionProvider>
|
||||
);
|
||||
};
|
||||
185
apps/bridge-frontend/app/_components/Login.tsx
Normal file
185
apps/bridge-frontend/app/_components/Login.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
"use client";
|
||||
|
||||
import { FC, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Container,
|
||||
IconButton,
|
||||
Typography,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
Apple as AppleIcon,
|
||||
Google as GoogleIcon,
|
||||
Key as KeyIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import LinkLogo from "@/app/_images/link-logo-small.png";
|
||||
import { colors, fonts } from "@link-stack/ui";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
type LoginProps = {
|
||||
session: any;
|
||||
};
|
||||
|
||||
export const Login: FC<LoginProps> = ({ session }) => {
|
||||
const origin =
|
||||
typeof window !== "undefined" && window.location.origin
|
||||
? window.location.origin
|
||||
: "";
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const params = useSearchParams();
|
||||
const error = params.get("error");
|
||||
const { darkGray, cdrLinkOrange, white } = colors;
|
||||
const { poppins } = fonts;
|
||||
const buttonStyles = {
|
||||
borderRadius: 500,
|
||||
width: "100%",
|
||||
fontSize: "16px",
|
||||
fontWeight: "bold",
|
||||
backgroundColor: white,
|
||||
"&:hover": {
|
||||
color: white,
|
||||
backgroundColor: cdrLinkOrange,
|
||||
},
|
||||
};
|
||||
const fieldStyles = {
|
||||
"& label.Mui-focused": {
|
||||
color: cdrLinkOrange,
|
||||
},
|
||||
"& .MuiInput-underline:after": {
|
||||
borderBottomColor: cdrLinkOrange,
|
||||
},
|
||||
"& .MuiFilledInput-underline:after": {
|
||||
borderBottomColor: cdrLinkOrange,
|
||||
},
|
||||
"& .MuiOutlinedInput-root": {
|
||||
"&.Mui-focused fieldset": {
|
||||
borderColor: cdrLinkOrange,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ backgroundColor: darkGray, height: "100vh" }}>
|
||||
<Container maxWidth="md" sx={{ p: 10 }}>
|
||||
<Grid container spacing={2} direction="column" alignItems="center">
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
direction="row"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
<Grid item>
|
||||
<Box
|
||||
sx={{
|
||||
width: "70px",
|
||||
height: "70px",
|
||||
margin: "0 auto",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={LinkLogo}
|
||||
alt="Link logo"
|
||||
width={70}
|
||||
height={70}
|
||||
style={{
|
||||
objectFit: "cover",
|
||||
filter: "grayscale(100) brightness(100)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontSize: 36,
|
||||
color: "white",
|
||||
fontWeight: 700,
|
||||
mt: 1,
|
||||
ml: 0.5,
|
||||
fontFamily: poppins.style.fontFamily,
|
||||
}}
|
||||
>
|
||||
CDR Bridge
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid item sx={{ width: "100%" }}>
|
||||
{!session ? (
|
||||
<Container
|
||||
maxWidth="xs"
|
||||
sx={{
|
||||
p: 3,
|
||||
mt: 3,
|
||||
}}
|
||||
>
|
||||
<Grid
|
||||
container
|
||||
spacing={3}
|
||||
direction="column"
|
||||
alignItems="center"
|
||||
>
|
||||
{error ? (
|
||||
<Grid item sx={{ width: "100%" }}>
|
||||
<Box sx={{ backgroundColor: "red", p: 3 }}>
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontSize: 18,
|
||||
color: "white",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{`${error} error`}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
) : null}
|
||||
<Grid item sx={{ width: "100%" }}>
|
||||
<IconButton
|
||||
sx={buttonStyles}
|
||||
onClick={() =>
|
||||
signIn("google", {
|
||||
callbackUrl: `${origin}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<GoogleIcon sx={{ mr: 1 }} />
|
||||
Sign in with Google
|
||||
</IconButton>
|
||||
</Grid>
|
||||
<Grid item sx={{ width: "100%" }}>
|
||||
<IconButton
|
||||
aria-label="Sign in with Apple"
|
||||
sx={buttonStyles}
|
||||
onClick={() =>
|
||||
signIn("apple", {
|
||||
callbackUrl: `${window.location.origin}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
<AppleIcon sx={{ mr: 1 }} />
|
||||
Sign in with Apple
|
||||
</IconButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
) : null}
|
||||
{session ? (
|
||||
<Box component="h4">
|
||||
{` ${session.user.name ?? session.user.email}.`}
|
||||
</Box>
|
||||
) : null}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
399
apps/bridge-frontend/app/_components/Sidebar.tsx
Normal file
399
apps/bridge-frontend/app/_components/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Typography,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
ListItemSecondaryAction,
|
||||
Drawer,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
ExpandCircleDown as ExpandCircleDownIcon,
|
||||
AccountCircle as AccountCircleIcon,
|
||||
Chat as ChatIcon,
|
||||
PermPhoneMsg as PhoneIcon,
|
||||
WhatsApp as WhatsAppIcon,
|
||||
Facebook as FacebookIcon,
|
||||
AirlineStops as AirlineStopsIcon,
|
||||
Logout as LogoutIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { typography, fonts, Button } from "@link-stack/ui";
|
||||
import LinkLogo from "@/app/_images/link-logo-small.png";
|
||||
import { useSession, signOut } from "next-auth/react";
|
||||
|
||||
const openWidth = 270;
|
||||
const closedWidth = 70;
|
||||
|
||||
const MenuItem = ({
|
||||
name,
|
||||
href,
|
||||
Icon,
|
||||
iconSize,
|
||||
inset = false,
|
||||
selected = false,
|
||||
open = true,
|
||||
badge,
|
||||
target = "_self",
|
||||
}: any) => (
|
||||
<Link href={href} target={target}>
|
||||
<ListItemButton
|
||||
sx={{
|
||||
p: 0,
|
||||
mb: 1,
|
||||
bl: iconSize === 0 ? "1px solid white" : "inherit",
|
||||
}}
|
||||
selected={selected}
|
||||
>
|
||||
{iconSize > 0 ? (
|
||||
<ListItemIcon
|
||||
sx={{
|
||||
color: `white`,
|
||||
minWidth: 0,
|
||||
mr: 2,
|
||||
textAlign: "center",
|
||||
margin: open ? "0 8 0 0" : "0 auto",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
mr: 0.5,
|
||||
mt: "-4px",
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
</Box>
|
||||
</ListItemIcon>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
width: 30,
|
||||
height: "28px",
|
||||
position: "relative",
|
||||
ml: "9px",
|
||||
mr: "1px",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: "1px",
|
||||
height: "56px",
|
||||
backgroundColor: "white",
|
||||
position: "absolute",
|
||||
left: "3px",
|
||||
top: "-10px",
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
width: "42px",
|
||||
height: "42px",
|
||||
position: "absolute",
|
||||
top: "-27px",
|
||||
left: "3px",
|
||||
border: "1px solid #fff",
|
||||
borderColor: "transparent transparent transparent #fff",
|
||||
borderRadius: "60px",
|
||||
rotate: "-35deg",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{open && (
|
||||
<ListItemText
|
||||
inset={inset}
|
||||
primary={
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
fontSize: 16,
|
||||
fontWeight: "bold",
|
||||
border: 0,
|
||||
textAlign: "left",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{badge && badge > 0 ? (
|
||||
<ListItemSecondaryAction>
|
||||
<Typography
|
||||
color="textSecondary"
|
||||
variant="body1"
|
||||
className="badge"
|
||||
sx={{
|
||||
backgroundColor: "#FFB620",
|
||||
color: "black !important",
|
||||
borderRadius: 10,
|
||||
px: 1,
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</Typography>
|
||||
</ListItemSecondaryAction>
|
||||
) : null}
|
||||
</ListItemButton>
|
||||
</Link>
|
||||
);
|
||||
|
||||
interface SidebarProps {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const Sidebar: FC<SidebarProps> = ({ open, setOpen }) => {
|
||||
const pathname = usePathname();
|
||||
const { poppins } = fonts;
|
||||
const { bodyLarge } = typography;
|
||||
const { data: session } = useSession();
|
||||
const user = session?.user;
|
||||
|
||||
const logout = () => {
|
||||
signOut({ callbackUrl: "/login" });
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
sx={{ width: open ? openWidth : closedWidth, flexShrink: 0 }}
|
||||
variant="permanent"
|
||||
anchor="left"
|
||||
open={open}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
width: open ? openWidth : closedWidth,
|
||||
border: 0,
|
||||
overflow: "visible",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
position: "absolute",
|
||||
top: 24,
|
||||
right: open ? -8 : -16,
|
||||
color: "#1C75FD",
|
||||
rotate: open ? "90deg" : "-90deg",
|
||||
}}
|
||||
onClick={() => {
|
||||
setOpen!(!open);
|
||||
}}
|
||||
>
|
||||
<ExpandCircleDownIcon
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
background: "white",
|
||||
borderRadius: 500,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Grid
|
||||
container
|
||||
direction="column"
|
||||
justifyContent="space-between"
|
||||
wrap="nowrap"
|
||||
spacing={0}
|
||||
sx={{ backgroundColor: "#25272A", height: "100%", p: 2 }}
|
||||
>
|
||||
<Grid item container>
|
||||
<Grid item sx={{ width: open ? "40px" : "100%" }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
margin: open ? "0" : "0 auto",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={LinkLogo}
|
||||
alt="Link logo"
|
||||
width={40}
|
||||
height={40}
|
||||
style={{
|
||||
objectFit: "cover",
|
||||
filter: "grayscale(100) brightness(100)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
.
|
||||
</Grid>
|
||||
{open && (
|
||||
<Grid item>
|
||||
<Typography
|
||||
variant="h2"
|
||||
sx={{
|
||||
fontSize: 26,
|
||||
color: "white",
|
||||
fontWeight: 700,
|
||||
mt: 1,
|
||||
ml: 0.5,
|
||||
fontFamily: poppins.style.fontFamily,
|
||||
}}
|
||||
>
|
||||
CDR Bridge
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Box
|
||||
sx={{
|
||||
height: "0.5px",
|
||||
width: "100%",
|
||||
backgroundColor: "#666",
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
direction="column"
|
||||
sx={{
|
||||
mt: "6px",
|
||||
overflow: "scroll",
|
||||
scrollbarWidth: "none",
|
||||
msOverflowStyle: "none",
|
||||
"&::-webkit-scrollbar": { display: "none" },
|
||||
}}
|
||||
flexGrow={1}
|
||||
>
|
||||
<List
|
||||
component="nav"
|
||||
sx={{
|
||||
a: {
|
||||
textDecoration: "none",
|
||||
|
||||
".MuiListItemButton-root": {
|
||||
p: 1,
|
||||
borderRadius: 2,
|
||||
"&:hover": {
|
||||
background: "#555",
|
||||
},
|
||||
".MuiTypography-root": {
|
||||
p: {
|
||||
color: "#999 !important",
|
||||
fontSize: 16,
|
||||
},
|
||||
},
|
||||
".badge": {
|
||||
p: { fontSize: 12, color: "black !important" },
|
||||
},
|
||||
},
|
||||
".Mui-selected": {
|
||||
background: "#444",
|
||||
color: "#fff !important",
|
||||
".MuiTypography-root": {
|
||||
p: {
|
||||
color: "#fff !important",
|
||||
fontSize: 16,
|
||||
},
|
||||
},
|
||||
".badge": {
|
||||
p: { fontSize: 12, color: "black !important" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
name="WhatsApp"
|
||||
href="/whatsapp"
|
||||
selected={pathname.endsWith("/whatsapp")}
|
||||
Icon={WhatsAppIcon}
|
||||
iconSize={20}
|
||||
/>
|
||||
<MenuItem
|
||||
name="Signal"
|
||||
href="/signal"
|
||||
selected={pathname.startsWith("/signal")}
|
||||
Icon={ChatIcon}
|
||||
iconSize={20}
|
||||
/>
|
||||
<MenuItem
|
||||
name="Facebook"
|
||||
href="/facebook"
|
||||
selected={pathname.startsWith("/facebook")}
|
||||
Icon={FacebookIcon}
|
||||
iconSize={20}
|
||||
/>
|
||||
<MenuItem
|
||||
name="Voice"
|
||||
href="/voice"
|
||||
selected={pathname.startsWith("/voice")}
|
||||
Icon={PhoneIcon}
|
||||
iconSize={20}
|
||||
/>
|
||||
<MenuItem
|
||||
name="Webhooks"
|
||||
href="/webhooks"
|
||||
selected={pathname.startsWith("/webhooks")}
|
||||
Icon={AirlineStopsIcon}
|
||||
iconSize={20}
|
||||
/>
|
||||
<MenuItem
|
||||
name="Users"
|
||||
href="/users"
|
||||
selected={pathname.startsWith("/users")}
|
||||
Icon={AccountCircleIcon}
|
||||
iconSize={20}
|
||||
/>
|
||||
</List>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={1}
|
||||
sx={{
|
||||
borderTop: "1px solid #ffffff33",
|
||||
pt: 0.5,
|
||||
}}
|
||||
>
|
||||
{user?.image && (
|
||||
<Grid item>
|
||||
<Box sx={{ width: 20, height: 20 }}>
|
||||
<Image
|
||||
src={user?.image ?? ""}
|
||||
alt="Profile image"
|
||||
width={20}
|
||||
height={20}
|
||||
unoptimized
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Grid item>
|
||||
<Box
|
||||
sx={{
|
||||
...bodyLarge,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
{user?.email}
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Button text="Logout" kind="secondary" onClick={logout} />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
BIN
apps/bridge-frontend/app/_images/link-logo-small.png
Normal file
BIN
apps/bridge-frontend/app/_images/link-logo-small.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
17
apps/bridge-frontend/app/_lib/authentication.ts
Normal file
17
apps/bridge-frontend/app/_lib/authentication.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import GoogleProvider from "next-auth/providers/google";
|
||||
import { KyselyAdapter } from "@auth/kysely-adapter";
|
||||
import { db } from "@link-stack/bridge-common";
|
||||
|
||||
export const authOptions = {
|
||||
// @ts-ignore
|
||||
adapter: KyselyAdapter(db),
|
||||
providers: [
|
||||
GoogleProvider({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt" as any,
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { receiveMessage as POST } from "@link-stack/bridge-ui";
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { getBot as GET } from "@link-stack/bridge-ui";
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { sendMessage as POST } from "@link-stack/bridge-ui";
|
||||
3
apps/bridge-frontend/app/api/[service]/webhooks/route.ts
Normal file
3
apps/bridge-frontend/app/api/[service]/webhooks/route.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { handleWebhook } from "@link-stack/bridge-ui";
|
||||
|
||||
export { handleWebhook as GET, handleWebhook as POST };
|
||||
7
apps/bridge-frontend/app/api/auth/[...nextauth]/route.ts
Normal file
7
apps/bridge-frontend/app/api/auth/[...nextauth]/route.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import NextAuth from "next-auth";
|
||||
import { authOptions } from "@/app/_lib/authentication";
|
||||
|
||||
// @ts-expect-error
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
23
apps/bridge-frontend/app/layout.tsx
Normal file
23
apps/bridge-frontend/app/layout.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { Metadata } from "next";
|
||||
import { LicenseInfo } from "@mui/x-license";
|
||||
|
||||
LicenseInfo.setLicenseKey(
|
||||
"c787ac6613c5f2aa0494c4285fe3e9f2Tz04OTY1NyxFPTE3NDYzNDE0ODkwMDAsUz1wcm8sTE09c3Vic2NyaXB0aW9uLEtWPTI=",
|
||||
);
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CDR Bridge",
|
||||
description: "",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
7
apps/bridge-frontend/docker-entrypoint.sh
Normal file
7
apps/bridge-frontend/docker-entrypoint.sh
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
echo "running migrations"
|
||||
(cd ../bridge-migrations/ && npm run migrate:up:all)
|
||||
echo "starting bridge-frontend"
|
||||
exec dumb-init npm run start
|
||||
24
apps/bridge-frontend/middleware.ts
Normal file
24
apps/bridge-frontend/middleware.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { withAuth } from "next-auth/middleware";
|
||||
|
||||
export default withAuth({
|
||||
pages: {
|
||||
signIn: `/login`,
|
||||
},
|
||||
callbacks: {
|
||||
authorized: ({ token }) => {
|
||||
if (process.env.SETUP_MODE === "true") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (token?.email) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!ws|wss|api|_next/static|_next/image|favicon.ico).*)"],
|
||||
};
|
||||
6
apps/bridge-frontend/next.config.js
Normal file
6
apps/bridge-frontend/next.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@link-stack/ui", "@link-stack/bridge-common", "@link-stack/bridge-ui"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
58
apps/bridge-frontend/package.json
Normal file
58
apps/bridge-frontend/package.json
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"name": "@link-stack/bridge-frontend",
|
||||
"version": "2.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"migrate:up:all": "tsx database/migrate.ts up:all",
|
||||
"migrate:up:one": "tsx database/migrate.ts up:one",
|
||||
"migrate:down:all": "tsx database/migrate.ts down:all",
|
||||
"migrate:down:one": "tsx database/migrate.ts down:one"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/kysely-adapter": "^1.4.2",
|
||||
"@emotion/cache": "^11.13.1",
|
||||
"@emotion/react": "^11.13.0",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@mui/icons-material": "^5",
|
||||
"@mui/material": "^5",
|
||||
"@mui/material-nextjs": "^5.16.6",
|
||||
"@mui/x-data-grid-pro": "^7.12.0",
|
||||
"@mui/x-date-pickers-pro": "^7.12.0",
|
||||
"@mui/x-license": "^7.12.0",
|
||||
"@link-stack/bridge-common": "*",
|
||||
"@link-stack/bridge-ui": "*",
|
||||
"@link-stack/signal-api": "*",
|
||||
"date-fns": "^3.6.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"graphile-worker": "^0.16.6",
|
||||
"kysely": "0.26.1",
|
||||
"material-ui-popup-state": "^5.1.2",
|
||||
"mui-chips-input": "^2.1.5",
|
||||
"next": "14.2.5",
|
||||
"next-auth": "^4.24.7",
|
||||
"pg": "^8.12.0",
|
||||
"react": "18.3.1",
|
||||
"react-cookie": "^7.2.0",
|
||||
"react-digit-input": "^2.1.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-qr-code": "^2.0.15",
|
||||
"react-timer-hook": "^3.0.7",
|
||||
"sharp": "^0.33.4",
|
||||
"tss-react": "^4.9.12",
|
||||
"tsx": "^4.16.5",
|
||||
"@link-stack/ui": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22",
|
||||
"@types/pg": "^8.11.6",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@link-stack/eslint-config": "*",
|
||||
"@link-stack/typescript-config": "*",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
27
apps/bridge-frontend/tsconfig.json
Normal file
27
apps/bridge-frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
93
apps/bridge-migrations/migrate.ts
Normal file
93
apps/bridge-migrations/migrate.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import * as path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { promises as fs } from "fs";
|
||||
import {
|
||||
Kysely,
|
||||
Migrator,
|
||||
MigrationResult,
|
||||
FileMigrationProvider,
|
||||
PostgresDialect,
|
||||
CamelCasePlugin,
|
||||
} from "kysely";
|
||||
import pkg from "pg";
|
||||
const { Pool } = pkg;
|
||||
import * as dotenv from "dotenv";
|
||||
|
||||
interface Database {}
|
||||
|
||||
export const migrate = async (arg: string) => {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
dotenv.config({ path: path.join(__dirname, "../.env.local") });
|
||||
}
|
||||
const db = new Kysely<Database>({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new Pool({
|
||||
host: process.env.DATABASE_HOST,
|
||||
database: process.env.DATABASE_NAME,
|
||||
port: parseInt(process.env.DATABASE_PORT!),
|
||||
user: process.env.DATABASE_USER,
|
||||
password: process.env.DATABASE_PASSWORD,
|
||||
}),
|
||||
}),
|
||||
plugins: [new CamelCasePlugin()],
|
||||
});
|
||||
const migrator = new Migrator({
|
||||
db,
|
||||
provider: new FileMigrationProvider({
|
||||
fs,
|
||||
path,
|
||||
migrationFolder: path.join(__dirname, "migrations"),
|
||||
}),
|
||||
});
|
||||
|
||||
let error: any = null;
|
||||
let results: MigrationResult[] = [];
|
||||
|
||||
if (arg === "up:all") {
|
||||
const out = await migrator.migrateToLatest();
|
||||
results = out.results ?? [];
|
||||
error = out.error;
|
||||
} else if (arg === "up:one") {
|
||||
const out = await migrator.migrateUp();
|
||||
results = out.results ?? [];
|
||||
error = out.error;
|
||||
} else if (arg === "down:all") {
|
||||
const migrations = await migrator.getMigrations();
|
||||
for (const _ of migrations) {
|
||||
const out = await migrator.migrateDown();
|
||||
if (out.results) {
|
||||
results = results.concat(out.results);
|
||||
error = out.error;
|
||||
}
|
||||
}
|
||||
} else if (arg === "down:one") {
|
||||
const out = await migrator.migrateDown();
|
||||
if (out.results) {
|
||||
results = out.results ?? [];
|
||||
error = out.error;
|
||||
}
|
||||
}
|
||||
|
||||
results?.forEach((it) => {
|
||||
if (it.status === "Success") {
|
||||
console.log(
|
||||
`Migration "${it.migrationName} ${it.direction.toLowerCase()}" was executed successfully`,
|
||||
);
|
||||
} else if (it.status === "Error") {
|
||||
console.error(`Failed to execute migration "${it.migrationName}"`);
|
||||
}
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error("Failed to migrate");
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await db.destroy();
|
||||
};
|
||||
|
||||
const arg = process.argv.slice(2).pop();
|
||||
migrate(arg as string);
|
||||
72
apps/bridge-migrations/migrations/0001-add-next-auth.ts
Normal file
72
apps/bridge-migrations/migrations/0001-add-next-auth.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { Kysely, sql } from "kysely";
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable("User")
|
||||
.addColumn("id", "uuid", (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
|
||||
)
|
||||
.addColumn("name", "text")
|
||||
.addColumn("email", "text", (col) => col.unique().notNull())
|
||||
.addColumn("emailVerified", "timestamptz")
|
||||
.addColumn("image", "text")
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable("Account")
|
||||
.addColumn("id", "uuid", (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
|
||||
)
|
||||
.addColumn("userId", "uuid", (col) =>
|
||||
col.references("User.id").onDelete("cascade").notNull(),
|
||||
)
|
||||
.addColumn("type", "text", (col) => col.notNull())
|
||||
.addColumn("provider", "text", (col) => col.notNull())
|
||||
.addColumn("providerAccountId", "text", (col) => col.notNull())
|
||||
.addColumn("refresh_token", "text")
|
||||
.addColumn("access_token", "text")
|
||||
.addColumn("expires_at", "bigint")
|
||||
.addColumn("token_type", "text")
|
||||
.addColumn("scope", "text")
|
||||
.addColumn("id_token", "text")
|
||||
.addColumn("session_state", "text")
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable("Session")
|
||||
.addColumn("id", "uuid", (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
|
||||
)
|
||||
.addColumn("userId", "uuid", (col) =>
|
||||
col.references("User.id").onDelete("cascade").notNull(),
|
||||
)
|
||||
.addColumn("sessionToken", "text", (col) => col.notNull().unique())
|
||||
.addColumn("expires", "timestamptz", (col) => col.notNull())
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable("VerificationToken")
|
||||
.addColumn("identifier", "text", (col) => col.notNull())
|
||||
.addColumn("token", "text", (col) => col.notNull().unique())
|
||||
.addColumn("expires", "timestamptz", (col) => col.notNull())
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex("Account_userId_index")
|
||||
.on("Account")
|
||||
.column("userId")
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex("Session_userId_index")
|
||||
.on("Session")
|
||||
.column("userId")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable("Account").ifExists().execute();
|
||||
await db.schema.dropTable("Session").ifExists().execute();
|
||||
await db.schema.dropTable("User").ifExists().execute();
|
||||
await db.schema.dropTable("VerificationToken").ifExists().execute();
|
||||
}
|
||||
33
apps/bridge-migrations/migrations/0002-add-signal.ts
Normal file
33
apps/bridge-migrations/migrations/0002-add-signal.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { Kysely, sql } from "kysely";
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable("SignalBot")
|
||||
.addColumn("id", "uuid", (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
|
||||
)
|
||||
.addColumn("phone_number", "text")
|
||||
.addColumn("token", "text", (col) => col.unique().notNull())
|
||||
.addColumn("user_id", "uuid")
|
||||
.addColumn("name", "text")
|
||||
.addColumn("description", "text")
|
||||
.addColumn("qr_code", "text")
|
||||
.addColumn("verified", "boolean", (col) => col.notNull().defaultTo(false))
|
||||
.addColumn("created_at", "timestamptz", (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn("updated_at", "timestamptz", (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex("SignalBotToken")
|
||||
.on("SignalBot")
|
||||
.column("token")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable("SignalBot").ifExists().execute();
|
||||
}
|
||||
33
apps/bridge-migrations/migrations/0003-add-whatsapp.ts
Normal file
33
apps/bridge-migrations/migrations/0003-add-whatsapp.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { Kysely, sql } from "kysely";
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable("WhatsappBot")
|
||||
.addColumn("id", "uuid", (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
|
||||
)
|
||||
.addColumn("phone_number", "text")
|
||||
.addColumn("token", "text", (col) => col.unique().notNull())
|
||||
.addColumn("user_id", "uuid")
|
||||
.addColumn("name", "text")
|
||||
.addColumn("description", "text")
|
||||
.addColumn("qr_code", "text")
|
||||
.addColumn("verified", "boolean", (col) => col.notNull().defaultTo(false))
|
||||
.addColumn("created_at", "timestamptz", (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn("updated_at", "timestamptz", (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex("WhatsappBotToken")
|
||||
.on("WhatsappBot")
|
||||
.column("token")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable("WhatsappBot").ifExists().execute();
|
||||
}
|
||||
77
apps/bridge-migrations/migrations/0004-add-voice.ts
Normal file
77
apps/bridge-migrations/migrations/0004-add-voice.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { Kysely, sql } from "kysely";
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable("VoiceProvider")
|
||||
.addColumn("id", "uuid", (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
|
||||
)
|
||||
.addColumn("kind", "text", (col) => col.notNull())
|
||||
.addColumn("name", "text", (col) => col.notNull())
|
||||
.addColumn("description", "text")
|
||||
.addColumn("credentials", "jsonb", (col) => col.notNull())
|
||||
.addColumn("created_at", "timestamptz", (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn("updated_at", "timestamptz", (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex("VoiceProviderName")
|
||||
.on("VoiceProvider")
|
||||
.column("name")
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable("VoiceLine")
|
||||
.addColumn("id", "uuid", (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
|
||||
)
|
||||
.addColumn("provider_id", "uuid", (col) =>
|
||||
col.notNull().references("VoiceProvider.id").onDelete("cascade"),
|
||||
)
|
||||
.addColumn("provider_line_sid", "text", (col) => col.notNull())
|
||||
.addColumn("number", "text", (col) => col.notNull())
|
||||
.addColumn("name", "text", (col) => col.notNull())
|
||||
.addColumn("description", "text")
|
||||
.addColumn("language", "text", (col) => col.notNull())
|
||||
.addColumn("voice", "text", (col) => col.notNull())
|
||||
.addColumn("prompt_text", "text")
|
||||
.addColumn("prompt_audio", "jsonb")
|
||||
.addColumn("audio_prompt_enabled", "boolean", (col) =>
|
||||
col.notNull().defaultTo(false),
|
||||
)
|
||||
.addColumn("audio_converted_at", "timestamptz")
|
||||
.addColumn("created_at", "timestamptz", (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn("updated_at", "timestamptz", (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex("VoiceLineProviderId")
|
||||
.on("VoiceLine")
|
||||
.column("provider_id")
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex("VoiceLineProviderLineSid")
|
||||
.on("VoiceLine")
|
||||
.column("provider_line_sid")
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex("VoiceLineNumber")
|
||||
.on("VoiceLine")
|
||||
.column("number")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable("VoiceLine").ifExists().execute();
|
||||
await db.schema.dropTable("VoiceProvider").ifExists().execute();
|
||||
}
|
||||
36
apps/bridge-migrations/migrations/0005-add-facebook.ts
Normal file
36
apps/bridge-migrations/migrations/0005-add-facebook.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Kysely, sql } from "kysely";
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable("FacebookBot")
|
||||
.addColumn("id", "uuid", (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
|
||||
)
|
||||
.addColumn("name", "text")
|
||||
.addColumn("description", "text")
|
||||
.addColumn("token", "text")
|
||||
.addColumn("page_access_token", "text")
|
||||
.addColumn("app_secret", "text")
|
||||
.addColumn("verify_token", "text")
|
||||
.addColumn("page_id", "text")
|
||||
.addColumn("app_id", "text")
|
||||
.addColumn("user_id", "uuid")
|
||||
.addColumn("verified", "boolean", (col) => col.notNull().defaultTo(false))
|
||||
.addColumn("created_at", "timestamptz", (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn("updated_at", "timestamptz", (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex("FacebookBotToken")
|
||||
.on("FacebookBot")
|
||||
.column("token")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable("FacebookBot").ifExists().execute();
|
||||
}
|
||||
41
apps/bridge-migrations/migrations/0006-add-webhooks.ts
Normal file
41
apps/bridge-migrations/migrations/0006-add-webhooks.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { Kysely, sql } from "kysely";
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable("Webhook")
|
||||
.addColumn("id", "uuid", (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
|
||||
)
|
||||
.addColumn("name", "text", (col) => col.notNull())
|
||||
.addColumn("description", "text")
|
||||
.addColumn("backend_type", "text", (col) => col.notNull())
|
||||
.addColumn("backend_id", "uuid", (col) => col.notNull())
|
||||
.addColumn("endpoint_url", "text", (col) =>
|
||||
col.notNull().check(sql`endpoint_url ~ '^https?://[^/]+'`),
|
||||
)
|
||||
.addColumn("http_method", "text", (col) =>
|
||||
col
|
||||
.notNull()
|
||||
.defaultTo("post")
|
||||
.check(sql`http_method in ('post', 'put')`),
|
||||
)
|
||||
.addColumn("headers", "jsonb")
|
||||
.addColumn("created_at", "timestamptz", (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn("updated_at", "timestamptz", (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex("WebhookBackendTypeBackendId")
|
||||
.on("Webhook")
|
||||
.column("backend_type")
|
||||
.column("backend_id")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable("Webhook").ifExists().execute();
|
||||
}
|
||||
28
apps/bridge-migrations/migrations/0007-add-settings.ts
Normal file
28
apps/bridge-migrations/migrations/0007-add-settings.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Kysely, sql } from "kysely";
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable("Setting")
|
||||
.addColumn("id", "uuid", (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
|
||||
)
|
||||
.addColumn("name", "text")
|
||||
.addColumn("value", "jsonb")
|
||||
.addColumn("created_at", "timestamptz", (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn("updated_at", "timestamptz", (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex("SettingName")
|
||||
.on("Setting")
|
||||
.column("name")
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable("Setting").ifExists().execute();
|
||||
}
|
||||
9
apps/bridge-migrations/migrations/0008-add-user-role.ts
Normal file
9
apps/bridge-migrations/migrations/0008-add-user-role.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Kysely } from "kysely";
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.alterTable("User").addColumn("role", "text").execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.alterTable("User").dropColumn("role").execute();
|
||||
}
|
||||
24
apps/bridge-migrations/package.json
Normal file
24
apps/bridge-migrations/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "@link-stack/bridge-migrations",
|
||||
"version": "2.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"migrate:up:all": "tsx migrate.ts up:all",
|
||||
"migrate:up:one": "tsx migrate.ts up:one",
|
||||
"migrate:down:all": "tsx migrate.ts down:all",
|
||||
"migrate:down:one": "tsx migrate.ts down:one"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.5",
|
||||
"kysely": "0.26.1",
|
||||
"pg": "^8.12.0",
|
||||
"tsx": "^4.16.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22",
|
||||
"@types/pg": "^8.11.6",
|
||||
"@link-stack/eslint-config": "*",
|
||||
"@link-stack/typescript-config": "*",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
39
apps/bridge-whatsapp/Dockerfile
Normal file
39
apps/bridge-whatsapp/Dockerfile
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
FROM node:20-bookworm AS base
|
||||
|
||||
FROM base AS builder
|
||||
ARG APP_DIR=/opt/bridge-whatsapp
|
||||
RUN mkdir -p ${APP_DIR}/
|
||||
RUN npm i -g turbo
|
||||
WORKDIR ${APP_DIR}
|
||||
COPY . .
|
||||
RUN turbo prune --scope=@link-stack/bridge-whatsapp --docker
|
||||
|
||||
FROM base AS installer
|
||||
ARG APP_DIR=/opt/bridge-whatsapp
|
||||
WORKDIR ${APP_DIR}
|
||||
COPY --from=builder ${APP_DIR}/out/json/ .
|
||||
COPY --from=builder ${APP_DIR}/out/full/ .
|
||||
COPY --from=builder ${APP_DIR}/out/package-lock.json ./package-lock.json
|
||||
RUN npm ci
|
||||
RUN npm i -g turbo
|
||||
RUN turbo run build --filter=@link-stack/bridge-whatsapp
|
||||
|
||||
FROM base as runner
|
||||
ARG BUILD_DATE
|
||||
ARG VERSION
|
||||
ARG APP_DIR=/opt/bridge-whatsapp
|
||||
RUN mkdir -p ${APP_DIR}/
|
||||
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
dumb-init
|
||||
WORKDIR ${APP_DIR}
|
||||
COPY --from=installer ${APP_DIR} ./
|
||||
RUN chown -R node:node ${APP_DIR}
|
||||
WORKDIR ${APP_DIR}/apps/bridge-whatsapp/
|
||||
RUN chmod +x docker-entrypoint.sh
|
||||
USER node
|
||||
RUN mkdir /home/node/baileys
|
||||
EXPOSE 5000
|
||||
ENV PORT 5000
|
||||
ENV NODE_ENV production
|
||||
ENTRYPOINT ["/opt/bridge-whatsapp/apps/bridge-whatsapp/docker-entrypoint.sh"]
|
||||
5
apps/bridge-whatsapp/docker-entrypoint.sh
Normal file
5
apps/bridge-whatsapp/docker-entrypoint.sh
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
echo "starting bridge-whatsapp"
|
||||
exec dumb-init npm run start
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"preset": "jest-config-link",
|
||||
"preset": "jest-config",
|
||||
"setupFiles": ["<rootDir>/src/setup.test.ts"]
|
||||
}
|
||||
31
apps/bridge-whatsapp/package.json
Normal file
31
apps/bridge-whatsapp/package.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "@link-stack/bridge-whatsapp",
|
||||
"version": "2.1.0",
|
||||
"main": "build/main/index.js",
|
||||
"author": "Darren Clarke <darren@redaranj.com>",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@adiwajshing/keyed-db": "0.2.4",
|
||||
"@hapi/boom": "^10.0.1",
|
||||
"@hapi/hapi": "^21.3.10",
|
||||
"@hapipal/schmervice": "^3.0.0",
|
||||
"@hapipal/toys": "^4.0.0",
|
||||
"@whiskeysockets/baileys": "^6.7.5",
|
||||
"hapi-pino": "^12.1.0",
|
||||
"link-preview-js": "^3.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@link-stack/eslint-config": "*",
|
||||
"@link-stack/jest-config": "*",
|
||||
"@link-stack/typescript-config": "*",
|
||||
"@types/node": "*",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"tsx": "^4.16.5",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "dotenv -- tsx src/index.ts",
|
||||
"start": "node build/main/index.js"
|
||||
}
|
||||
}
|
||||
39
apps/bridge-whatsapp/src/index.ts
Normal file
39
apps/bridge-whatsapp/src/index.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import * as Hapi from "@hapi/hapi";
|
||||
import hapiPino from "hapi-pino";
|
||||
import Schmervice from "@hapipal/schmervice";
|
||||
import WhatsappService from "./service.js";
|
||||
import {
|
||||
RegisterBotRoute,
|
||||
UnverifyBotRoute,
|
||||
GetBotRoute,
|
||||
SendMessageRoute,
|
||||
ReceiveMessageRoute,
|
||||
} from "./routes.js";
|
||||
|
||||
const server = Hapi.server({ port: 5000 });
|
||||
|
||||
const startServer = async () => {
|
||||
await server.register({ plugin: hapiPino });
|
||||
|
||||
server.route(RegisterBotRoute);
|
||||
server.route(UnverifyBotRoute);
|
||||
server.route(GetBotRoute);
|
||||
server.route(SendMessageRoute);
|
||||
server.route(ReceiveMessageRoute);
|
||||
|
||||
await server.register(Schmervice);
|
||||
server.registerService(WhatsappService);
|
||||
|
||||
await server.start();
|
||||
|
||||
return server;
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
await startServer();
|
||||
};
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
118
apps/bridge-whatsapp/src/routes.ts
Normal file
118
apps/bridge-whatsapp/src/routes.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import * as Hapi from "@hapi/hapi";
|
||||
import Toys from "@hapipal/toys";
|
||||
import WhatsappService from "./service";
|
||||
|
||||
const withDefaults = Toys.withRouteDefaults({
|
||||
options: {
|
||||
cors: true,
|
||||
},
|
||||
});
|
||||
|
||||
const getService = (request: Hapi.Request): WhatsappService => {
|
||||
const { whatsappService } = request.services();
|
||||
|
||||
return whatsappService as WhatsappService;
|
||||
};
|
||||
|
||||
interface MessageRequest {
|
||||
phoneNumber: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const SendMessageRoute = withDefaults({
|
||||
method: "post",
|
||||
path: "/api/bots/{id}/send",
|
||||
options: {
|
||||
description: "Send a message",
|
||||
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
|
||||
const { id } = request.params;
|
||||
console.log({ payload: request.payload });
|
||||
const { phoneNumber, message } = request.payload as MessageRequest;
|
||||
const whatsappService = getService(request);
|
||||
await whatsappService.send(id, phoneNumber, message as string);
|
||||
request.logger.info({ id }, "Sent a message at %s", new Date());
|
||||
|
||||
return _h
|
||||
.response({
|
||||
result: {
|
||||
recipient: phoneNumber,
|
||||
timestamp: new Date().toISOString(),
|
||||
source: id,
|
||||
},
|
||||
})
|
||||
.code(200);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ReceiveMessageRoute = withDefaults({
|
||||
method: "get",
|
||||
path: "/api/bots/{id}/receive",
|
||||
options: {
|
||||
description: "Receive messages",
|
||||
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
|
||||
const { id } = request.params;
|
||||
const whatsappService = getService(request);
|
||||
const date = new Date();
|
||||
const twoDaysAgo = new Date(date.getTime());
|
||||
twoDaysAgo.setDate(date.getDate() - 2);
|
||||
request.logger.info({ id }, "Received messages at %s", new Date());
|
||||
|
||||
return whatsappService.receive(id, twoDaysAgo);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const RegisterBotRoute = withDefaults({
|
||||
method: "post",
|
||||
path: "/api/bots/{id}/register",
|
||||
options: {
|
||||
description: "Register a bot",
|
||||
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
|
||||
const { id } = request.params;
|
||||
const whatsappService = getService(request);
|
||||
|
||||
await whatsappService.register(id);
|
||||
/*
|
||||
, (error: string) => {
|
||||
if (error) {
|
||||
return _h.response(error).code(500);
|
||||
}
|
||||
request.logger.info({ id }, "Register bot at %s", new Date());
|
||||
|
||||
return _h.response().code(200);
|
||||
});
|
||||
*/
|
||||
|
||||
return _h.response().code(200);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const UnverifyBotRoute = withDefaults({
|
||||
method: "post",
|
||||
path: "/api/bots/{id}/unverify",
|
||||
options: {
|
||||
description: "Unverify bot",
|
||||
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
|
||||
const { id } = request.params;
|
||||
const whatsappService = getService(request);
|
||||
|
||||
return whatsappService.unverify(id);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const GetBotRoute = withDefaults({
|
||||
method: "get",
|
||||
path: "/api/bots/{id}",
|
||||
options: {
|
||||
description: "Get bot info",
|
||||
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
|
||||
const { id } = request.params;
|
||||
const whatsappService = getService(request);
|
||||
|
||||
return whatsappService.getBot(id);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,8 +1,5 @@
|
|||
/* eslint-disable unicorn/no-abusive-eslint-disable */
|
||||
/* eslint-disable */
|
||||
import { Server } from "@hapi/hapi";
|
||||
import { Service } from "@hapipal/schmervice";
|
||||
import { SavedWhatsappBot as Bot } from "@digiresilience/metamigo-db";
|
||||
import makeWASocket, {
|
||||
DisconnectReason,
|
||||
proto,
|
||||
|
|
@ -14,16 +11,15 @@ import makeWASocket, {
|
|||
useMultiFileAuthState,
|
||||
} from "@whiskeysockets/baileys";
|
||||
import fs from "fs";
|
||||
import workerUtils from "../../worker-utils.js";
|
||||
|
||||
export type AuthCompleteCallback = (error?: string) => void;
|
||||
|
||||
export default class WhatsappService extends Service {
|
||||
connections: { [key: string]: any; } = {};
|
||||
loginConnections: { [key: string]: any; } = {};
|
||||
connections: { [key: string]: any } = {};
|
||||
loginConnections: { [key: string]: any } = {};
|
||||
|
||||
static browserDescription: [string, string, string] = [
|
||||
"Metamigo",
|
||||
"Bridge",
|
||||
"Chrome",
|
||||
"2.0",
|
||||
];
|
||||
|
|
@ -32,8 +28,16 @@ export default class WhatsappService extends Service {
|
|||
super(server, options);
|
||||
}
|
||||
|
||||
getAuthDirectory(bot: Bot): string {
|
||||
return `/baileys/${bot.id}`;
|
||||
getBaseDirectory(): string {
|
||||
return `/home/node/baileys`;
|
||||
}
|
||||
|
||||
getBotDirectory(id: string): string {
|
||||
return `${this.getBaseDirectory()}/${id}`;
|
||||
}
|
||||
|
||||
getAuthDirectory(id: string): string {
|
||||
return `${this.getBotDirectory(id)}/auth`;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
|
|
@ -45,7 +49,6 @@ export default class WhatsappService extends Service {
|
|||
}
|
||||
|
||||
private async sleep(ms: number): Promise<void> {
|
||||
console.log(`pausing ${ms}`);
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
|
|
@ -61,17 +64,18 @@ export default class WhatsappService extends Service {
|
|||
}
|
||||
|
||||
private async createConnection(
|
||||
bot: Bot,
|
||||
botID: string,
|
||||
server: Server,
|
||||
options: any,
|
||||
authCompleteCallback?: any
|
||||
authCompleteCallback?: any,
|
||||
) {
|
||||
const directory = this.getAuthDirectory(bot);
|
||||
const { state, saveCreds } = await useMultiFileAuthState(directory);
|
||||
const authDirectory = this.getAuthDirectory(botID);
|
||||
const { state, saveCreds } = await useMultiFileAuthState(authDirectory);
|
||||
const msgRetryCounterMap: any = {};
|
||||
const socket = makeWASocket({
|
||||
...options,
|
||||
auth: state,
|
||||
generateHighQualityLinkPreview: false,
|
||||
msgRetryCounterMap,
|
||||
shouldIgnoreJid: (jid) =>
|
||||
isJidBroadcast(jid) || isJidStatusBroadcast(jid),
|
||||
|
|
@ -89,27 +93,29 @@ export default class WhatsappService extends Service {
|
|||
} = update;
|
||||
if (qr) {
|
||||
console.log("got qr code");
|
||||
await this.server.db().whatsappBots.updateQR(bot, qr);
|
||||
const botDirectory = this.getBotDirectory(botID);
|
||||
const qrPath = `${botDirectory}/qr.txt`;
|
||||
fs.writeFileSync(qrPath, qr, "utf8");
|
||||
} else if (isNewLogin) {
|
||||
console.log("got new login");
|
||||
await this.server.db().whatsappBots.updateVerified(bot, true);
|
||||
const botDirectory = this.getBotDirectory(botID);
|
||||
const verifiedFile = `${botDirectory}/verified`;
|
||||
fs.writeFileSync(verifiedFile, "");
|
||||
} else if (connectionState === "open") {
|
||||
console.log("opened connection");
|
||||
} else if (connectionState === "close") {
|
||||
console.log("connection closed due to ", lastDisconnect.error);
|
||||
console.log("connection closed due to ", lastDisconnect?.error);
|
||||
const disconnectStatusCode = (lastDisconnect?.error as any)?.output
|
||||
?.statusCode;
|
||||
|
||||
if (disconnectStatusCode === DisconnectReason.restartRequired) {
|
||||
console.log("reconnecting after got new login");
|
||||
const updatedBot = await this.findById(bot.id);
|
||||
await this.createConnection(updatedBot, server, options);
|
||||
await this.createConnection(botID, server, options);
|
||||
authCompleteCallback?.();
|
||||
} else if (disconnectStatusCode !== DisconnectReason.loggedOut) {
|
||||
console.log("reconnecting");
|
||||
await this.sleep(pause);
|
||||
pause *= 2;
|
||||
this.createConnection(bot, server, options);
|
||||
this.createConnection(botID, server, options);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -124,39 +130,49 @@ export default class WhatsappService extends Service {
|
|||
const upsert = events["messages.upsert"];
|
||||
const { messages } = upsert;
|
||||
if (messages) {
|
||||
await this.queueUnreadMessages(bot, messages);
|
||||
await this.queueUnreadMessages(botID, messages);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.connections[bot.id] = { socket, msgRetryCounterMap };
|
||||
this.connections[botID] = { socket, msgRetryCounterMap };
|
||||
}
|
||||
|
||||
private async updateConnections() {
|
||||
this.resetConnections();
|
||||
|
||||
const bots = await this.server.db().whatsappBots.findAll();
|
||||
for await (const bot of bots) {
|
||||
if (bot.isVerified) {
|
||||
const baseDirectory = this.getBaseDirectory();
|
||||
const botIDs = fs.readdirSync(baseDirectory);
|
||||
console.log({ botIDs });
|
||||
for await (const botID of botIDs) {
|
||||
const directory = this.getBotDirectory(botID);
|
||||
const verifiedFile = `${directory}/verified`;
|
||||
if (fs.existsSync(verifiedFile)) {
|
||||
const { version, isLatest } = await fetchLatestBaileysVersion();
|
||||
console.log(`using WA v${version.join(".")}, isLatest: ${isLatest}`);
|
||||
|
||||
await this.createConnection(bot, this.server, {
|
||||
await this.createConnection(botID, this.server, {
|
||||
browser: WhatsappService.browserDescription,
|
||||
printQRInTerminal: false,
|
||||
printQRInTerminal: true,
|
||||
version,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async queueMessage(bot: Bot, webMessageInfo: proto.IWebMessageInfo) {
|
||||
private async queueMessage(
|
||||
botID: string,
|
||||
webMessageInfo: proto.IWebMessageInfo,
|
||||
) {
|
||||
const {
|
||||
key: { id, fromMe, remoteJid },
|
||||
message,
|
||||
messageTimestamp,
|
||||
} = webMessageInfo;
|
||||
if (!fromMe && message && remoteJid !== "status@broadcast") {
|
||||
console.log(webMessageInfo);
|
||||
const isValidMessage =
|
||||
message && remoteJid !== "status@broadcast" && !fromMe;
|
||||
if (isValidMessage) {
|
||||
const { audioMessage, documentMessage, imageMessage, videoMessage } =
|
||||
message;
|
||||
const isMediaMessage =
|
||||
|
|
@ -164,31 +180,32 @@ export default class WhatsappService extends Service {
|
|||
|
||||
const messageContent = Object.values(message)[0];
|
||||
let messageType: MediaType;
|
||||
let attachment: string;
|
||||
let filename: string;
|
||||
let mimetype: string;
|
||||
let attachment: string | null | undefined;
|
||||
let filename: string | null | undefined;
|
||||
let mimeType: string | null | undefined;
|
||||
if (isMediaMessage) {
|
||||
if (audioMessage) {
|
||||
messageType = "audio";
|
||||
filename = id + "." + audioMessage.mimetype.split("/").pop();
|
||||
mimetype = audioMessage.mimetype;
|
||||
filename = id + "." + audioMessage.mimetype?.split("/").pop();
|
||||
mimeType = audioMessage.mimetype;
|
||||
} else if (documentMessage) {
|
||||
messageType = "document";
|
||||
filename = documentMessage.fileName;
|
||||
mimetype = documentMessage.mimetype;
|
||||
mimeType = documentMessage.mimetype;
|
||||
} else if (imageMessage) {
|
||||
messageType = "image";
|
||||
filename = id + "." + imageMessage.mimetype.split("/").pop();
|
||||
mimetype = imageMessage.mimetype;
|
||||
filename = id + "." + imageMessage.mimetype?.split("/").pop();
|
||||
mimeType = imageMessage.mimetype;
|
||||
} else if (videoMessage) {
|
||||
messageType = "video";
|
||||
filename = id + "." + videoMessage.mimetype.split("/").pop();
|
||||
mimetype = videoMessage.mimetype;
|
||||
filename = id + "." + videoMessage.mimetype?.split("/").pop();
|
||||
mimeType = videoMessage.mimetype;
|
||||
}
|
||||
|
||||
const stream = await downloadContentFromMessage(
|
||||
messageContent,
|
||||
messageType
|
||||
// @ts-ignore
|
||||
messageType,
|
||||
);
|
||||
let buffer = Buffer.from([]);
|
||||
for await (const chunk of stream) {
|
||||
|
|
@ -198,100 +215,97 @@ export default class WhatsappService extends Service {
|
|||
}
|
||||
|
||||
if (messageContent || attachment) {
|
||||
const receivedMessage = {
|
||||
waMessageId: id,
|
||||
waMessage: JSON.stringify(webMessageInfo),
|
||||
waTimestamp: new Date((messageTimestamp as number) * 1000),
|
||||
const conversation = message?.conversation;
|
||||
const extendedTextMessage = message?.extendedTextMessage?.text;
|
||||
const imageMessage = message?.imageMessage?.caption;
|
||||
const videoMessage = message?.videoMessage?.caption;
|
||||
const messageText = [
|
||||
conversation,
|
||||
extendedTextMessage,
|
||||
imageMessage,
|
||||
videoMessage,
|
||||
].find((text) => text && text !== "");
|
||||
|
||||
const payload = {
|
||||
to: botID,
|
||||
from: remoteJid?.split("@")[0],
|
||||
messageId: id,
|
||||
sentAt: new Date((messageTimestamp as number) * 1000).toISOString(),
|
||||
message: messageText,
|
||||
attachment,
|
||||
filename,
|
||||
mimetype,
|
||||
whatsappBotId: bot.id,
|
||||
botPhoneNumber: bot.phoneNumber,
|
||||
mimeType,
|
||||
};
|
||||
|
||||
workerUtils.addJob("whatsapp-message", receivedMessage, {
|
||||
jobKey: id,
|
||||
});
|
||||
await fetch(
|
||||
`${process.env.BRIDGE_FRONTEND_URL}/api/whatsapp/bots/${botID}/receive`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async queueUnreadMessages(
|
||||
bot: Bot,
|
||||
messages: proto.IWebMessageInfo[]
|
||||
botID: string,
|
||||
messages: proto.IWebMessageInfo[],
|
||||
) {
|
||||
for await (const message of messages) {
|
||||
await this.queueMessage(bot, message);
|
||||
await this.queueMessage(botID, message);
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
phoneNumber: string,
|
||||
description: string,
|
||||
email: string
|
||||
): Promise<Bot> {
|
||||
const db = this.server.db();
|
||||
const user = await db.users.findBy({ email });
|
||||
const row = await db.whatsappBots.insert({
|
||||
phoneNumber,
|
||||
description,
|
||||
userId: user.id,
|
||||
});
|
||||
return row;
|
||||
getBot(botID: string): Record<string, any> {
|
||||
const botDirectory = this.getBotDirectory(botID);
|
||||
const qrPath = `${botDirectory}/qr.txt`;
|
||||
const verifiedFile = `${botDirectory}/verified`;
|
||||
const qr = fs.existsSync(qrPath) ? fs.readFileSync(qrPath, "utf8") : null;
|
||||
const verified = fs.existsSync(verifiedFile);
|
||||
|
||||
return { qr, verified };
|
||||
}
|
||||
|
||||
async unverify(bot: Bot): Promise<Bot> {
|
||||
const directory = this.getAuthDirectory(bot);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
return this.server.db().whatsappBots.updateVerified(bot, false);
|
||||
async unverify(botID: string): Promise<void> {
|
||||
const botDirectory = this.getBotDirectory(botID);
|
||||
fs.rmSync(botDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async remove(bot: Bot): Promise<number> {
|
||||
const directory = this.getAuthDirectory(bot);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
return this.server.db().whatsappBots.remove(bot);
|
||||
}
|
||||
|
||||
async findAll(): Promise<Bot[]> {
|
||||
return this.server.db().whatsappBots.findAll();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Bot> {
|
||||
return this.server.db().whatsappBots.findById({ id });
|
||||
}
|
||||
|
||||
async findByToken(token: string): Promise<Bot> {
|
||||
return this.server.db().whatsappBots.findBy({ token });
|
||||
}
|
||||
|
||||
async register(bot: Bot, callback: AuthCompleteCallback): Promise<void> {
|
||||
async register(
|
||||
botID: string,
|
||||
callback?: AuthCompleteCallback,
|
||||
): Promise<void> {
|
||||
const { version } = await fetchLatestBaileysVersion();
|
||||
await this.createConnection(bot, this.server, { version }, callback);
|
||||
await this.createConnection(
|
||||
botID,
|
||||
this.server,
|
||||
{ version, browser: WhatsappService.browserDescription },
|
||||
callback,
|
||||
);
|
||||
callback?.();
|
||||
}
|
||||
|
||||
async send(bot: Bot, phoneNumber: string, message: string): Promise<void> {
|
||||
const connection = this.connections[bot.id]?.socket;
|
||||
async send(
|
||||
botID: string,
|
||||
phoneNumber: string,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const connection = this.connections[botID]?.socket;
|
||||
const recipient = `${phoneNumber.replace(/\D+/g, "")}@s.whatsapp.net`;
|
||||
await connection.sendMessage(recipient, { text: message });
|
||||
}
|
||||
|
||||
async receiveSince(bot: Bot, lastReceivedDate: Date): Promise<void> {
|
||||
const connection = this.connections[bot.id]?.socket;
|
||||
const messages = await connection.messagesReceivedAfter(
|
||||
lastReceivedDate,
|
||||
false
|
||||
);
|
||||
for (const message of messages) {
|
||||
this.queueMessage(bot, message);
|
||||
}
|
||||
}
|
||||
|
||||
async receive(
|
||||
bot: Bot,
|
||||
_lastReceivedDate: Date
|
||||
botID: string,
|
||||
_lastReceivedDate: Date,
|
||||
): Promise<proto.IWebMessageInfo[]> {
|
||||
const connection = this.connections[bot.id]?.socket;
|
||||
const connection = this.connections[botID]?.socket;
|
||||
const messages = await connection.loadAllUnreadMessages();
|
||||
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
8
apps/bridge-whatsapp/src/types.ts
Normal file
8
apps/bridge-whatsapp/src/types.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import type WhatsappService from "./service.js";
|
||||
|
||||
declare module "@hapipal/schmervice" {
|
||||
interface SchmerviceDecorator {
|
||||
(namespace: "whatsapp"): WhatsappService;
|
||||
}
|
||||
type ServiceFunctionalInterface = { name: string };
|
||||
}
|
||||
17
apps/bridge-whatsapp/tsconfig.json
Normal file
17
apps/bridge-whatsapp/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"extends": "@link-stack/typescript-config/tsconfig.node.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es2018",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"outDir": "build/main",
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true,
|
||||
"types": ["jest", "node", "long"],
|
||||
"lib": ["es2020", "DOM"],
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/.*.ts"],
|
||||
"exclude": ["node_modules/**"]
|
||||
}
|
||||
|
|
@ -8,6 +8,5 @@
|
|||
**/.env*
|
||||
**/coverage
|
||||
**/.next
|
||||
**/amigo.*.json
|
||||
**/cypress/videos
|
||||
**/cypress/screenshots
|
||||
36
apps/bridge-worker/Dockerfile
Normal file
36
apps/bridge-worker/Dockerfile
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
FROM node:20-bookworm AS base
|
||||
|
||||
FROM base AS builder
|
||||
ARG APP_DIR=/opt/bridge-worker
|
||||
RUN mkdir -p ${APP_DIR}/
|
||||
RUN npm i -g turbo
|
||||
WORKDIR ${APP_DIR}
|
||||
COPY . .
|
||||
RUN turbo prune --scope=@link-stack/bridge-worker --docker
|
||||
|
||||
FROM base AS installer
|
||||
ARG APP_DIR=/opt/bridge-worker
|
||||
WORKDIR ${APP_DIR}
|
||||
COPY --from=builder ${APP_DIR}/out/json/ .
|
||||
COPY --from=builder ${APP_DIR}/out/full/ .
|
||||
COPY --from=builder ${APP_DIR}/out/package-lock.json ./package-lock.json
|
||||
RUN npm ci
|
||||
RUN npm i -g turbo
|
||||
RUN turbo run build --filter=@link-stack/bridge-worker
|
||||
|
||||
FROM base as runner
|
||||
ARG BUILD_DATE
|
||||
ARG VERSION
|
||||
ARG APP_DIR=/opt/bridge-worker
|
||||
RUN mkdir -p ${APP_DIR}/
|
||||
RUN DEBIAN_FRONTEND=noninteractive apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
dumb-init
|
||||
WORKDIR ${APP_DIR}
|
||||
COPY --from=installer ${APP_DIR} ./
|
||||
RUN chown -R node:node ${APP_DIR}
|
||||
WORKDIR ${APP_DIR}/apps/bridge-worker/
|
||||
RUN chmod +x docker-entrypoint.sh
|
||||
USER node
|
||||
ENV NODE_ENV production
|
||||
ENTRYPOINT ["/opt/bridge-worker/apps/bridge-worker/docker-entrypoint.sh"]
|
||||
1
apps/bridge-worker/crontab
Normal file
1
apps/bridge-worker/crontab
Normal file
|
|
@ -0,0 +1 @@
|
|||
*/1 * * * * fetch-signal-messages ?max=1
|
||||
5
apps/bridge-worker/docker-entrypoint.sh
Normal file
5
apps/bridge-worker/docker-entrypoint.sh
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
echo "starting bridge-worker"
|
||||
exec dumb-init npm run start
|
||||
13
apps/bridge-worker/graphile.config.ts
Normal file
13
apps/bridge-worker/graphile.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type {} from "graphile-config";
|
||||
import type {} from "graphile-worker";
|
||||
|
||||
const preset: GraphileConfig.Preset = {
|
||||
worker: {
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
maxPoolSize: 10,
|
||||
pollInterval: 2000,
|
||||
fileExtensions: [".ts"],
|
||||
},
|
||||
};
|
||||
|
||||
export default preset;
|
||||
28
apps/bridge-worker/index.ts
Normal file
28
apps/bridge-worker/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { run } from "graphile-worker";
|
||||
import * as path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const startWorker = async () => {
|
||||
console.log("Starting worker...");
|
||||
console.log(process.env);
|
||||
await run({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
concurrency: 10,
|
||||
noHandleSignals: false,
|
||||
pollInterval: 1000,
|
||||
taskDirectory: `${__dirname}/tasks`,
|
||||
crontabFile: `${__dirname}/crontab`,
|
||||
});
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
await startWorker();
|
||||
};
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,16 +1,18 @@
|
|||
/* eslint-disable camelcase */
|
||||
import { SavedVoiceProvider } from "@digiresilience/metamigo-db";
|
||||
// import { SavedVoiceProvider } from "@digiresilience/bridge-db";
|
||||
import Twilio from "twilio";
|
||||
import { CallInstance } from "twilio/lib/rest/api/v2010/account/call";
|
||||
import { Zammad, getOrCreateUser } from "./zammad";
|
||||
import { Zammad, getOrCreateUser } from "./zammad.js";
|
||||
|
||||
type SavedVoiceProvider = any;
|
||||
|
||||
export const twilioClientFor = (
|
||||
provider: SavedVoiceProvider
|
||||
provider: SavedVoiceProvider,
|
||||
): Twilio.Twilio => {
|
||||
const { accountSid, apiKeySid, apiKeySecret } = provider.credentials;
|
||||
if (!accountSid || !apiKeySid || !apiKeySecret)
|
||||
throw new Error(
|
||||
`twilio provider ${provider.name} does not have credentials`
|
||||
`twilio provider ${provider.name} does not have credentials`,
|
||||
);
|
||||
|
||||
return Twilio(apiKeySid, apiKeySecret, {
|
||||
|
|
@ -20,7 +22,7 @@ export const twilioClientFor = (
|
|||
|
||||
export const createZammadTicket = async (
|
||||
call: CallInstance,
|
||||
mp3: Buffer
|
||||
mp3: Buffer,
|
||||
): Promise<void> => {
|
||||
const title = `Call from ${call.fromFormatted} at ${call.startTime}`;
|
||||
const body = `<ul>
|
||||
|
|
@ -36,7 +38,7 @@ export const createZammadTicket = async (
|
|||
{
|
||||
token: "EviH_WL0p6YUlCoIER7noAZEAPsYA_fVU4FZCKdpq525Vmzzvl8d7dNuP_8d-Amb",
|
||||
},
|
||||
"https://demo.digiresilience.org"
|
||||
"https://demo.digiresilience.org",
|
||||
);
|
||||
try {
|
||||
const customer = await getOrCreateUser(zammad, call.fromFormatted);
|
||||
|
|
@ -1,12 +1,17 @@
|
|||
/*
|
||||
import pgPromise from "pg-promise";
|
||||
import * as pgMonitor from "pg-monitor";
|
||||
import { dbInitOptions, IRepositories, AppDatabase } from "@digiresilience/metamigo-db";
|
||||
import config from "@digiresilience/metamigo-config";
|
||||
import {
|
||||
dbInitOptions,
|
||||
IRepositories,
|
||||
AppDatabase,
|
||||
} from "@digiresilience/bridge-db";
|
||||
import config from "@digiresilience/bridge-config";
|
||||
import type { IInitOptions } from "pg-promise";
|
||||
|
||||
export const initDiagnostics = (
|
||||
logSql: boolean,
|
||||
initOpts: IInitOptions<IRepositories>
|
||||
initOpts: IInitOptions<IRepositories>,
|
||||
): void => {
|
||||
if (logSql) {
|
||||
pgMonitor.attach(initOpts);
|
||||
|
|
@ -33,15 +38,19 @@ const initDb = (): AppDatabase => {
|
|||
export const stopDb = async (db: AppDatabase): Promise<void> => {
|
||||
return db.$pool.end();
|
||||
};
|
||||
*/
|
||||
|
||||
export type AppDatabase = any;
|
||||
|
||||
export const withDb = <T>(f: (db: AppDatabase) => Promise<T>): Promise<T> => {
|
||||
const db = initDb();
|
||||
/*
|
||||
const db = initDb();
|
||||
initDiagnostics(config.logging.sql, pgpInitOptions);
|
||||
try {
|
||||
return f(db);
|
||||
} finally {
|
||||
stopDiagnostics();
|
||||
}
|
||||
*/
|
||||
return f(null);
|
||||
};
|
||||
|
||||
export type { AppDatabase } from "@digiresilience/metamigo-db";
|
||||
11
apps/bridge-worker/lib/logger.ts
Normal file
11
apps/bridge-worker/lib/logger.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
//import { defState } from "@digiresilience/montar";
|
||||
//import { configureLogger } from "@digiresilience/bridge-common";
|
||||
// import config from "@digiresilience/bridge-config";
|
||||
|
||||
//export const logger = defState("workerLogger", {
|
||||
// start: async () => configureLogger(config),
|
||||
//});
|
||||
//export default logger;
|
||||
|
||||
export const logger = {};
|
||||
export default logger;
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import * as Worker from "graphile-worker";
|
||||
import { defState } from "@digiresilience/montar";
|
||||
import config from "@digiresilience/metamigo-config";
|
||||
// import { defState } from "@digiresilience/montar";
|
||||
//import config from "@digiresilience/bridge-config";
|
||||
|
||||
/*
|
||||
const startWorkerUtils = async (): Promise<Worker.WorkerUtils> => {
|
||||
const workerUtils = await Worker.makeWorkerUtils({
|
||||
connectionString: config.worker.connection,
|
||||
|
|
@ -18,4 +19,8 @@ const workerUtils = defState("workerUtils", {
|
|||
stop: stopWorkerUtils,
|
||||
});
|
||||
|
||||
|
||||
*/
|
||||
|
||||
export const workerUtils: any = {};
|
||||
export default workerUtils;
|
||||
39
apps/bridge-worker/package.json
Normal file
39
apps/bridge-worker/package.json
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"name": "@link-stack/bridge-worker",
|
||||
"version": "2.1.0",
|
||||
"type": "module",
|
||||
"main": "build/main/index.js",
|
||||
"author": "Darren Clarke <darren@redaranj.com>",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json && cp crontab build/main/crontab",
|
||||
"dev": "dotenv -- graphile-worker",
|
||||
"start": "node build/main/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hapi/wreck": "^18.1.0",
|
||||
"@link-stack/bridge-common": "*",
|
||||
"@link-stack/signal-api": "*",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"graphile-worker": "^0.16.6",
|
||||
"html-to-text": "^9.0.5",
|
||||
"jest": "^29.7.0",
|
||||
"kysely": "^0.27.3",
|
||||
"pg": "^8.12.0",
|
||||
"remeda": "^2.10.0",
|
||||
"twilio": "^5.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.25.2",
|
||||
"@babel/preset-env": "7.25.3",
|
||||
"@babel/preset-typescript": "7.24.7",
|
||||
"@types/fluent-ffmpeg": "^2.1.25",
|
||||
"dotenv-cli": "^7.4.2",
|
||||
"@link-stack/eslint-config": "*",
|
||||
"prettier": "^3.3.3",
|
||||
"@link-stack/typescript-config": "*",
|
||||
"ts-node": "^10.9.2",
|
||||
"typedoc": "^0.26.5",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
32
apps/bridge-worker/tasks/common/notify-webhooks.ts
Normal file
32
apps/bridge-worker/tasks/common/notify-webhooks.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { db } from "@link-stack/bridge-common";
|
||||
|
||||
export interface NotifyWebhooksOptions {
|
||||
backendId: string;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
const notifyWebhooksTask = async (
|
||||
options: NotifyWebhooksOptions,
|
||||
): Promise<void> => {
|
||||
const { backendId, payload } = options;
|
||||
|
||||
const webhooks = await db
|
||||
.selectFrom("Webhook")
|
||||
.selectAll()
|
||||
.where("backendId", "=", backendId)
|
||||
.execute();
|
||||
|
||||
for (const webhook of webhooks) {
|
||||
const { endpointUrl, httpMethod, headers } = webhook;
|
||||
const finalHeaders = { "Content-Type": "application/json", ...headers };
|
||||
console.log({ endpointUrl, httpMethod, headers, finalHeaders });
|
||||
const result = await fetch(endpointUrl, {
|
||||
method: httpMethod,
|
||||
headers: finalHeaders,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
console.log(result);
|
||||
}
|
||||
};
|
||||
|
||||
export default notifyWebhooksTask;
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { db, getWorkerUtils } from "@link-stack/bridge-common";
|
||||
|
||||
interface ReceiveFacebookMessageTaskOptions {
|
||||
message: any;
|
||||
}
|
||||
|
||||
const receiveFacebookMessageTask = async ({
|
||||
message,
|
||||
}: ReceiveFacebookMessageTaskOptions): Promise<void> => {
|
||||
const worker = await getWorkerUtils();
|
||||
|
||||
for (const entry of message.entry) {
|
||||
for (const messaging of entry.messaging) {
|
||||
const pageId = messaging.recipient.id;
|
||||
const row = await db
|
||||
.selectFrom("FacebookBot")
|
||||
.selectAll()
|
||||
.where("pageId", "=", pageId)
|
||||
.executeTakeFirstOrThrow();
|
||||
const backendId = row.id;
|
||||
const payload = {
|
||||
to: pageId,
|
||||
from: messaging.sender.id,
|
||||
sent_at: new Date(messaging.timestamp).toISOString(),
|
||||
message: messaging.message.text,
|
||||
message_id: messaging.message.mid,
|
||||
};
|
||||
|
||||
await worker.addJob("common/notify-webhooks", { backendId, payload });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default receiveFacebookMessageTask;
|
||||
41
apps/bridge-worker/tasks/facebook/send-facebook-message.ts
Normal file
41
apps/bridge-worker/tasks/facebook/send-facebook-message.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { db } from "@link-stack/bridge-common";
|
||||
|
||||
interface SendFacebookMessageTaskOptions {
|
||||
token: string;
|
||||
to: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const sendFacebookMessageTask = async (
|
||||
options: SendFacebookMessageTaskOptions,
|
||||
): Promise<void> => {
|
||||
const { token, to, message } = options;
|
||||
const { pageId, pageAccessToken } = await db
|
||||
.selectFrom("FacebookBot")
|
||||
.selectAll()
|
||||
.where("token", "=", token)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const endpoint = `https://graph.facebook.com/v19.0/${pageId}/messages`;
|
||||
|
||||
const outgoingMessage = {
|
||||
recipient: { id: to },
|
||||
message: { text: message },
|
||||
messaging_type: "RESPONSE",
|
||||
access_token: pageAccessToken,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(outgoingMessage),
|
||||
});
|
||||
console.log({ response });
|
||||
} catch (error) {
|
||||
console.error({ error });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default sendFacebookMessageTask;
|
||||
44
apps/bridge-worker/tasks/fetch-signal-messages.ts
Normal file
44
apps/bridge-worker/tasks/fetch-signal-messages.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { db, getWorkerUtils } from "@link-stack/bridge-common";
|
||||
import * as signalApi from "@link-stack/signal-api";
|
||||
const { Configuration, MessagesApi } = signalApi;
|
||||
|
||||
const fetchSignalMessagesTask = async (): Promise<void> => {
|
||||
const worker = await getWorkerUtils();
|
||||
const rows = await db.selectFrom("SignalBot").selectAll().execute();
|
||||
const config = new Configuration({
|
||||
basePath: process.env.BRIDGE_SIGNAL_URL,
|
||||
});
|
||||
const messagesClient = new MessagesApi(config);
|
||||
|
||||
for (const row of rows) {
|
||||
const { id, phoneNumber: number } = row;
|
||||
const messages = await messagesClient.v1ReceiveNumberGet({ number });
|
||||
|
||||
for (const msg of messages) {
|
||||
const { envelope } = msg as any;
|
||||
const { source, sourceUuid, dataMessage } = envelope;
|
||||
const message = dataMessage?.message;
|
||||
const rawTimestamp = dataMessage?.timestamp;
|
||||
const timestamp = new Date(rawTimestamp);
|
||||
const messageId = `${sourceUuid}-${rawTimestamp}`;
|
||||
const attachment = undefined;
|
||||
const mimeType = undefined;
|
||||
const filename = undefined;
|
||||
if (source !== number && message) {
|
||||
await worker.addJob("signal/receive-signal-message", {
|
||||
token: id,
|
||||
to: number,
|
||||
from: source,
|
||||
messageId,
|
||||
message,
|
||||
sentAt: timestamp.toISOString(),
|
||||
attachment,
|
||||
filename,
|
||||
mimeType,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default fetchSignalMessagesTask;
|
||||
|
|
@ -1,31 +1,39 @@
|
|||
/* eslint-disable camelcase */
|
||||
/*
|
||||
import { convert } from "html-to-text";
|
||||
import fetch from "node-fetch";
|
||||
import { URLSearchParams } from "url";
|
||||
import { withDb, AppDatabase } from "../db";
|
||||
import { loadConfig } from "@digiresilience/metamigo-config";
|
||||
import { tagMap } from "../lib/tag-map";
|
||||
import { withDb, AppDatabase } from "../../lib/db.js";
|
||||
// import { loadConfig } from "@digiresilience/bridge-config";
|
||||
import { tagMap } from "../../lib/tag-map.js";
|
||||
|
||||
const config: any = {};
|
||||
|
||||
type FormattedZammadTicket = {
|
||||
data: Record<string, unknown>,
|
||||
data: Record<string, unknown>;
|
||||
predictions: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
const getZammadTickets = async (page: number, minUpdatedTimestamp: Date): Promise<[boolean, FormattedZammadTicket[]]> => {
|
||||
const { leafcutter: { zammadApiUrl, zammadApiKey, contributorName, contributorId } } = await loadConfig();
|
||||
const getZammadTickets = async (
|
||||
page: number,
|
||||
minUpdatedTimestamp: Date,
|
||||
): Promise<[boolean, FormattedZammadTicket[]]> => {
|
||||
const {
|
||||
leafcutter: { zammadApiUrl, zammadApiKey, contributorName, contributorId },
|
||||
} = config;
|
||||
const headers = { Authorization: `Token ${zammadApiKey}` };
|
||||
let shouldContinue = false;
|
||||
const docs = [];
|
||||
const ticketsQuery = new URLSearchParams({
|
||||
"expand": "true",
|
||||
"sort_by": "updated_at",
|
||||
"order_by": "asc",
|
||||
"query": "state.name: closed",
|
||||
"per_page": "25",
|
||||
"page": `${page}`,
|
||||
expand: "true",
|
||||
sort_by: "updated_at",
|
||||
order_by: "asc",
|
||||
query: "state.name: closed",
|
||||
per_page: "25",
|
||||
page: `${page}`,
|
||||
});
|
||||
const rawTickets = await fetch(`${zammadApiUrl}/tickets/search?${ticketsQuery}`,
|
||||
{ headers }
|
||||
const rawTickets = await fetch(
|
||||
`${zammadApiUrl}/tickets/search?${ticketsQuery}`,
|
||||
{ headers },
|
||||
);
|
||||
const tickets: any = await rawTickets.json();
|
||||
console.log({ tickets });
|
||||
|
|
@ -41,14 +49,25 @@ const getZammadTickets = async (page: number, minUpdatedTimestamp: Date): Promis
|
|||
shouldContinue = true;
|
||||
|
||||
if (source_closed_at <= minUpdatedTimestamp) {
|
||||
console.log(`Skipping ticket`, { source_id, source_updated_at, source_closed_at, minUpdatedTimestamp });
|
||||
console.log(`Skipping ticket`, {
|
||||
source_id,
|
||||
source_updated_at,
|
||||
source_closed_at,
|
||||
minUpdatedTimestamp,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Processing ticket`, { source_id, source_updated_at, source_closed_at, minUpdatedTimestamp });
|
||||
console.log(`Processing ticket`, {
|
||||
source_id,
|
||||
source_updated_at,
|
||||
source_closed_at,
|
||||
minUpdatedTimestamp,
|
||||
});
|
||||
|
||||
const rawArticles = await fetch(`${zammadApiUrl}/ticket_articles/by_ticket/${source_id}`,
|
||||
{ headers }
|
||||
const rawArticles = await fetch(
|
||||
`${zammadApiUrl}/ticket_articles/by_ticket/${source_id}`,
|
||||
{ headers },
|
||||
);
|
||||
const articles: any = await rawArticles.json();
|
||||
let articleText = "";
|
||||
|
|
@ -69,7 +88,9 @@ const getZammadTickets = async (page: number, minUpdatedTimestamp: Date): Promis
|
|||
o_id: source_id,
|
||||
});
|
||||
|
||||
const rawTags = await fetch(`${zammadApiUrl}/tags?${tagsQuery}`, { headers });
|
||||
const rawTags = await fetch(`${zammadApiUrl}/tags?${tagsQuery}`, {
|
||||
headers,
|
||||
});
|
||||
const { tags }: any = await rawTags.json();
|
||||
const transformedTags = [];
|
||||
for (const tag of tags) {
|
||||
|
|
@ -88,7 +109,7 @@ const getZammadTickets = async (page: number, minUpdatedTimestamp: Date): Promis
|
|||
source_created_at,
|
||||
source_updated_at,
|
||||
},
|
||||
predictions: []
|
||||
predictions: [],
|
||||
};
|
||||
|
||||
const result = transformedTags.map((tag) => {
|
||||
|
|
@ -115,12 +136,17 @@ const getZammadTickets = async (page: number, minUpdatedTimestamp: Date): Promis
|
|||
return [shouldContinue, docs];
|
||||
};
|
||||
|
||||
const fetchFromZammad = async (minUpdatedTimestamp: Date): Promise<FormattedZammadTicket[]> => {
|
||||
const fetchFromZammad = async (
|
||||
minUpdatedTimestamp: Date,
|
||||
): Promise<FormattedZammadTicket[]> => {
|
||||
const pages = [...Array.from({ length: 10000 }).keys()];
|
||||
const allTickets: FormattedZammadTicket[] = [];
|
||||
|
||||
for await (const page of pages) {
|
||||
const [shouldContinue, tickets] = await getZammadTickets(page + 1, minUpdatedTimestamp);
|
||||
const [shouldContinue, tickets] = await getZammadTickets(
|
||||
page + 1,
|
||||
minUpdatedTimestamp,
|
||||
);
|
||||
|
||||
if (!shouldContinue) {
|
||||
break;
|
||||
|
|
@ -135,7 +161,9 @@ const fetchFromZammad = async (minUpdatedTimestamp: Date): Promise<FormattedZamm
|
|||
};
|
||||
|
||||
const sendToLabelStudio = async (tickets: FormattedZammadTicket[]) => {
|
||||
const { leafcutter: { labelStudioApiUrl, labelStudioApiKey } } = await loadConfig();
|
||||
const {
|
||||
leafcutter: { labelStudioApiUrl, labelStudioApiKey },
|
||||
} = config;
|
||||
|
||||
const headers = {
|
||||
Authorization: `Token ${labelStudioApiKey}`,
|
||||
|
|
@ -154,13 +182,19 @@ const sendToLabelStudio = async (tickets: FormattedZammadTicket[]) => {
|
|||
console.log(JSON.stringify(importResult, undefined, 2));
|
||||
}
|
||||
};
|
||||
*/
|
||||
|
||||
const importLabelStudioTask = async (): Promise<void> => {
|
||||
/*
|
||||
withDb(async (db: AppDatabase) => {
|
||||
const { leafcutter: { contributorName } } = await loadConfig();
|
||||
const {
|
||||
leafcutter: { contributorName },
|
||||
} = config;
|
||||
const settingName = `${contributorName}ImportLabelStudioTask`;
|
||||
const res: any = await db.settings.findByName(settingName);
|
||||
const startTimestamp = res?.value?.minUpdatedTimestamp ? new Date(res.value.minUpdatedTimestamp as string) : new Date("2023-03-01");
|
||||
const startTimestamp = res?.value?.minUpdatedTimestamp
|
||||
? new Date(res.value.minUpdatedTimestamp as string)
|
||||
: new Date("2023-03-01");
|
||||
const tickets = await fetchFromZammad(startTimestamp);
|
||||
|
||||
if (tickets.length > 0) {
|
||||
|
|
@ -168,9 +202,12 @@ const importLabelStudioTask = async (): Promise<void> => {
|
|||
const lastTicket = tickets.pop();
|
||||
const newLastTimestamp = lastTicket.data.source_closed_at;
|
||||
console.log({ newLastTimestamp });
|
||||
await db.settings.upsert(settingName, { minUpdatedTimestamp: newLastTimestamp });
|
||||
await db.settings.upsert(settingName, {
|
||||
minUpdatedTimestamp: newLastTimestamp,
|
||||
});
|
||||
}
|
||||
});
|
||||
*/
|
||||
};
|
||||
|
||||
export default importLabelStudioTask;
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
/* eslint-disable camelcase */
|
||||
import fetch from "node-fetch";
|
||||
/*
|
||||
import { URLSearchParams } from "url";
|
||||
import { withDb, AppDatabase } from "../db";
|
||||
import { loadConfig } from "@digiresilience/metamigo-config";
|
||||
import { withDb, AppDatabase } from "../../lib/db.js";
|
||||
// import { loadConfig } from "@digiresilience/bridge-config";
|
||||
|
||||
const config: any = {};
|
||||
|
||||
type LabelStudioTicket = {
|
||||
id: string;
|
||||
|
|
@ -27,14 +29,12 @@ type LeafcutterTicket = {
|
|||
source_updated_at: string;
|
||||
};
|
||||
|
||||
const getLabelStudioTickets = async (page: number): Promise<LabelStudioTicket[]> => {
|
||||
const getLabelStudioTickets = async (
|
||||
page: number,
|
||||
): Promise<LabelStudioTicket[]> => {
|
||||
const {
|
||||
leafcutter: {
|
||||
labelStudioApiUrl,
|
||||
labelStudioApiKey,
|
||||
}
|
||||
} = await loadConfig();
|
||||
|
||||
leafcutter: { labelStudioApiUrl, labelStudioApiKey },
|
||||
} = config;
|
||||
const headers = {
|
||||
Authorization: `Token ${labelStudioApiKey}`,
|
||||
Accept: "application/json",
|
||||
|
|
@ -44,8 +44,10 @@ const getLabelStudioTickets = async (page: number): Promise<LabelStudioTicket[]>
|
|||
page: `${page}`,
|
||||
});
|
||||
console.log({ url: `${labelStudioApiUrl}/projects/1/tasks?${ticketsQuery}` });
|
||||
const res = await fetch(`${labelStudioApiUrl}/projects/1/tasks?${ticketsQuery}`,
|
||||
{ headers });
|
||||
const res = await fetch(
|
||||
`${labelStudioApiUrl}/projects/1/tasks?${ticketsQuery}`,
|
||||
{ headers },
|
||||
);
|
||||
console.log({ res });
|
||||
const tasksResult: any = await res.json();
|
||||
console.log({ tasksResult });
|
||||
|
|
@ -53,7 +55,9 @@ const getLabelStudioTickets = async (page: number): Promise<LabelStudioTicket[]>
|
|||
return tasksResult;
|
||||
};
|
||||
|
||||
const fetchFromLabelStudio = async (minUpdatedTimestamp: Date): Promise<LabelStudioTicket[]> => {
|
||||
const fetchFromLabelStudio = async (
|
||||
minUpdatedTimestamp: Date,
|
||||
): Promise<LabelStudioTicket[]> => {
|
||||
const pages = [...Array.from({ length: 10000 }).keys()];
|
||||
const allDocs: LabelStudioTicket[] = [];
|
||||
|
||||
|
|
@ -85,9 +89,9 @@ const sendToLeafcutter = async (tickets: LabelStudioTicket[]) => {
|
|||
contributorId,
|
||||
opensearchApiUrl,
|
||||
opensearchUsername,
|
||||
opensearchPassword
|
||||
}
|
||||
} = await loadConfig();
|
||||
opensearchPassword,
|
||||
},
|
||||
} = config;
|
||||
|
||||
console.log({ tickets });
|
||||
const filteredTickets = tickets.filter((ticket) => ticket.is_labeled);
|
||||
|
|
@ -96,11 +100,7 @@ const sendToLeafcutter = async (tickets: LabelStudioTicket[]) => {
|
|||
const {
|
||||
id,
|
||||
annotations,
|
||||
data: {
|
||||
source_id,
|
||||
source_created_at,
|
||||
source_updated_at
|
||||
}
|
||||
data: { source_id, source_created_at, source_updated_at },
|
||||
} = ticket;
|
||||
|
||||
const getTags = (tags: Record<string, any>[], name: string) =>
|
||||
|
|
@ -127,7 +127,7 @@ const sendToLeafcutter = async (tickets: LabelStudioTicket[]) => {
|
|||
origin: contributorId,
|
||||
origin_id: source_id as string,
|
||||
source_created_at: source_created_at as string,
|
||||
source_updated_at: source_updated_at as string
|
||||
source_updated_at: source_updated_at as string,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -144,21 +144,34 @@ const sendToLeafcutter = async (tickets: LabelStudioTicket[]) => {
|
|||
});
|
||||
console.log({ result });
|
||||
};
|
||||
|
||||
|
||||
*/
|
||||
const importLeafcutterTask = async (): Promise<void> => {
|
||||
/*
|
||||
withDb(async (db: AppDatabase) => {
|
||||
const { leafcutter: { contributorName } } = await loadConfig();
|
||||
const {
|
||||
leafcutter: { contributorName },
|
||||
} = config;
|
||||
const settingName = `${contributorName}ImportLeafcutterTask`;
|
||||
const res: any = await db.settings.findByName(settingName);
|
||||
const startTimestamp = res?.value?.minUpdatedTimestamp ? new Date(res.value.minUpdatedTimestamp as string) : new Date("2023-03-01");
|
||||
const startTimestamp = res?.value?.minUpdatedTimestamp
|
||||
? new Date(res.value.minUpdatedTimestamp as string)
|
||||
: new Date("2023-03-01");
|
||||
const newLastTimestamp = new Date();
|
||||
console.log({ contributorName, settingName, res, startTimestamp, newLastTimestamp });
|
||||
console.log({
|
||||
contributorName,
|
||||
settingName,
|
||||
res,
|
||||
startTimestamp,
|
||||
newLastTimestamp,
|
||||
});
|
||||
const tickets = await fetchFromLabelStudio(startTimestamp);
|
||||
console.log({ tickets });
|
||||
await sendToLeafcutter(tickets);
|
||||
await db.settings.upsert(settingName, { minUpdatedTimestamp: newLastTimestamp });
|
||||
await db.settings.upsert(settingName, {
|
||||
minUpdatedTimestamp: newLastTimestamp,
|
||||
});
|
||||
});
|
||||
*/
|
||||
};
|
||||
|
||||
export default importLeafcutterTask;
|
||||
49
apps/bridge-worker/tasks/signal/receive-signal-message.ts
Normal file
49
apps/bridge-worker/tasks/signal/receive-signal-message.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { db, getWorkerUtils } from "@link-stack/bridge-common";
|
||||
|
||||
interface ReceiveSignalMessageTaskOptions {
|
||||
token: string;
|
||||
to: string;
|
||||
from: string;
|
||||
messageId: string;
|
||||
sentAt: string;
|
||||
message: string;
|
||||
attachment?: string;
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
const receiveSignalMessageTask = async ({
|
||||
token,
|
||||
to,
|
||||
from,
|
||||
messageId,
|
||||
sentAt,
|
||||
message,
|
||||
attachment,
|
||||
filename,
|
||||
mimeType,
|
||||
}: ReceiveSignalMessageTaskOptions): Promise<void> => {
|
||||
console.log({ token, to, from });
|
||||
const worker = await getWorkerUtils();
|
||||
const row = await db
|
||||
.selectFrom("SignalBot")
|
||||
.selectAll()
|
||||
.where("id", "=", token)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const backendId = row.id;
|
||||
const payload = {
|
||||
to,
|
||||
from,
|
||||
message_id: messageId,
|
||||
sent_at: sentAt,
|
||||
message,
|
||||
attachment,
|
||||
filename,
|
||||
mime_type: mimeType,
|
||||
};
|
||||
|
||||
await worker.addJob("common/notify-webhooks", { backendId, payload });
|
||||
};
|
||||
|
||||
export default receiveSignalMessageTask;
|
||||
44
apps/bridge-worker/tasks/signal/send-signal-message.ts
Normal file
44
apps/bridge-worker/tasks/signal/send-signal-message.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { db } from "@link-stack/bridge-common";
|
||||
import * as signalApi from "@link-stack/signal-api";
|
||||
const { Configuration, MessagesApi } = signalApi;
|
||||
|
||||
interface SendSignalMessageTaskOptions {
|
||||
token: string;
|
||||
to: string;
|
||||
message: any;
|
||||
}
|
||||
|
||||
const sendSignalMessageTask = async ({
|
||||
token,
|
||||
to,
|
||||
message,
|
||||
}: SendSignalMessageTaskOptions): Promise<void> => {
|
||||
console.log({ token, to });
|
||||
const bot = await db
|
||||
.selectFrom("SignalBot")
|
||||
.selectAll()
|
||||
.where("token", "=", token)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const { phoneNumber: number } = bot;
|
||||
const config = new Configuration({
|
||||
basePath: process.env.BRIDGE_SIGNAL_URL,
|
||||
});
|
||||
const messagesClient = new MessagesApi(config);
|
||||
|
||||
try {
|
||||
const response = await messagesClient.v2SendPost({
|
||||
data: {
|
||||
number,
|
||||
recipients: [to],
|
||||
message,
|
||||
},
|
||||
});
|
||||
console.log({ response });
|
||||
} catch (error) {
|
||||
console.error({ error });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default sendSignalMessageTask;
|
||||
11
apps/bridge-worker/tasks/voice/receive-voice-message.ts
Normal file
11
apps/bridge-worker/tasks/voice/receive-voice-message.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// import { db, getWorkerUtils } from "@link-stack/bridge-common";
|
||||
|
||||
interface ReceiveVoiceMessageTaskOptions {
|
||||
message: any;
|
||||
}
|
||||
|
||||
const receiveVoiceMessageTask = async ({
|
||||
message,
|
||||
}: ReceiveVoiceMessageTaskOptions): Promise<void> => {};
|
||||
|
||||
export default receiveVoiceMessageTask;
|
||||
11
apps/bridge-worker/tasks/voice/send-voice-message.ts
Normal file
11
apps/bridge-worker/tasks/voice/send-voice-message.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// import { db, getWorkerUtils } from "@link-stack/bridge-common";
|
||||
|
||||
interface SendVoiceMessageTaskOptions {
|
||||
message: any;
|
||||
}
|
||||
|
||||
const sendVoiceMessageTask = async ({
|
||||
message,
|
||||
}: SendVoiceMessageTaskOptions): Promise<void> => {};
|
||||
|
||||
export default sendVoiceMessageTask;
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import Wreck from "@hapi/wreck";
|
||||
import { withDb, AppDatabase } from "../db";
|
||||
import { twilioClientFor } from "../common";
|
||||
import { withDb, AppDatabase } from "../../lib/db.js";
|
||||
import { twilioClientFor } from "../../lib/common.js";
|
||||
import { CallInstance } from "twilio/lib/rest/api/v2010/account/call";
|
||||
import workerUtils from "../utils";
|
||||
import workerUtils from "../../lib/utils.js";
|
||||
|
||||
interface WebhookPayload {
|
||||
startTime: string;
|
||||
|
|
@ -27,7 +27,7 @@ const getTwilioRecording = async (url: string) => {
|
|||
|
||||
const formatPayload = (
|
||||
call: CallInstance,
|
||||
recording: Buffer
|
||||
recording: Buffer,
|
||||
): WebhookPayload => {
|
||||
return {
|
||||
startTime: call.startTime.toISOString(),
|
||||
|
|
@ -45,12 +45,12 @@ const notifyWebhooks = async (
|
|||
db: AppDatabase,
|
||||
voiceLineId: string,
|
||||
call: CallInstance,
|
||||
recording: Buffer
|
||||
recording: Buffer,
|
||||
) => {
|
||||
const webhooks = await db.webhooks.findAllByBackendId("voice", voiceLineId);
|
||||
if (webhooks && webhooks.length === 0) return;
|
||||
|
||||
webhooks.forEach(({ id }) => {
|
||||
webhooks.forEach(({ id }: any) => {
|
||||
const payload = formatPayload(call, recording);
|
||||
workerUtils.addJob(
|
||||
"notify-webhook",
|
||||
|
|
@ -61,7 +61,7 @@ const notifyWebhooks = async (
|
|||
{
|
||||
// this de-depuplicates the job
|
||||
jobKey: `webhook-${id}-call-${call.sid}`,
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
|
@ -74,7 +74,7 @@ interface TwilioRecordingTaskOptions {
|
|||
}
|
||||
|
||||
const twilioRecordingTask = async (
|
||||
options: TwilioRecordingTaskOptions
|
||||
options: TwilioRecordingTaskOptions,
|
||||
): Promise<void> =>
|
||||
withDb(async (db: AppDatabase) => {
|
||||
const { voiceLineId, accountSid, callSid, recordingSid } = options;
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { createHash } from "crypto";
|
||||
import { withDb, AppDatabase } from "../db";
|
||||
import { convert } from "../lib/media-convert";
|
||||
import { withDb, AppDatabase } from "../../lib/db.js";
|
||||
import { convert } from "../../lib/media-convert.js";
|
||||
|
||||
interface VoiceLineAudioUpdateTaskOptions {
|
||||
voiceLineId: string;
|
||||
|
|
@ -13,7 +13,7 @@ const sha1sum = (v: any) => {
|
|||
};
|
||||
|
||||
const voiceLineAudioUpdateTask = async (
|
||||
payload: VoiceLineAudioUpdateTaskOptions
|
||||
payload: VoiceLineAudioUpdateTaskOptions,
|
||||
): Promise<void> =>
|
||||
withDb(async (db: AppDatabase) => {
|
||||
const { voiceLineId } = payload;
|
||||
|
|
@ -41,7 +41,7 @@ const voiceLineAudioUpdateTask = async (
|
|||
"audio/mpeg": mp3.toString("base64"),
|
||||
checksum: webmSha1,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import Twilio from "twilio";
|
||||
import config from "@digiresilience/metamigo-config";
|
||||
import { withDb, AppDatabase } from "../db";
|
||||
// import config from "@digiresilience/bridge-config";
|
||||
import { withDb, AppDatabase } from "../../lib/db.js";
|
||||
|
||||
const config: any = {};
|
||||
|
||||
interface VoiceLineDeleteTaskOptions {
|
||||
voiceLineId: string;
|
||||
|
|
@ -9,7 +11,7 @@ interface VoiceLineDeleteTaskOptions {
|
|||
}
|
||||
|
||||
const voiceLineDeleteTask = async (
|
||||
payload: VoiceLineDeleteTaskOptions
|
||||
payload: VoiceLineDeleteTaskOptions,
|
||||
): Promise<void> =>
|
||||
withDb(async (db: AppDatabase) => {
|
||||
const { voiceLineId, providerId, providerLineSid } = payload;
|
||||
|
|
@ -19,7 +21,7 @@ const voiceLineDeleteTask = async (
|
|||
const { accountSid, apiKeySid, apiKeySecret } = provider.credentials;
|
||||
if (!accountSid || !apiKeySid || !apiKeySecret)
|
||||
throw new Error(
|
||||
`twilio provider ${provider.name} does not have credentials`
|
||||
`twilio provider ${provider.name} does not have credentials`,
|
||||
);
|
||||
|
||||
const client = Twilio(apiKeySid, apiKeySecret, {
|
||||
|
|
@ -30,7 +32,7 @@ const voiceLineDeleteTask = async (
|
|||
if (
|
||||
number &&
|
||||
number.voiceUrl ===
|
||||
`${config.frontend.url}/api/v1/voice/twilio/record/${voiceLineId}`
|
||||
`${config.frontend.url}/api/v1/voice/twilio/record/${voiceLineId}`
|
||||
)
|
||||
await client.incomingPhoneNumbers(providerLineSid).update({
|
||||
voiceUrl: "",
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
import Twilio from "twilio";
|
||||
import config from "@digiresilience/metamigo-config";
|
||||
import { withDb, AppDatabase } from "../db";
|
||||
// import config from "@digiresilience/bridge-config";
|
||||
import { withDb, AppDatabase } from "../../lib/db.js";
|
||||
|
||||
const config: any = {};
|
||||
|
||||
interface VoiceLineUpdateTaskOptions {
|
||||
voiceLineId: string;
|
||||
}
|
||||
|
||||
const voiceLineUpdateTask = async (
|
||||
payload: VoiceLineUpdateTaskOptions
|
||||
payload: VoiceLineUpdateTaskOptions,
|
||||
): Promise<void> =>
|
||||
withDb(async (db: AppDatabase) => {
|
||||
const { voiceLineId } = payload;
|
||||
|
|
@ -22,7 +24,7 @@ const voiceLineUpdateTask = async (
|
|||
const { accountSid, apiKeySid, apiKeySecret } = provider.credentials;
|
||||
if (!accountSid || !apiKeySid || !apiKeySecret)
|
||||
throw new Error(
|
||||
`twilio provider ${provider.name} does not have credentials`
|
||||
`twilio provider ${provider.name} does not have credentials`,
|
||||
);
|
||||
|
||||
const client = Twilio(apiKeySid, apiKeySecret, {
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { db, getWorkerUtils } from "@link-stack/bridge-common";
|
||||
|
||||
interface ReceiveWhatsappMessageTaskOptions {
|
||||
token: string;
|
||||
to: string;
|
||||
from: string;
|
||||
messageId: string;
|
||||
sentAt: string;
|
||||
message: string;
|
||||
attachment?: string;
|
||||
filename?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
const receiveWhatsappMessageTask = async ({
|
||||
token,
|
||||
to,
|
||||
from,
|
||||
messageId,
|
||||
sentAt,
|
||||
message,
|
||||
attachment,
|
||||
filename,
|
||||
mimeType,
|
||||
}: ReceiveWhatsappMessageTaskOptions): Promise<void> => {
|
||||
console.log({ token, to, from });
|
||||
|
||||
const worker = await getWorkerUtils();
|
||||
const row = await db
|
||||
.selectFrom("WhatsappBot")
|
||||
.selectAll()
|
||||
.where("id", "=", token)
|
||||
.executeTakeFirstOrThrow();
|
||||
const backendId = row.id;
|
||||
const payload = {
|
||||
to,
|
||||
from,
|
||||
message_id: messageId,
|
||||
sent_at: sentAt,
|
||||
message,
|
||||
attachment,
|
||||
filename,
|
||||
mime_type: mimeType,
|
||||
};
|
||||
|
||||
await worker.addJob("common/notify-webhooks", { backendId, payload });
|
||||
};
|
||||
|
||||
export default receiveWhatsappMessageTask;
|
||||
35
apps/bridge-worker/tasks/whatsapp/send-whatsapp-message.ts
Normal file
35
apps/bridge-worker/tasks/whatsapp/send-whatsapp-message.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { db } from "@link-stack/bridge-common";
|
||||
|
||||
interface SendWhatsappMessageTaskOptions {
|
||||
token: string;
|
||||
to: string;
|
||||
message: any;
|
||||
}
|
||||
|
||||
const sendWhatsappMessageTask = async ({
|
||||
message,
|
||||
to,
|
||||
token,
|
||||
}: SendWhatsappMessageTaskOptions): Promise<void> => {
|
||||
const bot = await db
|
||||
.selectFrom("WhatsappBot")
|
||||
.selectAll()
|
||||
.where("token", "=", token)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const url = `${process.env.BRIDGE_WHATSAPP_URL}/api/bots/${bot.id}/send`;
|
||||
const params = { message, phoneNumber: to };
|
||||
try {
|
||||
const result = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
console.log({ result });
|
||||
} catch (error) {
|
||||
console.error({ error });
|
||||
throw new Error("Failed to send message");
|
||||
}
|
||||
};
|
||||
|
||||
export default sendWhatsappMessageTask;
|
||||
8
apps/bridge-worker/tsconfig.json
Normal file
8
apps/bridge-worker/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "@link-stack/typescript-config/tsconfig.node.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "build/main"
|
||||
},
|
||||
"include": ["**/*.ts", "**/.*.ts"],
|
||||
"exclude": ["node_modules", "build"]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { useTranslate } from "react-polyglot";
|
|||
import { LanguageSelect } from "app/_components/LanguageSelect";
|
||||
import LeafcutterLogoLarge from "images/leafcutter-logo-large.png";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useAppContext } from "app/_components/AppProvider";
|
||||
import { useLeafcutterContext } from "@link-stack/leafcutter-ui";
|
||||
|
||||
type LoginProps = {
|
||||
session: any;
|
||||
|
|
@ -20,7 +20,7 @@ export const Login: FC<LoginProps> = ({ session }) => {
|
|||
const {
|
||||
colors: { leafcutterElectricBlue, lightGray },
|
||||
typography: { h1, h4 },
|
||||
} = useAppContext();
|
||||
} = useLeafcutterContext();
|
||||
const buttonStyles = {
|
||||
backgroundColor: lightGray,
|
||||
borderRadius: 500,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { About } from "leafcutter-common";
|
||||
import { About } from "@link-stack/leafcutter-ui";
|
||||
|
||||
export default function Page() {
|
||||
return <About />;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { FAQ } from "leafcutter-common";
|
||||
import { FAQ } from "@link-stack/leafcutter-ui";
|
||||
|
||||
export default function Page() {
|
||||
return <FAQ />;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -11,13 +11,13 @@ import {
|
|||
bindTrigger,
|
||||
bindMenu,
|
||||
} from "material-ui-popup-state/hooks";
|
||||
import { useAppContext } from "leafcutter-common/components/AppProvider";
|
||||
import { useLeafcutterContext } from "@link-stack/leafcutter-ui/components/LeafcutterProvider";
|
||||
|
||||
export const AccountButton: FC = () => {
|
||||
const t = useTranslate();
|
||||
const {
|
||||
colors: { leafcutterElectricBlue },
|
||||
} = useAppContext();
|
||||
} = useLeafcutterContext();
|
||||
const popupState = usePopupState({ variant: "popover", popupId: "account" });
|
||||
|
||||
return (
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue