diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c9e32a2 --- /dev/null +++ b/.env.example @@ -0,0 +1,68 @@ +# ============================================================================= +# OpenThreads — Environment Variables +# Copy this file to .env and fill in your values. +# ============================================================================= + +# Server +PORT=3000 +NODE_ENV=development + +# Logging (structured JSON logging) +# LOG_LEVEL=info # debug | info | warn | error (default: info) +# LOG_FORMAT=text # json | text (default: json in production, text in development) + +# MongoDB +# When using Docker Compose: mongodb://openthreads:openthreads@localhost:27017/openthreads +MONGODB_URI=mongodb://openthreads:openthreads@localhost:27017/openthreads + +# Security +# IMPORTANT: Change this in production! Use a long, random string. +JWT_SECRET=change-me-in-production + +# Management API Key — required to access /api/channels, /api/recipients, /api/routes +# Leave unset to allow unauthenticated access in development. +# MANAGEMENT_API_KEY=change-me-in-production + +# Reply Token TTL (in seconds) +# Default: 86400 (24 hours) +REPLY_TOKEN_TTL=86400 + +# Base URL for OpenThreads (used to build replyTo URLs and form links) +OPENTHREADS_BASE_URL=http://localhost:3000 + +# ============================================================================= +# Channel Credentials +# Add credentials for each channel you want to enable. +# ============================================================================= + +# Slack +# SLACK_BOT_TOKEN=xoxb-your-slack-bot-token +# SLACK_SIGNING_SECRET=your-slack-signing-secret +# SLACK_APP_TOKEN=xapp-your-slack-app-token + +# Telegram +# TELEGRAM_BOT_TOKEN=your-telegram-bot-token + +# Discord +# DISCORD_BOT_TOKEN=your-discord-bot-token +# DISCORD_CLIENT_ID=your-discord-client-id + +# ============================================================================= +# Trust Layer (optional) +# Enable for production deployments requiring strong authentication and +# cryptographic evidence (JWS signing, WebAuthn, audit logging). +# ============================================================================= + +# TRUST_LAYER_ENABLED=false +# TRUST_JWS_ALGORITHM=RS256 +# TRUST_PRIVATE_KEY_PATH=./keys/private.pem +# TRUST_PUBLIC_KEY_PATH=./keys/public.pem + +# ============================================================================= +# Observability (optional) +# ============================================================================= + +# OpenTelemetry — set OTEL_EXPORTER_OTLP_ENDPOINT to enable tracing +# OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 +# OTEL_SERVICE_NAME=openthreads +# OTEL_SDK_DISABLED=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c305e91 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Dependencies +node_modules/ + +# Build outputs +dist/ +.next/ +*.tsbuildinfo + +# Environment files +.env +.env.local +.env.*.local +!.env.example + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +bun-debug.log* + +# Test coverage +coverage/ + +# OS +.DS_Store diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..5fed30d --- /dev/null +++ b/.prettierignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.next/ +coverage/ +*.lockb +bun.lockb diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..f5fd668 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e7d6822 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# ─── Build stage ───────────────────────────────────────────────────────────── +FROM oven/bun:1.2 AS builder + +WORKDIR /app + +# Copy workspace manifests first for layer caching +COPY package.json bun.lockb* ./ +COPY packages/core/package.json ./packages/core/ +COPY packages/server/package.json ./packages/server/ +COPY packages/storage/mongodb/package.json ./packages/storage/mongodb/ +COPY packages/trust/package.json ./packages/trust/ +COPY packages/channels/package.json ./packages/channels/ +COPY packages/channels/discord/package.json ./packages/channels/discord/ +COPY packages/channels/slack/package.json ./packages/channels/slack/ +COPY packages/channels/telegram/package.json ./packages/channels/telegram/ +COPY packages/channels/whatsapp/package.json ./packages/channels/whatsapp/ + +RUN bun install --frozen-lockfile + +# Copy source +COPY tsconfig.base.json tsconfig.json ./ +COPY packages ./packages + +# Build the Next.js server +WORKDIR /app/packages/server +ENV NEXT_TELEMETRY_DISABLED=1 +RUN bun run build + +# ─── Production stage ───────────────────────────────────────────────────────── +FROM oven/bun:1.2-slim AS runner + +WORKDIR /app + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +# Copy built artifacts +COPY --from=builder /app/packages/server/.next/standalone ./ +COPY --from=builder /app/packages/server/.next/static ./packages/server/.next/static +COPY --from=builder /app/packages/server/public ./packages/server/public 2>/dev/null || true + +USER nextjs + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD bun -e "fetch('http://localhost:3000/api/health').then(r=>r.ok?process.exit(0):process.exit(1)).catch(()=>process.exit(1))" + +CMD ["bun", "packages/server/server.js"] diff --git a/deploy/helm/openthreads/Chart.yaml b/deploy/helm/openthreads/Chart.yaml new file mode 100644 index 0000000..2d97963 --- /dev/null +++ b/deploy/helm/openthreads/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v2 +name: openthreads +description: > + OpenThreads — a unified messaging gateway with human-in-the-loop support. + Bridges Slack, Discord, Telegram, WhatsApp, and other channels to any + HTTP-based agent or service via the A2H protocol. +type: application +version: 0.1.0 +appVersion: "0.1.0" +keywords: + - openthreads + - a2h + - human-in-the-loop + - messaging + - webhook +home: https://github.com/deepducks/OpenThreads +sources: + - https://github.com/deepducks/OpenThreads +maintainers: + - name: DeepDucks + url: https://github.com/deepducks diff --git a/deploy/helm/openthreads/templates/_helpers.tpl b/deploy/helm/openthreads/templates/_helpers.tpl new file mode 100644 index 0000000..8d6003d --- /dev/null +++ b/deploy/helm/openthreads/templates/_helpers.tpl @@ -0,0 +1,60 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "openthreads.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "openthreads.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart label. +*/}} +{{- define "openthreads.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels. +*/}} +{{- define "openthreads.labels" -}} +helm.sh/chart: {{ include "openthreads.chart" . }} +{{ include "openthreads.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels. +*/}} +{{- define "openthreads.selectorLabels" -}} +app.kubernetes.io/name: {{ include "openthreads.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +ServiceAccount name. +*/}} +{{- define "openthreads.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "openthreads.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/deploy/helm/openthreads/templates/deployment.yaml b/deploy/helm/openthreads/templates/deployment.yaml new file mode 100644 index 0000000..bf9db38 --- /dev/null +++ b/deploy/helm/openthreads/templates/deployment.yaml @@ -0,0 +1,74 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "openthreads.fullname" . }} + labels: + {{- include "openthreads.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "openthreads.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + {{- if .Values.metrics.annotations }} + prometheus.io/scrape: "true" + prometheus.io/port: "{{ .Values.service.port }}" + prometheus.io/path: "/api/metrics" + {{- end }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "openthreads.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "openthreads.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + - name: http + containerPort: {{ .Values.service.port }} + protocol: TCP + envFrom: + - secretRef: + name: {{ if .Values.existingSecret }}{{ .Values.existingSecret }}{{ else }}{{ include "openthreads.fullname" . }}{{ end }} + env: + {{- range $key, $value := .Values.env }} + - name: {{ $key }} + value: {{ $value | quote }} + {{- end }} + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/helm/openthreads/templates/hpa.yaml b/deploy/helm/openthreads/templates/hpa.yaml new file mode 100644 index 0000000..6dfdd03 --- /dev/null +++ b/deploy/helm/openthreads/templates/hpa.yaml @@ -0,0 +1,22 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "openthreads.fullname" . }} + labels: + {{- include "openthreads.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "openthreads.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} +{{- end }} diff --git a/deploy/helm/openthreads/templates/ingress.yaml b/deploy/helm/openthreads/templates/ingress.yaml new file mode 100644 index 0000000..ae51274 --- /dev/null +++ b/deploy/helm/openthreads/templates/ingress.yaml @@ -0,0 +1,35 @@ +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "openthreads.fullname" . }} + labels: + {{- include "openthreads.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- toYaml .Values.ingress.tls | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "openthreads.fullname" $ }} + port: + number: {{ $.Values.service.port }} + {{- end }} + {{- end }} +{{- end }} diff --git a/deploy/helm/openthreads/templates/secret.yaml b/deploy/helm/openthreads/templates/secret.yaml new file mode 100644 index 0000000..6db4451 --- /dev/null +++ b/deploy/helm/openthreads/templates/secret.yaml @@ -0,0 +1,15 @@ +{{- if not .Values.existingSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "openthreads.fullname" . }} + labels: + {{- include "openthreads.labels" . | nindent 4 }} +type: Opaque +stringData: + {{- range $key, $value := .Values.secrets }} + {{- if $value }} + {{ $key }}: {{ $value | quote }} + {{- end }} + {{- end }} +{{- end }} diff --git a/deploy/helm/openthreads/templates/service.yaml b/deploy/helm/openthreads/templates/service.yaml new file mode 100644 index 0000000..0c3d9ad --- /dev/null +++ b/deploy/helm/openthreads/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "openthreads.fullname" . }} + labels: + {{- include "openthreads.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "openthreads.selectorLabels" . | nindent 4 }} diff --git a/deploy/helm/openthreads/templates/serviceaccount.yaml b/deploy/helm/openthreads/templates/serviceaccount.yaml new file mode 100644 index 0000000..d18712e --- /dev/null +++ b/deploy/helm/openthreads/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "openthreads.serviceAccountName" . }} + labels: + {{- include "openthreads.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} diff --git a/deploy/helm/openthreads/values.yaml b/deploy/helm/openthreads/values.yaml new file mode 100644 index 0000000..3578ab3 --- /dev/null +++ b/deploy/helm/openthreads/values.yaml @@ -0,0 +1,136 @@ +# Default values for the OpenThreads Helm chart. +# Override these in a custom values file: helm install openthreads ./openthreads -f my-values.yaml + +# ─── Image ──────────────────────────────────────────────────────────────────── +image: + repository: ghcr.io/deepducks/openthreads + tag: "" # Defaults to .Chart.AppVersion when empty + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +# ─── Deployment ─────────────────────────────────────────────────────────────── +replicaCount: 1 + +podAnnotations: {} +podLabels: {} + +podSecurityContext: + runAsNonRoot: true + runAsUser: 1001 + +securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: [ALL] + readOnlyRootFilesystem: true + +resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + +nodeSelector: {} +tolerations: [] +affinity: {} + +# ─── Service ────────────────────────────────────────────────────────────────── +service: + type: ClusterIP + port: 3000 + +# ─── Ingress ────────────────────────────────────────────────────────────────── +ingress: + enabled: false + className: "" + annotations: {} + hosts: + - host: openthreads.example.com + paths: + - path: / + pathType: Prefix + tls: [] + +# ─── Environment ────────────────────────────────────────────────────────────── +env: + NODE_ENV: production + PORT: "3000" + LOG_LEVEL: info + LOG_FORMAT: json + REPLY_TOKEN_TTL: "86400" + +# ─── Secrets (stored in a Kubernetes Secret — do NOT put plain values here) ─── +# Reference an existing secret instead: +# existingSecret: my-openthreads-secret +# Or let the chart create one from the values below (not recommended for prod): +secrets: + # Required + JWT_SECRET: "" + MONGODB_URI: "" + # Optional + MANAGEMENT_API_KEY: "" + OPENTHREADS_BASE_URL: "" + # Channel credentials + SLACK_BOT_TOKEN: "" + SLACK_SIGNING_SECRET: "" + SLACK_APP_TOKEN: "" + TELEGRAM_BOT_TOKEN: "" + DISCORD_BOT_TOKEN: "" + DISCORD_CLIENT_ID: "" + # Trust layer + TRUST_LAYER_ENABLED: "false" + +# Reference an existing secret instead of creating one from 'secrets' above. +existingSecret: "" + +# ─── Health check ───────────────────────────────────────────────────────────── +livenessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 15 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + +readinessProbe: + httpGet: + path: /api/health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 3 + +# ─── MongoDB (sub-chart, disabled by default — use an external MongoDB in prod) ─ +mongodb: + enabled: false + auth: + rootPassword: "" + username: openthreads + password: openthreads + database: openthreads + +# ─── ServiceAccount ─────────────────────────────────────────────────────────── +serviceAccount: + create: true + automount: true + annotations: {} + name: "" + +# ─── Autoscaling ────────────────────────────────────────────────────────────── +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 5 + targetCPUUtilizationPercentage: 70 + +# ─── Prometheus scraping ────────────────────────────────────────────────────── +metrics: + # Add Prometheus scrape annotations to the pod + annotations: true diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b0b1e16 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,73 @@ +version: '3.8' + +# ─── Profiles ───────────────────────────────────────────────────────────────── +# +# (default) mongodb only — use for local dev with `bun run dev` +# production full stack: mongodb + openthreads app +# +# Usage: +# docker compose up # dev: MongoDB only +# docker compose --profile production up -d # prod: full stack + +services: + mongodb: + image: mongo:7.0 + container_name: openthreads-mongodb + ports: + - '27017:27017' + environment: + MONGO_INITDB_ROOT_USERNAME: openthreads + MONGO_INITDB_ROOT_PASSWORD: openthreads + MONGO_INITDB_DATABASE: openthreads + volumes: + - mongodb_data:/data/db + healthcheck: + test: ['CMD', 'mongosh', '--eval', "db.adminCommand('ping')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + restart: unless-stopped + + app: + profiles: [production] + image: ghcr.io/deepducks/openthreads:latest + build: + context: . + dockerfile: Dockerfile + container_name: openthreads-app + ports: + - '${PORT:-3000}:3000' + environment: + NODE_ENV: production + PORT: 3000 + MONGODB_URI: mongodb://openthreads:openthreads@mongodb:27017/openthreads + JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set} + MANAGEMENT_API_KEY: ${MANAGEMENT_API_KEY:-} + OPENTHREADS_BASE_URL: ${OPENTHREADS_BASE_URL:-http://localhost:3000} + REPLY_TOKEN_TTL: ${REPLY_TOKEN_TTL:-86400} + LOG_LEVEL: ${LOG_LEVEL:-info} + LOG_FORMAT: ${LOG_FORMAT:-json} + # Channel credentials (set the ones you need) + SLACK_BOT_TOKEN: ${SLACK_BOT_TOKEN:-} + SLACK_SIGNING_SECRET: ${SLACK_SIGNING_SECRET:-} + SLACK_APP_TOKEN: ${SLACK_APP_TOKEN:-} + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} + DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN:-} + DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID:-} + # Trust layer (optional) + TRUST_LAYER_ENABLED: ${TRUST_LAYER_ENABLED:-false} + depends_on: + mongodb: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ['CMD-SHELL', 'curl -f http://localhost:3000/api/health || exit 1'] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + +volumes: + mongodb_data: + driver: local diff --git a/docs/adapter-authoring.md b/docs/adapter-authoring.md new file mode 100644 index 0000000..c2eb6da --- /dev/null +++ b/docs/adapter-authoring.md @@ -0,0 +1,147 @@ +# Custom Channel Adapter Guide + +OpenThreads uses a `ChannelAdapter` interface to abstract platform-specific +messaging. This guide explains how to implement a custom adapter for any +platform not natively supported. + +## When do you need a custom adapter? + +- Your platform isn't supported out of the box (WhatsApp via Baileys, Signal, Matrix, etc.) +- You need custom rendering for A2H intents on a supported platform +- You're integrating with an internal messaging system + +## The ChannelAdapter interface + +```typescript +import type { ChannelAdapter } from '@openthreads/core'; + +export class MyAdapter implements ChannelAdapter { + // Report what your platform supports + capabilities(): ChannelCapabilities { ... } + + // Set up webhooks / subscriptions when a channel is registered + async register(config: ChannelConfig): Promise { ... } + + // Send a message to a target (channel, group, user) + async sendMessage(target: string, message: ...): Promise { ... } + + // Render a Chat SDK message in platform-native format + async renderChatSDK(message: ChatSDKMessage, capabilities): Promise { ... } + + // Render an A2H intent as inline interactive elements (buttons, menus) + // Only called for Method 1 (inline rendering) + async renderA2HInline(intent: A2HMessage, capabilities): Promise { ... } + + // Capture a free-text response from the human (Method 2) + async captureResponse(thread: Thread, turn: Turn): Promise { ... } +} +``` + +## Step-by-step: implement a minimal adapter + +### 1. Create a new package + +``` +packages/channels/my-platform/ + src/ + adapter.ts # ChannelAdapter implementation + index.ts # public exports + package.json +``` + +### 2. Declare capabilities + +```typescript +capabilities(): ChannelCapabilities { + return { + threads: false, // does your platform have native threads? + buttons: true, // can you render interactive buttons? + selectMenus: false, // can you render dropdown menus? + replyMessages: true, // can senders reply to specific messages? + dms: true, // does it support DMs? + fileUpload: false, // can you upload files? + }; +} +``` + +The Reply Engine uses these flags to select the best method (1-4) for each +A2H intent. Reporting wrong capabilities leads to degraded UX. + +### 3. Implement `renderChatSDK` + +Map the Chat SDK message to your platform's native format: + +```typescript +async renderChatSDK( + message: ChatSDKMessage, + _capabilities: ChannelCapabilities, +): Promise { + return { + text: message.text ?? '', + // Add platform-specific fields + }; +} +``` + +### 4. Implement `renderA2HInline` (Method 1) + +For `AUTHORIZE` (approve/deny) and `COLLECT` with closed options: + +```typescript +async renderA2HInline( + intent: A2HMessage, + _capabilities: ChannelCapabilities, +): Promise { + if (intent.intent === 'AUTHORIZE') { + return { + text: intent.description ?? 'Action requires your approval', + // Platform-specific button payload + buttons: [ + { id: 'approve', text: 'Approve', value: 'true' }, + { id: 'deny', text: 'Deny', value: 'false' }, + ], + }; + } + // Handle COLLECT with options... +} +``` + +### 5. Implement `captureResponse` (Method 2) + +For free-text collection via thread/reply: + +```typescript +async captureResponse(thread: Thread, turn: Turn): Promise { + // Set up a listener for the next message in this thread from the sender + // This depends heavily on your platform's event system + return new Promise((resolve) => { + // ... platform-specific listener + }); +} +``` + +### 6. Implement `register` + +```typescript +async register(config: ChannelConfig): Promise { + // Store config + // Register webhooks with the platform + // Subscribe to events +} +``` + +## Example: WhatsApp via Baileys + +See `packages/channels/whatsapp/` for a real-world example using the +[Baileys](https://github.com/WhiskeySockets/Baileys) library. + +## Publishing your adapter + +Adapters are standalone npm packages. Publish yours and users can install it +alongside OpenThreads: + +```bash +npm install @my-org/openthreads-adapter-myplatform +``` + +Then register it in the OpenThreads channel configuration. diff --git a/docs/channels/slack.md b/docs/channels/slack.md new file mode 100644 index 0000000..6251c57 --- /dev/null +++ b/docs/channels/slack.md @@ -0,0 +1,77 @@ +# Channel Setup: Slack + +## Prerequisites + +- A Slack workspace where you have admin permissions +- OpenThreads running at a publicly accessible HTTPS URL (required for webhooks) + +## Step 1 — Create a Slack App + +1. Go to [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → **From scratch** +2. Name your app (e.g., `OpenThreads`) and choose your workspace +3. Note your **App ID** and **Signing Secret** (under **Basic Information**) + +## Step 2 — Configure OAuth scopes + +Under **OAuth & Permissions** → **Scopes** → **Bot Token Scopes**, add: + +| Scope | Purpose | +|---|---| +| `channels:history` | Read messages in public channels | +| `channels:read` | List channels | +| `chat:write` | Post messages | +| `im:history` | Read DMs | +| `im:write` | Send DMs | +| `groups:history` | Read private channels | +| `users:read` | Look up user info | +| `app_mentions:read` | Receive mention events | +| `reactions:write` | Add emoji reactions | + +For interactive components (A2H Method 1 — buttons): + +| Scope | Purpose | +|---|---| +| `chat:write.customize` | Post as custom names/icons | + +## Step 3 — Enable Event Subscriptions + +Under **Event Subscriptions**: +1. Toggle **Enable Events** on +2. Set **Request URL** to: `https://your-openthreads.example.com/webhook/` +3. Subscribe to bot events: + - `message.channels` + - `message.im` + - `message.groups` + - `app_mention` + +## Step 4 — Enable Interactivity (for A2H Method 1) + +Under **Interactivity & Shortcuts**: +1. Toggle **Interactivity** on +2. Set **Request URL** to: `https://your-openthreads.example.com/webhook//interactive` + +## Step 5 — Install the app + +Under **OAuth & Permissions** → **Install to Workspace**. Copy the **Bot User OAuth Token** (starts with `xoxb-`). + +## Step 6 — Register in OpenThreads + +```bash +curl -s -X POST http://localhost:3000/api/channels \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $MANAGEMENT_API_KEY" \ + -d '{ + "id": "my-slack", + "name": "My Slack", + "platform": "slack", + "credentialsRef": "slack-main" + }' | jq . +``` + +Set environment variables: + +```bash +SLACK_BOT_TOKEN=xoxb-your-bot-token +SLACK_SIGNING_SECRET=your-signing-secret +SLACK_APP_TOKEN=xapp-your-app-token # for Socket Mode +``` diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md new file mode 100644 index 0000000..e694541 --- /dev/null +++ b/docs/channels/telegram.md @@ -0,0 +1,62 @@ +# Channel Setup: Telegram + +## Prerequisites + +- A Telegram account +- OpenThreads running at a publicly accessible HTTPS URL (required for webhooks) + +## Step 1 — Create a bot + +1. Open Telegram and message [@BotFather](https://t.me/botfather) +2. Send `/newbot` and follow the prompts +3. Copy the **bot token** (format: `123456789:ABCdefGhIJKlmNoPQRstUVwxyZ`) + +## Step 2 — Configure the bot (optional) + +``` +/setdescription — add a description +/setuserpic — add a profile photo +/setcommands — define bot commands (e.g., /help) +``` + +## Step 3 — Register webhook + +OpenThreads registers the webhook automatically when you start the server with +`TELEGRAM_BOT_TOKEN` set. You can verify: + +```bash +curl https://api.telegram.org/bot/getWebhookInfo +``` + +## Step 4 — Register in OpenThreads + +```bash +curl -s -X POST http://localhost:3000/api/channels \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $MANAGEMENT_API_KEY" \ + -d '{ + "id": "my-telegram", + "name": "My Telegram Bot", + "platform": "telegram", + "credentialsRef": "telegram-main" + }' | jq . +``` + +Set environment variables: + +```bash +TELEGRAM_BOT_TOKEN=123456789:ABCdefGhIJKlmNoPQRstUVwxyZ +# Optional: restrict webhook updates to a secret token +TELEGRAM_WEBHOOK_SECRET=your-random-secret +``` + +## Telegram capabilities + +| Feature | Supported | +|---|---| +| Inline keyboards (buttons) | Yes — A2H Method 1 | +| Reply to message | Yes — A2H Method 2 | +| Native threads | No (groups only, not DMs) | +| File uploads | Yes | +| Group chats | Yes | +| DMs | Yes | diff --git a/docs/self-hosting.md b/docs/self-hosting.md new file mode 100644 index 0000000..a91c7ac --- /dev/null +++ b/docs/self-hosting.md @@ -0,0 +1,169 @@ +# Self-Hosting Guide + +OpenThreads is designed to be self-hosted. This guide covers Docker, environment +variable configuration, and MongoDB setup. + +## Prerequisites + +- Docker + Docker Compose (v2.x) +- A domain name with HTTPS (required for Slack/Discord webhooks in production) +- MongoDB 7.x (managed by Docker Compose or an external service) + +--- + +## Quick start with Docker Compose + +### 1. Clone or scaffold + +```bash +# Scaffold a new deployment: +bunx create-openthreads my-deployment +cd my-deployment + +# Or clone the repository: +git clone https://github.com/deepducks/OpenThreads.git +cd OpenThreads +``` + +### 2. Configure environment + +```bash +cp .env.example .env +# Edit .env — at minimum, set JWT_SECRET and MONGODB_URI +``` + +### 3. Start + +```bash +# Start MongoDB + OpenThreads +docker compose --profile production up -d + +# Check logs +docker compose logs -f app + +# Health check +curl http://localhost:3000/api/health +``` + +--- + +## Environment Variables + +### Required + +| Variable | Description | Example | +|---|---|---| +| `MONGODB_URI` | MongoDB connection URI | `mongodb://user:pass@host:27017/openthreads` | +| `JWT_SECRET` | Secret for signing JWTs — use a long random string | `openssl rand -hex 32` | + +### Recommended for production + +| Variable | Description | Default | +|---|---|---| +| `MANAGEMENT_API_KEY` | Protects `/api/*` management endpoints | unset (open) | +| `OPENTHREADS_BASE_URL` | Public base URL (used in `replyTo` URLs) | `http://localhost:3000` | +| `NODE_ENV` | Set to `production` in prod | `development` | +| `LOG_LEVEL` | `debug` \| `info` \| `warn` \| `error` | `info` | +| `LOG_FORMAT` | `json` for structured logging, `text` for human-readable | `json` in prod | +| `REPLY_TOKEN_TTL` | `replyTo` token TTL in seconds | `86400` (24h) | + +### Channel credentials + +Set the credentials for each channel you want to enable. See the [channel setup guides](./channels/) for how to obtain these values. + +| Variable | Platform | +|---|---| +| `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET`, `SLACK_APP_TOKEN` | Slack | +| `TELEGRAM_BOT_TOKEN` | Telegram | +| `DISCORD_BOT_TOKEN`, `DISCORD_CLIENT_ID` | Discord | + +### Trust layer (optional) + +| Variable | Description | Default | +|---|---|---| +| `TRUST_LAYER_ENABLED` | Enable JWS signing and WebAuthn | `false` | +| `TRUST_JWS_ALGORITHM` | JWS algorithm | `RS256` | +| `TRUST_PRIVATE_KEY_PATH` | Path to private key PEM | — | +| `TRUST_PUBLIC_KEY_PATH` | Path to public key PEM | — | + +### OpenTelemetry (optional) + +| Variable | Description | +|---|---| +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP endpoint, e.g. `http://otel-collector:4318` | +| `OTEL_SERVICE_NAME` | Service name tag (default: `openthreads`) | +| `OTEL_SDK_DISABLED` | Set to `true` to disable tracing | + +--- + +## MongoDB Setup + +### Using the bundled Docker Compose MongoDB + +The included `docker-compose.yml` starts a MongoDB 7 instance with: +- Username: `openthreads` +- Password: `openthreads` +- Database: `openthreads` +- Data persisted in the `mongodb_data` Docker volume + +Connection string: `mongodb://openthreads:openthreads@localhost:27017/openthreads` + +### Using an external MongoDB + +Set `MONGODB_URI` to your connection string. OpenThreads creates indexes +automatically on first start. + +Recommended: MongoDB Atlas free tier for small deployments. + +### Indexes + +OpenThreads automatically ensures the following indexes on startup: +- `channels.id` (unique) +- `recipients.id` (unique) +- `threads.threadId` (unique), `threads.channelId+nativeThreadId` +- `turns.turnId` (unique), `turns.threadId+timestamp` +- `routes.id` (unique), `routes.priority` +- `tokens.value` (unique, with TTL) +- `audit_log.*` (several indexes) + +--- + +## Production checklist + +- [ ] `JWT_SECRET` is a long random string (not `change-me`) +- [ ] `MANAGEMENT_API_KEY` is set (protects admin API) +- [ ] `OPENTHREADS_BASE_URL` is your public HTTPS URL +- [ ] `NODE_ENV=production` +- [ ] `LOG_FORMAT=json` (for log aggregators like Loki/CloudWatch) +- [ ] TLS termination via reverse proxy (nginx, Caddy, Cloudflare Tunnel) +- [ ] MongoDB has authentication enabled and is not exposed publicly +- [ ] Prometheus scraping configured (if using `LOG_FORMAT=json`) + +--- + +## Reverse proxy setup + +### Caddy (recommended) + +```caddyfile +openthreads.example.com { + reverse_proxy localhost:3000 +} +``` + +### Nginx + +```nginx +server { + listen 443 ssl; + server_name openthreads.example.com; + + location / { + proxy_pass http://localhost:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..8b0fdbe --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,38 @@ +import js from '@eslint/js' +import tsPlugin from '@typescript-eslint/eslint-plugin' +import tsParser from '@typescript-eslint/parser' +import prettierConfig from 'eslint-config-prettier' + +export default [ + js.configs.recommended, + { + files: ['**/*.{ts,tsx}'], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + }, + plugins: { + '@typescript-eslint': tsPlugin, + }, + rules: { + ...tsPlugin.configs.recommended.rules, + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + '@typescript-eslint/no-explicit-any': 'warn', + }, + }, + prettierConfig, + { + ignores: [ + '**/dist/**', + '**/node_modules/**', + '**/.next/**', + '**/coverage/**', + '*.config.js', + '*.config.mjs', + '*.config.ts', + ], + }, +] diff --git a/examples/langgraph/README.md b/examples/langgraph/README.md new file mode 100644 index 0000000..f29a55c --- /dev/null +++ b/examples/langgraph/README.md @@ -0,0 +1,69 @@ +# Example: LangGraph Integration + +Use OpenThreads as the human-in-the-loop channel for a LangGraph agent. When +the agent needs human input (approval, data collection, escalation), it sends +an A2H intent to OpenThreads via the `replyTo` URL and blocks until the human +responds. + +## Architecture + +``` +Human (Slack/Telegram/Discord) + │ inbound message + ▼ +OpenThreads ──envelope──► LangGraph agent + │ (processes, may interrupt) + │ POST replyTo A2H intent + ▼ +OpenThreads ──renders──► Human (approve/deny buttons, form, etc.) + │ human responds + ▼ +OpenThreads ──response──► LangGraph agent (interrupt resolves) +``` + +## Prerequisites + +```bash +pip install langgraph langchain-openai httpx +``` + +## Example agent + +See `agent.py` for a complete example of a LangGraph agent that: +1. Receives a task from OpenThreads +2. Runs an autonomous sub-task +3. Sends an `AUTHORIZE` intent to OpenThreads when it needs human approval +4. Resumes after the human responds + +## Key pattern + +```python +import httpx + +async def ask_human(reply_to: str, intent: dict) -> dict: + """ + Send an A2H intent to OpenThreads and wait for the human's response. + OpenThreads blocks the POST until the human responds (or the token expires). + """ + async with httpx.AsyncClient(timeout=300) as client: + response = await client.post( + reply_to, + json={"message": [intent]}, + ) + response.raise_for_status() + return response.json() + +# In your LangGraph node: +result = await ask_human( + reply_to=state["replyTo"], + intent={ + "intent": "AUTHORIZE", + "context": { + "action": "send-email", + "details": f"Send report to {recipient} (150KB attachment)" + } + } +) + +approved = result.get("responses", [{}])[0].get("response", False) +``` diff --git a/examples/langgraph/agent.py b/examples/langgraph/agent.py new file mode 100644 index 0000000..7904422 --- /dev/null +++ b/examples/langgraph/agent.py @@ -0,0 +1,245 @@ +""" +LangGraph + OpenThreads Integration Example. + +This example shows a LangGraph agent that: + 1. Receives a task envelope from OpenThreads (via its own HTTP server) + 2. Processes the task autonomously + 3. Sends an A2H AUTHORIZE intent to the human via OpenThreads + 4. Waits for the human's approval before completing the task + 5. Replies with the result + +Requirements: + pip install langgraph langchain-openai httpx fastapi uvicorn + +Run: + python agent.py +""" + +import asyncio +import json +import os +from typing import TypedDict, Annotated + +import httpx +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +try: + from langgraph.graph import StateGraph, END + from langgraph.graph.message import add_messages + from langchain_core.messages import HumanMessage, AIMessage + LANGGRAPH_AVAILABLE = True +except ImportError: + LANGGRAPH_AVAILABLE = False + print("LangGraph not installed. Install with: pip install langgraph langchain-openai") + +# ─── Agent state ────────────────────────────────────────────────────────────── + +class AgentState(TypedDict): + # OpenThreads envelope fields + thread_id: str + turn_id: str + reply_to: str + sender_name: str + # Task state + task: str + plan: str + approved: bool + result: str + + +# ─── A2H helper ─────────────────────────────────────────────────────────────── + +async def send_a2h_intent(reply_to: str, intent: dict, timeout: float = 300) -> dict: + """ + POST an A2H intent to OpenThreads' replyTo URL and return the response. + + OpenThreads renders the intent to the human (buttons, form, etc.) and + blocks the HTTP request until the human responds. The response contains + the human's answer (approved/denied, collected data, etc.). + + Args: + reply_to: The replyTo URL from the OpenThreads envelope. + intent: An A2H message dict (must contain 'intent' key). + timeout: Seconds to wait for the human response (default: 5 min). + + Returns: + The JSON response from OpenThreads, e.g.: + {"responses": [{"intent": "AUTHORIZE", "response": true, "respondedAt": "..."}]} + """ + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.post( + reply_to, + json={"message": [intent]}, + headers={"Content-Type": "application/json"}, + ) + response.raise_for_status() + return response.json() + + +async def send_text(reply_to: str, text: str) -> None: + """Send a simple text message via OpenThreads.""" + async with httpx.AsyncClient(timeout=30) as client: + await client.post( + reply_to, + json={"message": {"text": text}}, + headers={"Content-Type": "application/json"}, + ) + + +# ─── LangGraph agent ────────────────────────────────────────────────────────── + +async def plan_task(state: AgentState) -> dict: + """Generate a plan for the task (runs autonomously).""" + task = state["task"] + # In a real agent, call an LLM here + plan = f"Plan for '{task}': Step 1 → analyse, Step 2 → execute, Step 3 → report" + print(f"[agent] planned: {plan}") + return {"plan": plan} + + +async def request_approval(state: AgentState) -> dict: + """Send an AUTHORIZE intent to the human and wait for approval.""" + print(f"[agent] requesting approval via OpenThreads...") + + try: + result = await send_a2h_intent( + reply_to=state["reply_to"], + intent={ + "intent": "AUTHORIZE", + "context": { + "action": "execute-plan", + "details": state["plan"], + "requestedBy": "LangGraph agent", + }, + "description": "Please review and approve the plan before execution.", + }, + ) + + responses = result.get("responses", []) + approved = bool(responses[0].get("response", False)) if responses else False + print(f"[agent] human {'approved' if approved else 'denied'}") + return {"approved": approved} + + except httpx.TimeoutException: + print("[agent] approval request timed out") + return {"approved": False} + + +async def execute_task(state: AgentState) -> dict: + """Execute the task (only runs if approved).""" + if not state["approved"]: + return {"result": "Task was not approved by the human."} + + # Simulate task execution + await asyncio.sleep(1) + result = f"Task completed successfully. Plan executed: {state['plan']}" + print(f"[agent] {result}") + return {"result": result} + + +async def send_result(state: AgentState) -> dict: + """Send the final result back to the human via OpenThreads.""" + await send_text(state["reply_to"], state["result"]) + print("[agent] result sent to human") + return {} + + +def should_execute(state: AgentState) -> str: + return "execute" if state.get("approved") else "send_result" + + +def build_graph(): + """Build and compile the LangGraph state machine.""" + graph = StateGraph(AgentState) + + graph.add_node("plan", plan_task) + graph.add_node("request_approval", request_approval) + graph.add_node("execute", execute_task) + graph.add_node("send_result", send_result) + + graph.set_entry_point("plan") + graph.add_edge("plan", "request_approval") + graph.add_conditional_edges( + "request_approval", + should_execute, + {"execute": "execute", "send_result": "send_result"}, + ) + graph.add_edge("execute", "send_result") + graph.add_edge("send_result", END) + + return graph.compile() + + +# ─── HTTP server (receives OpenThreads envelopes) ──────────────────────────── + +app = FastAPI(title="LangGraph + OpenThreads Agent") + +@app.post("/inbound") +async def inbound(request: Request): + """Receive an OpenThreads envelope and kick off the LangGraph agent.""" + envelope = await request.json() + + thread_id = envelope.get("threadId", "") + turn_id = envelope.get("turnId", "") + reply_to = envelope.get("replyTo", "") + source = envelope.get("source", {}) + sender_name = source.get("sender", {}).get("name", "unknown") + + # Extract the task text from the message + message = envelope.get("message", []) + if isinstance(message, dict): + message = [message] + task = " ".join( + m.get("text", "") for m in message if isinstance(m, dict) and "text" in m + ).strip() or "Do something useful" + + print(f"[inbound] task='{task}' from={sender_name} thread={thread_id}") + + # Acknowledge immediately + asyncio.create_task(run_agent(thread_id, turn_id, reply_to, sender_name, task)) + return JSONResponse({"ok": True}) + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +async def run_agent( + thread_id: str, + turn_id: str, + reply_to: str, + sender_name: str, + task: str, +) -> None: + if not LANGGRAPH_AVAILABLE: + print("[agent] LangGraph not available — sending error reply") + await send_text(reply_to, "Error: LangGraph is not installed.") + return + + graph = build_graph() + initial_state: AgentState = { + "thread_id": thread_id, + "turn_id": turn_id, + "reply_to": reply_to, + "sender_name": sender_name, + "task": task, + "plan": "", + "approved": False, + "result": "", + } + try: + await graph.ainvoke(initial_state) + except Exception as exc: + print(f"[agent] error: {exc}") + await send_text(reply_to, f"Error processing task: {exc}") + + +# ─── Entry point ───────────────────────────────────────────────────────────── + +if __name__ == "__main__": + import uvicorn + port = int(os.environ.get("PORT", 4001)) + print(f"[server] LangGraph agent listening on http://localhost:{port}") + uvicorn.run(app, host="0.0.0.0", port=port) diff --git a/examples/n8n/README.md b/examples/n8n/README.md new file mode 100644 index 0000000..3fc83de --- /dev/null +++ b/examples/n8n/README.md @@ -0,0 +1,117 @@ +# Example: n8n Integration + +Receive OpenThreads message envelopes in an n8n workflow and reply via the +`replyTo` URL — no custom code required. + +## Architecture + +``` +Human (Slack) → OpenThreads → n8n Webhook node + ↓ + [your n8n workflow] + ↓ +Human (Slack) ← OpenThreads ← HTTP Request node (POST replyTo) +``` + +## Step 1 — Create an n8n Webhook + +1. In your n8n workflow, add a **Webhook** node. +2. Set **HTTP Method** to `POST`. +3. Set **Response Mode** to `Immediately` (return 200 at once; process async). +4. Copy the **Webhook URL** (e.g., `https://your-n8n.example.com/webhook/openthreads`). + +## Step 2 — Create an OpenThreads Route + +Register a recipient in OpenThreads that points to your n8n webhook URL: + +```bash +curl -s -X POST http://localhost:3000/api/recipients \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $MANAGEMENT_API_KEY" \ + -d '{ + "id": "n8n-workflow", + "name": "n8n Workflow", + "webhookUrl": "https://your-n8n.example.com/webhook/openthreads" + }' | jq . +``` + +Then create a route to forward messages to it: + +```bash +curl -s -X POST http://localhost:3000/api/routes \ + -H 'Content-Type: application/json' \ + -H "Authorization: Bearer $MANAGEMENT_API_KEY" \ + -d '{ + "id": "slack-to-n8n", + "name": "Slack → n8n", + "recipientId": "n8n-workflow", + "criteria": { "channelId": "my-slack-channel" }, + "enabled": true, + "priority": 1 + }' | jq . +``` + +## Step 3 — Build your n8n workflow + +The Webhook node receives the OpenThreads envelope: + +```json +{ + "threadId": "ot_thr_abc123", + "turnId": "ot_turn_001", + "replyTo": "http://localhost:3000/send/channel/my-slack/target/C0123/thread/ot_thr_abc123?token=ot_tk_...", + "source": { + "channel": "slack", + "channelId": "my-slack", + "sender": { "id": "U456", "name": "Alice" } + }, + "message": [{ "text": "Hello from Slack!" }] +} +``` + +Access envelope fields in n8n expressions: +- `{{ $json.threadId }}` +- `{{ $json.turnId }}` +- `{{ $json.replyTo }}` +- `{{ $json.source.sender.name }}` +- `{{ $json.message[0].text }}` + +## Step 4 — Send a reply + +Add an **HTTP Request** node after your processing steps: + +| Field | Value | +|---|---| +| Method | `POST` | +| URL | `{{ $('Webhook').item.json.replyTo }}` | +| Body Content Type | `JSON` | +| Body | See below | + +**Simple text reply:** +```json +{ + "message": { "text": "Hello! I processed your message." } +} +``` + +**A2H AUTHORIZE intent (blocking — OpenThreads waits for human response):** +```json +{ + "message": [ + { "text": "I need your approval to deploy." }, + { + "intent": "AUTHORIZE", + "context": { + "action": "deploy-to-production", + "details": "Branch feature-x → production" + } + } + ] +} +``` + +## Tips + +- The `replyTo` token expires after 24 hours (configurable via `REPLY_TOKEN_TTL`). +- Store `threadId` if you need to send follow-up messages without a `replyTo`. +- Use the **Split In Batches** node to handle multiple messages in the envelope array. diff --git a/examples/plain-http/README.md b/examples/plain-http/README.md new file mode 100644 index 0000000..91ad009 --- /dev/null +++ b/examples/plain-http/README.md @@ -0,0 +1,76 @@ +# Example: Plain HTTP Webhook Consumer + +The simplest possible OpenThreads integration — a standalone HTTP server that +receives OpenThreads envelopes, processes them, and replies using `replyTo`. + +No frameworks, no SDK. Just `curl`-level HTTP. + +## How it works + +``` +Human (Slack) → OpenThreads → POST /inbound (this server) + ↓ + processes message + ↓ +Human (Slack) ← OpenThreads ← POST replyTo (this server) +``` + +## Prerequisites + +- An OpenThreads instance running at `http://localhost:3000` (see root `docker-compose.yml`) +- A channel registered in OpenThreads (Slack, Telegram, Discord, etc.) +- A route pointing to `http://localhost:4000/inbound` + +## Run the server + +```bash +bun run server.ts +# or +node server.js +``` + +The server listens on port `4000` and: +1. Receives POST requests from OpenThreads at `/inbound` +2. Echoes the message back with a text reply via `replyTo` + +## cURL test (without OpenThreads) + +You can test the inbound handler directly with curl: + +```bash +curl -s -X POST http://localhost:4000/inbound \ + -H 'Content-Type: application/json' \ + -d '{ + "threadId": "ot_thr_test", + "turnId": "ot_turn_test", + "replyTo": "http://localhost:3000/send/channel/my-slack/target/C0123/thread/ot_thr_test?token=ot_tk_test", + "source": { + "channel": "slack", + "channelId": "my-slack", + "sender": { "id": "U456", "name": "Alice" } + }, + "message": [{ "text": "Hello, agent!" }] + }' | jq . +``` + +## A2H example + +To send a human approval request back: + +```bash +# POST to replyTo with an A2H AUTHORIZE intent +curl -s -X POST "$REPLY_TO_URL" \ + -H 'Content-Type: application/json' \ + -d '{ + "message": [ + { "text": "I need your approval to proceed." }, + { + "intent": "AUTHORIZE", + "context": { + "action": "deploy-to-production", + "details": "Branch feature-x → production (12 services)" + } + } + ] + }' | jq . +``` diff --git a/examples/plain-http/server.ts b/examples/plain-http/server.ts new file mode 100644 index 0000000..d8086cd --- /dev/null +++ b/examples/plain-http/server.ts @@ -0,0 +1,120 @@ +/** + * Plain HTTP Webhook Consumer — OpenThreads example. + * + * Minimal Bun HTTP server that: + * 1. Receives POST /inbound — OpenThreads envelope (outbound from OT) + * 2. Processes the message + * 3. Replies via replyTo URL + * + * Run: bun run server.ts + */ + +const PORT = Number(process.env.PORT ?? 4000); + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface Envelope { + threadId: string; + turnId: string; + replyTo: string; + source: { + channel: string; + channelId: string; + sender: { id: string; name?: string }; + }; + message: unknown; +} + +// ─── Message processing ─────────────────────────────────────────────────────── + +async function processEnvelope(envelope: Envelope): Promise { + const { replyTo, source, message } = envelope; + console.log(`[inbound] message from ${source.sender.name ?? source.sender.id} on ${source.channel}:`, message); + + // Build a reply + const reply = buildReply(message, source.sender.name ?? source.sender.id); + + // Send the reply via replyTo + const response = await fetch(replyTo, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(reply), + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + console.error(`[reply] failed: ${response.status} ${body}`); + } else { + console.log(`[reply] sent to ${replyTo}`); + } +} + +function buildReply(message: unknown, senderName: string): { message: unknown } { + // Extract text from the message + let text = ''; + if (Array.isArray(message)) { + const texts = message + .filter((m): m is { text: string } => typeof m === 'object' && m !== null && 'text' in m) + .map((m) => m.text); + text = texts.join(' '); + } else if (typeof message === 'object' && message !== null && 'text' in message) { + text = (message as { text: string }).text; + } + + // Echo the message back + return { + message: { + text: `Echo from webhook consumer: "${text}" (from ${senderName})`, + }, + }; +} + +// ─── HTTP server ────────────────────────────────────────────────────────────── + +const server = Bun.serve({ + port: PORT, + async fetch(req) { + const url = new URL(req.url); + + // POST /inbound — receive OpenThreads envelope + if (req.method === 'POST' && url.pathname === '/inbound') { + let body: unknown; + try { + body = await req.json(); + } catch { + return new Response(JSON.stringify({ error: 'Invalid JSON' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const envelope = body as Envelope; + + // Acknowledge immediately, process asynchronously + void processEnvelope(envelope).catch((err: unknown) => { + console.error('[processEnvelope] error:', err); + }); + + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // GET /health — liveness probe + if (req.method === 'GET' && url.pathname === '/health') { + return new Response(JSON.stringify({ status: 'ok' }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ error: 'Not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + }, +}); + +console.log(`[server] listening on http://localhost:${server.port}`); +console.log(`[server] POST /inbound — receive OpenThreads envelopes`); +console.log(`[server] GET /health — liveness probe`); diff --git a/package.json b/package.json new file mode 100644 index 0000000..fecad18 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "openthreads", + "version": "0.1.0", + "private": true, + "workspaces": [ + "packages/core", + "packages/storage/*", + "packages/channels/*", + "packages/server", + "packages/trust", + "packages/create-openthreads" + ], + "scripts": { + "test": "bun test", + "typecheck": "bun run --filter '*' typecheck" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.0.0" + } +} diff --git a/packages/channels/discord/package.json b/packages/channels/discord/package.json new file mode 100644 index 0000000..a02d5a7 --- /dev/null +++ b/packages/channels/discord/package.json @@ -0,0 +1,31 @@ +{ + "name": "@openthreads/channel-discord", + "version": "0.1.0", + "description": "Discord channel adapter for OpenThreads", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc --project tsconfig.json", + "test": "bun test", + "test:integration": "bun test --grep integration", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "discord.js": "^14.16.3" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.7.2" + }, + "peerDependencies": { + "@openthreads/core": "workspace:*" + } +} diff --git a/packages/channels/discord/src/__tests__/adapter.test.ts b/packages/channels/discord/src/__tests__/adapter.test.ts new file mode 100644 index 0000000..b5f3a89 --- /dev/null +++ b/packages/channels/discord/src/__tests__/adapter.test.ts @@ -0,0 +1,316 @@ +/** + * Unit tests for the DiscordAdapter. + * + * These tests use mock Discord.js objects and do NOT require a real Discord + * bot token. Integration tests (requiring a real Discord test server) are + * tagged with "integration" and are excluded from the default test run. + */ + +import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; +import { DiscordAdapter } from "../adapter.js"; +import type { + DiscordAdapterConfig, + IncomingMessage, + SendMessageParams, +} from "../types.js"; + +// --------------------------------------------------------------------------- +// Capabilities +// --------------------------------------------------------------------------- + +describe("DiscordAdapter.capabilities()", () => { + it("reports correct capabilities", () => { + const adapter = new DiscordAdapter(); + const caps = adapter.capabilities(); + expect(caps.threads).toBe(true); + expect(caps.buttons).toBe(true); + expect(caps.selectMenus).toBe(true); + expect(caps.replyMessages).toBe(false); + expect(caps.dms).toBe(true); + expect(caps.fileUpload).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Message parsing +// --------------------------------------------------------------------------- + +describe("parseMessage()", () => { + it("returns null for bot messages", async () => { + const { parseMessage } = await import("../inbound/messages.js"); + + const fakeMessage = { + partial: false, + author: { bot: true, id: "bot-id", username: "TestBot" }, + webhookId: null, + type: 0, // Default + mentions: { users: { has: () => false } }, + channel: { isThread: () => false }, + channelId: "ch-1", + content: "I am a bot", + attachments: { map: () => [] }, + createdAt: new Date(), + client: { user: { id: "bot-id" } }, + member: null, + id: "msg-1", + }; + + // @ts-expect-error — minimal mock + expect(parseMessage(fakeMessage)).toBeNull(); + }); + + it("parses a regular user message", async () => { + const { parseMessage } = await import("../inbound/messages.js"); + + const fakeMessage = { + partial: false, + author: { bot: false, id: "user-1", username: "alice" }, + webhookId: null, + type: 0, // Default + mentions: { users: { has: () => false } }, + channel: { isThread: () => false }, + channelId: "ch-1", + content: "Hello world", + attachments: { map: (fn: (a: { url: string }) => string) => fn({ url: "https://example.com/image.png" }) ? ["https://example.com/image.png"] : [] }, + createdAt: new Date("2026-01-01T00:00:00Z"), + client: { user: { id: "bot-99" } }, + member: { displayName: "Alice" }, + id: "msg-42", + }; + + // @ts-expect-error — minimal mock + const result = parseMessage(fakeMessage); + + expect(result).not.toBeNull(); + expect(result!.type).toBe("text"); + expect(result!.text).toBe("Hello world"); + expect(result!.sender.id).toBe("user-1"); + expect(result!.sender.displayName).toBe("Alice"); + expect(result!.threadId).toBeUndefined(); + }); + + it("detects @mention messages", async () => { + const { parseMessage } = await import("../inbound/messages.js"); + + const fakeMessage = { + partial: false, + author: { bot: false, id: "user-1", username: "alice" }, + webhookId: null, + type: 0, + mentions: { users: { has: (id: string) => id === "bot-99" } }, + channel: { isThread: () => false }, + channelId: "ch-1", + content: "<@bot-99> deploy to staging", + attachments: { map: () => [] }, + createdAt: new Date(), + client: { user: { id: "bot-99" } }, + member: null, + id: "msg-43", + }; + + // @ts-expect-error — minimal mock + const result = parseMessage(fakeMessage); + + expect(result).not.toBeNull(); + expect(result!.type).toBe("mention"); + }); + + it("captures threadId when channel is a thread", async () => { + const { parseMessage } = await import("../inbound/messages.js"); + + const fakeMessage = { + partial: false, + author: { bot: false, id: "user-1", username: "alice" }, + webhookId: null, + type: 0, + mentions: { users: { has: () => false } }, + channel: { isThread: () => true }, + channelId: "thread-99", + content: "Thread message", + attachments: { map: () => [] }, + createdAt: new Date(), + client: { user: { id: "bot-1" } }, + member: null, + id: "msg-44", + }; + + // @ts-expect-error — minimal mock + const result = parseMessage(fakeMessage); + + expect(result!.threadId).toBe("thread-99"); + }); +}); + +// --------------------------------------------------------------------------- +// Slash command parsing +// --------------------------------------------------------------------------- + +describe("parseSlashCommand()", () => { + it("converts a slash command interaction to IncomingMessage", async () => { + const { parseSlashCommand } = await import("../inbound/commands.js"); + + const fakeInteraction = { + id: "int-1", + channelId: "ch-1", + channel: { isThread: () => false }, + user: { id: "user-1", username: "bob" }, + member: { displayName: "Bob" }, + commandName: "deploy", + options: { + data: [ + { name: "env", type: 3 /* String */, value: "staging" }, + ], + }, + }; + + // @ts-expect-error — minimal mock + const result = parseSlashCommand(fakeInteraction); + + expect(result.type).toBe("slash_command"); + expect(result.commandName).toBe("deploy"); + expect(result.commandOptions?.env).toBe("staging"); + expect(result.text).toBe("/deploy"); + }); +}); + +// --------------------------------------------------------------------------- +// Component builders +// --------------------------------------------------------------------------- + +describe("buildAuthorizeButtons()", () => { + it("builds approve and deny buttons", async () => { + const { buildAuthorizeButtons } = await import("../outbound/components.js"); + const row = buildAuthorizeButtons("intent-123"); + // @ts-expect-error — APIActionRowComponent typing + const ids = row.components.map((c: { custom_id: string }) => c.custom_id); + expect(ids).toContain("ot_a2h_approve:intent-123"); + expect(ids).toContain("ot_a2h_deny:intent-123"); + }); +}); + +describe("buildSelectMenu()", () => { + it("builds a select menu with given options", async () => { + const { buildSelectMenu } = await import("../outbound/components.js"); + const row = buildSelectMenu( + [ + { label: "Option A", value: "a" }, + { label: "Option B", value: "b" }, + ], + "Pick one", + "intent-456" + ); + // @ts-expect-error — APIActionRowComponent typing + expect(row.components[0].custom_id).toBe("ot_a2h_select:intent-456"); + // @ts-expect-error — APIActionRowComponent typing + expect(row.components[0].options).toHaveLength(2); + }); +}); + +describe("buildA2HComponents()", () => { + it("returns authorize buttons for AUTHORIZE intent", async () => { + const { buildA2HComponents } = await import("../outbound/components.js"); + const components = buildA2HComponents({ + intent: "AUTHORIZE", + context: { action: "deploy", details: "Deploy to production" }, + }); + expect(components).not.toBeNull(); + expect(components).toHaveLength(1); + }); + + it("returns select menu for COLLECT intent with options", async () => { + const { buildA2HComponents } = await import("../outbound/components.js"); + const components = buildA2HComponents({ + intent: "COLLECT", + context: { + question: "Which environment?", + options: [ + { label: "Staging", value: "staging" }, + { label: "Production", value: "production" }, + ], + }, + }); + expect(components).not.toBeNull(); + expect(components).toHaveLength(1); + }); + + it("returns null for free-text COLLECT (no options)", async () => { + const { buildA2HComponents } = await import("../outbound/components.js"); + const components = buildA2HComponents({ + intent: "COLLECT", + context: { question: "What is your name?" }, + }); + expect(components).toBeNull(); + }); + + it("returns empty array for INFORM intent", async () => { + const { buildA2HComponents } = await import("../outbound/components.js"); + const components = buildA2HComponents({ + intent: "INFORM", + context: { action: "build", details: "Build succeeded" }, + }); + expect(components).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// parseA2HCustomId +// --------------------------------------------------------------------------- + +describe("parseA2HCustomId()", () => { + it("parses approve custom ID", async () => { + const { parseA2HCustomId } = await import("../outbound/components.js"); + const result = parseA2HCustomId("ot_a2h_approve:my-intent"); + expect(result?.type).toBe("approve"); + expect(result?.intentId).toBe("my-intent"); + }); + + it("parses deny custom ID", async () => { + const { parseA2HCustomId } = await import("../outbound/components.js"); + const result = parseA2HCustomId("ot_a2h_deny:my-intent"); + expect(result?.type).toBe("deny"); + expect(result?.intentId).toBe("my-intent"); + }); + + it("parses select custom ID", async () => { + const { parseA2HCustomId } = await import("../outbound/components.js"); + const result = parseA2HCustomId("ot_a2h_select:my-intent"); + expect(result?.type).toBe("select"); + expect(result?.intentId).toBe("my-intent"); + }); + + it("returns null for non-OpenThreads custom ID", async () => { + const { parseA2HCustomId } = await import("../outbound/components.js"); + expect(parseA2HCustomId("some_other_button")).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Embed builders +// --------------------------------------------------------------------------- + +describe("Embed builders", () => { + it("buildInformEmbed sets correct color", async () => { + const { buildInformEmbed } = await import("../outbound/embeds.js"); + const embed = buildInformEmbed({ action: "Build complete", details: "All checks passed" }); + expect(embed.color).toBe(0x5865f2); + expect(embed.title).toBe("ℹ️ Build complete"); + }); + + it("buildAuthorizeEmbed sets correct color", async () => { + const { buildAuthorizeEmbed } = await import("../outbound/embeds.js"); + const embed = buildAuthorizeEmbed({ action: "Deploy to prod" }); + expect(embed.color).toBe(0xffa500); + }); + + it("buildCollectEmbed shows question as description", async () => { + const { buildCollectEmbed } = await import("../outbound/embeds.js"); + const embed = buildCollectEmbed({ question: "What is your name?" }); + expect(embed.description).toBe("What is your name?"); + }); + + it("buildEscalateEmbed sets red color", async () => { + const { buildEscalateEmbed } = await import("../outbound/embeds.js"); + const embed = buildEscalateEmbed({ action: "Critical error" }); + expect(embed.color).toBe(0xed4245); + }); +}); diff --git a/packages/channels/discord/src/__tests__/conformance.test.ts b/packages/channels/discord/src/__tests__/conformance.test.ts new file mode 100644 index 0000000..08fe29c --- /dev/null +++ b/packages/channels/discord/src/__tests__/conformance.test.ts @@ -0,0 +1,124 @@ +/** + * Shared adapter conformance tests. + * + * These tests verify that DiscordAdapter satisfies the ChannelAdapter contract + * expected by the OpenThreads core. They use a fully-mocked Discord client so + * no real bot token is required. + * + * Real integration tests (tagged "integration") are run manually or in a + * dedicated CI step with a real Discord test server. + */ + +import { describe, it, expect } from "bun:test"; +import { DiscordAdapter } from "../adapter.js"; +import type { ChannelAdapter, ChannelCapabilities } from "../types.js"; + +// --------------------------------------------------------------------------- +// Conformance helpers +// --------------------------------------------------------------------------- + +/** + * Assert that all required ChannelAdapter properties / methods are present on + * the adapter instance. This is the shape @openthreads/core will use. + */ +function assertChannelAdapterInterface(adapter: ChannelAdapter): void { + expect(typeof adapter.channelType).toBe("string"); + expect(typeof adapter.capabilities).toBe("function"); + expect(typeof adapter.connect).toBe("function"); + expect(typeof adapter.disconnect).toBe("function"); + expect(typeof adapter.sendMessage).toBe("function"); + expect(typeof adapter.onIncomingMessage).toBe("function"); +} + +// --------------------------------------------------------------------------- +// Interface conformance +// --------------------------------------------------------------------------- + +describe("DiscordAdapter conformance", () => { + it("implements the ChannelAdapter interface", () => { + const adapter = new DiscordAdapter(); + assertChannelAdapterInterface(adapter); + }); + + it("channelType is 'discord'", () => { + const adapter = new DiscordAdapter(); + expect(adapter.channelType).toBe("discord"); + }); + + it("capabilities() returns all required keys", () => { + const adapter = new DiscordAdapter(); + const caps: ChannelCapabilities = adapter.capabilities(); + + const requiredKeys: Array = [ + "threads", + "buttons", + "selectMenus", + "replyMessages", + "dms", + "fileUpload", + ]; + + for (const key of requiredKeys) { + expect(typeof caps[key]).toBe("boolean"); + } + }); + + it("capabilities() matches declared Discord capabilities", () => { + const adapter = new DiscordAdapter(); + const caps = adapter.capabilities(); + + // Discord-specific expected values + expect(caps.threads).toBe(true); + expect(caps.buttons).toBe(true); + expect(caps.selectMenus).toBe(true); + expect(caps.replyMessages).toBe(false); // Discord has reactions/threads, not reply capture + expect(caps.dms).toBe(true); + expect(caps.fileUpload).toBe(true); + }); + + it("onIncomingMessage returns an unsubscribe function", () => { + const adapter = new DiscordAdapter(); + const unsubscribe = adapter.onIncomingMessage(() => {}); + expect(typeof unsubscribe).toBe("function"); + // Calling unsubscribe should not throw + expect(() => unsubscribe()).not.toThrow(); + }); + + it("disconnect() resolves without error when never connected", async () => { + const adapter = new DiscordAdapter(); + await expect(adapter.disconnect()).resolves.toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Inbound handler registration +// --------------------------------------------------------------------------- + +describe("onIncomingMessage handler management", () => { + it("registers multiple handlers", () => { + const adapter = new DiscordAdapter(); + let count = 0; + adapter.onIncomingMessage(() => { count++; }); + adapter.onIncomingMessage(() => { count++; }); + // handlers are stored; we can't easily call them without a full Discord + // connection, but we can verify registration doesn't throw + expect(count).toBe(0); // handlers called lazily + }); + + it("unsubscribes a specific handler", () => { + const adapter = new DiscordAdapter(); + const calls: number[] = []; + + const unsub1 = adapter.onIncomingMessage(() => calls.push(1)); + const unsub2 = adapter.onIncomingMessage(() => calls.push(2)); + + unsub1(); // remove handler 1 + + // After unsubscribing, re-subscribing should still work + const unsub3 = adapter.onIncomingMessage(() => calls.push(3)); + unsub2(); + unsub3(); + + expect(calls).toHaveLength(0); + }); +}); diff --git a/packages/channels/discord/src/adapter.ts b/packages/channels/discord/src/adapter.ts new file mode 100644 index 0000000..c8c54d6 --- /dev/null +++ b/packages/channels/discord/src/adapter.ts @@ -0,0 +1,477 @@ +import { + Client, + GatewayIntentBits, + Partials, + Events, + Message, + ChatInputCommandInteraction, + ButtonInteraction, + StringSelectMenuInteraction, + REST, + Routes, + InteractionType, + ChannelType, + BaseMessageOptions, + MessageFlags, +} from "discord.js"; +import { + ChannelAdapter, + ChannelCapabilities, + DiscordAdapterConfig, + IncomingMessage, + IncomingMessageHandler, + SendMessageParams, + SentMessage, + Unsubscribe, + A2HMessage, + TextMessage, +} from "./types.js"; +import { parseMessage } from "./inbound/messages.js"; +import { parseSlashCommand } from "./inbound/commands.js"; +import { + buildAuthorizeEmbed, + buildCollectEmbed, + buildInformEmbed, + buildEscalateEmbed, +} from "./outbound/embeds.js"; +import { buildA2HComponents, parseA2HCustomId } from "./outbound/components.js"; +import { getThread, createThread, ensureThreadActive } from "./threads/index.js"; +import { SlashCommandDefinition } from "./types.js"; + +// --------------------------------------------------------------------------- +// Type guard helpers +// --------------------------------------------------------------------------- + +function isA2HMessage(item: unknown): item is A2HMessage { + return ( + typeof item === "object" && + item !== null && + "intent" in item && + typeof (item as A2HMessage).intent === "string" + ); +} + +function isTextMessage(item: unknown): item is TextMessage { + return ( + typeof item === "object" && + item !== null && + "text" in item && + typeof (item as TextMessage).text === "string" + ); +} + +// --------------------------------------------------------------------------- +// Response-capture bookkeeping +// --------------------------------------------------------------------------- + +interface PendingCapture { + resolve: (value: string) => void; + reject: (reason?: unknown) => void; + intentId: string | undefined; + /** channel or thread ID where we expect the human reply */ + captureChannelId: string; + /** If set, only accept replies from this user ID */ + userId?: string; + timer: ReturnType; +} + +// --------------------------------------------------------------------------- +// Discord Adapter +// --------------------------------------------------------------------------- + +/** + * Discord channel adapter for OpenThreads. + * + * Implements the full ChannelAdapter interface: + * - Inbound: message create, slash commands, @mentions + * - Outbound: text messages, Discord embeds, message components (buttons, + * select menus) for A2H intents + * - Thread support: Discord threads and forum-channel posts (1:1 mapping) + * - A2H inline (method 1): AUTHORIZE → approve/deny buttons, + * COLLECT (closed options) → select menu + * - Response capture (method 2): component interactions + thread replies + */ +export class DiscordAdapter implements ChannelAdapter { + readonly channelType = "discord"; + + private client: Client | null = null; + private config: DiscordAdapterConfig | null = null; + private handlers: Set = new Set(); + + /** + * Map from intentId → pending capture for method-2 response collection. + * Key is intentId when set, otherwise the Discord message ID of the + * component message. + */ + private pendingCaptures: Map = new Map(); + + // --------------------------------------------------------------------------- + // ChannelAdapter interface + // --------------------------------------------------------------------------- + + capabilities(): ChannelCapabilities { + return { + threads: true, + buttons: true, + selectMenus: true, + replyMessages: false, + dms: true, + fileUpload: true, + }; + } + + async connect(config: DiscordAdapterConfig): Promise { + this.config = config; + + this.client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMessageReactions, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.GuildMembers, + ], + partials: [Partials.Channel, Partials.Message], + }); + + this.registerClientEvents(); + + await this.client.login(config.token); + + if (config.slashCommands && config.slashCommands.length > 0) { + await this.registerSlashCommands(config); + } + } + + async disconnect(): Promise { + if (this.client) { + this.client.destroy(); + this.client = null; + } + // Reject all pending captures + for (const capture of this.pendingCaptures.values()) { + clearTimeout(capture.timer); + capture.reject(new Error("Adapter disconnected")); + } + this.pendingCaptures.clear(); + } + + onIncomingMessage(handler: IncomingMessageHandler): Unsubscribe { + this.handlers.add(handler); + return () => { + this.handlers.delete(handler); + }; + } + + async sendMessage(params: SendMessageParams): Promise { + if (!this.client) throw new Error("DiscordAdapter not connected"); + + const { channelId, threadId, messages } = params; + + // Resolve the target channel / thread + const channel = await this.client.channels.fetch(channelId); + if (!channel) { + throw new Error(`Discord channel ${channelId} not found`); + } + + let targetChannelId = channelId; + + // If a threadId is provided, ensure the thread exists and is active + if (threadId) { + await ensureThreadActive(this.client, threadId); + targetChannelId = threadId; + } + + const targetChannel = threadId + ? await this.client.channels.fetch(threadId) + : channel; + + if (!targetChannel || !("send" in targetChannel)) { + throw new Error(`Cannot send to channel ${targetChannelId}`); + } + + let lastMessageId = ""; + + for (const item of messages) { + if (isA2HMessage(item)) { + lastMessageId = await this.sendA2HMessage( + targetChannel as Parameters[0], + item + ); + } else if (isTextMessage(item)) { + const payload: BaseMessageOptions = { content: item.text }; + const sent = await (targetChannel as { send: (opts: BaseMessageOptions) => Promise<{ id: string }> }).send(payload); + lastMessageId = sent.id; + } + } + + return { + messageId: lastMessageId, + channelId, + threadId: threadId ?? undefined, + }; + } + + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + + private registerClientEvents(): void { + if (!this.client) return; + + // Text messages and @mentions + this.client.on(Events.MessageCreate, (message: Message) => { + const parsed = parseMessage(message); + if (parsed) this.dispatchIncoming(parsed); + }); + + // Slash commands and component interactions + this.client.on(Events.InteractionCreate, async (interaction) => { + if (interaction.type === InteractionType.ApplicationCommand) { + const slashInteraction = interaction as ChatInputCommandInteraction; + const parsed = parseSlashCommand(slashInteraction); + + // Defer the reply — the actual response will come through sendMessage + await slashInteraction.deferReply({ flags: MessageFlags.Ephemeral }); + this.dispatchIncoming(parsed); + } + + // Button interactions (A2H method 1) + if (interaction.isButton()) { + await this.handleButtonInteraction(interaction as ButtonInteraction); + } + + // Select-menu interactions (A2H method 1) + if (interaction.isStringSelectMenu()) { + await this.handleSelectMenuInteraction(interaction as StringSelectMenuInteraction); + } + }); + + // Thread messages for method-2 response capture + this.client.on(Events.MessageCreate, (message: Message) => { + if (!message.channel.isThread()) return; + this.tryResolvePendingCapture(message); + }); + } + + private dispatchIncoming(message: IncomingMessage): void { + for (const handler of this.handlers) { + Promise.resolve(handler(message)).catch((err) => { + console.error("[DiscordAdapter] Handler error:", err); + }); + } + } + + // --------------------------------------------------------------------------- + // A2H outbound rendering + // --------------------------------------------------------------------------- + + private async sendA2HMessage( + channel: { send: (opts: BaseMessageOptions) => Promise<{ id: string }> }, + intent: A2HMessage + ): Promise { + const components = buildA2HComponents(intent); + + let embed; + switch (intent.intent) { + case "INFORM": + embed = buildInformEmbed(intent.context as { action?: string; details?: string }); + break; + case "AUTHORIZE": + embed = buildAuthorizeEmbed(intent.context as { action?: string; details?: string }); + break; + case "COLLECT": + embed = buildCollectEmbed( + intent.context as { question?: string; details?: string } + ); + break; + case "ESCALATE": + embed = buildEscalateEmbed(intent.context as { action?: string; details?: string }); + break; + default: + embed = undefined; + } + + const payload: BaseMessageOptions = { + ...(embed ? { embeds: [embed] } : {}), + ...(components && components.length > 0 ? { components } : {}), + }; + + const sent = await channel.send(payload); + return sent.id; + } + + // --------------------------------------------------------------------------- + // Component interaction handlers (method 1 / method 2) + // --------------------------------------------------------------------------- + + private async handleButtonInteraction( + interaction: ButtonInteraction + ): Promise { + const parsed = parseA2HCustomId(interaction.customId); + if (!parsed) return; + + const value = parsed.type === "approve" ? "approved" : "denied"; + const label = parsed.type === "approve" ? "Approved" : "Denied"; + + // Acknowledge the interaction immediately + await interaction.update({ + components: [], + embeds: interaction.message.embeds, + content: `${label} by ${interaction.user.username}`, + }); + + this.resolveCapture(parsed.intentId ?? interaction.message.id, value); + } + + private async handleSelectMenuInteraction( + interaction: StringSelectMenuInteraction + ): Promise { + const parsed = parseA2HCustomId(interaction.customId); + if (!parsed) return; + + const value = interaction.values[0] ?? ""; + + await interaction.update({ + components: [], + embeds: interaction.message.embeds, + content: `Selected: **${value}** by ${interaction.user.username}`, + }); + + this.resolveCapture(parsed.intentId ?? interaction.message.id, value); + } + + private resolveCapture(key: string, value: string): void { + const capture = this.pendingCaptures.get(key); + if (!capture) return; + + clearTimeout(capture.timer); + this.pendingCaptures.delete(key); + capture.resolve(value); + } + + private tryResolvePendingCapture(message: Message): void { + if (!message.channel.isThread()) return; + + for (const [key, capture] of this.pendingCaptures) { + if ( + capture.captureChannelId === message.channelId && + (!capture.userId || capture.userId === message.author.id) + ) { + clearTimeout(capture.timer); + this.pendingCaptures.delete(key); + capture.resolve(message.content); + return; + } + } + } + + /** + * Wait for a human response to an A2H intent via component interaction or + * thread reply (method 2). + * + * @param captureChannelId - Discord thread/channel to watch for text replies + * @param intentId - Correlates with the component customId suffix + * @param userId - If set, only accept responses from this user + * @param timeoutMs - Reject after this many milliseconds + */ + awaitResponse( + captureChannelId: string, + intentId: string | undefined, + userId?: string, + timeoutMs?: number + ): Promise { + const key = intentId ?? captureChannelId; + const timeout = timeoutMs ?? (this.config?.interactionTimeoutSeconds ?? 300) * 1000; + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingCaptures.delete(key); + reject(new Error(`A2H response timeout for intent ${key}`)); + }, timeout); + + this.pendingCaptures.set(key, { + resolve, + reject, + intentId, + captureChannelId, + userId, + timer, + }); + }); + } + + // --------------------------------------------------------------------------- + // Slash command registration + // --------------------------------------------------------------------------- + + private async registerSlashCommands( + config: DiscordAdapterConfig + ): Promise { + const rest = new REST().setToken(config.token); + const commands = (config.slashCommands ?? []).map( + (cmd: SlashCommandDefinition) => ({ + name: cmd.name, + description: cmd.description, + options: cmd.options?.map((opt) => ({ + name: opt.name, + description: opt.description, + type: commandOptionTypeToDiscord(opt.type), + required: opt.required ?? false, + choices: opt.choices, + })), + }) + ); + + if (config.guildIds && config.guildIds.length > 0) { + for (const guildId of config.guildIds) { + await rest.put( + Routes.applicationGuildCommands(config.applicationId, guildId), + { body: commands } + ); + } + } else { + await rest.put(Routes.applicationCommands(config.applicationId), { + body: commands, + }); + } + } + + // --------------------------------------------------------------------------- + // Thread helpers (exposed for convenience) + // --------------------------------------------------------------------------- + + async getThread( + discordThreadId: string + ): ReturnType { + if (!this.client) throw new Error("DiscordAdapter not connected"); + return getThread(this.client, discordThreadId); + } + + async createThread( + parentChannelId: string, + name: string, + options?: Parameters[3] + ): ReturnType { + if (!this.client) throw new Error("DiscordAdapter not connected"); + return createThread(this.client, parentChannelId, name, options); + } +} + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +function commandOptionTypeToDiscord(type: string): number { + // https://discord.com/developers/docs/interactions/application-commands#application-command-object-application-command-option-type + const map: Record = { + string: 3, + integer: 4, + boolean: 5, + user: 6, + channel: 7, + role: 8, + }; + return map[type] ?? 3; +} diff --git a/packages/channels/discord/src/inbound/commands.ts b/packages/channels/discord/src/inbound/commands.ts new file mode 100644 index 0000000..a488076 --- /dev/null +++ b/packages/channels/discord/src/inbound/commands.ts @@ -0,0 +1,50 @@ +import { + ChatInputCommandInteraction, + ApplicationCommandOptionType, +} from "discord.js"; +import { IncomingMessage } from "../types.js"; + +/** + * Convert a Discord slash-command interaction into an IncomingMessage. + */ +export function parseSlashCommand( + interaction: ChatInputCommandInteraction +): IncomingMessage { + // Collect all options into a plain record + const commandOptions: Record = {}; + for (const option of interaction.options.data) { + if ( + option.type === ApplicationCommandOptionType.String || + option.type === ApplicationCommandOptionType.Integer || + option.type === ApplicationCommandOptionType.Number || + option.type === ApplicationCommandOptionType.Boolean + ) { + commandOptions[option.name] = option.value as string | number | boolean; + } + } + + const threadId = interaction.channel?.isThread() + ? interaction.channelId + : undefined; + + return { + id: interaction.id, + channelId: interaction.channelId, + threadId, + sender: { + id: interaction.user.id, + username: interaction.user.username, + displayName: + interaction.member && "displayName" in interaction.member + ? (interaction.member as { displayName: string }).displayName + : interaction.user.username, + }, + type: "slash_command", + text: `/${interaction.commandName}`, + commandName: interaction.commandName, + commandOptions, + attachments: [], + raw: interaction, + timestamp: new Date(), + }; +} diff --git a/packages/channels/discord/src/inbound/index.ts b/packages/channels/discord/src/inbound/index.ts new file mode 100644 index 0000000..d879c91 --- /dev/null +++ b/packages/channels/discord/src/inbound/index.ts @@ -0,0 +1,2 @@ +export { parseMessage } from "./messages.js"; +export { parseSlashCommand } from "./commands.js"; diff --git a/packages/channels/discord/src/inbound/messages.ts b/packages/channels/discord/src/inbound/messages.ts new file mode 100644 index 0000000..166cc25 --- /dev/null +++ b/packages/channels/discord/src/inbound/messages.ts @@ -0,0 +1,44 @@ +import { + Message, + MessageType as DjsMessageType, + PartialMessage, +} from "discord.js"; +import { IncomingMessage } from "../types.js"; + +/** + * Convert a Discord.js Message into the OpenThreads IncomingMessage shape. + * + * Returns null for messages sent by bots (including the bot itself), system + * messages, and webhook messages so that callers can safely skip them. + */ +export function parseMessage( + message: Message | PartialMessage +): IncomingMessage | null { + // Ignore partial messages that couldn't be fetched + if (message.partial) return null; + // Ignore bots and webhooks + if (message.author.bot || message.webhookId) return null; + // Ignore system messages + if (message.type !== DjsMessageType.Default && message.type !== DjsMessageType.Reply) { + return null; + } + + const isMention = message.mentions.users.has(message.client.user?.id ?? ""); + const threadId = message.channel.isThread() ? message.channelId : undefined; + + return { + id: message.id, + channelId: message.channelId, + threadId, + sender: { + id: message.author.id, + username: message.author.username, + displayName: message.member?.displayName ?? message.author.username, + }, + type: isMention ? "mention" : "text", + text: message.content, + attachments: message.attachments.map((a) => a.url), + raw: message, + timestamp: message.createdAt, + }; +} diff --git a/packages/channels/discord/src/index.ts b/packages/channels/discord/src/index.ts new file mode 100644 index 0000000..337118f --- /dev/null +++ b/packages/channels/discord/src/index.ts @@ -0,0 +1,34 @@ +export { DiscordAdapter } from "./adapter.js"; +export type { + ChannelAdapter, + ChannelCapabilities, + DiscordAdapterConfig, + SlashCommandDefinition, + SlashCommandOption, + IncomingMessage, + IncomingMessageHandler, + Unsubscribe, + SendMessageParams, + SentMessage, + ThreadInfo, + TextMessage, + A2HMessage, + A2HIntent, + OutboundMessageItem, + MessageType, +} from "./types.js"; + +// Outbound helpers (for advanced use) +export { + buildEmbed, + buildAuthorizeEmbed, + buildCollectEmbed, + buildInformEmbed, + buildEscalateEmbed, +} from "./outbound/embeds.js"; +export { + buildAuthorizeButtons, + buildSelectMenu, + buildA2HComponents, + parseA2HCustomId, +} from "./outbound/components.js"; diff --git a/packages/channels/discord/src/outbound/components.ts b/packages/channels/discord/src/outbound/components.ts new file mode 100644 index 0000000..a27f98e --- /dev/null +++ b/packages/channels/discord/src/outbound/components.ts @@ -0,0 +1,148 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + MessageActionRowComponentBuilder, + APIActionRowComponent, + APIMessageActionRowComponent, +} from "discord.js"; +import { A2HMessage, A2HIntent } from "../types.js"; + +export interface ComponentInteractionResult { + intentId: string | undefined; + value: string; + label: string; +} + +// --------------------------------------------------------------------------- +// Button helpers +// --------------------------------------------------------------------------- + +export const APPROVE_CUSTOM_ID = "ot_a2h_approve"; +export const DENY_CUSTOM_ID = "ot_a2h_deny"; + +/** + * Build the approve/deny button row used for A2H AUTHORIZE intents. + */ +export function buildAuthorizeButtons( + intentId?: string +): APIActionRowComponent { + const suffix = intentId ? `:${intentId}` : ""; + + const approveBtn = new ButtonBuilder() + .setCustomId(`${APPROVE_CUSTOM_ID}${suffix}`) + .setLabel("Approve") + .setStyle(ButtonStyle.Success) + .setEmoji("✅"); + + const denyBtn = new ButtonBuilder() + .setCustomId(`${DENY_CUSTOM_ID}${suffix}`) + .setLabel("Deny") + .setStyle(ButtonStyle.Danger) + .setEmoji("❌"); + + return new ActionRowBuilder() + .addComponents(approveBtn, denyBtn) + .toJSON(); +} + +// --------------------------------------------------------------------------- +// Select-menu helpers +// --------------------------------------------------------------------------- + +export const SELECT_CUSTOM_ID_PREFIX = "ot_a2h_select"; + +/** + * Build a select-menu row for A2H COLLECT intents that provide closed options. + */ +export function buildSelectMenu( + options: Array<{ label: string; value: string }>, + placeholder = "Choose an option…", + intentId?: string +): APIActionRowComponent { + const customId = intentId + ? `${SELECT_CUSTOM_ID_PREFIX}:${intentId}` + : SELECT_CUSTOM_ID_PREFIX; + + const select = new StringSelectMenuBuilder() + .setCustomId(customId) + .setPlaceholder(placeholder) + .addOptions( + options.map((opt) => + new StringSelectMenuOptionBuilder().setLabel(opt.label).setValue(opt.value) + ) + ); + + return new ActionRowBuilder() + .addComponents(select) + .toJSON(); +} + +// --------------------------------------------------------------------------- +// A2H → component mapper +// --------------------------------------------------------------------------- + +/** + * Derive message-action-row components for a given A2H intent. + * + * Returns null when the intent cannot be rendered inline (e.g., free-text + * COLLECT without options — should fall back to method 2 or method 3). + */ +export function buildA2HComponents( + intent: A2HMessage +): APIActionRowComponent[] | null { + switch (intent.intent as A2HIntent) { + case "AUTHORIZE": { + return [buildAuthorizeButtons(intent.intentId)]; + } + + case "COLLECT": { + const options = intent.context.options as + | Array<{ label: string; value: string }> + | undefined; + if (options && options.length > 0) { + return [buildSelectMenu(options, intent.context.question as string | undefined, intent.intentId)]; + } + // Free-text COLLECT — cannot render inline; caller should use method 2/3 + return null; + } + + case "INFORM": + case "ESCALATE": + case "RESULT": + // These don't require interaction components + return []; + + default: + return []; + } +} + +// --------------------------------------------------------------------------- +// Interaction ID parser +// --------------------------------------------------------------------------- + +/** + * Parse the intentId from a Discord component customId. + * Returns null if the customId is not an OpenThreads A2H component. + */ +export function parseA2HCustomId(customId: string): { + type: "approve" | "deny" | "select"; + intentId: string | undefined; +} | null { + if (customId.startsWith(APPROVE_CUSTOM_ID)) { + const parts = customId.split(":"); + return { type: "approve", intentId: parts[1] }; + } + if (customId.startsWith(DENY_CUSTOM_ID)) { + const parts = customId.split(":"); + return { type: "deny", intentId: parts[1] }; + } + if (customId.startsWith(SELECT_CUSTOM_ID_PREFIX)) { + const parts = customId.split(":"); + return { type: "select", intentId: parts[1] }; + } + return null; +} diff --git a/packages/channels/discord/src/outbound/embeds.ts b/packages/channels/discord/src/outbound/embeds.ts new file mode 100644 index 0000000..aaa195c --- /dev/null +++ b/packages/channels/discord/src/outbound/embeds.ts @@ -0,0 +1,99 @@ +import { EmbedBuilder, APIEmbed } from "discord.js"; + +export interface EmbedOptions { + title?: string; + description?: string; + color?: number; + fields?: Array<{ name: string; value: string; inline?: boolean }>; + footer?: string; + url?: string; + thumbnail?: string; + timestamp?: Date; +} + +/** + * Build a Discord embed from simple options. + */ +export function buildEmbed(options: EmbedOptions): APIEmbed { + const embed = new EmbedBuilder(); + + if (options.title) embed.setTitle(options.title); + if (options.description) embed.setDescription(options.description); + if (options.color !== undefined) embed.setColor(options.color); + if (options.url) embed.setURL(options.url); + if (options.thumbnail) embed.setThumbnail(options.thumbnail); + if (options.footer) embed.setFooter({ text: options.footer }); + if (options.timestamp) embed.setTimestamp(options.timestamp); + + if (options.fields) { + embed.addFields( + options.fields.map((f) => ({ + name: f.name, + value: f.value, + inline: f.inline ?? false, + })) + ); + } + + return embed.toJSON(); +} + +/** + * Build a styled embed for an A2H INFORM intent. + */ +export function buildInformEmbed(context: { + action?: string; + details?: string; +}): APIEmbed { + return buildEmbed({ + title: context.action ? `ℹ️ ${context.action}` : "ℹ️ Notification", + description: context.details, + color: 0x5865f2, // Discord blurple + timestamp: new Date(), + }); +} + +/** + * Build a styled embed for an A2H AUTHORIZE intent. + */ +export function buildAuthorizeEmbed(context: { + action?: string; + details?: string; +}): APIEmbed { + return buildEmbed({ + title: context.action ? `🔐 Authorize: ${context.action}` : "🔐 Authorization Request", + description: context.details, + color: 0xffa500, // Orange — requires attention + timestamp: new Date(), + }); +} + +/** + * Build a styled embed for an A2H COLLECT intent. + */ +export function buildCollectEmbed(context: { + question?: string; + details?: string; +}): APIEmbed { + return buildEmbed({ + title: "📋 Input Requested", + description: context.question ?? context.details, + color: 0x57f287, // Green + timestamp: new Date(), + }); +} + +/** + * Build a styled embed for an A2H ESCALATE intent. + */ +export function buildEscalateEmbed(context: { + action?: string; + details?: string; +}): APIEmbed { + return buildEmbed({ + title: context.action ? `🚨 Escalation: ${context.action}` : "🚨 Human Escalation", + description: context.details, + color: 0xed4245, // Red — urgent + timestamp: new Date(), + }); +} diff --git a/packages/channels/discord/src/outbound/index.ts b/packages/channels/discord/src/outbound/index.ts new file mode 100644 index 0000000..351196a --- /dev/null +++ b/packages/channels/discord/src/outbound/index.ts @@ -0,0 +1,17 @@ +export { sendTextMessage, buildTextPayload } from "./text.js"; +export { + buildEmbed, + buildInformEmbed, + buildAuthorizeEmbed, + buildCollectEmbed, + buildEscalateEmbed, +} from "./embeds.js"; +export { + buildAuthorizeButtons, + buildSelectMenu, + buildA2HComponents, + parseA2HCustomId, + APPROVE_CUSTOM_ID, + DENY_CUSTOM_ID, + SELECT_CUSTOM_ID_PREFIX, +} from "./components.js"; diff --git a/packages/channels/discord/src/outbound/text.ts b/packages/channels/discord/src/outbound/text.ts new file mode 100644 index 0000000..3bb593c --- /dev/null +++ b/packages/channels/discord/src/outbound/text.ts @@ -0,0 +1,55 @@ +import { + TextChannel, + DMChannel, + NewsChannel, + ThreadChannel, + ForumChannel, + BaseMessageOptions, + AnyThreadChannel, +} from "discord.js"; +import { TextMessage } from "../types.js"; + +type SendableChannel = + | TextChannel + | DMChannel + | NewsChannel + | ThreadChannel + | AnyThreadChannel; + +/** + * Build Discord message options from an OpenThreads TextMessage. + */ +export function buildTextPayload(msg: TextMessage): BaseMessageOptions { + return { + content: msg.text || undefined, + }; +} + +/** + * Send a plain-text message to a Discord channel or thread. + * Returns the Discord message ID of the sent message. + */ +export async function sendTextMessage( + channel: SendableChannel | ForumChannel, + msg: TextMessage, + threadId?: string +): Promise { + const payload = buildTextPayload(msg); + + // If a thread ID is provided, send inside that thread + if (threadId && "threads" in channel) { + const thread = await (channel as TextChannel).threads.fetch(threadId); + if (thread) { + const sent = await thread.send(payload); + return sent.id; + } + } + + // Send directly in the channel (which may already be a thread channel) + if ("send" in channel) { + const sent = await (channel as SendableChannel).send(payload); + return sent.id; + } + + throw new Error(`Channel type does not support sending messages: ${channel.type}`); +} diff --git a/packages/channels/discord/src/threads/index.ts b/packages/channels/discord/src/threads/index.ts new file mode 100644 index 0000000..188a6a9 --- /dev/null +++ b/packages/channels/discord/src/threads/index.ts @@ -0,0 +1,113 @@ +import { + Client, + TextChannel, + ForumChannel, + AnyThreadChannel, + ChannelType, + ThreadAutoArchiveDuration, +} from "discord.js"; +import { ThreadInfo } from "../types.js"; + +/** + * Fetch an existing Discord thread and return its ThreadInfo. + * Returns null if the thread does not exist or is not accessible. + */ +export async function getThread( + client: Client, + discordThreadId: string +): Promise { + try { + const channel = await client.channels.fetch(discordThreadId); + if (!channel || !channel.isThread()) return null; + + const thread = channel as AnyThreadChannel; + return { + id: discordThreadId, // 1:1 mapping: OpenThreads thread ID = Discord thread ID + discordThreadId, + discordParentChannelId: thread.parentId ?? "", + name: thread.name, + archived: thread.archived ?? false, + createdAt: thread.createdAt ?? new Date(), + }; + } catch { + return null; + } +} + +/** + * Create a new Discord thread in a text channel. + * + * For forum channels, `name` is required (it becomes the forum post title). + * For text channels, the thread is started as a standalone thread (no starter + * message) so that any OpenThreads message can open a thread ad-hoc. + */ +export async function createThread( + client: Client, + parentChannelId: string, + name: string, + options: { + autoArchiveDuration?: ThreadAutoArchiveDuration; + reason?: string; + } = {} +): Promise { + const parent = await client.channels.fetch(parentChannelId); + if (!parent) { + throw new Error(`Channel ${parentChannelId} not found`); + } + + const archiveDuration = + options.autoArchiveDuration ?? ThreadAutoArchiveDuration.OneDay; + + let thread: AnyThreadChannel; + + if (parent.type === ChannelType.GuildForum) { + // Forum channel — create a post (which is a thread with a starter message) + const forumChannel = parent as ForumChannel; + const post = await forumChannel.threads.create({ + name, + autoArchiveDuration: archiveDuration, + message: { content: name }, + reason: options.reason, + }); + thread = post; + } else if ( + parent.type === ChannelType.GuildText || + parent.type === ChannelType.GuildAnnouncement + ) { + const textChannel = parent as TextChannel; + thread = await textChannel.threads.create({ + name, + autoArchiveDuration: archiveDuration, + reason: options.reason, + }); + } else { + throw new Error( + `Cannot create thread in channel type ${parent.type}` + ); + } + + return { + id: thread.id, + discordThreadId: thread.id, + discordParentChannelId: thread.parentId ?? parentChannelId, + name: thread.name, + archived: false, + createdAt: thread.createdAt ?? new Date(), + }; +} + +/** + * Ensure a thread is unarchived before attempting to send messages into it. + */ +export async function ensureThreadActive( + client: Client, + discordThreadId: string +): Promise { + const channel = await client.channels.fetch(discordThreadId); + if (!channel?.isThread()) return; + + const thread = channel as AnyThreadChannel; + if (thread.archived) { + await thread.setArchived(false, "Reactivated by OpenThreads"); + } +} diff --git a/packages/channels/discord/src/types.ts b/packages/channels/discord/src/types.ts new file mode 100644 index 0000000..a39271e --- /dev/null +++ b/packages/channels/discord/src/types.ts @@ -0,0 +1,166 @@ +/** + * Core types for the Discord channel adapter. + * These mirror the @openthreads/core interfaces — when that package is + * available the types here should be replaced with imports from it. + */ + +// --------------------------------------------------------------------------- +// Channel capabilities +// --------------------------------------------------------------------------- + +export interface ChannelCapabilities { + threads: boolean; + buttons: boolean; + selectMenus: boolean; + replyMessages: boolean; + dms: boolean; + fileUpload: boolean; +} + +// --------------------------------------------------------------------------- +// Inbound messages +// --------------------------------------------------------------------------- + +export type MessageType = "text" | "slash_command" | "mention"; + +export interface IncomingMessage { + id: string; + channelId: string; + /** OpenThreads thread ID (if a thread context is detected) */ + threadId?: string; + sender: { + id: string; + username: string; + displayName: string; + }; + type: MessageType; + text: string; + /** Slash command name when type === "slash_command" */ + commandName?: string; + /** Slash command options when type === "slash_command" */ + commandOptions?: Record; + /** Attachment URLs */ + attachments: string[]; + /** Raw Discord message / interaction, for adapter-internal use */ + raw: unknown; + timestamp: Date; +} + +export type IncomingMessageHandler = (message: IncomingMessage) => void | Promise; +export type Unsubscribe = () => void; + +// --------------------------------------------------------------------------- +// Outbound messages +// --------------------------------------------------------------------------- + +export type A2HIntent = "INFORM" | "COLLECT" | "AUTHORIZE" | "ESCALATE" | "RESULT"; + +export interface TextMessage { + text: string; + attachments?: string[]; +} + +export interface A2HMessage { + intent: A2HIntent; + context: { + action?: string; + details?: string; + question?: string; + options?: Array<{ label: string; value: string }>; + [key: string]: unknown; + }; + /** Correlation id forwarded back to the recipient */ + intentId?: string; +} + +export type OutboundMessageItem = TextMessage | A2HMessage; + +export interface SendMessageParams { + /** The Discord channel / DM channel ID */ + channelId: string; + /** + * Discord channel thread ID (for replies inside threads) or + * OpenThreads thread ID (resolved to Discord thread by the adapter). + */ + threadId?: string; + messages: OutboundMessageItem[]; +} + +export interface SentMessage { + /** Discord message snowflake */ + messageId: string; + /** Discord channel ID */ + channelId: string; + /** Discord thread ID (if the message was sent inside a thread) */ + threadId?: string; +} + +// --------------------------------------------------------------------------- +// Thread +// --------------------------------------------------------------------------- + +export interface ThreadInfo { + /** OpenThreads thread ID */ + id: string; + /** Discord channel snowflake (the thread channel itself) */ + discordThreadId: string; + /** Parent Discord channel */ + discordParentChannelId: string; + name?: string; + archived: boolean; + createdAt: Date; +} + +// --------------------------------------------------------------------------- +// Adapter config +// --------------------------------------------------------------------------- + +export interface DiscordAdapterConfig { + /** Discord bot token */ + token: string; + /** + * Discord Application ID — required for registering slash commands. + */ + applicationId: string; + /** + * Guild IDs to register slash commands on (guild-scoped = instant). + * Leave empty for global slash commands (takes up to 1 h to propagate). + */ + guildIds?: string[]; + /** + * Slash commands to register on connect. + */ + slashCommands?: SlashCommandDefinition[]; + /** + * Seconds to wait for a component-interaction response before timing out + * (method-2 response capture). Defaults to 300 (5 min). + */ + interactionTimeoutSeconds?: number; +} + +export interface SlashCommandDefinition { + name: string; + description: string; + options?: SlashCommandOption[]; +} + +export interface SlashCommandOption { + name: string; + description: string; + type: "string" | "integer" | "boolean" | "user" | "channel" | "role"; + required?: boolean; + choices?: Array<{ name: string; value: string | number }>; +} + +// --------------------------------------------------------------------------- +// ChannelAdapter interface (mirrors @openthreads/core) +// --------------------------------------------------------------------------- + +export interface ChannelAdapter { + readonly channelType: string; + capabilities(): ChannelCapabilities; + connect(config: DiscordAdapterConfig): Promise; + disconnect(): Promise; + sendMessage(params: SendMessageParams): Promise; + onIncomingMessage(handler: IncomingMessageHandler): Unsubscribe; +} diff --git a/packages/channels/discord/tsconfig.json b/packages/channels/discord/tsconfig.json new file mode 100644 index 0000000..f7929b3 --- /dev/null +++ b/packages/channels/discord/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/__tests__"] +} diff --git a/packages/channels/package.json b/packages/channels/package.json new file mode 100644 index 0000000..9dab3e3 --- /dev/null +++ b/packages/channels/package.json @@ -0,0 +1,31 @@ +{ + "name": "@openthreads/channels", + "version": "0.0.1", + "private": false, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + }, + "./conformance-suite": "./src/conformance-suite.ts", + "./mocks": "./src/mocks/mock-channel-server.ts" + }, + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "test": "bun test" + }, + "devDependencies": { + "vite": "*", + "vite-plugin-dts": "*", + "typescript": "*" + }, + "dependencies": { + "@openthreads/core": "workspace:*" + } +} diff --git a/packages/channels/slack/package.json b/packages/channels/slack/package.json new file mode 100644 index 0000000..f68d2d3 --- /dev/null +++ b/packages/channels/slack/package.json @@ -0,0 +1,24 @@ +{ + "name": "@openthreads/channels-slack", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "tsc", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@openthreads/core": "workspace:*", + "@slack/bolt": "^3.21.1", + "@slack/web-api": "^7.3.4" + }, + "devDependencies": { + "@types/node": "^20.14.0", + "typescript": "^5.4.5" + } +} diff --git a/packages/channels/slack/src/SlackAdapter.ts b/packages/channels/slack/src/SlackAdapter.ts new file mode 100644 index 0000000..2489a62 --- /dev/null +++ b/packages/channels/slack/src/SlackAdapter.ts @@ -0,0 +1,593 @@ +/** + * Slack channel adapter for OpenThreads. + * + * Implements the full ChannelAdapter interface using the Slack Bolt framework. + * + * A2H delivery methods used: + * Method 1 (inline) — AUTHORIZE → Approve/Deny buttons + * — COLLECT with options → static_select menu + * Method 2 (thread capture) — COLLECT free-text → awaits thread reply + */ + +import { App } from '@slack/bolt'; +import { WebClient } from '@slack/web-api'; +import { randomUUID } from 'crypto'; +import type { + ChannelAdapter, + ChannelCapabilities, + InboundEnvelope, + OutboundEnvelope, + A2HIntent, + A2HAuthorizeIntent, + A2HCollectIntent, + A2HResponse, + MessageHandler, + SendResult, + A2HSendOptions, + MessageItem, +} from '@openthreads/core'; +import type { + GenericMessageEvent, + AppMentionEvent, + ButtonAction, + StaticSelectAction, + BlockAction, + SlashCommand, +} from '@slack/bolt'; +import { + buildAuthorizeBlocks, + buildApprovedBlock, + buildDeniedBlock, + buildCollectSelectBlocks, + buildCollectResponseBlock, +} from './utils/blocks.js'; +import { + extractText, + isBot, + buildReplyToUrl, + collectThreadKey, +} from './utils/normalize.js'; + +// --------------------------------------------------------------------------- +// Config & dependencies +// --------------------------------------------------------------------------- + +export interface SlackAdapterConfig { + /** Bot token (xoxb-…) */ + token: string; + /** App signing secret for request verification */ + signingSecret: string; + /** App-level token for Socket Mode (xapp-…) */ + appToken?: string; + /** HTTP port to listen on (default: 3000). Ignored in Socket Mode. */ + port?: number; + /** Use Socket Mode instead of HTTP webhooks */ + socketMode?: boolean; + /** OpenThreads base URL used to generate `replyTo` URLs */ + baseUrl?: string; +} + +/** + * Optional dependency overrides — primarily for testing. + */ +export interface SlackAdapterDeps { + app?: Pick; + client?: Pick; +} + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +/** Resolves a pending A2H interaction with a raw string value */ +type PendingResolver = (value: string) => void; + +interface PendingContext { + channelId: string; + /** Timestamp of the Slack message that rendered the intent */ + ts: string; + /** For AUTHORIZE: action label used in confirmation message */ + action?: string; + /** For COLLECT: question text used in confirmation message */ + question?: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function isTextItem(item: MessageItem): item is { text: string } { + return !('intent' in item); +} + +function isA2HItem(item: MessageItem): item is A2HIntent { + return 'intent' in item; +} + +const DEFAULT_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24 hours + +// --------------------------------------------------------------------------- +// SlackAdapter +// --------------------------------------------------------------------------- + +export class SlackAdapter implements ChannelAdapter { + readonly channelType = 'slack'; + + /** + * Slack supports native threads, buttons, select menus, DMs, and file uploads. + * It does NOT have native "reply-to-message" (WhatsApp-style quoting is + * separate from thread replies in Slack's model). + */ + readonly capabilities: ChannelCapabilities = { + threads: true, + buttons: true, + selectMenus: true, + replyMessages: false, + dms: true, + fileUpload: true, + }; + + private readonly app: Pick; + private readonly client: Pick; + private messageHandler?: MessageHandler; + + /** + * Pending A2H interactions keyed by either: + * - `intent.id` — for button / select-menu interactions + * - `thread::` — for free-text thread captures + */ + private readonly pending = new Map(); + + /** + * Stores display context (message ts, action label, etc.) for each pending + * intent so we can update the original Slack message after resolution. + */ + private readonly pendingCtx = new Map(); + + constructor(config: SlackAdapterConfig, deps: SlackAdapterDeps = {}) { + if (deps.app) { + this.app = deps.app; + } else { + const appOptions: ConstructorParameters[0] = { + token: config.token, + signingSecret: config.signingSecret, + }; + if (config.socketMode && config.appToken) { + appOptions.socketMode = true; + appOptions.appToken = config.appToken; + } + this.app = new App(appOptions); + } + + this.client = deps.client ?? new WebClient(config.token); + this.config = config; + this.registerHandlers(); + } + + // TypeScript requires the field to be initialised in the constructor body + private readonly config: SlackAdapterConfig; + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + async initialize(): Promise { + await this.app.start(this.config.port ?? 3000); + } + + async shutdown(): Promise { + await this.app.stop(); + } + + // --------------------------------------------------------------------------- + // Message handler registration + // --------------------------------------------------------------------------- + + onMessage(handler: MessageHandler): void { + this.messageHandler = handler; + } + + // --------------------------------------------------------------------------- + // Outbound + // --------------------------------------------------------------------------- + + async send(envelope: OutboundEnvelope): Promise { + const items: MessageItem[] = Array.isArray(envelope.message) + ? envelope.message + : [envelope.message]; + + let lastTs: string | undefined; + + for (const item of items) { + if (isTextItem(item)) { + const result = await this.client.chat.postMessage({ + channel: envelope.channelId, + thread_ts: envelope.threadId, + text: item.text, + mrkdwn: true, + }); + lastTs = (result as { ts?: string }).ts; + } else if (isA2HItem(item) && item.intent === 'INFORM') { + const result = await this.client.chat.postMessage({ + channel: envelope.channelId, + thread_ts: envelope.threadId, + text: item.text, + mrkdwn: true, + }); + lastTs = (result as { ts?: string }).ts; + } + // Blocking A2H intents (AUTHORIZE, COLLECT) should go through sendA2H() + } + + return { + messageId: lastTs ?? randomUUID(), + threadId: envelope.threadId ?? lastTs, + }; + } + + // --------------------------------------------------------------------------- + // A2H + // --------------------------------------------------------------------------- + + async sendA2H( + channelId: string, + threadId: string | undefined, + intent: A2HIntent, + options: A2HSendOptions = {}, + ): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + + switch (intent.intent) { + case 'AUTHORIZE': + return this.sendAuthorize(channelId, threadId, intent, timeoutMs); + + case 'COLLECT': + return this.sendCollect(channelId, threadId, intent, timeoutMs); + + case 'INFORM': { + await this.client.chat.postMessage({ + channel: channelId, + thread_ts: threadId, + text: intent.text, + mrkdwn: true, + }); + return { intentId: intent.id, type: 'INFORM' }; + } + } + } + + // --------------------------------------------------------------------------- + // Handler registration + // --------------------------------------------------------------------------- + + private registerHandlers(): void { + // --- Inbound messages --- + this.app.message(async ({ message }) => { + const msg = message as GenericMessageEvent; + if (isBot(msg)) return; + await this.handleIncomingEvent(msg); + }); + + // --- App mentions (also fires for DMs; deduplicate with message handler) --- + this.app.event('app_mention', async ({ event }) => { + // app_mention fires in addition to message for channel mentions. + // We gate on message handler presence and use thread_ts for dedup. + await this.handleIncomingEvent(event as GenericMessageEvent); + }); + + // --- Slash commands --- + this.app.command('/openthreads', async ({ command, ack }) => { + await ack(); + await this.handleSlashCommand(command); + }); + + // --- Block Kit interactions (A2H method 1) --- + + // Approve button + this.app.action('a2h_approve', async ({ action, body, ack }) => { + await ack(); + await this.handleAuthorizeAction(action as ButtonAction, body as BlockAction, true); + }); + + // Deny button + this.app.action('a2h_deny', async ({ action, body, ack }) => { + await ack(); + await this.handleAuthorizeAction(action as ButtonAction, body as BlockAction, false); + }); + + // Select menu + this.app.action('a2h_collect_select', async ({ action, body, ack }) => { + await ack(); + await this.handleCollectSelect(action as StaticSelectAction, body as BlockAction); + }); + } + + // --------------------------------------------------------------------------- + // Inbound dispatchers + // --------------------------------------------------------------------------- + + private async handleIncomingEvent( + event: GenericMessageEvent | AppMentionEvent, + ): Promise { + const channelId = event.channel; + const messageTs = event.ts; + const threadTs = (event as { thread_ts?: string }).thread_ts; + + // --- Method 2: free-text COLLECT capture --- + // If this message is a thread reply and there is a pending listener, resolve it. + if (threadTs) { + const key = collectThreadKey(channelId, threadTs); + const resolver = this.pending.get(key); + if (resolver) { + const text = extractText(event as GenericMessageEvent); + resolver(text); + this.pending.delete(key); + return; // Do NOT dispatch as a normal inbound message + } + } + + if (!this.messageHandler) return; + + // Resolve display name from Slack user info + const userId = (event as { user?: string }).user ?? ''; + let senderName = userId; + try { + const info = await this.client.users.info({ user: userId }); + const user = (info as { user?: { real_name?: string; name?: string } }).user; + senderName = user?.real_name ?? user?.name ?? userId; + } catch { + // fall back to userId as name + } + + const openThreadId = threadTs ?? messageTs; + const baseUrl = this.config.baseUrl ?? 'http://localhost:3001'; + + const envelope: InboundEnvelope = { + threadId: openThreadId, + turnId: `ot_turn_${randomUUID()}`, + replyTo: buildReplyToUrl(baseUrl, channelId, openThreadId), + source: { + channel: 'slack', + channelId, + sender: { id: userId, name: senderName }, + raw: event, + }, + message: [{ text: extractText(event as GenericMessageEvent) }], + }; + + await this.messageHandler(envelope); + } + + private async handleSlashCommand(command: SlashCommand): Promise { + if (!this.messageHandler) return; + + const baseUrl = this.config.baseUrl ?? 'http://localhost:3001'; + const threadId = `slash_${command.trigger_id}`; + + const envelope: InboundEnvelope = { + threadId, + turnId: `ot_turn_${randomUUID()}`, + replyTo: buildReplyToUrl(baseUrl, command.channel_id, threadId), + source: { + channel: 'slack', + channelId: command.channel_id, + sender: { id: command.user_id, name: command.user_name }, + raw: command, + }, + message: [{ text: command.text }], + }; + + await this.messageHandler(envelope); + } + + // --------------------------------------------------------------------------- + // Block action handlers + // --------------------------------------------------------------------------- + + private async handleAuthorizeAction( + _action: ButtonAction, + body: BlockAction, + approved: boolean, + ): Promise { + // Recover intentId from block_id: "auth_actions_" + const blockId: string = (body.actions[0] as { block_id?: string })?.block_id ?? ''; + const intentId = blockId.replace(/^auth_actions_/, ''); + + const resolver = this.pending.get(intentId); + if (!resolver) return; + + resolver(approved ? 'approve' : 'deny'); + this.pending.delete(intentId); + + // Update the original Slack message to reflect the decision + const ctx = this.pendingCtx.get(intentId); + if (ctx) { + this.pendingCtx.delete(intentId); + const blocks = approved + ? buildApprovedBlock(ctx.action ?? '') + : buildDeniedBlock(ctx.action ?? ''); + + await this.client.chat.update({ + channel: ctx.channelId, + ts: ctx.ts, + text: approved + ? `✅ Approved: ${ctx.action}` + : `❌ Denied: ${ctx.action}`, + blocks, + }); + } + } + + private async handleCollectSelect( + action: StaticSelectAction, + body: BlockAction, + ): Promise { + // Recover intentId from block_id: "collect_section_" + const blockId: string = (body.actions[0] as { block_id?: string })?.block_id ?? ''; + const intentId = blockId.replace(/^collect_section_/, ''); + + const resolver = this.pending.get(intentId); + if (!resolver) return; + + const selectedValue = (action as { selected_option?: { value?: string; text?: { text?: string } } }) + .selected_option?.value ?? ''; + const selectedLabel = (action as { selected_option?: { text?: { text?: string } } }) + .selected_option?.text?.text ?? selectedValue; + + resolver(selectedValue); + this.pending.delete(intentId); + + // Update the original message + const ctx = this.pendingCtx.get(intentId); + if (ctx) { + this.pendingCtx.delete(intentId); + await this.client.chat.update({ + channel: ctx.channelId, + ts: ctx.ts, + text: `✅ Selected: ${selectedLabel}`, + blocks: buildCollectResponseBlock(ctx.question ?? '', selectedLabel), + }); + } + } + + // --------------------------------------------------------------------------- + // A2H senders + // --------------------------------------------------------------------------- + + private sendAuthorize( + channelId: string, + threadId: string | undefined, + intent: A2HAuthorizeIntent, + timeoutMs: number, + ): Promise { + return new Promise((resolve, reject) => { + void (async () => { + const blocks = buildAuthorizeBlocks(intent); + const result = await this.client.chat.postMessage({ + channel: channelId, + thread_ts: threadId, + text: `🔐 Authorization required: ${intent.context.action}`, + blocks, + }); + + const messageTs = (result as { ts?: string }).ts ?? ''; + this.pendingCtx.set(intent.id, { + channelId, + ts: messageTs, + action: intent.context.action, + }); + + const timer = setTimeout(() => { + this.pending.delete(intent.id); + this.pendingCtx.delete(intent.id); + reject(new Error(`AUTHORIZE timeout for intent ${intent.id}`)); + }, timeoutMs); + + this.pending.set(intent.id, (value) => { + clearTimeout(timer); + resolve({ + intentId: intent.id, + type: 'AUTHORIZE', + approved: value === 'approve', + }); + }); + })().catch(reject); + }); + } + + private sendCollect( + channelId: string, + threadId: string | undefined, + intent: A2HCollectIntent, + timeoutMs: number, + ): Promise { + if (intent.options && intent.options.length > 0) { + return this.sendCollectSelect(channelId, threadId, intent, timeoutMs); + } + return this.sendCollectFreeText(channelId, threadId, intent, timeoutMs); + } + + /** + * Method 1 — renders COLLECT as a static_select Block Kit menu. + */ + private sendCollectSelect( + channelId: string, + threadId: string | undefined, + intent: A2HCollectIntent, + timeoutMs: number, + ): Promise { + return new Promise((resolve, reject) => { + void (async () => { + const blocks = buildCollectSelectBlocks(intent); + const result = await this.client.chat.postMessage({ + channel: channelId, + thread_ts: threadId, + text: intent.question, + blocks, + }); + + const messageTs = (result as { ts?: string }).ts ?? ''; + this.pendingCtx.set(intent.id, { + channelId, + ts: messageTs, + question: intent.question, + }); + + const timer = setTimeout(() => { + this.pending.delete(intent.id); + this.pendingCtx.delete(intent.id); + reject(new Error(`COLLECT select timeout for intent ${intent.id}`)); + }, timeoutMs); + + this.pending.set(intent.id, (value) => { + clearTimeout(timer); + resolve({ + intentId: intent.id, + type: 'COLLECT', + response: value, + }); + }); + })().catch(reject); + }); + } + + /** + * Method 2 — posts the question in the thread and captures the next reply. + */ + private sendCollectFreeText( + channelId: string, + threadId: string | undefined, + intent: A2HCollectIntent, + timeoutMs: number, + ): Promise { + return new Promise((resolve, reject) => { + void (async () => { + const result = await this.client.chat.postMessage({ + channel: channelId, + thread_ts: threadId, + text: `📝 *${intent.question}*\n\n_Please reply in this thread to respond._`, + mrkdwn: true, + }); + + // If there is an existing thread, listen to it; otherwise listen to + // the thread created by this message. + const listenTs = threadId ?? ((result as { ts?: string }).ts ?? ''); + const key = collectThreadKey(channelId, listenTs); + + const timer = setTimeout(() => { + this.pending.delete(key); + reject(new Error(`COLLECT free-text timeout for intent ${intent.id}`)); + }, timeoutMs); + + this.pending.set(key, (text) => { + clearTimeout(timer); + resolve({ + intentId: intent.id, + type: 'COLLECT', + response: text, + }); + }); + })().catch(reject); + }); + } +} diff --git a/packages/channels/slack/src/__tests__/SlackAdapter.test.ts b/packages/channels/slack/src/__tests__/SlackAdapter.test.ts new file mode 100644 index 0000000..d9445bb --- /dev/null +++ b/packages/channels/slack/src/__tests__/SlackAdapter.test.ts @@ -0,0 +1,620 @@ +/** + * Integration-style tests for SlackAdapter. + * + * We inject mock App and WebClient implementations to avoid real HTTP calls + * while still exercising the adapter's full logic. + */ +import { describe, test, expect, beforeEach, mock } from 'bun:test'; +import { SlackAdapter } from '../SlackAdapter.js'; +import type { SlackAdapterConfig } from '../SlackAdapter.js'; +import type { InboundEnvelope } from '@openthreads/core'; + +// --------------------------------------------------------------------------- +// Mock helpers +// --------------------------------------------------------------------------- + +type Handler = (args: Record) => Promise; + +function createMockApp() { + const handlers: Record = {}; + + return { + app: { + message: (handler: Handler) => { + handlers['message'] = handler; + }, + event: (name: string, handler: Handler) => { + handlers[`event:${name}`] = handler; + }, + command: (name: string, handler: Handler) => { + handlers[`command:${name}`] = handler; + }, + action: (name: string, handler: Handler) => { + handlers[`action:${name}`] = handler; + }, + start: async (_port?: number) => {}, + stop: async () => {}, + } as unknown as import('@slack/bolt').App, + + /** Trigger a registered handler by event key */ + trigger: async (key: string, args: Record) => { + const handler = handlers[key]; + if (!handler) throw new Error(`No handler registered for "${key}"`); + await handler(args); + }, + + handlers, + }; +} + +interface PostedMessage { + channel: string; + thread_ts?: string; + text?: string; + blocks?: unknown[]; + mrkdwn?: boolean; +} + +interface UpdatedMessage { + channel: string; + ts: string; + text?: string; + blocks?: unknown[]; +} + +function createMockClient() { + const posted: PostedMessage[] = []; + const updated: UpdatedMessage[] = []; + let tsCounter = 1000; + + return { + client: { + chat: { + postMessage: async (opts: PostedMessage) => { + posted.push(opts); + return { ok: true, ts: `${++tsCounter}.000100` }; + }, + update: async (opts: UpdatedMessage) => { + updated.push(opts); + return { ok: true }; + }, + }, + users: { + info: async ({ user }: { user: string }) => ({ + ok: true, + user: { id: user, name: `user_${user}`, real_name: `User ${user}` }, + }), + }, + } as unknown as import('@slack/web-api').WebClient, + posted, + updated, + }; +} + +// --------------------------------------------------------------------------- +// Test setup +// --------------------------------------------------------------------------- + +const testConfig: SlackAdapterConfig = { + token: 'xoxb-test', + signingSecret: 'test-secret', + baseUrl: 'https://ot.example.com', +}; + +function makeAdapter() { + const mockApp = createMockApp(); + const mockClient = createMockClient(); + const adapter = new SlackAdapter(testConfig, { + app: mockApp.app, + client: mockClient.client, + }); + return { adapter, mockApp, mockClient }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('SlackAdapter — capabilities', () => { + test('channelType is "slack"', () => { + const { adapter } = makeAdapter(); + expect(adapter.channelType).toBe('slack'); + }); + + test('reports correct capabilities', () => { + const { adapter } = makeAdapter(); + expect(adapter.capabilities).toEqual({ + threads: true, + buttons: true, + selectMenus: true, + replyMessages: false, + dms: true, + fileUpload: true, + }); + }); +}); + +// --------------------------------------------------------------------------- + +describe('SlackAdapter — send()', () => { + test('posts a text message', async () => { + const { adapter, mockClient } = makeAdapter(); + const result = await adapter.send({ + channelId: 'C01234', + targetId: 'U5678', + message: { text: 'Hello, world!' }, + }); + expect(mockClient.posted).toHaveLength(1); + expect(mockClient.posted[0]?.channel).toBe('C01234'); + expect(mockClient.posted[0]?.text).toBe('Hello, world!'); + expect(result.messageId).toBeDefined(); + }); + + test('posts to a thread when threadId is provided', async () => { + const { adapter, mockClient } = makeAdapter(); + await adapter.send({ + channelId: 'C01234', + targetId: 'U5678', + threadId: '9876543210.000001', + message: { text: 'In a thread' }, + }); + expect(mockClient.posted[0]?.thread_ts).toBe('9876543210.000001'); + }); + + test('posts multiple items in sequence', async () => { + const { adapter, mockClient } = makeAdapter(); + await adapter.send({ + channelId: 'C01234', + targetId: 'U5678', + message: [ + { text: 'First message' }, + { text: 'Second message' }, + ], + }); + expect(mockClient.posted).toHaveLength(2); + }); + + test('sends INFORM A2H items as plain text', async () => { + const { adapter, mockClient } = makeAdapter(); + await adapter.send({ + channelId: 'C01234', + targetId: 'U5678', + message: { intent: 'INFORM', id: 'i1', text: 'Heads up!' }, + }); + expect(mockClient.posted[0]?.text).toBe('Heads up!'); + }); +}); + +// --------------------------------------------------------------------------- + +describe('SlackAdapter — sendA2H() INFORM', () => { + test('posts text and returns INFORM response', async () => { + const { adapter, mockClient } = makeAdapter(); + const response = await adapter.sendA2H('C01234', undefined, { + intent: 'INFORM', + id: 'inform-001', + text: 'Deploy complete.', + }); + expect(mockClient.posted).toHaveLength(1); + expect(mockClient.posted[0]?.text).toBe('Deploy complete.'); + expect(response.intentId).toBe('inform-001'); + expect(response.type).toBe('INFORM'); + }); + + test('posts to thread when threadId provided', async () => { + const { adapter, mockClient } = makeAdapter(); + await adapter.sendA2H('C01234', '1234.000001', { + intent: 'INFORM', + id: 'inform-002', + text: 'Step done.', + }); + expect(mockClient.posted[0]?.thread_ts).toBe('1234.000001'); + }); +}); + +// --------------------------------------------------------------------------- + +describe('SlackAdapter — sendA2H() AUTHORIZE', () => { + test('posts a Block Kit message with Approve and Deny buttons', async () => { + const { adapter, mockClient } = makeAdapter(); + + const authPromise = adapter.sendA2H( + 'C01234', + undefined, + { + intent: 'AUTHORIZE', + id: 'auth-001', + context: { action: 'deploy-to-production', details: 'Branch feature-x' }, + }, + { timeoutMs: 50 }, + ); + + // Message should be posted immediately + expect(mockClient.posted).toHaveLength(1); + expect(mockClient.posted[0]?.blocks).toBeDefined(); + const blocks = mockClient.posted[0]?.blocks as Array>; + const actionsBlock = blocks.find((b) => b['type'] === 'actions'); + expect(actionsBlock).toBeDefined(); + const elements = actionsBlock?.['elements'] as Array>; + expect(elements.some((e) => e['action_id'] === 'a2h_approve')).toBe(true); + expect(elements.some((e) => e['action_id'] === 'a2h_deny')).toBe(true); + + // Promise should eventually time out (no one clicks) + await expect(authPromise).rejects.toThrow('AUTHORIZE timeout'); + }); + + test('resolves with approved=true when Approve action fires', async () => { + const { adapter, mockClient, mockApp } = makeAdapter(); + + const authPromise = adapter.sendA2H( + 'C01234', + undefined, + { intent: 'AUTHORIZE', id: 'auth-002', context: { action: 'restart-service' } }, + { timeoutMs: 5000 }, + ); + + // Simulate the Approve button click + const blocks = mockClient.posted[0]?.blocks as Array>; + const actionsBlock = blocks.find((b) => b['type'] === 'actions') as Record; + const blockId = actionsBlock?.['block_id'] as string; + + await mockApp.trigger('action:a2h_approve', { + action: { action_id: 'a2h_approve', value: 'approve', block_id: blockId }, + body: { + actions: [{ action_id: 'a2h_approve', value: 'approve', block_id: blockId }], + channel: { id: 'C01234' }, + message: { ts: '1001.000100' }, + }, + ack: async () => {}, + }); + + const response = await authPromise; + expect(response.intentId).toBe('auth-002'); + expect(response.type).toBe('AUTHORIZE'); + expect(response.approved).toBe(true); + }); + + test('resolves with approved=false when Deny action fires', async () => { + const { adapter, mockClient, mockApp } = makeAdapter(); + + const authPromise = adapter.sendA2H( + 'C01234', + undefined, + { intent: 'AUTHORIZE', id: 'auth-003', context: { action: 'delete-database' } }, + { timeoutMs: 5000 }, + ); + + const blocks = mockClient.posted[0]?.blocks as Array>; + const actionsBlock = blocks.find((b) => b['type'] === 'actions') as Record; + const blockId = actionsBlock?.['block_id'] as string; + + await mockApp.trigger('action:a2h_deny', { + action: { action_id: 'a2h_deny', value: 'deny', block_id: blockId }, + body: { + actions: [{ action_id: 'a2h_deny', value: 'deny', block_id: blockId }], + channel: { id: 'C01234' }, + message: { ts: '1001.000100' }, + }, + ack: async () => {}, + }); + + const response = await authPromise; + expect(response.approved).toBe(false); + }); + + test('updates the original message after resolution', async () => { + const { adapter, mockClient, mockApp } = makeAdapter(); + + const authPromise = adapter.sendA2H( + 'C01234', + undefined, + { intent: 'AUTHORIZE', id: 'auth-004', context: { action: 'scale-up' } }, + { timeoutMs: 5000 }, + ); + + const blocks = mockClient.posted[0]?.blocks as Array>; + const actionsBlock = blocks.find((b) => b['type'] === 'actions') as Record; + const blockId = actionsBlock?.['block_id'] as string; + + await mockApp.trigger('action:a2h_approve', { + action: { action_id: 'a2h_approve', value: 'approve', block_id: blockId }, + body: { + actions: [{ action_id: 'a2h_approve', value: 'approve', block_id: blockId }], + channel: { id: 'C01234' }, + message: { ts: '1001.000100' }, + }, + ack: async () => {}, + }); + + await authPromise; + expect(mockClient.updated).toHaveLength(1); + expect(mockClient.updated[0]?.text).toContain('✅'); + }); +}); + +// --------------------------------------------------------------------------- + +describe('SlackAdapter — sendA2H() COLLECT (select menu)', () => { + const collectIntent = { + intent: 'COLLECT' as const, + id: 'collect-select-001', + question: 'Which environment?', + options: [ + { label: 'Staging', value: 'staging' }, + { label: 'Production', value: 'production' }, + ], + }; + + test('posts a select-menu Block Kit message', async () => { + const { adapter, mockClient } = makeAdapter(); + + const collectPromise = adapter.sendA2H('C01234', undefined, collectIntent, { + timeoutMs: 50, + }); + + expect(mockClient.posted).toHaveLength(1); + const blocks = mockClient.posted[0]?.blocks as Array>; + const section = blocks.find((b) => b['type'] === 'section') as Record; + const accessory = section?.['accessory'] as Record; + expect(accessory?.['type']).toBe('static_select'); + + await expect(collectPromise).rejects.toThrow('COLLECT select timeout'); + }); + + test('resolves with selected value when action fires', async () => { + const { adapter, mockClient, mockApp } = makeAdapter(); + + const collectPromise = adapter.sendA2H('C01234', undefined, collectIntent, { + timeoutMs: 5000, + }); + + const blocks = mockClient.posted[0]?.blocks as Array>; + const section = blocks.find((b) => b['type'] === 'section') as Record; + const blockId = section?.['block_id'] as string; + + await mockApp.trigger('action:a2h_collect_select', { + action: { + action_id: 'a2h_collect_select', + block_id: blockId, + selected_option: { value: 'staging', text: { text: 'Staging' } }, + }, + body: { + actions: [{ action_id: 'a2h_collect_select', block_id: blockId }], + channel: { id: 'C01234' }, + message: { ts: '1001.000100' }, + }, + ack: async () => {}, + }); + + const response = await collectPromise; + expect(response.intentId).toBe('collect-select-001'); + expect(response.type).toBe('COLLECT'); + expect(response.response).toBe('staging'); + }); +}); + +// --------------------------------------------------------------------------- + +describe('SlackAdapter — sendA2H() COLLECT (free-text)', () => { + test('posts a prompt and resolves when a thread reply arrives', async () => { + const { adapter, mockClient, mockApp } = makeAdapter(); + + const collectPromise = adapter.sendA2H( + 'C01234', + '9876543210.000001', + { + intent: 'COLLECT', + id: 'collect-text-001', + question: 'What is the deployment reason?', + }, + { timeoutMs: 5000 }, + ); + + // Adapter posts the question + expect(mockClient.posted[0]?.text).toContain('What is the deployment reason?'); + + // Simulate a thread reply from the human + await mockApp.trigger('message', { + message: { + user: 'U99999', + text: 'Fixing the auth bug', + ts: '9876543210.000200', + thread_ts: '9876543210.000001', + channel: 'C01234', + bot_id: undefined, + }, + ack: async () => {}, + }); + + const response = await collectPromise; + expect(response.intentId).toBe('collect-text-001'); + expect(response.type).toBe('COLLECT'); + expect(response.response).toBe('Fixing the auth bug'); + }); + + test('times out when no reply arrives', async () => { + const { adapter } = makeAdapter(); + + const collectPromise = adapter.sendA2H( + 'C01234', + '1111.000001', + { intent: 'COLLECT', id: 'collect-text-timeout', question: 'Why?' }, + { timeoutMs: 50 }, + ); + + await expect(collectPromise).rejects.toThrow('COLLECT free-text timeout'); + }); +}); + +// --------------------------------------------------------------------------- + +describe('SlackAdapter — inbound messages', () => { + test('dispatches a plain message to the registered handler', async () => { + const { adapter, mockApp } = makeAdapter(); + + const received: InboundEnvelope[] = []; + adapter.onMessage(async (env) => { received.push(env); }); + + await mockApp.trigger('message', { + message: { + user: 'U12345', + text: 'Hello bot', + ts: '1234567890.000100', + channel: 'C01234', + bot_id: undefined, + }, + ack: async () => {}, + }); + + expect(received).toHaveLength(1); + const env = received[0]!; + expect(env.source.channel).toBe('slack'); + expect(env.source.channelId).toBe('C01234'); + expect(env.source.sender.id).toBe('U12345'); + expect(env.message[0]).toMatchObject({ text: 'Hello bot' }); + }); + + test('sets threadId to message ts for top-level messages', async () => { + const { adapter, mockApp } = makeAdapter(); + const received: InboundEnvelope[] = []; + adapter.onMessage(async (env) => { received.push(env); }); + + await mockApp.trigger('message', { + message: { + user: 'U12345', + text: 'Top level', + ts: '1000000001.000100', + channel: 'C01234', + bot_id: undefined, + }, + ack: async () => {}, + }); + + expect(received[0]?.threadId).toBe('1000000001.000100'); + }); + + test('sets threadId to thread_ts for thread replies', async () => { + const { adapter, mockApp } = makeAdapter(); + const received: InboundEnvelope[] = []; + adapter.onMessage(async (env) => { received.push(env); }); + + await mockApp.trigger('message', { + message: { + user: 'U12345', + text: 'Reply in thread', + ts: '1000000002.000200', + thread_ts: '1000000001.000100', + channel: 'C01234', + bot_id: undefined, + }, + ack: async () => {}, + }); + + expect(received[0]?.threadId).toBe('1000000001.000100'); + }); + + test('ignores bot messages', async () => { + const { adapter, mockApp } = makeAdapter(); + const received: InboundEnvelope[] = []; + adapter.onMessage(async (env) => { received.push(env); }); + + await mockApp.trigger('message', { + message: { + bot_id: 'B999', + text: 'I am a bot', + ts: '9999.000100', + channel: 'C01234', + }, + ack: async () => {}, + }); + + expect(received).toHaveLength(0); + }); + + test('builds correct replyTo URL', async () => { + const { adapter, mockApp } = makeAdapter(); + const received: InboundEnvelope[] = []; + adapter.onMessage(async (env) => { received.push(env); }); + + await mockApp.trigger('message', { + message: { + user: 'U12345', + text: 'hi', + ts: '1234567890.000100', + channel: 'CABC123', + bot_id: undefined, + }, + ack: async () => {}, + }); + + expect(received[0]?.replyTo).toContain('https://ot.example.com'); + expect(received[0]?.replyTo).toContain('CABC123'); + }); + + test('does NOT dispatch free-text COLLECT capture to message handler', async () => { + const { adapter, mockClient, mockApp } = makeAdapter(); + + // Start a free-text COLLECT — this sets up the thread listener + const collectPromise = adapter.sendA2H( + 'C01234', + '9000.000001', + { intent: 'COLLECT', id: 'ct-dedup', question: 'Your input?' }, + { timeoutMs: 5000 }, + ); + + const received: InboundEnvelope[] = []; + adapter.onMessage(async (env) => { received.push(env); }); + + // Simulate a thread reply — should resolve the COLLECT, not dispatch + await mockApp.trigger('message', { + message: { + user: 'U12345', + text: 'My answer', + ts: '9000.000200', + thread_ts: '9000.000001', + channel: 'C01234', + bot_id: undefined, + }, + ack: async () => {}, + }); + + const response = await collectPromise; + expect(response.response).toBe('My answer'); + expect(received).toHaveLength(0); // NOT dispatched as a normal message + }); +}); + +// --------------------------------------------------------------------------- + +describe('SlackAdapter — slash commands', () => { + test('dispatches slash command as inbound envelope', async () => { + const { adapter, mockApp } = makeAdapter(); + const received: InboundEnvelope[] = []; + adapter.onMessage(async (env) => { received.push(env); }); + + await mockApp.trigger('command:/openthreads', { + command: { + user_id: 'U12345', + user_name: 'alice', + channel_id: 'C01234', + text: 'status', + trigger_id: 'trigger_abc', + }, + ack: async () => {}, + }); + + expect(received).toHaveLength(1); + expect(received[0]?.message[0]).toMatchObject({ text: 'status' }); + expect(received[0]?.source.sender.id).toBe('U12345'); + }); +}); + +// --------------------------------------------------------------------------- + +describe('SlackAdapter — lifecycle', () => { + test('initialize() and shutdown() complete without error', async () => { + const { adapter } = makeAdapter(); + await expect(adapter.initialize()).resolves.toBeUndefined(); + await expect(adapter.shutdown()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/channels/slack/src/__tests__/blocks.test.ts b/packages/channels/slack/src/__tests__/blocks.test.ts new file mode 100644 index 0000000..f15ae19 --- /dev/null +++ b/packages/channels/slack/src/__tests__/blocks.test.ts @@ -0,0 +1,186 @@ +/** + * Unit tests for Block Kit builders. + * + * These are pure function tests — no Slack API calls, no mocking needed. + */ +import { describe, test, expect } from 'bun:test'; +import { + buildAuthorizeBlocks, + buildApprovedBlock, + buildDeniedBlock, + buildCollectSelectBlocks, + buildCollectResponseBlock, +} from '../utils/blocks.js'; +import type { A2HAuthorizeIntent, A2HCollectIntent } from '@openthreads/core'; + +// --------------------------------------------------------------------------- +// buildAuthorizeBlocks +// --------------------------------------------------------------------------- + +describe('buildAuthorizeBlocks()', () => { + const base: A2HAuthorizeIntent = { + intent: 'AUTHORIZE', + id: 'test-intent-001', + context: { action: 'deploy-to-production' }, + }; + + test('returns an array of blocks', () => { + const blocks = buildAuthorizeBlocks(base); + expect(Array.isArray(blocks)).toBe(true); + expect(blocks.length).toBeGreaterThan(0); + }); + + test('includes a header block', () => { + const blocks = buildAuthorizeBlocks(base); + const header = blocks.find((b) => b.type === 'header'); + expect(header).toBeDefined(); + }); + + test('includes an actions block with Approve and Deny buttons', () => { + const blocks = buildAuthorizeBlocks(base); + const actions = blocks.find((b) => b.type === 'actions') as Record; + expect(actions).toBeDefined(); + + const elements = actions['elements'] as Array>; + expect(elements).toHaveLength(2); + + const approveBtn = elements.find((e) => e['action_id'] === 'a2h_approve'); + const denyBtn = elements.find((e) => e['action_id'] === 'a2h_deny'); + expect(approveBtn).toBeDefined(); + expect(denyBtn).toBeDefined(); + }); + + test('actions block_id encodes the intent ID', () => { + const blocks = buildAuthorizeBlocks(base); + const actions = blocks.find((b) => b.type === 'actions') as Record; + expect(actions?.['block_id']).toBe(`auth_actions_${base.id}`); + }); + + test('includes action details when provided', () => { + const withDetails: A2HAuthorizeIntent = { + ...base, + context: { action: 'deploy', details: 'Branch feature-x → production' }, + }; + const blocks = buildAuthorizeBlocks(withDetails); + const section = blocks.find((b) => b.type === 'section') as Record; + const text = section?.['text'] as Record; + expect(String(text?.['text'])).toContain('Branch feature-x → production'); + }); + + test('omits details line when details not provided', () => { + const blocks = buildAuthorizeBlocks(base); + const section = blocks.find((b) => b.type === 'section') as Record; + const text = section?.['text'] as Record; + expect(String(text?.['text'])).not.toContain('Details:'); + }); + + test('Approve button has primary style', () => { + const blocks = buildAuthorizeBlocks(base); + const actions = blocks.find((b) => b.type === 'actions') as Record; + const elements = actions?.['elements'] as Array>; + const approve = elements?.find((e) => e['action_id'] === 'a2h_approve'); + expect(approve?.['style']).toBe('primary'); + }); + + test('Deny button has danger style', () => { + const blocks = buildAuthorizeBlocks(base); + const actions = blocks.find((b) => b.type === 'actions') as Record; + const elements = actions?.['elements'] as Array>; + const deny = elements?.find((e) => e['action_id'] === 'a2h_deny'); + expect(deny?.['style']).toBe('danger'); + }); +}); + +// --------------------------------------------------------------------------- +// buildApprovedBlock / buildDeniedBlock +// --------------------------------------------------------------------------- + +describe('buildApprovedBlock()', () => { + test('contains ✅ and the action name', () => { + const blocks = buildApprovedBlock('deploy-to-production'); + const text = JSON.stringify(blocks); + expect(text).toContain('✅'); + expect(text).toContain('deploy-to-production'); + }); +}); + +describe('buildDeniedBlock()', () => { + test('contains ❌ and the action name', () => { + const blocks = buildDeniedBlock('deploy-to-production'); + const text = JSON.stringify(blocks); + expect(text).toContain('❌'); + expect(text).toContain('deploy-to-production'); + }); +}); + +// --------------------------------------------------------------------------- +// buildCollectSelectBlocks +// --------------------------------------------------------------------------- + +describe('buildCollectSelectBlocks()', () => { + const intent: A2HCollectIntent = { + intent: 'COLLECT', + id: 'collect-001', + question: 'Which environment?', + options: [ + { label: 'Staging', value: 'staging' }, + { label: 'Production', value: 'production' }, + ], + }; + + test('returns a section block with static_select accessory', () => { + const blocks = buildCollectSelectBlocks(intent); + expect(blocks).toHaveLength(1); + const section = blocks[0] as Record; + expect(section?.['type']).toBe('section'); + const accessory = section?.['accessory'] as Record; + expect(accessory?.['type']).toBe('static_select'); + }); + + test('section block_id encodes the intent ID', () => { + const blocks = buildCollectSelectBlocks(intent); + const section = blocks[0] as Record; + expect(section?.['block_id']).toBe(`collect_section_${intent.id}`); + }); + + test('select action_id is a2h_collect_select', () => { + const blocks = buildCollectSelectBlocks(intent); + const section = blocks[0] as Record; + const accessory = section?.['accessory'] as Record; + expect(accessory?.['action_id']).toBe('a2h_collect_select'); + }); + + test('maps options correctly', () => { + const blocks = buildCollectSelectBlocks(intent); + const section = blocks[0] as Record; + const accessory = section?.['accessory'] as Record; + const options = accessory?.['options'] as Array>; + expect(options).toHaveLength(2); + expect(options[0]?.['value']).toBe('staging'); + expect(options[1]?.['value']).toBe('production'); + }); + + test('throws when options array is empty', () => { + const empty: A2HCollectIntent = { ...intent, options: [] }; + expect(() => buildCollectSelectBlocks(empty)).toThrow(); + }); + + test('throws when options is undefined', () => { + const noOpts: A2HCollectIntent = { ...intent, options: undefined }; + expect(() => buildCollectSelectBlocks(noOpts)).toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// buildCollectResponseBlock +// --------------------------------------------------------------------------- + +describe('buildCollectResponseBlock()', () => { + test('includes the question and selected answer', () => { + const blocks = buildCollectResponseBlock('Which env?', 'staging'); + const text = JSON.stringify(blocks); + expect(text).toContain('Which env?'); + expect(text).toContain('staging'); + expect(text).toContain('✅'); + }); +}); diff --git a/packages/channels/slack/src/__tests__/conformance.test.ts b/packages/channels/slack/src/__tests__/conformance.test.ts new file mode 100644 index 0000000..5d40fa0 --- /dev/null +++ b/packages/channels/slack/src/__tests__/conformance.test.ts @@ -0,0 +1,242 @@ +/** + * Shared adapter conformance tests. + * + * These tests verify that SlackAdapter correctly implements the ChannelAdapter + * interface contract. Any adapter should be able to pass this suite by + * providing a factory function and test doubles. + */ +import { describe, test, expect } from 'bun:test'; +import { SlackAdapter } from '../SlackAdapter.js'; +import type { ChannelAdapter, ChannelCapabilities } from '@openthreads/core'; + +// --------------------------------------------------------------------------- +// Test doubles (same helpers as SlackAdapter.test.ts) +// --------------------------------------------------------------------------- + +type Handler = (args: Record) => Promise; + +function createMockApp() { + const handlers: Record = {}; + return { + app: { + message: (h: Handler) => { handlers['message'] = h; }, + event: (name: string, h: Handler) => { handlers[`event:${name}`] = h; }, + command: (name: string, h: Handler) => { handlers[`command:${name}`] = h; }, + action: (name: string, h: Handler) => { handlers[`action:${name}`] = h; }, + start: async () => {}, + stop: async () => {}, + } as unknown as import('@slack/bolt').App, + handlers, + }; +} + +function createMockClient() { + let ts = 5000; + return { + client: { + chat: { + postMessage: async () => ({ ok: true, ts: `${++ts}.000100` }), + update: async () => ({ ok: true }), + }, + users: { + info: async ({ user }: { user: string }) => ({ + ok: true, + user: { name: user, real_name: user }, + }), + }, + } as unknown as import('@slack/web-api').WebClient, + }; +} + +function makeAdapter(): ChannelAdapter { + const mockApp = createMockApp(); + const mockClient = createMockClient(); + return new SlackAdapter( + { token: 'xoxb-test', signingSecret: 'secret', baseUrl: 'https://ot.example.com' }, + { app: mockApp.app, client: mockClient.client }, + ); +} + +// --------------------------------------------------------------------------- +// Conformance: interface shape +// --------------------------------------------------------------------------- + +describe('ChannelAdapter conformance — interface', () => { + test('has channelType string property', () => { + const adapter = makeAdapter(); + expect(typeof adapter.channelType).toBe('string'); + expect(adapter.channelType.length).toBeGreaterThan(0); + }); + + test('has capabilities object with all required flags', () => { + const adapter = makeAdapter(); + const requiredFlags: Array = [ + 'threads', + 'buttons', + 'selectMenus', + 'replyMessages', + 'dms', + 'fileUpload', + ]; + for (const flag of requiredFlags) { + expect(typeof adapter.capabilities[flag]).toBe('boolean'); + } + }); + + test('exposes initialize() method', () => { + const adapter = makeAdapter(); + expect(typeof adapter.initialize).toBe('function'); + }); + + test('exposes shutdown() method', () => { + const adapter = makeAdapter(); + expect(typeof adapter.shutdown).toBe('function'); + }); + + test('exposes onMessage() method', () => { + const adapter = makeAdapter(); + expect(typeof adapter.onMessage).toBe('function'); + }); + + test('exposes send() method', () => { + const adapter = makeAdapter(); + expect(typeof adapter.send).toBe('function'); + }); + + test('exposes sendA2H() method', () => { + const adapter = makeAdapter(); + expect(typeof adapter.sendA2H).toBe('function'); + }); +}); + +// --------------------------------------------------------------------------- +// Conformance: send() contract +// --------------------------------------------------------------------------- + +describe('ChannelAdapter conformance — send()', () => { + test('returns a SendResult with messageId', async () => { + const adapter = makeAdapter(); + const result = await adapter.send({ + channelId: 'C01234', + targetId: 'U5678', + message: { text: 'conformance test' }, + }); + expect(result).toBeDefined(); + expect(typeof result.messageId).toBe('string'); + expect(result.messageId.length).toBeGreaterThan(0); + }); + + test('accepts a MessageItem array in message field', async () => { + const adapter = makeAdapter(); + const result = await adapter.send({ + channelId: 'C01234', + targetId: 'U5678', + message: [ + { text: 'item 1' }, + { text: 'item 2' }, + ], + }); + expect(result.messageId).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Conformance: sendA2H() contract +// --------------------------------------------------------------------------- + +describe('ChannelAdapter conformance — sendA2H()', () => { + test('INFORM returns response with correct intentId and type', async () => { + const adapter = makeAdapter(); + const response = await adapter.sendA2H('C01234', undefined, { + intent: 'INFORM', + id: 'conform-inform-001', + text: 'Test notification', + }); + expect(response.intentId).toBe('conform-inform-001'); + expect(response.type).toBe('INFORM'); + }); + + test('AUTHORIZE times out and rejects when no interaction occurs', async () => { + const adapter = makeAdapter(); + await expect( + adapter.sendA2H( + 'C01234', + undefined, + { intent: 'AUTHORIZE', id: 'conform-auth-001', context: { action: 'test' } }, + { timeoutMs: 50 }, + ), + ).rejects.toThrow(); + }); + + test('COLLECT (select) times out and rejects when no selection made', async () => { + const adapter = makeAdapter(); + await expect( + adapter.sendA2H( + 'C01234', + undefined, + { + intent: 'COLLECT', + id: 'conform-collect-001', + question: 'Pick one', + options: [{ label: 'A', value: 'a' }, { label: 'B', value: 'b' }], + }, + { timeoutMs: 50 }, + ), + ).rejects.toThrow(); + }); + + test('COLLECT (free-text) times out and rejects when no reply arrives', async () => { + const adapter = makeAdapter(); + await expect( + adapter.sendA2H( + 'C01234', + '1234.000001', + { intent: 'COLLECT', id: 'conform-freetext-001', question: 'Tell me why' }, + { timeoutMs: 50 }, + ), + ).rejects.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Conformance: onMessage() contract +// --------------------------------------------------------------------------- + +describe('ChannelAdapter conformance — onMessage()', () => { + test('accepts a handler function without error', () => { + const adapter = makeAdapter(); + expect(() => { + adapter.onMessage(async () => {}); + }).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Conformance: Slack-specific capability assertions +// --------------------------------------------------------------------------- + +describe('SlackAdapter capabilities', () => { + test('reports threads:true', () => { + expect(makeAdapter().capabilities.threads).toBe(true); + }); + + test('reports buttons:true', () => { + expect(makeAdapter().capabilities.buttons).toBe(true); + }); + + test('reports selectMenus:true', () => { + expect(makeAdapter().capabilities.selectMenus).toBe(true); + }); + + test('reports replyMessages:false (Slack uses threads, not quote-replies)', () => { + expect(makeAdapter().capabilities.replyMessages).toBe(false); + }); + + test('reports dms:true', () => { + expect(makeAdapter().capabilities.dms).toBe(true); + }); + + test('reports fileUpload:true', () => { + expect(makeAdapter().capabilities.fileUpload).toBe(true); + }); +}); diff --git a/packages/channels/slack/src/__tests__/normalize.test.ts b/packages/channels/slack/src/__tests__/normalize.test.ts new file mode 100644 index 0000000..fd8a4b0 --- /dev/null +++ b/packages/channels/slack/src/__tests__/normalize.test.ts @@ -0,0 +1,77 @@ +/** + * Unit tests for message normalization utilities. + */ +import { describe, test, expect } from 'bun:test'; +import { extractText, isBot, buildReplyToUrl, collectThreadKey } from '../utils/normalize.js'; +import type { GenericMessageEvent } from '@slack/bolt'; + +// --------------------------------------------------------------------------- +// extractText +// --------------------------------------------------------------------------- + +describe('extractText()', () => { + test('returns trimmed text from a message event', () => { + const event = { text: ' hello world ' } as unknown as GenericMessageEvent; + expect(extractText(event)).toBe('hello world'); + }); + + test('returns empty string when text is undefined', () => { + const event = {} as unknown as GenericMessageEvent; + expect(extractText(event)).toBe(''); + }); + + test('returns empty string when text is null', () => { + const event = { text: null } as unknown as GenericMessageEvent; + expect(extractText(event)).toBe(''); + }); +}); + +// --------------------------------------------------------------------------- +// isBot +// --------------------------------------------------------------------------- + +describe('isBot()', () => { + test('returns true when bot_id is present', () => { + const event = { bot_id: 'B12345' } as unknown as GenericMessageEvent; + expect(isBot(event)).toBe(true); + }); + + test('returns false when bot_id is absent', () => { + const event = { user: 'U12345' } as unknown as GenericMessageEvent; + expect(isBot(event)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// buildReplyToUrl +// --------------------------------------------------------------------------- + +describe('buildReplyToUrl()', () => { + test('builds a correct OpenThreads reply URL', () => { + const url = buildReplyToUrl( + 'https://openthreads.example.com', + 'C01234', + '1234567890.000100', + ); + expect(url).toBe( + 'https://openthreads.example.com/send/channel/slack/target/C01234/thread/1234567890.000100', + ); + }); +}); + +// --------------------------------------------------------------------------- +// collectThreadKey +// --------------------------------------------------------------------------- + +describe('collectThreadKey()', () => { + test('produces a stable, unique key', () => { + const key = collectThreadKey('C01234', '9876543210.000200'); + expect(key).toBe('thread:C01234:9876543210.000200'); + }); + + test('different channels produce different keys', () => { + const k1 = collectThreadKey('C111', '1234.000'); + const k2 = collectThreadKey('C222', '1234.000'); + expect(k1).not.toBe(k2); + }); +}); diff --git a/packages/channels/slack/src/index.ts b/packages/channels/slack/src/index.ts new file mode 100644 index 0000000..e913492 --- /dev/null +++ b/packages/channels/slack/src/index.ts @@ -0,0 +1,2 @@ +export { SlackAdapter } from './SlackAdapter.js'; +export type { SlackAdapterConfig, SlackAdapterDeps } from './SlackAdapter.js'; diff --git a/packages/channels/slack/src/utils/blocks.ts b/packages/channels/slack/src/utils/blocks.ts new file mode 100644 index 0000000..f39bd9f --- /dev/null +++ b/packages/channels/slack/src/utils/blocks.ts @@ -0,0 +1,162 @@ +/** + * Block Kit builders for A2H intents. + * + * All block_id values encode the intent ID so the interaction handler can + * look up the pending resolver without relying on fragile action_id parsing. + */ + +import type { Block } from '@slack/bolt'; +import type { A2HAuthorizeIntent, A2HCollectIntent } from '@openthreads/core'; + +// --------------------------------------------------------------------------- +// AUTHORIZE blocks +// --------------------------------------------------------------------------- + +/** + * Renders an AUTHORIZE intent as a Block Kit message with Approve / Deny buttons. + * + * block_id on the actions block: `auth_actions_` + * action_id on buttons: `a2h_approve` / `a2h_deny` + */ +export function buildAuthorizeBlocks(intent: A2HAuthorizeIntent): Block[] { + const detailsLine = intent.context.details + ? `\n*Details:* ${intent.context.details}` + : ''; + + return [ + { + type: 'header', + text: { + type: 'plain_text', + text: '🔐 Authorization Required', + emoji: true, + }, + }, + { + type: 'section', + block_id: `auth_info_${intent.id}`, + text: { + type: 'mrkdwn', + text: `*Action:* ${intent.context.action}${detailsLine}`, + }, + }, + { + type: 'divider', + }, + { + type: 'actions', + block_id: `auth_actions_${intent.id}`, + elements: [ + { + type: 'button', + text: { type: 'plain_text', text: '✅ Approve', emoji: true }, + style: 'primary', + action_id: 'a2h_approve', + value: 'approve', + confirm: { + title: { type: 'plain_text', text: 'Confirm Approval' }, + text: { + type: 'mrkdwn', + text: `Are you sure you want to approve *${intent.context.action}*?`, + }, + confirm: { type: 'plain_text', text: 'Yes, approve' }, + deny: { type: 'plain_text', text: 'Cancel' }, + style: 'primary', + }, + }, + { + type: 'button', + text: { type: 'plain_text', text: '❌ Deny', emoji: true }, + style: 'danger', + action_id: 'a2h_deny', + value: 'deny', + }, + ], + }, + ] as Block[]; +} + +/** + * Replacement block shown after an AUTHORIZE is resolved (approved). + */ +export function buildApprovedBlock(action: string): Block[] { + return [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `✅ *Approved:* ${action}`, + }, + }, + ] as Block[]; +} + +/** + * Replacement block shown after an AUTHORIZE is resolved (denied). + */ +export function buildDeniedBlock(action: string): Block[] { + return [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `❌ *Denied:* ${action}`, + }, + }, + ] as Block[]; +} + +// --------------------------------------------------------------------------- +// COLLECT (select menu) blocks +// --------------------------------------------------------------------------- + +/** + * Renders a COLLECT intent with `options` as a static-select menu. + * + * block_id on the section: `collect_section_` + * action_id on the select: `a2h_collect_select` + */ +export function buildCollectSelectBlocks(intent: A2HCollectIntent): Block[] { + if (!intent.options || intent.options.length === 0) { + throw new Error('buildCollectSelectBlocks requires at least one option'); + } + + return [ + { + type: 'section', + block_id: `collect_section_${intent.id}`, + text: { + type: 'mrkdwn', + text: `📋 *${intent.question}*`, + }, + accessory: { + type: 'static_select', + placeholder: { + type: 'plain_text', + text: 'Select an option', + emoji: true, + }, + action_id: 'a2h_collect_select', + options: intent.options.map((opt) => ({ + text: { type: 'plain_text', text: opt.label, emoji: true }, + value: opt.value, + })), + }, + }, + ] as Block[]; +} + +/** + * Replacement block shown after a COLLECT select is resolved. + */ +export function buildCollectResponseBlock(question: string, answer: string): Block[] { + return [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: `📋 *${question}*\n✅ *Selected:* ${answer}`, + }, + }, + ] as Block[]; +} diff --git a/packages/channels/slack/src/utils/normalize.ts b/packages/channels/slack/src/utils/normalize.ts new file mode 100644 index 0000000..4187121 --- /dev/null +++ b/packages/channels/slack/src/utils/normalize.ts @@ -0,0 +1,43 @@ +/** + * Message normalization utilities for the Slack adapter. + */ + +import type { GenericMessageEvent, AppMentionEvent } from '@slack/bolt'; + +/** + * Extracts plain text from a Slack message or mention event. + * Strips leading/trailing whitespace. + */ +export function extractText( + event: GenericMessageEvent | AppMentionEvent, +): string { + const raw = 'text' in event ? (event.text ?? '') : ''; + return raw.trim(); +} + +/** + * Returns true when the event originates from a bot (not a human). + */ +export function isBot(event: GenericMessageEvent): boolean { + return !!(event.bot_id ?? (event as { subtype?: string }).subtype === 'bot_message'); +} + +/** + * Builds the OpenThreads replyTo URL for a given channel + thread. + * + * Format: `{baseUrl}/send/channel/slack/target/{channelId}/thread/{threadTs}` + */ +export function buildReplyToUrl( + baseUrl: string, + channelId: string, + threadTs: string, +): string { + return `${baseUrl}/send/channel/slack/target/${channelId}/thread/${threadTs}`; +} + +/** + * Generates the cache key used to track free-text COLLECT listeners. + */ +export function collectThreadKey(channelId: string, threadTs: string): string { + return `thread:${channelId}:${threadTs}`; +} diff --git a/packages/channels/slack/tsconfig.json b/packages/channels/slack/tsconfig.json new file mode 100644 index 0000000..cabba2c --- /dev/null +++ b/packages/channels/slack/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "paths": { + "@openthreads/core": ["../../core/src/index.ts"] + } + }, + "include": ["src"] +} diff --git a/packages/channels/src/conformance-suite.ts b/packages/channels/src/conformance-suite.ts new file mode 100644 index 0000000..efbde42 --- /dev/null +++ b/packages/channels/src/conformance-suite.ts @@ -0,0 +1,226 @@ +/** + * Shared adapter conformance test suite. + * + * Provides a factory function `runConformanceSuite()` that generates a + * standardised set of Bun tests for any `ChannelAdapter` implementation. + * + * ### Usage + * ```ts + * // In your adapter's conformance.test.ts: + * import { runConformanceSuite } from '@openthreads/channels/conformance-suite'; + * import { MyAdapter } from '../MyAdapter.js'; + * + * runConformanceSuite({ + * channelType: 'my-platform', + * create: () => new MyAdapter({ ... }), + * expectedCapabilities: { + * threads: true, + * buttons: true, + * selectMenus: false, + * replyMessages: true, + * dms: true, + * fileUpload: false, + * }, + * }); + * ``` + * + * The suite tests: + * - Interface shape (required methods / properties are present) + * - `capabilities` object shape and values + * - `send()` returns a `SendResult`-compatible object + * - `onMessage()` / `onInboundMessage()` accepts a handler + * - `initialize()` / `connect()` and `shutdown()` / `disconnect()` lifecycle + */ + +import { describe, test, expect } from 'bun:test'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** + * Minimal capability descriptor expected from every channel adapter. + * Matches the `ChannelCapabilities` type exported from `@openthreads/core`. + */ +export interface AdapterCapabilities { + threads: boolean; + buttons: boolean; + selectMenus: boolean; + replyMessages: boolean; + dms: boolean; + fileUpload: boolean; +} + +/** + * Factory descriptor passed to `runConformanceSuite`. + */ +export interface ConformanceSuiteFactory { + /** Human-readable name used in test suite titles. */ + channelType: string; + /** Factory that creates a fresh adapter instance for each test. */ + create(): TAdapter; + /** + * Expected capability values for this adapter. + * The suite asserts each value matches. + */ + expectedCapabilities: AdapterCapabilities; + /** + * When `true`, tests that call `initialize()` / `connect()` are skipped. + * Use this when the adapter cannot be initialized without external services. + * Default: false + */ + skipLifecycle?: boolean; +} + +// --------------------------------------------------------------------------- +// Capability keys that must be present on every adapter +// --------------------------------------------------------------------------- + +const REQUIRED_CAPABILITY_KEYS: Array = [ + 'threads', + 'buttons', + 'selectMenus', + 'replyMessages', + 'dms', + 'fileUpload', +]; + +// --------------------------------------------------------------------------- +// Suite runner +// --------------------------------------------------------------------------- + +/** + * Generate a standardised conformance test suite for the given adapter factory. + * + * Call at the top level of a test file — the function registers `describe` blocks + * via Bun's test runner. + */ +export function runConformanceSuite>( + factory: ConformanceSuiteFactory, +): void { + const { channelType, create, expectedCapabilities, skipLifecycle = false } = factory; + + // ── Interface shape ──────────────────────────────────────────────────────── + describe(`${channelType} conformance — interface shape`, () => { + test('channelType or type property is a non-empty string', () => { + const adapter = create(); + const type = (adapter.channelType ?? adapter.type) as unknown; + expect(typeof type).toBe('string'); + expect((type as string).length).toBeGreaterThan(0); + }); + + test('has capabilities object or capabilities() function', () => { + const adapter = create(); + const caps = adapter.capabilities; + // Some adapters expose capabilities as a plain object, others as a method + expect(caps !== undefined || typeof adapter.capabilities === 'function').toBe(true); + }); + + test('exposes a send / sendMessage / renderA2HIntent method', () => { + const adapter = create(); + // Different adapters use different method names for outbound sending. + // Slack: send(), WhatsApp: sendMessage(), Telegram: send() + renderA2HIntent() + const sendFn = + adapter.send ?? adapter.sendMessage ?? adapter.renderA2HIntent; + expect(typeof sendFn).toBe('function'); + }); + + test('exposes an onMessage / onInboundMessage / onIncomingMessage / parseInbound method', () => { + const adapter = create(); + // Each adapter surface varies: + // Slack: onMessage() + // WhatsApp: onInboundMessage() + // Discord: onIncomingMessage() + // Telegram: parseInbound() (pull-based, no subscription registration) + const onMsg = + adapter.onMessage ?? + adapter.onInboundMessage ?? + adapter.onIncomingMessage ?? + adapter.parseInbound; + expect(typeof onMsg).toBe('function'); + }); + }); + + // ── Capabilities ────────────────────────────────────────────────────────── + describe(`${channelType} conformance — capabilities`, () => { + function getCaps(adapter: TAdapter): AdapterCapabilities { + const raw = adapter.capabilities; + if (typeof raw === 'function') { + return (raw as () => AdapterCapabilities)(); + } + return raw as AdapterCapabilities; + } + + test('capabilities object has all required boolean flags', () => { + const adapter = create(); + const caps = getCaps(adapter); + + for (const key of REQUIRED_CAPABILITY_KEYS) { + expect(typeof caps[key]).toBe('boolean'); + } + }); + + test('capabilities.threads matches expected value', () => { + expect(getCaps(create()).threads).toBe(expectedCapabilities.threads); + }); + + test('capabilities.buttons matches expected value', () => { + expect(getCaps(create()).buttons).toBe(expectedCapabilities.buttons); + }); + + test('capabilities.selectMenus matches expected value', () => { + expect(getCaps(create()).selectMenus).toBe(expectedCapabilities.selectMenus); + }); + + test('capabilities.replyMessages matches expected value', () => { + expect(getCaps(create()).replyMessages).toBe(expectedCapabilities.replyMessages); + }); + + test('capabilities.dms matches expected value', () => { + expect(getCaps(create()).dms).toBe(expectedCapabilities.dms); + }); + + test('capabilities.fileUpload matches expected value', () => { + expect(getCaps(create()).fileUpload).toBe(expectedCapabilities.fileUpload); + }); + }); + + // ── onMessage handler registration ──────────────────────────────────────── + describe(`${channelType} conformance — message handler registration`, () => { + test('onMessage / onInboundMessage / onIncomingMessage accepts a handler without throwing', () => { + const adapter = create(); + const register = ( + adapter.onMessage ?? + adapter.onInboundMessage ?? + adapter.onIncomingMessage + ) as ((h: () => void) => unknown) | undefined; + + if (typeof register !== 'function') { + // Adapter uses pull-based pattern (e.g., Telegram parseInbound) — skip. + return; + } + + expect(() => register.call(adapter, () => {})).not.toThrow(); + }); + }); + + // ── Lifecycle ───────────────────────────────────────────────────────────── + if (!skipLifecycle) { + describe(`${channelType} conformance — lifecycle`, () => { + test('shutdown / disconnect / destroy resolves without error when not connected', async () => { + const adapter = create(); + const shutdownFn = + (adapter.shutdown ?? adapter.disconnect ?? adapter.destroy) as + | (() => Promise) + | undefined; + + if (typeof shutdownFn !== 'function') { + // Adapter does not expose a shutdown method — skip gracefully. + return; + } + + await expect(shutdownFn.call(adapter)).resolves.not.toThrow(); + }); + }); + } +} diff --git a/packages/channels/src/index.test.ts b/packages/channels/src/index.test.ts new file mode 100644 index 0000000..7a02e09 --- /dev/null +++ b/packages/channels/src/index.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'bun:test' + +describe('@openthreads/channels', () => { + it('exports channel types', async () => { + const mod = await import('./index') + expect(mod).toBeDefined() + }) +}) diff --git a/packages/channels/src/index.ts b/packages/channels/src/index.ts new file mode 100644 index 0000000..2f27a63 --- /dev/null +++ b/packages/channels/src/index.ts @@ -0,0 +1,7 @@ +// @openthreads/channels +// Custom channel adapters (Baileys/WhatsApp, etc.) +// Native Chat SDK adapters live in @openthreads/core + +export * from './types'; +export { ReconnectManager, computeReconnectDelay } from './reconnect.js'; +export type { ReconnectOptions } from './reconnect.js'; diff --git a/packages/channels/src/mocks/mock-channel-server.ts b/packages/channels/src/mocks/mock-channel-server.ts new file mode 100644 index 0000000..d639d94 --- /dev/null +++ b/packages/channels/src/mocks/mock-channel-server.ts @@ -0,0 +1,324 @@ +/** + * Mock channel servers for integration and E2E testing. + * + * Each `MockChannelServer` simulates a platform's webhook callback mechanism: + * - Accepts outbound messages "sent" by an adapter and records them. + * - Provides helpers to emit inbound events (as if a user sent a message). + * - Exposes a `lastSent` accessor to assert on outbound messages. + * + * These mocks are pure in-process objects — no real HTTP servers are started. + * They are intended to be injected into adapter constructors via dependency- + * injection interfaces wherever possible, or patched onto adapter internals + * when the adapter does not expose a DI surface. + * + * ### Usage + * + * ```ts + * const server = new MockSlackServer(); + * + * // Simulate an inbound Slack message: + * server.emitMessage({ userId: 'U123', channelId: 'C456', text: 'Hello!' }); + * + * // Assert the adapter sent back an outbound message: + * expect(server.lastSent?.text).toBe('Got it!'); + * ``` + */ + +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- + +export interface MockSentMessage { + target: string; + payload: unknown; + sentAt: Date; +} + +export interface MockInboundEvent { + senderId: string; + senderName?: string; + targetId: string; + text: string; + nativeThreadId?: string; + isDm?: boolean; + isMention?: boolean; +} + +// --------------------------------------------------------------------------- +// Base class +// --------------------------------------------------------------------------- + +/** + * Base class for mock channel servers. + * + * Tracks outbound messages and provides helpers common to all platforms. + */ +export abstract class BaseMockChannelServer { + protected readonly _sent: MockSentMessage[] = []; + private readonly inboundListeners: Array<(event: MockInboundEvent) => void> = []; + + /** All outbound messages recorded so far (oldest first). */ + get sent(): ReadonlyArray { + return this._sent; + } + + /** The most recent outbound message, or `undefined` if none yet. */ + get lastSent(): MockSentMessage | undefined { + return this._sent[this._sent.length - 1]; + } + + /** Clear all recorded outbound messages. */ + clearSent(): void { + this._sent.length = 0; + } + + /** Register a listener that receives emulated inbound events. */ + onInbound(listener: (event: MockInboundEvent) => void): () => void { + this.inboundListeners.push(listener); + return () => { + const idx = this.inboundListeners.indexOf(listener); + if (idx !== -1) this.inboundListeners.splice(idx, 1); + }; + } + + /** Emit a simulated inbound message to all registered listeners. */ + emitInbound(event: MockInboundEvent): void { + for (const listener of this.inboundListeners) { + listener(event); + } + } + + /** + * Record an outbound message (called by the mock send implementation). + */ + protected recordSent(target: string, payload: unknown): void { + this._sent.push({ target, payload, sentAt: new Date() }); + } +} + +// --------------------------------------------------------------------------- +// Slack mock +// --------------------------------------------------------------------------- + +export interface MockSlackMessage { + channel: string; + thread_ts?: string; + text?: string; + blocks?: unknown[]; +} + +/** + * Mock Slack server. + * + * Simulates the Slack API's `chat.postMessage` / `chat.update` surface. + * Inject via `SlackAdapterDeps.client` when creating a `SlackAdapter` for tests. + */ +export class MockSlackServer extends BaseMockChannelServer { + private ts = 1_000; + + /** Create a mock Slack `WebClient`-compatible client surface. */ + createMockClient() { + const server = this; + return { + chat: { + postMessage: async (msg: MockSlackMessage) => { + const messageTs = `${++server.ts}.000000`; + server.recordSent(msg.channel, msg); + return { ok: true, ts: messageTs }; + }, + update: async (msg: { channel: string; ts: string }) => { + server.recordSent(msg.channel, { ...msg, _type: 'update' }); + return { ok: true }; + }, + }, + users: { + info: async ({ user }: { user: string }) => ({ + ok: true, + user: { name: user, real_name: `Mock User (${user})` }, + }), + }, + }; + } + + /** Create a mock Slack `App`-compatible event dispatcher. */ + createMockApp() { + const handlers: Record) => Promise> = {}; + + return { + app: { + message: (h: (args: Record) => Promise) => { + handlers['message'] = h; + }, + event: (name: string, h: (args: Record) => Promise) => { + handlers[`event:${name}`] = h; + }, + command: (name: string, h: (args: Record) => Promise) => { + handlers[`command:${name}`] = h; + }, + action: (name: string, h: (args: Record) => Promise) => { + handlers[`action:${name}`] = h; + }, + start: async () => {}, + stop: async () => {}, + }, + /** Trigger a registered handler directly (for testing). */ + trigger: async (key: string, args: Record) => { + const handler = handlers[key]; + if (!handler) throw new Error(`No Slack handler registered for "${key}"`); + await handler(args); + }, + handlers, + }; + } +} + +// --------------------------------------------------------------------------- +// Telegram mock +// --------------------------------------------------------------------------- + +export interface MockTelegramMessage { + chat_id: string | number; + text?: string; + reply_markup?: unknown; + parse_mode?: string; + reply_to_message_id?: number; +} + +/** + * Mock Telegram server. + * + * Simulates the Telegram Bot API's `sendMessage` / `answerCallbackQuery` + * surface. Inject via `TelegramAdapterOptions.apiClient` when creating a + * `TelegramAdapter` for tests. + */ +export class MockTelegramServer extends BaseMockChannelServer { + private messageId = 100; + private readonly callbackListeners: Array<(queryId: string, text?: string) => void> = []; + + /** Create a mock `TelegramApiClient`-compatible surface. */ + createMockApiClient() { + const server = this; + return { + sendMessage: async (params: MockTelegramMessage) => { + const id = ++server.messageId; + server.recordSent(String(params.chat_id), params); + return { message_id: id, date: Math.floor(Date.now() / 1000) }; + }, + editMessageReplyMarkup: async (params: unknown) => { + server.recordSent('_edit', params); + return {}; + }, + answerCallbackQuery: async (params: { callback_query_id: string; text?: string }) => { + for (const listener of server.callbackListeners) { + listener(params.callback_query_id, params.text); + } + return {}; + }, + setWebhook: async (_params: unknown) => ({ ok: true }), + deleteWebhook: async () => ({ ok: true }), + }; + } + + /** Listen for `answerCallbackQuery` calls (useful for testing A2H flows). */ + onCallbackAnswered(listener: (queryId: string, text?: string) => void): () => void { + this.callbackListeners.push(listener); + return () => { + const idx = this.callbackListeners.indexOf(listener); + if (idx !== -1) this.callbackListeners.splice(idx, 1); + }; + } +} + +// --------------------------------------------------------------------------- +// Generic (HTTP webhook) mock server +// --------------------------------------------------------------------------- + +export interface MockWebhookRequest { + url: string; + method: string; + headers: Record; + body: unknown; + receivedAt: Date; +} + +export interface MockWebhookResponse { + status: number; + body?: unknown; +} + +/** + * Mock HTTP webhook server. + * + * Records all "sent" webhook requests and allows tests to inspect them. + * Used to simulate the recipient's endpoint that receives OpenThreads envelopes. + * + * Replace the real `fetch` in tests via the `interceptFetch` helper. + */ +export class MockWebhookServer { + private readonly _requests: MockWebhookRequest[] = []; + private responseMap = new Map(); + + /** All recorded webhook requests (oldest first). */ + get requests(): ReadonlyArray { + return this._requests; + } + + /** The most recent request, or `undefined` if none. */ + get lastRequest(): MockWebhookRequest | undefined { + return this._requests[this._requests.length - 1]; + } + + /** Clear all recorded requests. */ + clear(): void { + this._requests.length = 0; + } + + /** + * Configure a response to return for requests matching the given URL prefix. + * Default response is `{ status: 200 }`. + */ + setResponse(urlPrefix: string, response: MockWebhookResponse): void { + this.responseMap.set(urlPrefix, response); + } + + /** + * Returns a `fetch`-compatible mock function that records calls and + * returns configured responses. + * + * Inject this as a replacement for `globalThis.fetch` in your test setup. + */ + createFetchMock(): typeof fetch { + const server = this; + + return async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + + let body: unknown; + try { + body = init?.body ? JSON.parse(init.body as string) : undefined; + } catch { + body = init?.body; + } + + server._requests.push({ + url, + method: init?.method ?? 'GET', + headers: Object.fromEntries(new Headers(init?.headers ?? {}).entries()), + body, + receivedAt: new Date(), + }); + + // Find the best matching response. + let response: MockWebhookResponse = { status: 200 }; + for (const [prefix, res] of server.responseMap) { + if (url.startsWith(prefix)) { + response = res; + break; + } + } + + const responseBody = response.body !== undefined ? JSON.stringify(response.body) : '{}'; + return new Response(responseBody, { status: response.status }); + }; + } +} diff --git a/packages/channels/src/reconnect.ts b/packages/channels/src/reconnect.ts new file mode 100644 index 0000000..c007e63 --- /dev/null +++ b/packages/channels/src/reconnect.ts @@ -0,0 +1,186 @@ +/** + * Generic reconnect manager for WebSocket-based channel adapters. + * + * Provides exponential backoff reconnection that can be reused by + * any adapter that maintains a persistent connection (Slack Socket Mode, + * Discord gateway, WhatsApp WebSocket). + * + * The WhatsApp adapter ships its own `SessionManager` with equivalent logic. + * This module provides the same behaviour as a reusable utility for Slack + * Socket Mode and Discord adapters. + * + * Usage: + * ```ts + * const reconnect = new ReconnectManager( + * () => this.wsClient.connect(), + * { + * maxAttempts: 10, + * initialDelayMs: 1_000, + * onRetry: (attempt, delay) => console.log(`Reconnecting (attempt ${attempt}), delay ${delay}ms`), + * }, + * ); + * + * // On disconnect: + * reconnect.scheduleReconnect(disconnectError); + * + * // On destroy: + * reconnect.stop(); + * ``` + */ + +export interface ReconnectOptions { + /** + * Maximum number of reconnect attempts before giving up. + * Default: 10 + */ + maxAttempts: number; + /** + * Delay before the first reconnect attempt (ms). + * Default: 1000 + */ + initialDelayMs: number; + /** + * Upper bound on the computed delay (ms). + * Default: 30000 + */ + maxDelayMs: number; + /** + * Multiplier applied to the delay after each attempt. + * Default: 2 + */ + backoffFactor: number; + /** + * Called before each reconnect attempt (after the delay). + */ + onRetry?: (attempt: number, delayMs: number, error: unknown) => void; + /** + * Called when reconnection succeeds. + */ + onConnected?: () => void; + /** + * Called when all attempts are exhausted. + */ + onExhausted?: (attempts: number) => void; +} + +const DEFAULTS: ReconnectOptions = { + maxAttempts: 10, + initialDelayMs: 1_000, + maxDelayMs: 30_000, + backoffFactor: 2, +}; + +export class ReconnectManager { + private attempts = 0; + private stopped = false; + private pendingTimer: ReturnType | null = null; + + private readonly options: ReconnectOptions; + + constructor( + /** Function that establishes the connection. Should throw on failure. */ + private readonly connectFn: () => Promise, + options: Partial = {}, + ) { + this.options = { ...DEFAULTS, ...options }; + } + + /** + * Establish the initial connection. + * Does not use retry — throws immediately on failure. + * Call `scheduleReconnect()` from the disconnect handler to begin retrying. + */ + async connect(): Promise { + this.stopped = false; + this.attempts = 0; + await this.connectFn(); + this.attempts = 0; + this.options.onConnected?.(); + } + + /** + * Schedule a reconnect attempt after a disconnect. + * + * Should be called from the adapter's disconnect/close event handler. + * Ignored if `stop()` has been called. + */ + scheduleReconnect(error?: unknown): void { + if (this.stopped) return; + if (this.attempts >= this.options.maxAttempts) { + this.options.onExhausted?.(this.attempts); + return; + } + + this.attempts++; + + const rawDelay = + this.options.initialDelayMs * Math.pow(this.options.backoffFactor, this.attempts - 1); + const delayMs = Math.min(rawDelay, this.options.maxDelayMs); + + this.pendingTimer = setTimeout(() => { + if (this.stopped) return; + this.options.onRetry?.(this.attempts, delayMs, error); + void this.attemptReconnect(error); + }, delayMs); + } + + /** + * Permanently stop reconnecting. + * Cancels any pending scheduled reconnect. + */ + stop(): void { + this.stopped = true; + if (this.pendingTimer !== null) { + clearTimeout(this.pendingTimer); + this.pendingTimer = null; + } + } + + /** + * Reset the attempt counter (call after a successful reconnection). + */ + resetAttempts(): void { + this.attempts = 0; + } + + /** Returns the current attempt count. */ + get currentAttempts(): number { + return this.attempts; + } + + /** Returns whether this manager has been stopped. */ + get isStopped(): boolean { + return this.stopped; + } + + // --------------------------------------------------------------------------- + // Private + // --------------------------------------------------------------------------- + + private async attemptReconnect(originalError: unknown): Promise { + try { + await this.connectFn(); + this.attempts = 0; + this.options.onConnected?.(); + } catch (err) { + if (!this.stopped) { + this.scheduleReconnect(err ?? originalError); + } + } + } +} + +/** + * Compute the reconnect delay for attempt N (1-indexed) without actually sleeping. + * Useful for logging and unit-testing the backoff curve. + */ +export function computeReconnectDelay( + attempt: number, + options: Partial> = {}, +): number { + const initialDelayMs = options.initialDelayMs ?? DEFAULTS.initialDelayMs; + const maxDelayMs = options.maxDelayMs ?? DEFAULTS.maxDelayMs; + const backoffFactor = options.backoffFactor ?? DEFAULTS.backoffFactor; + const raw = initialDelayMs * Math.pow(backoffFactor, attempt - 1); + return Math.min(raw, maxDelayMs); +} diff --git a/packages/channels/src/types.ts b/packages/channels/src/types.ts new file mode 100644 index 0000000..dcd6594 --- /dev/null +++ b/packages/channels/src/types.ts @@ -0,0 +1,8 @@ +import type { Channel } from '@openthreads/core' + +export interface ChannelAdapter { + channel: Channel + connect(): Promise + disconnect(): Promise + send(target: string, message: unknown): Promise +} diff --git a/packages/channels/telegram/package.json b/packages/channels/telegram/package.json new file mode 100644 index 0000000..fc56002 --- /dev/null +++ b/packages/channels/telegram/package.json @@ -0,0 +1,20 @@ +{ + "name": "@openthreads/channel-telegram", + "version": "0.1.0", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "@openthreads/core": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.4.0", + "@types/bun": "latest" + } +} diff --git a/packages/channels/telegram/src/a2h-renderer.ts b/packages/channels/telegram/src/a2h-renderer.ts new file mode 100644 index 0000000..b620615 --- /dev/null +++ b/packages/channels/telegram/src/a2h-renderer.ts @@ -0,0 +1,328 @@ +/** + * A2H intent renderer for the Telegram adapter. + * + * Telegram capabilities: + * threads: false → no native thread support + * buttons: true → inline keyboards (method 1) + * selectMenus: false → no native select menus + * replyMessages: true → method 2 (reply-to capture) + * dms: true → implicit DM capture + * + * Render strategy: + * AUTHORIZE → method 1 (inline keyboard: APPROVE / DENY) + * COLLECT w/ options → method 1 (inline keyboard: one button per option) + * COLLECT free-text → method 2 (question sent, reply-to captured) + * INFORM → plain message, no response expected + */ + +import type { + A2HIntent, + A2HIntentBase, + A2HRenderResult, + A2HResponse, + AuthorizeIntent, + CollectIntent, + InformIntent, +} from "@openthreads/core"; +import type { TelegramApiClient } from "./api-client.js"; +import type { A2HCallbackData, SendMessageParams } from "./types.js"; + +// --------------------------------------------------------------------------- +// Callback data helpers +// --------------------------------------------------------------------------- + +const MAX_CALLBACK_DATA_BYTES = 64; + +/** + * Encode an A2H callback payload into a compact string. + * Format: "a||" + * The turnId is abbreviated to keep within the 64-byte limit. + */ +export function encodeA2HCallbackData(turnId: string, value: string): string { + const payload: A2HCallbackData = { t: "a", tid: turnId, v: value }; + const encoded = JSON.stringify(payload); + if (new TextEncoder().encode(encoded).length > MAX_CALLBACK_DATA_BYTES) { + throw new Error( + `A2H callback data exceeds ${MAX_CALLBACK_DATA_BYTES} bytes. ` + + `Consider shortening the turnId or value. Encoded: ${encoded}`, + ); + } + return encoded; +} + +/** + * Decode an A2H callback data string. + * Returns null if the data is not a valid A2H payload. + */ +export function decodeA2HCallbackData(data: string): A2HCallbackData | null { + try { + const parsed = JSON.parse(data) as unknown; + if ( + typeof parsed === "object" && + parsed !== null && + "t" in parsed && + (parsed as A2HCallbackData).t === "a" + ) { + return parsed as A2HCallbackData; + } + return null; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Safely add reply_to_message_id to sendMessage params only when defined. + * Required because exactOptionalPropertyTypes prevents assigning undefined + * to an optional property in an object literal. + */ +function withReplyTo( + base: SendMessageParams, + replyToMessageId?: string, +): SendMessageParams { + if (replyToMessageId === undefined) return base; + return { ...base, reply_to_message_id: Number(replyToMessageId) }; +} + +// --------------------------------------------------------------------------- +// Renderers by intent type +// --------------------------------------------------------------------------- + +async function renderAuthorize( + api: TelegramApiClient, + chatId: string, + intent: AuthorizeIntent, + replyToMessageId?: string, +): Promise { + const { action, details } = intent.context; + const evidenceLines = intent.context.evidence + ? Object.entries(intent.context.evidence) + .map(([k, v]) => ` • ${k}: ${String(v)}`) + .join("\n") + : undefined; + + const lines = [ + `🔐 *Authorization Required*`, + ``, + `*Action:* ${escapeMarkdown(action)}`, + details !== undefined ? `*Details:* ${escapeMarkdown(details)}` : null, + evidenceLines !== undefined ? `*Evidence:*\n${evidenceLines}` : null, + ].filter((l): l is string => l !== null); + + const approveData = encodeA2HCallbackData(intent.turnId, "APPROVED"); + const denyData = encodeA2HCallbackData(intent.turnId, "DENIED"); + + const msg = await api.sendMessage( + withReplyTo( + { + chat_id: chatId, + text: lines.join("\n"), + parse_mode: "Markdown", + reply_markup: { + inline_keyboard: [ + [ + { text: "✅ Approve", callback_data: approveData }, + { text: "❌ Deny", callback_data: denyData }, + ], + ], + }, + }, + replyToMessageId, + ), + ); + + return { + messageId: String(msg.message_id), + method: "inline", + }; +} + +async function renderCollectWithOptions( + api: TelegramApiClient, + chatId: string, + intent: CollectIntent, + options: string[], + replyToMessageId?: string, +): Promise { + const rows: Array> = []; + // Group options into rows of at most 3 buttons each + for (let i = 0; i < options.length; i += 3) { + const row = options.slice(i, i + 3).map((opt) => ({ + text: opt, + callback_data: encodeA2HCallbackData(intent.turnId, opt), + })); + rows.push(row); + } + + const msg = await api.sendMessage( + withReplyTo( + { chat_id: chatId, text: intent.context.question, reply_markup: { inline_keyboard: rows } }, + replyToMessageId, + ), + ); + + return { + messageId: String(msg.message_id), + method: "inline", + }; +} + +async function renderCollectFreeText( + api: TelegramApiClient, + chatId: string, + intent: CollectIntent, + replyToMessageId?: string, +): Promise { + const text = + `💬 *${escapeMarkdown(intent.context.question)}*\n\n` + + `_Reply to this message with your answer._`; + + const msg = await api.sendMessage( + withReplyTo({ chat_id: chatId, text, parse_mode: "Markdown" }, replyToMessageId), + ); + + return { + messageId: String(msg.message_id), + method: "reply-capture", + }; +} + +async function renderInform( + api: TelegramApiClient, + chatId: string, + intent: InformIntent, + replyToMessageId?: string, +): Promise { + const msg = await api.sendMessage( + withReplyTo( + { chat_id: chatId, text: `ℹ️ ${intent.context.message}` }, + replyToMessageId, + ), + ); + + return { + messageId: String(msg.message_id), + method: "inline", + }; +} + +// --------------------------------------------------------------------------- +// Main renderer +// --------------------------------------------------------------------------- + +/** + * Render an A2H intent as an interactive message in Telegram. + * Selects the best method based on Telegram capabilities. + */ +export async function renderA2HIntent( + api: TelegramApiClient, + chatId: string, + intent: A2HIntent, + replyToMessageId?: string, +): Promise { + switch (intent.intent) { + case "AUTHORIZE": + return renderAuthorize(api, chatId, intent as AuthorizeIntent, replyToMessageId); + + case "COLLECT": { + const collectIntent = intent as CollectIntent; + if ( + collectIntent.context.options !== undefined && + collectIntent.context.options.length > 0 + ) { + return renderCollectWithOptions( + api, + chatId, + collectIntent, + collectIntent.context.options, + replyToMessageId, + ); + } + return renderCollectFreeText(api, chatId, collectIntent, replyToMessageId); + } + + case "INFORM": + return renderInform(api, chatId, intent as InformIntent, replyToMessageId); + + default: { + // Fallback: send as plain text notification + const msg = await api.sendMessage( + withReplyTo( + { + chat_id: chatId, + text: `[A2H ${intent.intent}] ${JSON.stringify((intent as A2HIntentBase).context ?? {})}`, + }, + replyToMessageId, + ), + ); + return { messageId: String(msg.message_id), method: "inline" }; + } + } +} + +/** + * Attempt to capture an A2H response from an inbound payload. + * + * Two capture paths: + * 1. Callback query (method 1) — data is a JSON A2H payload + * 2. Reply-to message (method 2) — message replies to the intent message + */ +export function captureA2HResponse( + payload: unknown, + pendingTurnId: string, + pendingMessageId: string, +): A2HResponse | null { + if (typeof payload !== "object" || payload === null) return null; + + const update = payload as Record; + + // Method 1: callback_query + if (update["callback_query"] !== undefined) { + const cq = update["callback_query"] as Record; + const data = typeof cq["data"] === "string" ? cq["data"] : null; + if (data === null) return null; + + const decoded = decodeA2HCallbackData(data); + if (decoded === null || decoded.tid !== pendingTurnId) return null; + + return { + turnId: pendingTurnId, + intent: "COLLECT", // will be overridden by the caller if AUTHORIZE + response: decoded.v, + respondedAt: new Date(), + }; + } + + // Method 2: reply-to message + if (update["message"] !== undefined) { + const msg = update["message"] as Record; + const replyTo = msg["reply_to_message"] as Record | undefined; + if (replyTo === undefined) return null; + + const repliedToId = String(replyTo["message_id"]); + if (repliedToId !== pendingMessageId) return null; + + return { + turnId: pendingTurnId, + intent: "COLLECT", + response: msg["text"] ?? null, + respondedAt: new Date(), + }; + } + + return null; +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +function escapeMarkdown(text: string): string { + // Escape Markdown special chars (legacy mode, not MarkdownV2) + return text.replace(/([_*[\]`])/g, "\\$1"); +} + diff --git a/packages/channels/telegram/src/api-client.ts b/packages/channels/telegram/src/api-client.ts new file mode 100644 index 0000000..50cbc64 --- /dev/null +++ b/packages/channels/telegram/src/api-client.ts @@ -0,0 +1,87 @@ +/** + * Lightweight Telegram Bot API HTTP client. + * + * Uses the native fetch API (available in Bun / Node 18+). + * Raises TelegramApiError on non-ok responses. + */ + +import type { + SendMessageParams, + AnswerCallbackQueryParams, + SetWebhookParams, + TelegramApiResponse, + TelegramMessage, +} from "./types.js"; + +export class TelegramApiError extends Error { + constructor( + public readonly errorCode: number, + description: string, + ) { + super(`Telegram API error ${errorCode}: ${description}`); + this.name = "TelegramApiError"; + } +} + +export class TelegramApiClient { + private readonly baseUrl: string; + + constructor(private readonly botToken: string) { + this.baseUrl = `https://api.telegram.org/bot${botToken}`; + } + + private async call(method: string, params: Record): Promise { + const response = await fetch(`${this.baseUrl}/${method}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + }); + + const body = (await response.json()) as TelegramApiResponse; + + if (!body.ok) { + throw new TelegramApiError( + body.error_code ?? response.status, + body.description ?? "Unknown error", + ); + } + + return body.result as T; + } + + /** + * Send a message to a chat. + */ + async sendMessage(params: SendMessageParams): Promise { + return this.call("sendMessage", params as unknown as Record); + } + + /** + * Answer a callback query (clears the spinner on the button). + */ + async answerCallbackQuery(params: AnswerCallbackQueryParams): Promise { + return this.call("answerCallbackQuery", params as unknown as Record); + } + + /** + * Register a webhook URL with Telegram. + * Telegram will POST updates to this URL. + */ + async setWebhook(params: SetWebhookParams): Promise { + return this.call("setWebhook", params as unknown as Record); + } + + /** + * Remove the registered webhook. + */ + async deleteWebhook(dropPendingUpdates = false): Promise { + return this.call("deleteWebhook", { drop_pending_updates: dropPendingUpdates }); + } + + /** + * Get basic information about the bot. + */ + async getMe(): Promise<{ id: number; username: string; first_name: string }> { + return this.call("getMe", {}); + } +} diff --git a/packages/channels/telegram/src/inbound.ts b/packages/channels/telegram/src/inbound.ts new file mode 100644 index 0000000..f958530 --- /dev/null +++ b/packages/channels/telegram/src/inbound.ts @@ -0,0 +1,194 @@ +/** + * Inbound message parser for the Telegram adapter. + * + * Translates raw Telegram Update objects into normalised + * InboundMessage / CallbackQuery objects. + */ + +import type { + InboundMessage, + CallbackQuery, + MessageAttachment, +} from "@openthreads/core"; +import type { TelegramUpdate, TelegramMessage } from "./types.js"; +import type { ThreadStore } from "./thread-store.js"; + +function generateMessageId(): string { + return `tg_msg_${Date.now().toString(16)}_${Math.random().toString(16).slice(2)}`; +} + +/** + * Extract attachments from a Telegram message. + */ +function extractAttachments(msg: TelegramMessage): MessageAttachment[] { + const attachments: MessageAttachment[] = []; + + if (msg.photo !== undefined && msg.photo.length > 0) { + // Use the largest photo variant + const largest = msg.photo.reduce((a, b) => (a.file_size ?? 0) > (b.file_size ?? 0) ? a : b); + attachments.push({ + type: "image", + fileId: largest.file_id, + fileSize: largest.file_size, + }); + } + + if (msg.document !== undefined) { + attachments.push({ + type: "document", + fileId: msg.document.file_id, + mimeType: msg.document.mime_type, + fileName: msg.document.file_name, + fileSize: msg.document.file_size, + }); + } + + if (msg.audio !== undefined) { + attachments.push({ + type: "audio", + fileId: msg.audio.file_id, + mimeType: msg.audio.mime_type, + fileSize: msg.audio.file_size, + }); + } + + if (msg.video !== undefined) { + attachments.push({ + type: "video", + fileId: msg.video.file_id, + mimeType: msg.video.mime_type, + fileSize: msg.video.file_size, + }); + } + + if (msg.voice !== undefined) { + attachments.push({ + type: "voice", + fileId: msg.voice.file_id, + mimeType: msg.voice.mime_type, + fileSize: msg.voice.file_size, + }); + } + + if (msg.sticker !== undefined) { + attachments.push({ + type: "sticker", + fileId: msg.sticker.file_id, + }); + } + + return attachments; +} + +/** + * Parse a Telegram Message into a normalised InboundMessage. + * Returns null if the message has no usable content (e.g. service messages). + */ +export function parseMessage( + msg: TelegramMessage, + channelId: string, + threadStore: ThreadStore, +): InboundMessage | null { + // Require a sender (service messages have no `from`) + if (msg.from === undefined) return null; + + const chatId = String(msg.chat.id); + const messageId = String(msg.message_id); + const replyToId = msg.reply_to_message !== undefined + ? String(msg.reply_to_message.message_id) + : undefined; + + const threadId = threadStore.resolveThread(chatId, messageId, replyToId); + const attachments = extractAttachments(msg); + const text = msg.text ?? msg.caption; + + // Require either text or at least one attachment + if (text === undefined && attachments.length === 0) return null; + + const sender = { + id: String(msg.from.id), + name: [msg.from.first_name, msg.from.last_name].filter(Boolean).join(" "), + username: msg.from.username, + }; + + return { + id: generateMessageId(), + threadId, + replyToMessageId: replyToId, + channel: channelId, + chatId, + sender, + text, + attachments: attachments.length > 0 ? attachments : undefined, + raw: msg, + receivedAt: new Date(msg.date * 1000), + }; +} + +/** + * Parse a Telegram Update into a normalised InboundMessage. + * Returns null for non-message updates (callback queries, etc.). + */ +export function parseUpdateAsInbound( + update: TelegramUpdate, + channelId: string, + threadStore: ThreadStore, +): InboundMessage | null { + const msg = update.message ?? update.edited_message ?? update.channel_post; + if (msg === undefined) return null; + return parseMessage(msg, channelId, threadStore); +} + +/** + * Parse a Telegram Update into a normalised CallbackQuery. + * Returns null if the update does not contain a callback_query. + */ +export function parseUpdateAsCallbackQuery( + update: TelegramUpdate, +): CallbackQuery | null { + if (update.callback_query === undefined) return null; + + const cq = update.callback_query; + const chatId = cq.message !== undefined ? String(cq.message.chat.id) : ""; + const originMessageId = cq.message !== undefined ? String(cq.message.message_id) : ""; + + return { + id: cq.id, + data: cq.data ?? "", + sender: { + id: String(cq.from.id), + name: [cq.from.first_name, cq.from.last_name].filter(Boolean).join(" "), + username: cq.from.username, + }, + originMessageId, + chatId, + raw: cq, + }; +} + +/** + * Determine whether a Telegram update is a bot command (e.g. /start). + */ +export function isCommand(update: TelegramUpdate): boolean { + const msg = update.message; + if (msg?.text === undefined || msg.entities === undefined) return false; + return msg.entities.some((e) => e.type === "bot_command" && e.offset === 0); +} + +/** + * Extract the command name and arguments from a Telegram message. + * Returns null if the message is not a command. + */ +export function parseCommand( + update: TelegramUpdate, +): { command: string; args: string[] } | null { + if (!isCommand(update)) return null; + const text = update.message!.text!; + // Strip @botname suffix from the command token + const parts = text.split(/\s+/); + const commandToken = (parts[0] ?? "").split("@")[0] ?? ""; + return { + command: commandToken, + args: parts.slice(1), + }; +} diff --git a/packages/channels/telegram/src/index.ts b/packages/channels/telegram/src/index.ts new file mode 100644 index 0000000..09cf0df --- /dev/null +++ b/packages/channels/telegram/src/index.ts @@ -0,0 +1,216 @@ +/** + * Telegram channel adapter for OpenThreads. + * + * Implements the ChannelAdapter interface using the Telegram Bot API. + * + * Capabilities: + * threads: false — Telegram has no native thread/reply chains as named threads + * buttons: true — inline keyboards via callback_query + * selectMenus: false — no native select-menu control + * replyMessages: true — reply-to message capture for free-text COLLECT + * dms: true — private chat support + * fileUpload: true — documents, photos, audio, video + * + * Virtual threads: + * Telegram DMs and groups do not have named threads. OpenThreads emulates threads + * by tracking reply chains. A reply to message X belongs to the same virtual thread + * as X. Messages with no reply start a new virtual thread. + * + * A2H rendering: + * AUTHORIZE → inline keyboard (✅ Approve / ❌ Deny) + * COLLECT+options → inline keyboard (one button per option) + * COLLECT free → question message; response captured via reply-to + * INFORM → plain notification, no response + */ + +import type { + ChannelAdapter, + ChannelCapabilities, + AdapterConfig, + InboundMessage, + CallbackQuery, + OutboundMessage, + SentMessage, + SendTarget, + A2HIntent, + A2HRenderResult, + A2HResponse, +} from "@openthreads/core"; + +import { TelegramApiClient } from "./api-client.js"; +import { InMemoryThreadStore, type ThreadStore } from "./thread-store.js"; +import type { + SetWebhookParams, + AnswerCallbackQueryParams, + TelegramAdapterConfig, + TelegramUpdate, +} from "./types.js"; +import { + parseUpdateAsInbound, + parseUpdateAsCallbackQuery, +} from "./inbound.js"; +import { buildSendMessageParams } from "./outbound.js"; +import { + renderA2HIntent as _renderA2HIntent, + captureA2HResponse as _captureA2HResponse, +} from "./a2h-renderer.js"; + +// --------------------------------------------------------------------------- +// Telegram adapter +// --------------------------------------------------------------------------- + +export interface TelegramAdapterOptions { + threadStore?: ThreadStore; + /** + * Optional pre-built API client. When provided, the adapter uses this + * client instead of constructing one from the botToken credential. + * Useful for testing and dependency injection. + */ + apiClient?: TelegramApiClient; +} + +export class TelegramAdapter implements ChannelAdapter { + readonly capabilities: ChannelCapabilities = { + threads: false, + buttons: true, + selectMenus: false, + replyMessages: true, + dms: true, + fileUpload: true, + }; + + private api: TelegramApiClient | undefined; + private threadStore: ThreadStore; + private channelId = ""; + private readonly injectedApiClient?: TelegramApiClient; + + constructor(options?: TelegramAdapterOptions | ThreadStore) { + // Accept either an options object or a bare ThreadStore for backwards compat + if (options !== undefined && "resolveThread" in options) { + this.threadStore = options as ThreadStore; + } else { + const opts = (options ?? {}) as TelegramAdapterOptions; + this.threadStore = opts.threadStore ?? new InMemoryThreadStore(); + this.injectedApiClient = opts.apiClient; + } + } + + // --------------------------------------------------------------------------- + // Lifecycle + // --------------------------------------------------------------------------- + + async setup(config: AdapterConfig): Promise { + this.channelId = config.channelId; + + if (this.injectedApiClient !== undefined) { + // Use the pre-injected client (e.g. in tests) + this.api = this.injectedApiClient; + } else { + const botToken = config.credentials["botToken"]; + if (botToken === undefined || botToken === "") { + throw new Error("TelegramAdapter: credentials.botToken is required"); + } + this.api = new TelegramApiClient(botToken); + } + + if (config.webhookUrl !== undefined && config.webhookUrl !== "") { + const secretToken = config.credentials["webhookSecretToken"]; + const webhookParams: SetWebhookParams = { + url: config.webhookUrl, + drop_pending_updates: false, + }; + if (secretToken !== undefined) webhookParams.secret_token = secretToken; + await this.api.setWebhook(webhookParams); + } + } + + async teardown(): Promise { + if (this.api !== undefined) { + await this.api.deleteWebhook(); + } + } + + // --------------------------------------------------------------------------- + // Inbound + // --------------------------------------------------------------------------- + + async parseInbound(payload: unknown): Promise { + const update = payload as TelegramUpdate; + return parseUpdateAsInbound(update, this.channelId, this.threadStore); + } + + async parseCallbackQuery(payload: unknown): Promise { + const update = payload as TelegramUpdate; + return parseUpdateAsCallbackQuery(update); + } + + // --------------------------------------------------------------------------- + // Outbound + // --------------------------------------------------------------------------- + + async send(target: SendTarget, message: OutboundMessage): Promise { + const api = this.requireApi(); + const params = buildSendMessageParams(target.chatId, message); + const sent = await api.sendMessage(params); + return { + messageId: String(sent.message_id), + sentAt: new Date(sent.date * 1000), + }; + } + + async answerCallbackQuery(queryId: string, text?: string): Promise { + const params: AnswerCallbackQueryParams = { callback_query_id: queryId }; + if (text !== undefined) params.text = text; + await this.requireApi().answerCallbackQuery(params); + } + + // --------------------------------------------------------------------------- + // A2H + // --------------------------------------------------------------------------- + + async renderA2HIntent( + chatId: string, + intent: A2HIntent, + replyToMessageId?: string, + ): Promise { + return _renderA2HIntent(this.requireApi(), chatId, intent, replyToMessageId); + } + + async captureA2HResponse( + payload: unknown, + pendingTurnId: string, + pendingMessageId: string, + ): Promise { + return _captureA2HResponse(payload, pendingTurnId, pendingMessageId); + } + + // --------------------------------------------------------------------------- + // Accessors (useful in integration tests) + // --------------------------------------------------------------------------- + + private requireApi(): TelegramApiClient { + if (this.api === undefined) { + throw new Error("TelegramAdapter: setup() must be called before using the adapter"); + } + return this.api; + } + + getThreadStore(): ThreadStore { + return this.threadStore; + } + + getApiClient(): TelegramApiClient { + return this.requireApi(); + } +} + +// --------------------------------------------------------------------------- +// Re-exports +// --------------------------------------------------------------------------- + +export type { TelegramAdapterConfig, VirtualThread } from "./types.js"; +export { InMemoryThreadStore } from "./thread-store.js"; +export type { ThreadStore } from "./thread-store.js"; +export { encodeA2HCallbackData, decodeA2HCallbackData } from "./a2h-renderer.js"; +export { escapeMarkdownV2 } from "./outbound.js"; +export { parseCommand, isCommand } from "./inbound.js"; diff --git a/packages/channels/telegram/src/outbound.ts b/packages/channels/telegram/src/outbound.ts new file mode 100644 index 0000000..d7cb509 --- /dev/null +++ b/packages/channels/telegram/src/outbound.ts @@ -0,0 +1,98 @@ +/** + * Outbound message formatter for the Telegram adapter. + * + * Converts normalised OutboundMessage objects into Telegram Bot API request params. + */ + +import type { OutboundMessage } from "@openthreads/core"; +import type { + SendMessageParams, + TelegramInlineKeyboardMarkup, + TelegramReplyKeyboardMarkup, + TelegramReplyKeyboardRemove, + TelegramReplyMarkup, +} from "./types.js"; + +/** + * Build the Telegram reply_markup from an OutboundMessage. + */ +function buildReplyMarkup(msg: OutboundMessage): TelegramReplyMarkup | undefined { + if (msg.removeKeyboard === true) { + const remove: TelegramReplyKeyboardRemove = { remove_keyboard: true }; + return remove; + } + + if (msg.inlineKeyboard !== undefined && msg.inlineKeyboard.length > 0) { + const markup: TelegramInlineKeyboardMarkup = { + inline_keyboard: msg.inlineKeyboard.map((row) => + row.map((btn) => ({ + text: btn.text, + callback_data: btn.callbackData, + url: btn.url, + })), + ), + }; + return markup; + } + + if (msg.replyKeyboard !== undefined && msg.replyKeyboard.length > 0) { + const markup: TelegramReplyKeyboardMarkup = { + keyboard: msg.replyKeyboard.map((row) => + row.map((btn) => ({ + text: btn.text, + request_contact: btn.requestContact, + request_location: btn.requestLocation, + })), + ), + resize_keyboard: true, + one_time_keyboard: true, + }; + return markup; + } + + return undefined; +} + +/** + * Convert a normalised OutboundMessage into Telegram Bot API sendMessage params. + */ +export function buildSendMessageParams( + chatId: string | number, + msg: OutboundMessage, +): SendMessageParams { + let text: string; + let parseMode: "HTML" | "MarkdownV2" | "Markdown" | undefined; + + if (msg.html !== undefined) { + text = msg.html; + parseMode = "HTML"; + } else if (msg.markdown !== undefined) { + text = msg.markdown; + parseMode = "MarkdownV2"; + } else { + text = msg.text ?? ""; + parseMode = undefined; + } + + const params: SendMessageParams = { + chat_id: chatId, + text, + }; + + if (parseMode !== undefined) params.parse_mode = parseMode; + if (msg.disableWebPagePreview !== undefined) params.disable_web_page_preview = msg.disableWebPagePreview; + if (msg.replyToMessageId !== undefined) params.reply_to_message_id = Number(msg.replyToMessageId); + + const markup = buildReplyMarkup(msg); + if (markup !== undefined) params.reply_markup = markup; + + return params; +} + +/** + * Escape special characters for Telegram MarkdownV2 parse mode. + * See: https://core.telegram.org/bots/api#markdownv2-style + */ +export function escapeMarkdownV2(text: string): string { + return text.replace(/[_*[\]()~`>#+\-=|{}.!\\]/g, "\\$&"); +} diff --git a/packages/channels/telegram/src/thread-store.ts b/packages/channels/telegram/src/thread-store.ts new file mode 100644 index 0000000..1a65fd6 --- /dev/null +++ b/packages/channels/telegram/src/thread-store.ts @@ -0,0 +1,106 @@ +/** + * Virtual thread store for the Telegram adapter. + * + * Telegram does not have native threads (unlike Slack or Discord forums). + * OpenThreads emulates them via reply chains: when message B is a reply to + * message A, they share a virtual thread. + * + * Thread resolution algorithm: + * 1. A message with reply_to_message_id X → look up the thread that contains X. + * 2. If found, add this message to that thread and return the same threadId. + * 3. If not found, create a new thread seeded with [X, this_message_id]. + * 4. A message with no reply_to → always starts a new thread. + * + * This in-memory implementation is suitable for development and testing. + * Production deployments should swap it for a persistent backend. + */ + +import type { VirtualThread } from "./types.js"; + +export interface ThreadStore { + /** + * Resolve or create the threadId for a given (chatId, messageId) pair. + * @param chatId Telegram chat ID + * @param messageId The current message ID (string) + * @param replyToId The message ID being replied to, if any + * @returns The threadId to associate with this message + */ + resolveThread(chatId: string, messageId: string, replyToId?: string): string; + + getThread(threadId: string): VirtualThread | undefined; + + getAllThreadsForChat(chatId: string): VirtualThread[]; +} + +function generateThreadId(): string { + // Simple deterministic but unique ID: ot_thr_ + random hex + return `ot_thr_${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`; +} + +export class InMemoryThreadStore implements ThreadStore { + /** threadId → VirtualThread */ + private readonly threads = new Map(); + /** "chatId:messageId" → threadId */ + private readonly messageIndex = new Map(); + + private key(chatId: string, messageId: string): string { + return `${chatId}:${messageId}`; + } + + resolveThread(chatId: string, messageId: string, replyToId?: string): string { + // If we already know this message's thread, return it + const existing = this.messageIndex.get(this.key(chatId, messageId)); + if (existing !== undefined) return existing; + + if (replyToId !== undefined) { + // Check if the replied-to message belongs to a known thread + const parentThreadId = this.messageIndex.get(this.key(chatId, replyToId)); + if (parentThreadId !== undefined) { + // Extend the existing thread + const thread = this.threads.get(parentThreadId)!; + thread.messageIds.push(messageId); + thread.updatedAt = new Date(); + this.messageIndex.set(this.key(chatId, messageId), parentThreadId); + return parentThreadId; + } + + // Neither the reply-to nor the current message is in a known thread. + // Create a new virtual thread seeded with both. + const newThreadId = generateThreadId(); + const now = new Date(); + const thread: VirtualThread = { + threadId: newThreadId, + chatId, + messageIds: [replyToId, messageId], + createdAt: now, + updatedAt: now, + }; + this.threads.set(newThreadId, thread); + this.messageIndex.set(this.key(chatId, replyToId), newThreadId); + this.messageIndex.set(this.key(chatId, messageId), newThreadId); + return newThreadId; + } + + // No reply → new standalone thread + const newThreadId = generateThreadId(); + const now = new Date(); + const thread: VirtualThread = { + threadId: newThreadId, + chatId, + messageIds: [messageId], + createdAt: now, + updatedAt: now, + }; + this.threads.set(newThreadId, thread); + this.messageIndex.set(this.key(chatId, messageId), newThreadId); + return newThreadId; + } + + getThread(threadId: string): VirtualThread | undefined { + return this.threads.get(threadId); + } + + getAllThreadsForChat(chatId: string): VirtualThread[] { + return [...this.threads.values()].filter((t) => t.chatId === chatId); + } +} diff --git a/packages/channels/telegram/src/types.ts b/packages/channels/telegram/src/types.ts new file mode 100644 index 0000000..14b42e6 --- /dev/null +++ b/packages/channels/telegram/src/types.ts @@ -0,0 +1,250 @@ +/** + * Telegram Bot API type definitions. + * + * These are minimal, focused definitions for the Telegram API objects + * used by the OpenThreads Telegram adapter. They mirror the official + * Telegram Bot API schema (https://core.telegram.org/bots/api). + */ + +// --------------------------------------------------------------------------- +// Telegram Bot API objects +// --------------------------------------------------------------------------- + +export interface TelegramUser { + id: number; + is_bot: boolean; + first_name: string; + last_name?: string; + username?: string; + language_code?: string; +} + +export interface TelegramChat { + id: number; + type: "private" | "group" | "supergroup" | "channel"; + title?: string; + username?: string; + first_name?: string; + last_name?: string; +} + +export interface TelegramPhotoSize { + file_id: string; + file_unique_id: string; + width: number; + height: number; + file_size?: number; +} + +export interface TelegramDocument { + file_id: string; + file_unique_id: string; + file_name?: string; + mime_type?: string; + file_size?: number; +} + +export interface TelegramAudio { + file_id: string; + file_unique_id: string; + duration: number; + performer?: string; + title?: string; + mime_type?: string; + file_size?: number; +} + +export interface TelegramVideo { + file_id: string; + file_unique_id: string; + width: number; + height: number; + duration: number; + mime_type?: string; + file_size?: number; +} + +export interface TelegramVoice { + file_id: string; + file_unique_id: string; + duration: number; + mime_type?: string; + file_size?: number; +} + +export interface TelegramSticker { + file_id: string; + file_unique_id: string; + width: number; + height: number; + is_animated: boolean; + is_video: boolean; + type: "regular" | "mask" | "custom_emoji"; +} + +export interface TelegramMessage { + message_id: number; + from?: TelegramUser; + chat: TelegramChat; + date: number; + text?: string; + entities?: TelegramMessageEntity[]; + caption?: string; + photo?: TelegramPhotoSize[]; + document?: TelegramDocument; + audio?: TelegramAudio; + video?: TelegramVideo; + voice?: TelegramVoice; + sticker?: TelegramSticker; + reply_to_message?: TelegramMessage; +} + +export interface TelegramMessageEntity { + type: + | "mention" + | "hashtag" + | "cashtag" + | "bot_command" + | "url" + | "email" + | "phone_number" + | "bold" + | "italic" + | "underline" + | "strikethrough" + | "spoiler" + | "code" + | "pre" + | "text_link" + | "text_mention" + | "custom_emoji"; + offset: number; + length: number; + url?: string; +} + +export interface TelegramCallbackQuery { + id: string; + from: TelegramUser; + message?: TelegramMessage; + inline_message_id?: string; + chat_instance: string; + data?: string; + game_short_name?: string; +} + +export interface TelegramUpdate { + update_id: number; + message?: TelegramMessage; + edited_message?: TelegramMessage; + channel_post?: TelegramMessage; + edited_channel_post?: TelegramMessage; + callback_query?: TelegramCallbackQuery; +} + +// --------------------------------------------------------------------------- +// Telegram Bot API request/response objects +// --------------------------------------------------------------------------- + +export interface TelegramInlineKeyboardButton { + text: string; + callback_data?: string; + url?: string; +} + +export interface TelegramInlineKeyboardMarkup { + inline_keyboard: TelegramInlineKeyboardButton[][]; +} + +export interface TelegramReplyKeyboardButton { + text: string; + request_contact?: boolean; + request_location?: boolean; +} + +export interface TelegramReplyKeyboardMarkup { + keyboard: TelegramReplyKeyboardButton[][]; + resize_keyboard?: boolean; + one_time_keyboard?: boolean; + input_field_placeholder?: string; +} + +export interface TelegramReplyKeyboardRemove { + remove_keyboard: true; + selective?: boolean; +} + +export type TelegramReplyMarkup = + | TelegramInlineKeyboardMarkup + | TelegramReplyKeyboardMarkup + | TelegramReplyKeyboardRemove; + +export interface SendMessageParams { + chat_id: string | number; + text: string; + parse_mode?: "Markdown" | "MarkdownV2" | "HTML"; + disable_web_page_preview?: boolean; + reply_to_message_id?: number; + reply_markup?: TelegramReplyMarkup; +} + +export interface AnswerCallbackQueryParams { + callback_query_id: string; + text?: string; + show_alert?: boolean; +} + +export interface SetWebhookParams { + url: string; + secret_token?: string; + drop_pending_updates?: boolean; +} + +export interface TelegramApiResponse { + ok: boolean; + result?: T; + error_code?: number; + description?: string; +} + +// --------------------------------------------------------------------------- +// Adapter-specific types +// --------------------------------------------------------------------------- + +export interface TelegramAdapterConfig { + /** Telegram Bot API token from BotFather */ + botToken: string; + /** Public HTTPS webhook URL */ + webhookUrl?: string; + /** + * Secret token sent by Telegram in the X-Telegram-Bot-Api-Secret-Token header. + * Used to verify that requests come from Telegram. + */ + webhookSecretToken?: string; +} + +/** + * Stored state for a virtual thread. + * A virtual thread groups a sequence of reply-chain messages under a single threadId. + */ +export interface VirtualThread { + threadId: string; + chatId: string; + /** The message IDs that form this reply chain */ + messageIds: string[]; + createdAt: Date; + updatedAt: Date; +} + +/** + * Callback data structure embedded in A2H inline keyboard buttons. + * Must fit within Telegram's 64-byte callback_data limit. + */ +export interface A2HCallbackData { + /** "a" = a2h (differentiates from other callback data) */ + t: "a"; + /** turnId (abbreviated) */ + tid: string; + /** response value */ + v: string; +} diff --git a/packages/channels/telegram/tests/a2h-renderer.test.ts b/packages/channels/telegram/tests/a2h-renderer.test.ts new file mode 100644 index 0000000..dc2040d --- /dev/null +++ b/packages/channels/telegram/tests/a2h-renderer.test.ts @@ -0,0 +1,290 @@ +/** + * Unit tests for the A2H intent renderer. + * + * The renderer makes HTTP calls to the Telegram API. Tests use a mock + * TelegramApiClient to avoid real network calls. + */ + +import { describe, it, expect, mock } from "bun:test"; +import { + encodeA2HCallbackData, + decodeA2HCallbackData, + captureA2HResponse, +} from "../src/a2h-renderer.js"; +import { renderA2HIntent } from "../src/a2h-renderer.js"; +import type { TelegramApiClient } from "../src/api-client.js"; +import type { AuthorizeIntent, CollectIntent, InformIntent } from "@openthreads/core"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeMockApi(messageId = 99): TelegramApiClient { + return { + sendMessage: mock(async () => ({ + message_id: messageId, + chat: { id: 100, type: "private" }, + date: 1700000000, + })), + answerCallbackQuery: mock(async () => true), + setWebhook: mock(async () => true), + deleteWebhook: mock(async () => true), + getMe: mock(async () => ({ id: 1, username: "testbot", first_name: "Test" })), + } as unknown as TelegramApiClient; +} + +// --------------------------------------------------------------------------- +// encodeA2HCallbackData / decodeA2HCallbackData +// --------------------------------------------------------------------------- + +describe("encodeA2HCallbackData", () => { + it("encodes and decodes symmetrically", () => { + const encoded = encodeA2HCallbackData("turn-123", "APPROVED"); + const decoded = decodeA2HCallbackData(encoded); + expect(decoded).not.toBeNull(); + expect(decoded!.tid).toBe("turn-123"); + expect(decoded!.v).toBe("APPROVED"); + expect(decoded!.t).toBe("a"); + }); + + it("throws if the payload exceeds 64 bytes", () => { + const longTurnId = "a".repeat(100); + expect(() => encodeA2HCallbackData(longTurnId, "APPROVED")).toThrow(); + }); +}); + +describe("decodeA2HCallbackData", () => { + it("returns null for invalid JSON", () => { + expect(decodeA2HCallbackData("not-json")).toBeNull(); + }); + + it("returns null for non-A2H payload", () => { + expect(decodeA2HCallbackData('{"t":"other"}')).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(decodeA2HCallbackData("")).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// renderA2HIntent +// --------------------------------------------------------------------------- + +describe("renderA2HIntent", () => { + describe("AUTHORIZE", () => { + it("renders an inline keyboard with APPROVE and DENY buttons", async () => { + const api = makeMockApi(42); + const intent: AuthorizeIntent = { + intent: "AUTHORIZE", + turnId: "turn-1", + context: { action: "deploy-to-prod", details: "Branch main → production" }, + }; + + const result = await renderA2HIntent(api, "100", intent); + + expect(result.method).toBe("inline"); + expect(result.messageId).toBe("42"); + expect(api.sendMessage).toHaveBeenCalledTimes(1); + + const callArgs = (api.sendMessage as ReturnType).mock.calls[0]![0]; + const markup = callArgs.reply_markup as { inline_keyboard: Array> }; + expect(markup.inline_keyboard).toHaveLength(1); + const buttons = markup.inline_keyboard[0]!; + expect(buttons.some((b) => b.text.includes("Approve"))).toBe(true); + expect(buttons.some((b) => b.text.includes("Deny"))).toBe(true); + + // Verify callback data encodes the turnId + const approveBtn = buttons.find((b) => b.text.includes("Approve"))!; + const decoded = decodeA2HCallbackData(approveBtn.callback_data); + expect(decoded!.tid).toBe("turn-1"); + expect(decoded!.v).toBe("APPROVED"); + }); + + it("passes replyToMessageId when provided", async () => { + const api = makeMockApi(); + const intent: AuthorizeIntent = { + intent: "AUTHORIZE", + turnId: "t1", + context: { action: "test" }, + }; + await renderA2HIntent(api, "100", intent, "77"); + const callArgs = (api.sendMessage as ReturnType).mock.calls[0]![0]; + expect(callArgs.reply_to_message_id).toBe(77); + }); + }); + + describe("COLLECT with options", () => { + it("renders an inline keyboard with one button per option", async () => { + const api = makeMockApi(); + const intent: CollectIntent = { + intent: "COLLECT", + turnId: "turn-2", + context: { question: "Which env?", options: ["staging", "prod", "local"] }, + }; + + const result = await renderA2HIntent(api, "100", intent); + + expect(result.method).toBe("inline"); + const callArgs = (api.sendMessage as ReturnType).mock.calls[0]![0]; + const markup = callArgs.reply_markup as { inline_keyboard: Array> }; + const allButtons = markup.inline_keyboard.flat(); + expect(allButtons.map((b) => b.text)).toContain("staging"); + expect(allButtons.map((b) => b.text)).toContain("prod"); + expect(allButtons.map((b) => b.text)).toContain("local"); + }); + }); + + describe("COLLECT free-text", () => { + it("renders a question message with reply capture", async () => { + const api = makeMockApi(); + const intent: CollectIntent = { + intent: "COLLECT", + turnId: "turn-3", + context: { question: "What is your name?" }, + }; + + const result = await renderA2HIntent(api, "100", intent); + + expect(result.method).toBe("reply-capture"); + const callArgs = (api.sendMessage as ReturnType).mock.calls[0]![0]; + expect(callArgs.text).toContain("What is your name?"); + expect(callArgs.reply_markup).toBeUndefined(); + }); + }); + + describe("INFORM", () => { + it("renders a plain notification", async () => { + const api = makeMockApi(); + const intent: InformIntent = { + intent: "INFORM", + turnId: "turn-4", + context: { message: "Deploy completed successfully." }, + }; + + const result = await renderA2HIntent(api, "100", intent); + + expect(result.method).toBe("inline"); + const callArgs = (api.sendMessage as ReturnType).mock.calls[0]![0]; + expect(callArgs.text).toContain("Deploy completed successfully."); + }); + }); +}); + +// --------------------------------------------------------------------------- +// captureA2HResponse +// --------------------------------------------------------------------------- + +describe("captureA2HResponse", () => { + describe("method 1: callback_query", () => { + it("captures AUTHORIZE response via callback query", () => { + const callbackData = encodeA2HCallbackData("turn-1", "APPROVED"); + const payload = { + update_id: 1, + callback_query: { + id: "cq1", + from: { id: 42, is_bot: false, first_name: "Alice" }, + message: { message_id: 99, chat: { id: 100, type: "private" }, date: 1700000000 }, + chat_instance: "abc", + data: callbackData, + }, + }; + + const response = captureA2HResponse(payload, "turn-1", "99"); + expect(response).not.toBeNull(); + expect(response!.turnId).toBe("turn-1"); + expect(response!.response).toBe("APPROVED"); + }); + + it("returns null when callback data belongs to a different turn", () => { + const callbackData = encodeA2HCallbackData("turn-X", "APPROVED"); + const payload = { + update_id: 1, + callback_query: { + id: "cq1", + from: { id: 42, is_bot: false, first_name: "Alice" }, + chat_instance: "abc", + data: callbackData, + }, + }; + const response = captureA2HResponse(payload, "turn-1", "99"); + expect(response).toBeNull(); + }); + + it("returns null when callback data is not an A2H payload", () => { + const payload = { + callback_query: { + id: "cq1", + from: { id: 42, is_bot: false, first_name: "Alice" }, + chat_instance: "abc", + data: '{"action":"other"}', + }, + }; + const response = captureA2HResponse(payload, "turn-1", "99"); + expect(response).toBeNull(); + }); + }); + + describe("method 2: reply-to message", () => { + it("captures free-text COLLECT response via reply", () => { + const payload = { + update_id: 1, + message: { + message_id: 200, + from: { id: 42, is_bot: false, first_name: "Alice" }, + chat: { id: 100, type: "private" }, + date: 1700000001, + text: "My free-text answer", + reply_to_message: { + message_id: 99, + chat: { id: 100, type: "private" }, + date: 1700000000, + }, + }, + }; + + const response = captureA2HResponse(payload, "turn-1", "99"); + expect(response).not.toBeNull(); + expect(response!.turnId).toBe("turn-1"); + expect(response!.response).toBe("My free-text answer"); + }); + + it("returns null when the reply is to a different message", () => { + const payload = { + message: { + message_id: 200, + from: { id: 42, is_bot: false, first_name: "Alice" }, + chat: { id: 100, type: "private" }, + date: 1700000001, + text: "irrelevant", + reply_to_message: { + message_id: 50, // different from pendingMessageId=99 + chat: { id: 100, type: "private" }, + date: 1700000000, + }, + }, + }; + const response = captureA2HResponse(payload, "turn-1", "99"); + expect(response).toBeNull(); + }); + + it("returns null for a message without a reply", () => { + const payload = { + message: { + message_id: 200, + from: { id: 42, is_bot: false, first_name: "Alice" }, + chat: { id: 100, type: "private" }, + date: 1700000001, + text: "no reply", + }, + }; + const response = captureA2HResponse(payload, "turn-1", "99"); + expect(response).toBeNull(); + }); + }); + + it("returns null for a non-object payload", () => { + expect(captureA2HResponse(null, "t", "m")).toBeNull(); + expect(captureA2HResponse("string", "t", "m")).toBeNull(); + }); +}); diff --git a/packages/channels/telegram/tests/inbound.test.ts b/packages/channels/telegram/tests/inbound.test.ts new file mode 100644 index 0000000..e75298b --- /dev/null +++ b/packages/channels/telegram/tests/inbound.test.ts @@ -0,0 +1,301 @@ +/** + * Unit tests for the Telegram inbound message parser. + */ + +import { describe, it, expect } from "bun:test"; +import { InMemoryThreadStore } from "../src/thread-store.js"; +import { + parseUpdateAsInbound, + parseUpdateAsCallbackQuery, + isCommand, + parseCommand, +} from "../src/inbound.js"; +import type { TelegramUpdate } from "../src/types.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeTextUpdate(overrides: Partial<{ + messageId: number; + chatId: number; + chatType: "private" | "group" | "supergroup" | "channel"; + userId: number; + firstName: string; + username: string; + text: string; + replyToMessageId: number; +}>): TelegramUpdate { + const { + messageId = 1, + chatId = 100, + chatType = "private", + userId = 42, + firstName = "Alice", + username = "alice", + text = "Hello", + replyToMessageId, + } = overrides; + + return { + update_id: 999, + message: { + message_id: messageId, + from: { + id: userId, + is_bot: false, + first_name: firstName, + username, + }, + chat: { id: chatId, type: chatType }, + date: 1700000000, + text, + reply_to_message: replyToMessageId !== undefined + ? { + message_id: replyToMessageId, + chat: { id: chatId, type: chatType }, + date: 1700000000, + } + : undefined, + }, + }; +} + +function makeCallbackQueryUpdate(overrides: Partial<{ + queryId: string; + userId: number; + firstName: string; + chatId: number; + messageId: number; + data: string; +}>): TelegramUpdate { + const { + queryId = "cq1", + userId = 42, + firstName = "Alice", + chatId = 100, + messageId = 5, + data = "test-data", + } = overrides; + + return { + update_id: 999, + callback_query: { + id: queryId, + from: { id: userId, is_bot: false, first_name: firstName }, + message: { + message_id: messageId, + chat: { id: chatId, type: "private" }, + date: 1700000000, + }, + chat_instance: "abc", + data, + }, + }; +} + +// --------------------------------------------------------------------------- +// parseUpdateAsInbound +// --------------------------------------------------------------------------- + +describe("parseUpdateAsInbound", () => { + it("parses a simple text message", () => { + const store = new InMemoryThreadStore(); + const update = makeTextUpdate({ text: "Hello world", chatId: 100, messageId: 1 }); + const msg = parseUpdateAsInbound(update, "tg-main", store); + + expect(msg).not.toBeNull(); + expect(msg!.text).toBe("Hello world"); + expect(msg!.chatId).toBe("100"); + expect(msg!.channel).toBe("tg-main"); + expect(msg!.sender.id).toBe("42"); + expect(msg!.sender.name).toBe("Alice"); + expect(msg!.sender.username).toBe("alice"); + expect(msg!.threadId).toMatch(/^ot_thr_/); + expect(msg!.attachments).toBeUndefined(); + }); + + it("assigns the same threadId to a reply as to the original message", () => { + const store = new InMemoryThreadStore(); + const msg1 = parseUpdateAsInbound( + makeTextUpdate({ messageId: 1, chatId: 100, text: "Original" }), + "tg", + store, + ); + const msg2 = parseUpdateAsInbound( + makeTextUpdate({ messageId: 2, chatId: 100, text: "Reply", replyToMessageId: 1 }), + "tg", + store, + ); + + expect(msg1).not.toBeNull(); + expect(msg2).not.toBeNull(); + expect(msg1!.threadId).toBe(msg2!.threadId); + }); + + it("assigns a different threadId to a non-reply message", () => { + const store = new InMemoryThreadStore(); + const msg1 = parseUpdateAsInbound( + makeTextUpdate({ messageId: 1, chatId: 100, text: "First" }), + "tg", + store, + ); + const msg2 = parseUpdateAsInbound( + makeTextUpdate({ messageId: 2, chatId: 100, text: "Second" }), + "tg", + store, + ); + + expect(msg1!.threadId).not.toBe(msg2!.threadId); + }); + + it("returns null for a callback_query update", () => { + const store = new InMemoryThreadStore(); + const update = makeCallbackQueryUpdate({}); + const msg = parseUpdateAsInbound(update, "tg", store); + expect(msg).toBeNull(); + }); + + it("parses a photo message", () => { + const store = new InMemoryThreadStore(); + const update: TelegramUpdate = { + update_id: 1, + message: { + message_id: 10, + from: { id: 1, is_bot: false, first_name: "Bob" }, + chat: { id: 200, type: "private" }, + date: 1700000000, + caption: "Look at this", + photo: [ + { file_id: "small", file_unique_id: "u1", width: 90, height: 90, file_size: 100 }, + { file_id: "large", file_unique_id: "u2", width: 800, height: 600, file_size: 50000 }, + ], + }, + }; + + const msg = parseUpdateAsInbound(update, "tg", store); + expect(msg).not.toBeNull(); + expect(msg!.text).toBe("Look at this"); + expect(msg!.attachments).toHaveLength(1); + expect(msg!.attachments![0]!.type).toBe("image"); + expect(msg!.attachments![0]!.fileId).toBe("large"); // largest selected + }); + + it("returns null for a message with no usable content", () => { + const store = new InMemoryThreadStore(); + const update: TelegramUpdate = { + update_id: 1, + message: { + message_id: 1, + from: { id: 1, is_bot: false, first_name: "Bot" }, + chat: { id: 1, type: "private" }, + date: 1700000000, + // No text, no attachments + }, + }; + const msg = parseUpdateAsInbound(update, "tg", store); + expect(msg).toBeNull(); + }); + + it("returns null for a message with no sender (service message)", () => { + const store = new InMemoryThreadStore(); + const update: TelegramUpdate = { + update_id: 1, + message: { + message_id: 1, + // no `from` + chat: { id: 1, type: "group" }, + date: 1700000000, + text: "A service message", + }, + }; + const msg = parseUpdateAsInbound(update, "tg", store); + expect(msg).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// parseUpdateAsCallbackQuery +// --------------------------------------------------------------------------- + +describe("parseUpdateAsCallbackQuery", () => { + it("parses a callback query", () => { + const update = makeCallbackQueryUpdate({ + queryId: "cq99", + userId: 7, + firstName: "Bob", + chatId: 200, + messageId: 5, + data: "some_data", + }); + + const cq = parseUpdateAsCallbackQuery(update); + expect(cq).not.toBeNull(); + expect(cq!.id).toBe("cq99"); + expect(cq!.data).toBe("some_data"); + expect(cq!.sender.id).toBe("7"); + expect(cq!.chatId).toBe("200"); + expect(cq!.originMessageId).toBe("5"); + }); + + it("returns null for a message update", () => { + const update = makeTextUpdate({ text: "Hello" }); + const cq = parseUpdateAsCallbackQuery(update); + expect(cq).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// isCommand / parseCommand +// --------------------------------------------------------------------------- + +describe("isCommand", () => { + it("detects /start command", () => { + const update = makeTextUpdate({ text: "/start" }); + update.message!.entities = [{ type: "bot_command", offset: 0, length: 6 }]; + expect(isCommand(update)).toBe(true); + }); + + it("returns false for a plain message", () => { + const update = makeTextUpdate({ text: "hello" }); + expect(isCommand(update)).toBe(false); + }); + + it("returns false for a command not at offset 0", () => { + const update = makeTextUpdate({ text: "run /start" }); + update.message!.entities = [{ type: "bot_command", offset: 4, length: 6 }]; + expect(isCommand(update)).toBe(false); + }); +}); + +describe("parseCommand", () => { + it("parses /start with no args", () => { + const update = makeTextUpdate({ text: "/start" }); + update.message!.entities = [{ type: "bot_command", offset: 0, length: 6 }]; + const result = parseCommand(update); + expect(result).not.toBeNull(); + expect(result!.command).toBe("/start"); + expect(result!.args).toEqual([]); + }); + + it("parses /connect botname with args", () => { + const update = makeTextUpdate({ text: "/connect arg1 arg2" }); + update.message!.entities = [{ type: "bot_command", offset: 0, length: 8 }]; + const result = parseCommand(update); + expect(result!.command).toBe("/connect"); + expect(result!.args).toEqual(["arg1", "arg2"]); + }); + + it("strips @botname suffix from command token", () => { + const update = makeTextUpdate({ text: "/start@mybot arg1" }); + update.message!.entities = [{ type: "bot_command", offset: 0, length: 13 }]; + const result = parseCommand(update); + expect(result!.command).toBe("/start"); + expect(result!.args).toEqual(["arg1"]); + }); + + it("returns null for a non-command update", () => { + const update = makeTextUpdate({ text: "hello" }); + expect(parseCommand(update)).toBeNull(); + }); +}); diff --git a/packages/channels/telegram/tests/outbound.test.ts b/packages/channels/telegram/tests/outbound.test.ts new file mode 100644 index 0000000..0c61bf3 --- /dev/null +++ b/packages/channels/telegram/tests/outbound.test.ts @@ -0,0 +1,107 @@ +/** + * Unit tests for the Telegram outbound message formatter. + */ + +import { describe, it, expect } from "bun:test"; +import { buildSendMessageParams, escapeMarkdownV2 } from "../src/outbound.js"; +import type { OutboundMessage } from "@openthreads/core"; + +describe("buildSendMessageParams", () => { + it("builds plain text message", () => { + const msg: OutboundMessage = { text: "Hello world" }; + const params = buildSendMessageParams(100, msg); + expect(params.chat_id).toBe(100); + expect(params.text).toBe("Hello world"); + expect(params.parse_mode).toBeUndefined(); + expect(params.reply_markup).toBeUndefined(); + }); + + it("uses HTML parse mode when html is set", () => { + const msg: OutboundMessage = { html: "bold" }; + const params = buildSendMessageParams(100, msg); + expect(params.text).toBe("bold"); + expect(params.parse_mode).toBe("HTML"); + }); + + it("uses MarkdownV2 parse mode when markdown is set", () => { + const msg: OutboundMessage = { markdown: "*bold*" }; + const params = buildSendMessageParams(100, msg); + expect(params.text).toBe("*bold*"); + expect(params.parse_mode).toBe("MarkdownV2"); + }); + + it("html takes precedence over markdown and text", () => { + const msg: OutboundMessage = { + text: "plain", + markdown: "*md*", + html: "html", + }; + const params = buildSendMessageParams(100, msg); + expect(params.text).toBe("html"); + expect(params.parse_mode).toBe("HTML"); + }); + + it("builds inline keyboard", () => { + const msg: OutboundMessage = { + text: "Choose:", + inlineKeyboard: [ + [ + { text: "Yes", callbackData: "yes" }, + { text: "No", callbackData: "no" }, + ], + ], + }; + const params = buildSendMessageParams(100, msg); + const markup = params.reply_markup as { inline_keyboard: unknown[][] }; + expect(markup.inline_keyboard).toHaveLength(1); + expect(markup.inline_keyboard[0]).toHaveLength(2); + expect((markup.inline_keyboard[0]![0] as { callback_data: string }).callback_data).toBe("yes"); + }); + + it("builds reply keyboard", () => { + const msg: OutboundMessage = { + text: "Pick:", + replyKeyboard: [ + [{ text: "Option A" }, { text: "Option B" }], + ], + }; + const params = buildSendMessageParams(100, msg); + const markup = params.reply_markup as { keyboard: unknown[][], one_time_keyboard: boolean }; + expect(markup.keyboard).toHaveLength(1); + expect(markup.one_time_keyboard).toBe(true); + }); + + it("builds remove keyboard", () => { + const msg: OutboundMessage = { text: "Done", removeKeyboard: true }; + const params = buildSendMessageParams(100, msg); + expect(params.reply_markup).toEqual({ remove_keyboard: true }); + }); + + it("sets reply_to_message_id", () => { + const msg: OutboundMessage = { text: "Reply", replyToMessageId: "42" }; + const params = buildSendMessageParams(100, msg); + expect(params.reply_to_message_id).toBe(42); + }); + + it("sets disable_web_page_preview", () => { + const msg: OutboundMessage = { text: "https://example.com", disableWebPagePreview: true }; + const params = buildSendMessageParams(100, msg); + expect(params.disable_web_page_preview).toBe(true); + }); + + it("accepts string chatId", () => { + const params = buildSendMessageParams("@channelname", { text: "hi" }); + expect(params.chat_id).toBe("@channelname"); + }); +}); + +describe("escapeMarkdownV2", () => { + it("escapes all special characters", () => { + const result = escapeMarkdownV2("hello_world*test[link](url)~`>#+=-|{}.!"); + expect(result).not.toMatch(/[_*[\]()~`>#+\-=|{}.!]/); + }); + + it("preserves plain text unchanged", () => { + expect(escapeMarkdownV2("hello world 123")).toBe("hello world 123"); + }); +}); diff --git a/packages/channels/telegram/tests/telegram-adapter.test.ts b/packages/channels/telegram/tests/telegram-adapter.test.ts new file mode 100644 index 0000000..f88869f --- /dev/null +++ b/packages/channels/telegram/tests/telegram-adapter.test.ts @@ -0,0 +1,351 @@ +/** + * Integration-style tests for the TelegramAdapter. + * + * These tests exercise the full adapter without making real HTTP calls — + * the Telegram API client is replaced with a lightweight stub. + * + * For real end-to-end tests against the Telegram Test Environment + * (https://core.telegram.org/bots/webapps/tgwebapptest) you need: + * - A test bot token from @BotFather on https://t.me/botfather + * - The environment variables: TELEGRAM_TEST_BOT_TOKEN, TELEGRAM_TEST_CHAT_ID + * + * Those tests are skipped unless the env vars are present. + */ + +import { describe, it, expect, beforeEach, mock } from "bun:test"; +import { TelegramAdapter } from "../src/index.js"; +import type { TelegramApiClient } from "../src/api-client.js"; +import type { AdapterConfig } from "@openthreads/core"; + +// --------------------------------------------------------------------------- +// Mock API client factory +// --------------------------------------------------------------------------- + +let sentMessages: Array> = []; +let answeredQueries: string[] = []; +let webhookUrl: string | undefined; + +function makeMockApiClient(): TelegramApiClient { + return { + sendMessage: mock(async (params: Record) => { + sentMessages.push(params); + return { message_id: sentMessages.length, chat: { id: params["chat_id"], type: "private" }, date: 1700000000 }; + }), + answerCallbackQuery: mock(async (params: Record) => { + answeredQueries.push(params["callback_query_id"] as string); + return true; + }), + setWebhook: mock(async (params: Record) => { + webhookUrl = params["url"] as string; + return true; + }), + deleteWebhook: mock(async () => { + webhookUrl = undefined; + return true; + }), + getMe: mock(async () => ({ id: 1, username: "testbot", first_name: "Test" })), + } as unknown as TelegramApiClient; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeConfig(extra?: Partial): AdapterConfig { + return { + channelId: "tg-test", + credentials: { botToken: "test-token" }, + webhookUrl: "https://example.com/tg-webhook", + ...extra, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("TelegramAdapter", () => { + let adapter: TelegramAdapter; + let mockApi: TelegramApiClient; + + beforeEach(async () => { + sentMessages = []; + answeredQueries = []; + webhookUrl = undefined; + + mockApi = makeMockApiClient(); + adapter = new TelegramAdapter({ apiClient: mockApi }); + // Setup without webhookUrl so the mock's setWebhook is not called + await adapter.setup({ ...makeConfig(), webhookUrl: undefined }); + }); + + // ------------------------------------------------------------------------- + // Capabilities + // ------------------------------------------------------------------------- + + describe("capabilities", () => { + it("reports correct Telegram capabilities", () => { + expect(adapter.capabilities).toEqual({ + threads: false, + buttons: true, + selectMenus: false, + replyMessages: true, + dms: true, + fileUpload: true, + }); + }); + }); + + // ------------------------------------------------------------------------- + // Inbound parsing + // ------------------------------------------------------------------------- + + describe("parseInbound", () => { + it("parses a text message", async () => { + const payload = { + update_id: 1, + message: { + message_id: 1, + from: { id: 42, is_bot: false, first_name: "Alice", username: "alice" }, + chat: { id: 100, type: "private" }, + date: 1700000000, + text: "Hello!", + }, + }; + + const msg = await adapter.parseInbound(payload); + expect(msg).not.toBeNull(); + expect(msg!.text).toBe("Hello!"); + expect(msg!.channel).toBe("tg-test"); + expect(msg!.sender.name).toBe("Alice"); + }); + + it("returns null for a callback_query update", async () => { + const payload = { + update_id: 1, + callback_query: { + id: "cq1", + from: { id: 42, is_bot: false, first_name: "Alice" }, + chat_instance: "x", + data: "something", + }, + }; + const msg = await adapter.parseInbound(payload); + expect(msg).toBeNull(); + }); + }); + + describe("parseCallbackQuery", () => { + it("parses a callback_query update", async () => { + const payload = { + update_id: 1, + callback_query: { + id: "cq1", + from: { id: 42, is_bot: false, first_name: "Alice" }, + message: { message_id: 5, chat: { id: 100, type: "private" }, date: 1700000000 }, + chat_instance: "abc", + data: "btn_data", + }, + }; + const cq = await adapter.parseCallbackQuery(payload); + expect(cq).not.toBeNull(); + expect(cq!.data).toBe("btn_data"); + }); + }); + + // ------------------------------------------------------------------------- + // Outbound sending + // ------------------------------------------------------------------------- + + describe("send", () => { + it("sends a text message", async () => { + const result = await adapter.send({ chatId: "100" }, { text: "Hi there" }); + expect(result.messageId).toBe("1"); + expect(sentMessages).toHaveLength(1); + expect(sentMessages[0]!["text"]).toBe("Hi there"); + }); + + it("sends a message with inline keyboard", async () => { + await adapter.send( + { chatId: "100" }, + { + text: "Choose:", + inlineKeyboard: [[{ text: "Yes", callbackData: "yes" }, { text: "No", callbackData: "no" }]], + }, + ); + expect(sentMessages[0]!["reply_markup"]).toBeDefined(); + }); + }); + + describe("answerCallbackQuery", () => { + it("calls the API with the correct query ID", async () => { + await adapter.answerCallbackQuery("cq-42", "Thanks!"); + expect(mockApi.answerCallbackQuery).toHaveBeenCalledTimes(1); + }); + }); + + // ------------------------------------------------------------------------- + // A2H rendering + // ------------------------------------------------------------------------- + + describe("renderA2HIntent - AUTHORIZE", () => { + it("renders AUTHORIZE as inline keyboard with APPROVE/DENY", async () => { + const result = await adapter.renderA2HIntent("100", { + intent: "AUTHORIZE", + turnId: "turn-auth-1", + context: { action: "deploy", details: "main → prod" }, + }); + + expect(result.method).toBe("inline"); + expect(sentMessages).toHaveLength(1); + const markup = sentMessages[0]!["reply_markup"] as { + inline_keyboard: Array>; + }; + const buttons = markup.inline_keyboard.flat(); + expect(buttons.some((b) => b.text.includes("Approve"))).toBe(true); + expect(buttons.some((b) => b.text.includes("Deny"))).toBe(true); + }); + }); + + describe("renderA2HIntent - COLLECT with options", () => { + it("renders COLLECT with options as inline keyboard", async () => { + const result = await adapter.renderA2HIntent("100", { + intent: "COLLECT", + turnId: "turn-collect-1", + context: { question: "Pick an env", options: ["dev", "staging"] }, + }); + + expect(result.method).toBe("inline"); + const markup = sentMessages[0]!["reply_markup"] as { + inline_keyboard: Array>; + }; + const buttons = markup.inline_keyboard.flat(); + expect(buttons.map((b) => b.text)).toContain("dev"); + expect(buttons.map((b) => b.text)).toContain("staging"); + }); + }); + + describe("renderA2HIntent - COLLECT free-text", () => { + it("renders free-text COLLECT as reply-capture message", async () => { + const result = await adapter.renderA2HIntent("100", { + intent: "COLLECT", + turnId: "turn-collect-2", + context: { question: "What is your name?" }, + }); + + expect(result.method).toBe("reply-capture"); + expect(sentMessages[0]!["text"]).toContain("What is your name?"); + }); + }); + + // ------------------------------------------------------------------------- + // A2H response capture + // ------------------------------------------------------------------------- + + describe("captureA2HResponse", () => { + it("captures APPROVE from callback_query", async () => { + const { encodeA2HCallbackData } = await import("../src/a2h-renderer.js"); + const data = encodeA2HCallbackData("turn-1", "APPROVED"); + + const payload = { + callback_query: { + id: "cq1", + from: { id: 42, is_bot: false, first_name: "Alice" }, + message: { message_id: 99, chat: { id: 100, type: "private" }, date: 1700000000 }, + chat_instance: "abc", + data, + }, + }; + + const response = await adapter.captureA2HResponse(payload, "turn-1", "99"); + expect(response).not.toBeNull(); + expect(response!.response).toBe("APPROVED"); + }); + + it("captures free-text via reply-to", async () => { + const payload = { + message: { + message_id: 200, + from: { id: 42, is_bot: false, first_name: "Alice" }, + chat: { id: 100, type: "private" }, + date: 1700000001, + text: "The answer", + reply_to_message: { + message_id: 99, + chat: { id: 100, type: "private" }, + date: 1700000000, + }, + }, + }; + + const response = await adapter.captureA2HResponse(payload, "turn-1", "99"); + expect(response).not.toBeNull(); + expect(response!.response).toBe("The answer"); + }); + }); + + // ------------------------------------------------------------------------- + // Virtual thread grouping (full flow) + // ------------------------------------------------------------------------- + + describe("virtual thread management", () => { + it("groups a reply chain into the same thread", async () => { + const msg1 = await adapter.parseInbound({ + update_id: 1, + message: { + message_id: 10, + from: { id: 1, is_bot: false, first_name: "Alice" }, + chat: { id: 500, type: "group" }, + date: 1700000000, + text: "First message", + }, + }); + + const msg2 = await adapter.parseInbound({ + update_id: 2, + message: { + message_id: 11, + from: { id: 2, is_bot: false, first_name: "Bob" }, + chat: { id: 500, type: "group" }, + date: 1700000001, + text: "Reply to first", + reply_to_message: { + message_id: 10, + chat: { id: 500, type: "group" }, + date: 1700000000, + }, + }, + }); + + expect(msg1).not.toBeNull(); + expect(msg2).not.toBeNull(); + expect(msg1!.threadId).toBe(msg2!.threadId); + }); + + it("gives independent messages separate threads", async () => { + const msg1 = await adapter.parseInbound({ + update_id: 1, + message: { + message_id: 20, + from: { id: 1, is_bot: false, first_name: "Alice" }, + chat: { id: 500, type: "group" }, + date: 1700000000, + text: "Topic A", + }, + }); + + const msg2 = await adapter.parseInbound({ + update_id: 2, + message: { + message_id: 21, + from: { id: 2, is_bot: false, first_name: "Bob" }, + chat: { id: 500, type: "group" }, + date: 1700000001, + text: "Topic B", + }, + }); + + expect(msg1!.threadId).not.toBe(msg2!.threadId); + }); + }); +}); diff --git a/packages/channels/telegram/tests/thread-store.test.ts b/packages/channels/telegram/tests/thread-store.test.ts new file mode 100644 index 0000000..7296859 --- /dev/null +++ b/packages/channels/telegram/tests/thread-store.test.ts @@ -0,0 +1,107 @@ +/** + * Unit tests for the InMemoryThreadStore (virtual thread management). + */ + +import { describe, it, expect } from "bun:test"; +import { InMemoryThreadStore } from "../src/thread-store.js"; + +describe("InMemoryThreadStore", () => { + describe("resolveThread", () => { + it("creates a new thread for a standalone message", () => { + const store = new InMemoryThreadStore(); + const threadId = store.resolveThread("chat1", "msg1"); + expect(threadId).toMatch(/^ot_thr_/); + }); + + it("returns the same threadId for the same message", () => { + const store = new InMemoryThreadStore(); + const t1 = store.resolveThread("chat1", "msg1"); + const t2 = store.resolveThread("chat1", "msg1"); + expect(t1).toBe(t2); + }); + + it("different standalone messages get different threads", () => { + const store = new InMemoryThreadStore(); + const t1 = store.resolveThread("chat1", "msg1"); + const t2 = store.resolveThread("chat1", "msg2"); + expect(t1).not.toBe(t2); + }); + + it("a reply to an unknown message creates a new thread seeded with both messages", () => { + const store = new InMemoryThreadStore(); + const threadId = store.resolveThread("chat1", "msg2", "msg1"); + expect(threadId).toMatch(/^ot_thr_/); + + const thread = store.getThread(threadId); + expect(thread).toBeDefined(); + expect(thread!.messageIds).toContain("msg1"); + expect(thread!.messageIds).toContain("msg2"); + }); + + it("a reply to a known message extends the existing thread", () => { + const store = new InMemoryThreadStore(); + const t1 = store.resolveThread("chat1", "msg1"); // standalone + const t2 = store.resolveThread("chat1", "msg2", "msg1"); // reply to msg1 + + expect(t2).toBe(t1); + + const thread = store.getThread(t1); + expect(thread!.messageIds).toContain("msg2"); + }); + + it("a reply chain (A → B → C) all belong to the same thread", () => { + const store = new InMemoryThreadStore(); + const tA = store.resolveThread("chat1", "msgA"); + const tB = store.resolveThread("chat1", "msgB", "msgA"); + const tC = store.resolveThread("chat1", "msgC", "msgB"); + + expect(tB).toBe(tA); + expect(tC).toBe(tA); + }); + + it("threads are isolated by chatId", () => { + const store = new InMemoryThreadStore(); + const t1 = store.resolveThread("chat1", "msg1"); + const t2 = store.resolveThread("chat2", "msg1"); + expect(t1).not.toBe(t2); + }); + }); + + describe("getThread", () => { + it("returns undefined for unknown threadId", () => { + const store = new InMemoryThreadStore(); + expect(store.getThread("ot_thr_unknown")).toBeUndefined(); + }); + + it("returns the thread with correct metadata", () => { + const store = new InMemoryThreadStore(); + const threadId = store.resolveThread("chat1", "msg1"); + const thread = store.getThread(threadId); + + expect(thread).toBeDefined(); + expect(thread!.threadId).toBe(threadId); + expect(thread!.chatId).toBe("chat1"); + expect(thread!.messageIds).toEqual(["msg1"]); + expect(thread!.createdAt).toBeInstanceOf(Date); + expect(thread!.updatedAt).toBeInstanceOf(Date); + }); + }); + + describe("getAllThreadsForChat", () => { + it("returns empty array for unknown chatId", () => { + const store = new InMemoryThreadStore(); + expect(store.getAllThreadsForChat("unknown")).toEqual([]); + }); + + it("returns all threads for a given chatId", () => { + const store = new InMemoryThreadStore(); + store.resolveThread("chat1", "msg1"); + store.resolveThread("chat1", "msg2"); + store.resolveThread("chat2", "msg3"); + + const threads = store.getAllThreadsForChat("chat1"); + expect(threads).toHaveLength(2); + expect(threads.every((t) => t.chatId === "chat1")).toBe(true); + }); + }); +}); diff --git a/packages/channels/telegram/tsconfig.json b/packages/channels/telegram/tsconfig.json new file mode 100644 index 0000000..76551cc --- /dev/null +++ b/packages/channels/telegram/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "paths": { + "@openthreads/core": ["../../core/src/index.ts"] + } + }, + "include": ["src/**/*", "tests/**/*"] +} diff --git a/packages/channels/tests/reconnect.test.ts b/packages/channels/tests/reconnect.test.ts new file mode 100644 index 0000000..3e3afb9 --- /dev/null +++ b/packages/channels/tests/reconnect.test.ts @@ -0,0 +1,214 @@ +/** + * Unit tests for the generic ReconnectManager. + */ + +import { describe, it, expect, mock } from 'bun:test'; +import { ReconnectManager, computeReconnectDelay } from '../src/reconnect.js'; + +// --------------------------------------------------------------------------- +// computeReconnectDelay +// --------------------------------------------------------------------------- + +describe('computeReconnectDelay', () => { + it('returns initialDelayMs for attempt 1', () => { + expect(computeReconnectDelay(1, { initialDelayMs: 1000 })).toBe(1000); + }); + + it('doubles for attempt 2 with default backoffFactor=2', () => { + expect(computeReconnectDelay(2, { initialDelayMs: 1000 })).toBe(2000); + }); + + it('caps at maxDelayMs', () => { + expect( + computeReconnectDelay(20, { initialDelayMs: 1000, maxDelayMs: 5000 }), + ).toBe(5000); + }); + + it('supports custom backoffFactor', () => { + expect( + computeReconnectDelay(3, { initialDelayMs: 100, backoffFactor: 3 }), + ).toBe(900); // 100 * 3^2 = 900 + }); +}); + +// --------------------------------------------------------------------------- +// ReconnectManager — connect() +// --------------------------------------------------------------------------- + +describe('ReconnectManager — connect()', () => { + it('calls connectFn and resolves on success', async () => { + const fn = mock(async () => {}); + const manager = new ReconnectManager(fn); + + await manager.connect(); + + expect(fn).toHaveBeenCalledTimes(1); + expect(manager.currentAttempts).toBe(0); + }); + + it('throws immediately when connectFn throws', async () => { + const fn = mock(async () => { + throw new Error('connect failed'); + }); + const manager = new ReconnectManager(fn); + + await expect(manager.connect()).rejects.toThrow('connect failed'); + }); + + it('calls onConnected callback after successful connect()', async () => { + const onConnected = mock(() => {}); + const manager = new ReconnectManager(async () => {}, { onConnected }); + + await manager.connect(); + + expect(onConnected).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// ReconnectManager — scheduleReconnect() +// --------------------------------------------------------------------------- + +describe('ReconnectManager — scheduleReconnect()', () => { + it('reconnects successfully after a disconnect', async () => { + let calls = 0; + const onConnected = mock(() => {}); + const manager = new ReconnectManager( + async () => { calls++; }, + { initialDelayMs: 1, onConnected }, + ); + + await manager.connect(); + expect(calls).toBe(1); + + // Simulate a disconnect + manager.scheduleReconnect(new Error('ws close')); + + // Wait for the reconnect to fire + await new Promise((r) => setTimeout(r, 20)); + + expect(calls).toBe(2); + expect(manager.currentAttempts).toBe(0); // reset after success + }); + + it('increments attempt counter on failure', async () => { + let shouldFail = true; + const manager = new ReconnectManager( + async () => { + if (shouldFail) throw new Error('fail'); + }, + { initialDelayMs: 1, maxAttempts: 3 }, + ); + + // First connection attempt — ignore failure here + try { await manager.connect(); } catch { /* expected */ } + + manager.scheduleReconnect(); + await new Promise((r) => setTimeout(r, 5)); + + // At least one attempt was made + expect(manager.currentAttempts).toBeGreaterThan(0); + + shouldFail = false; + manager.stop(); // stop to prevent further retries + }); + + it('calls onExhausted when maxAttempts is reached', async () => { + const onExhausted = mock((_attempts: number) => {}); + const manager = new ReconnectManager( + async () => { throw new Error('always fails'); }, + { maxAttempts: 2, initialDelayMs: 1, onExhausted }, + ); + + // Manually schedule reconnects up to max + manager['attempts'] = 2; // bypass initial connect + manager.scheduleReconnect(); + + // Should NOT fire a reconnect (maxAttempts already reached) + await new Promise((r) => setTimeout(r, 10)); + expect(onExhausted).toHaveBeenCalledTimes(1); + expect(onExhausted.mock.calls[0][0]).toBe(2); + }); + + it('calls onRetry before each retry attempt', async () => { + const onRetry = mock((_attempt: number, _delay: number) => {}); + let connectCalls = 0; + + const manager = new ReconnectManager( + async () => { + if (++connectCalls < 3) throw new Error('fail'); + }, + { maxAttempts: 3, initialDelayMs: 1, onRetry }, + ); + + // First connect + try { await manager.connect(); } catch { /* expected */ } + + manager.scheduleReconnect(); + await new Promise((r) => setTimeout(r, 50)); + + expect(onRetry.mock.calls.length).toBeGreaterThan(0); + manager.stop(); + }); +}); + +// --------------------------------------------------------------------------- +// ReconnectManager — stop() +// --------------------------------------------------------------------------- + +describe('ReconnectManager — stop()', () => { + it('does not reconnect after stop() is called', async () => { + let calls = 0; + const manager = new ReconnectManager( + async () => { calls++; throw new Error('fail'); }, + { initialDelayMs: 1 }, + ); + + // Schedule a reconnect then immediately stop + manager.scheduleReconnect(); + manager.stop(); + + // Give enough time for a reconnect to have fired if stop() didn't work + await new Promise((r) => setTimeout(r, 20)); + + expect(calls).toBe(0); + expect(manager.isStopped).toBe(true); + }); + + it('calling stop() multiple times is safe', () => { + const manager = new ReconnectManager(async () => {}); + expect(() => { + manager.stop(); + manager.stop(); + }).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// ReconnectManager — isStopped / currentAttempts +// --------------------------------------------------------------------------- + +describe('ReconnectManager — state accessors', () => { + it('isStopped starts as false', () => { + const manager = new ReconnectManager(async () => {}); + expect(manager.isStopped).toBe(false); + }); + + it('isStopped is true after stop()', () => { + const manager = new ReconnectManager(async () => {}); + manager.stop(); + expect(manager.isStopped).toBe(true); + }); + + it('currentAttempts starts at 0', () => { + const manager = new ReconnectManager(async () => {}); + expect(manager.currentAttempts).toBe(0); + }); + + it('resetAttempts sets currentAttempts to 0', () => { + const manager = new ReconnectManager(async () => { throw new Error('x'); }); + manager['attempts'] = 3; + manager.resetAttempts(); + expect(manager.currentAttempts).toBe(0); + }); +}); diff --git a/packages/channels/tsconfig.json b/packages/channels/tsconfig.json new file mode 100644 index 0000000..77c920c --- /dev/null +++ b/packages/channels/tsconfig.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "references": [ + { "path": "../core" } + ], + "include": ["src"] +} diff --git a/packages/channels/vite.config.ts b/packages/channels/vite.config.ts new file mode 100644 index 0000000..bea0790 --- /dev/null +++ b/packages/channels/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite' +import dts from 'vite-plugin-dts' +import { resolve } from 'path' + +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'OpenThreadsChannels', + formats: ['es', 'cjs'], + fileName: (format) => `index.${format === 'es' ? 'js' : 'cjs'}`, + }, + rollupOptions: { + external: ['@openthreads/core'], + }, + }, + plugins: [dts({ rollupTypes: true })], +}) diff --git a/packages/channels/whatsapp/package.json b/packages/channels/whatsapp/package.json new file mode 100644 index 0000000..02e5101 --- /dev/null +++ b/packages/channels/whatsapp/package.json @@ -0,0 +1,31 @@ +{ + "name": "@openthreads/channel-whatsapp", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "tsc --project tsconfig.json", + "dev": "tsc --project tsconfig.json --watch", + "test": "bun test", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@openthreads/core": "workspace:*", + "@whiskeysockets/baileys": "^6.7.18", + "@hapi/boom": "^10.0.1", + "pino": "^8.21.0", + "qrcode": "^1.5.4", + "qrcode-terminal": "^0.12.0", + "node-cache": "^5.1.2" + }, + "devDependencies": { + "@types/node": "^20.12.0", + "@types/qrcode": "^1.5.5", + "@types/qrcode-terminal": "^0.12.2", + "typescript": "^5.4.0" + } +} diff --git a/packages/channels/whatsapp/src/SessionManager.ts b/packages/channels/whatsapp/src/SessionManager.ts new file mode 100644 index 0000000..2a23d94 --- /dev/null +++ b/packages/channels/whatsapp/src/SessionManager.ts @@ -0,0 +1,228 @@ +import { + makeWASocket, + DisconnectReason, + useMultiFileAuthState, + fetchLatestBaileysVersion, + type WASocket, + type ConnectionState, + type AuthenticationState, +} from "@whiskeysockets/baileys"; +import { Boom } from "@hapi/boom"; +import P from "pino"; +import type { WhatsAppConfig } from "./types.js"; + +type SocketReadyCallback = (socket: WASocket) => void; + +/** + * Manages the Baileys WebSocket connection lifecycle: + * - Initial QR-code authentication + * - Credential persistence via multi-file auth state + * - Automatic reconnection with exponential backoff + * - Clean shutdown + * + * The adapter delegates all Baileys socket creation to this class so that + * reconnection can transparently swap in a new socket without the caller + * needing to know. + */ +export class SessionManager { + private socket: WASocket | null = null; + private reconnectAttempts = 0; + private reconnecting = false; + private destroyed = false; + + /** + * @param config Adapter configuration. + * @param onQRCode Fired when the socket emits a new QR code. + * @param onConnected Fired when the connection reaches "open" state. + * @param onDisconnected Fired on every clean or unexpected close. + * @param onSocketReady Fired every time a new socket is created so that + * the adapter can (re-)attach its event listeners. + */ + constructor( + private readonly config: WhatsAppConfig, + private readonly onQRCode: (qr: string) => void | Promise, + private readonly onConnected: (phone: string) => void | Promise, + private readonly onDisconnected: (reason: string) => void | Promise, + private readonly onSocketReady: SocketReadyCallback, + ) {} + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /** + * Opens the connection for the first time. + * Subsequent reconnections are handled internally. + */ + async connect(): Promise { + await this.createSocket(); + } + + /** + * Gracefully closes the connection and marks the manager as destroyed. + * After calling this, no further reconnections will be attempted. + */ + async disconnect(): Promise { + this.destroyed = true; + + if (this.socket) { + try { + // ev.removeAllListeners is available on the Baileys EventEmitter + this.socket.ev.removeAllListeners("connection.update"); + this.socket.ev.removeAllListeners("creds.update"); + await this.socket.logout(); + } catch { + // Ignore errors during shutdown — the socket may already be closed. + } finally { + this.socket = null; + } + } + } + + /** + * Returns the active socket. + * Throws if the session has not been initialized yet. + */ + getSocket(): WASocket { + if (!this.socket) { + throw new Error( + "WhatsApp socket is not initialized. Ensure connect() has resolved before using the adapter.", + ); + } + return this.socket; + } + + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + + private async createSocket(): Promise { + const { state, saveCreds } = await useMultiFileAuthState( + this.config.sessionDir, + ); + + const { version, isLatest } = await fetchLatestBaileysVersion(); + + const logger = P({ level: this.config.logLevel ?? "silent" }); + + if (!isLatest) { + logger.warn( + { version }, + "Baileys: using an older WhatsApp Web version — consider upgrading @whiskeysockets/baileys", + ); + } + + this.socket = makeWASocket({ + version, + auth: state as AuthenticationState, + logger, + // Never print the QR to stdout — let the onQRCode callback handle it. + printQRInTerminal: false, + // Generous timeouts for slow mobile connections. + connectTimeoutMs: 30_000, + defaultQueryTimeoutMs: 60_000, + keepAliveIntervalMs: 25_000, + // Receive messages even while the socket was offline. + syncFullHistory: false, + // Ignore the status broadcast list. + shouldIgnoreJid: (jid) => jid === "status@broadcast", + }); + + // Persist credentials whenever they change. + this.socket.ev.on("creds.update", saveCreds); + + // Handle connection state changes. + this.socket.ev.on("connection.update", (update) => { + void this.handleConnectionUpdate(update); + }); + + // Notify the adapter so it can attach its own listeners. + this.onSocketReady(this.socket); + } + + private async handleConnectionUpdate( + update: Partial, + ): Promise { + const { connection, lastDisconnect, qr } = update; + + if (qr) { + await this.onQRCode(qr); + } + + if (connection === "open") { + this.reconnectAttempts = 0; + this.reconnecting = false; + + // Extract the bare phone number from the JID (e.g. "15551234567:1@s.whatsapp.net" → "15551234567") + const rawId = this.socket?.user?.id ?? ""; + const phone = rawId.split(":")[0] ?? rawId.split("@")[0] ?? rawId; + + await this.onConnected(phone); + } + + if (connection === "close") { + const err = lastDisconnect?.error as Boom | undefined; + const statusCode = err?.output?.statusCode ?? 0; + + const loggedOut = statusCode === DisconnectReason.loggedOut; + const reasonLabel = + (DisconnectReason as Record)[statusCode] ?? + err?.message ?? + "Unknown"; + + await this.onDisconnected(reasonLabel); + + if (loggedOut) { + // The session is invalid — cannot reconnect without re-scanning the QR. + // Surface a clear message so operators know what action to take. + await this.onDisconnected( + "Session logged out. Delete the session directory and reconnect to scan a new QR code.", + ); + return; + } + + if (!this.destroyed) { + await this.scheduleReconnect(); + } + } + } + + private async scheduleReconnect(): Promise { + if (this.reconnecting || this.destroyed) return; + + const maxAttempts = this.config.maxReconnectAttempts ?? 10; + + if (this.reconnectAttempts >= maxAttempts) { + await this.onDisconnected( + `WhatsApp reconnection failed after ${maxAttempts} attempts.`, + ); + return; + } + + this.reconnecting = true; + this.reconnectAttempts++; + + // Exponential backoff: 1 s, 2 s, 4 s … capped at 30 s. + const baseMs = this.config.reconnectIntervalMs ?? 1_000; + const delayMs = Math.min( + baseMs * Math.pow(2, this.reconnectAttempts - 1), + 30_000, + ); + + await sleep(delayMs); + + this.reconnecting = false; + + if (!this.destroyed) { + try { + await this.createSocket(); + } catch { + await this.scheduleReconnect(); + } + } + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/channels/whatsapp/src/WhatsAppAdapter.ts b/packages/channels/whatsapp/src/WhatsAppAdapter.ts new file mode 100644 index 0000000..5b082a6 --- /dev/null +++ b/packages/channels/whatsapp/src/WhatsAppAdapter.ts @@ -0,0 +1,574 @@ +import { + getContentType, + type WASocket, + type WAMessage, + proto, +} from "@whiskeysockets/baileys"; +import type { + ChannelAdapter, + ChannelCapabilities, + InboundMessage, + InboundMessageHandler, + OutboundPayload, + SentMessage, + A2HIntent, + OutboundContent, +} from "@openthreads/core"; +import { SessionManager } from "./SessionManager.js"; +import { + WHATSAPP_CAPABILITIES, + WHATSAPP_MAX_BUTTONS, + type WhatsAppAdapterOptions, + type PendingCapture, +} from "./types.js"; + +/** + * WhatsApp channel adapter built on the Baileys library (WhatsApp Web protocol). + * + * ## Session management + * Authentication state is persisted to disk via Baileys' `useMultiFileAuthState`. + * On first run the adapter emits a QR code via `onQRCode`; subsequent runs + * restore the session automatically. Disconnects trigger automatic reconnection + * with exponential backoff. + * + * ## Thread model + * WhatsApp has no native thread concept. The adapter creates *virtual threads* + * by pairing messages through quoted replies: + * - The `threadId` of an inbound message equals the ID of the message it + * quoted, or the JID when there is no quoted context. + * - Outbound messages that carry a `replyToId` are sent as quoted replies, + * allowing the human to see the full context inline. + * + * ## A2H support + * | Intent | Options ≤ 3 | Options > 3 | Notes | + * |------------- |-------------|-------------|------------------------------| + * | AUTHORIZE | Method 1 | Method 3 | Max 3 WhatsApp buttons | + * | COLLECT | Method 3 | Method 3 | Always falls back to form | + * | INFORM | Text only | — | No response expected | + * + * Method-2 (quoted-reply capture) is used for free-text responses when the + * human replies to a COLLECT prompt. + * + * ## Capabilities + * ```json + * { "threads": false, "buttons": true, "selectMenus": false, + * "replyMessages": true, "dms": true, "fileUpload": true } + * ``` + */ +export class WhatsAppAdapter implements ChannelAdapter { + readonly type = "whatsapp" as const; + readonly capabilities: ChannelCapabilities = WHATSAPP_CAPABILITIES; + + private readonly session: SessionManager; + private inboundHandler: InboundMessageHandler | null = null; + + /** + * messageId → PendingCapture — tracks in-flight method-2 response captures. + */ + private readonly pendingCaptures = new Map(); + + constructor(private readonly options: WhatsAppAdapterOptions) { + this.session = new SessionManager( + options.config, + (qr) => options.onQRCode?.(qr), + (phone) => options.onConnected?.(phone), + (reason) => options.onDisconnected?.(reason), + (socket) => this.attachListeners(socket), + ); + } + + // --------------------------------------------------------------------------- + // ChannelAdapter lifecycle + // --------------------------------------------------------------------------- + + async initialize(_config?: Record): Promise { + await this.session.connect(); + } + + async destroy(): Promise { + // Reject all pending captures so callers are not left hanging. + for (const [, capture] of this.pendingCaptures) { + clearTimeout(capture.timeoutHandle); + capture.reject(new Error("WhatsApp adapter destroyed")); + } + this.pendingCaptures.clear(); + + await this.session.disconnect(); + this.inboundHandler = null; + } + + // --------------------------------------------------------------------------- + // ChannelAdapter message interface + // --------------------------------------------------------------------------- + + onInboundMessage(handler: InboundMessageHandler): void { + this.inboundHandler = handler; + } + + async sendMessage(payload: OutboundPayload): Promise { + const sock = this.session.getSocket(); + const jid = toJid(payload.targetId); + + const contents: OutboundContent[] = Array.isArray(payload.content) + ? payload.content + : [payload.content]; + + let lastId = ""; + + for (const content of contents) { + const result = await this.sendContent(sock, jid, content, payload.replyToId); + lastId = result.id; + } + + return { id: lastId, threadId: payload.threadId ?? lastId }; + } + + /** + * Renders an A2H intent to WhatsApp using the most appropriate method. + * + * The method selection follows the logic from VISION.md §4: + * - AUTHORIZE with ≤3 options → method 1 (buttons) + * - AUTHORIZE with >3 options → method 3 (external form link) + * - COLLECT (any) → method 3 (external form link) + * - INFORM → plain text, no response expected + */ + async renderA2H( + intent: A2HIntent, + payload: OutboundPayload, + ): Promise { + switch (intent.intent) { + case "AUTHORIZE": { + const options: string[] = intent.context.options ?? ["Approve", "Reject"]; + + if (options.length <= WHATSAPP_MAX_BUTTONS) { + return this.sendAuthorizeButtons(intent, options, payload); + } + // Fallthrough to external form when there are too many options. + return this.sendExternalFormLink(intent, payload); + } + + case "COLLECT": + return this.sendExternalFormLink(intent, payload); + + case "INFORM": { + const text = buildInformText(intent); + return this.sendMessage({ + ...payload, + content: { type: "text", text }, + }); + } + + default: + return this.sendExternalFormLink(intent, payload); + } + } + + // --------------------------------------------------------------------------- + // A2H rendering helpers + // --------------------------------------------------------------------------- + + private async sendAuthorizeButtons( + intent: A2HIntent & { intent: "AUTHORIZE" }, + options: string[], + payload: OutboundPayload, + ): Promise { + const sock = this.session.getSocket(); + const jid = toJid(payload.targetId); + + const bodyText = buildAuthorizeBody(intent); + + const buttons = options.slice(0, WHATSAPP_MAX_BUTTONS).map((label, idx) => ({ + buttonId: `a2h_${intent.traceId ?? "auth"}_${idx}`, + buttonText: { displayText: label }, + type: 1 as const, + })); + + const sendOpts = await this.buildQuotedOptions(jid, payload.replyToId); + + const result = await sock.sendMessage( + jid, + { + text: bodyText, + footer: buildFooter(intent), + buttons, + headerType: proto.Message.ButtonsMessage.HeaderType.TEXT, + } as Parameters[1], + sendOpts, + ); + + const id = result?.key?.id ?? ""; + return { id, threadId: payload.threadId ?? id }; + } + + private async sendExternalFormLink( + intent: A2HIntent, + payload: OutboundPayload, + ): Promise { + const sock = this.session.getSocket(); + const jid = toJid(payload.targetId); + + const formUrl = this.options.config.serverBaseUrl + ? `${this.options.config.serverBaseUrl}/form/${intent.traceId ?? "unknown"}` + : null; + + const body = buildA2HBody(intent); + const linkLine = formUrl + ? `\n\n🔗 *Respond via secure form:*\n${formUrl}` + : `\n\n_(No form URL configured — contact the system operator.)_`; + + const sendOpts = await this.buildQuotedOptions(jid, payload.replyToId); + + const result = await sock.sendMessage( + jid, + { text: `${body}${linkLine}` }, + sendOpts, + ); + + const id = result?.key?.id ?? ""; + return { id, threadId: payload.threadId ?? id }; + } + + // --------------------------------------------------------------------------- + // Outbound content dispatch + // --------------------------------------------------------------------------- + + private async sendContent( + sock: WASocket, + jid: string, + content: OutboundContent, + replyToId?: string, + ): Promise { + const sendOpts = await this.buildQuotedOptions(jid, replyToId); + let waContent: Parameters[1]; + + switch (content.type) { + case "text": + waContent = { text: content.text }; + break; + + case "buttons": { + const waButtons = (content.buttons ?? []) + .slice(0, WHATSAPP_MAX_BUTTONS) + .map((btn, idx) => ({ + buttonId: btn.id ?? `btn_${idx}`, + buttonText: { displayText: btn.label }, + type: 1 as const, + })); + + waContent = { + text: content.body, + footer: content.footer, + buttons: waButtons, + headerType: proto.Message.ButtonsMessage.HeaderType.TEXT, + } as Parameters[1]; + break; + } + + case "list": { + waContent = { + listMessage: { + title: content.title, + text: content.body, + footerText: content.footer, + buttonText: content.buttonLabel ?? "Options", + listType: proto.Message.ListMessage.ListType.SINGLE_SELECT, + sections: (content.sections ?? []).map((section) => ({ + title: section.title, + rows: section.rows.map((row) => ({ + rowId: row.id, + title: row.title, + description: row.description ?? "", + })), + })), + }, + } as Parameters[1]; + break; + } + + case "image": + waContent = { + image: { url: content.url }, + caption: content.caption, + } as Parameters[1]; + break; + + case "video": + waContent = { + video: { url: content.url }, + caption: content.caption, + } as Parameters[1]; + break; + + case "audio": + waContent = { + audio: { url: content.url }, + ptt: false, + } as Parameters[1]; + break; + + case "document": + waContent = { + document: { url: content.url }, + fileName: content.filename ?? "document", + caption: content.caption, + } as Parameters[1]; + break; + + default: + // Graceful degradation for unknown content types. + waContent = { text: `[Unsupported content type: ${(content as { type: string }).type}]` }; + } + + const result = await sock.sendMessage(jid, waContent, sendOpts); + const id = result?.key?.id ?? ""; + return { id, threadId: id }; + } + + // --------------------------------------------------------------------------- + // Inbound message handling + // --------------------------------------------------------------------------- + + private attachListeners(socket: WASocket): void { + socket.ev.on("messages.upsert", ({ messages, type }) => { + if (type !== "notify") return; + + for (const msg of messages) { + // Skip outgoing messages (sent by us). + if (msg.key.fromMe) continue; + if (!msg.message) continue; + + void this.handleInbound(msg); + } + }); + } + + private async handleInbound(msg: WAMessage): Promise { + // Check if this message resolves a pending method-2 capture first. + this.tryResolvePendingCapture(msg); + + // Then dispatch to the general inbound handler. + const parsed = parseInboundMessage(msg); + if (!parsed) return; + + try { + await this.inboundHandler?.(parsed); + } catch (err) { + console.error("[openthreads/channel-whatsapp] inbound handler error:", err); + } + } + + private tryResolvePendingCapture(msg: WAMessage): void { + const quotedId = + msg.message?.extendedTextMessage?.contextInfo?.stanzaId ?? + msg.message?.imageMessage?.contextInfo?.stanzaId ?? + msg.message?.videoMessage?.contextInfo?.stanzaId ?? + msg.message?.audioMessage?.contextInfo?.stanzaId ?? + msg.message?.documentMessage?.contextInfo?.stanzaId; + + if (!quotedId) return; + + const pending = this.pendingCaptures.get(quotedId); + if (!pending) return; + + clearTimeout(pending.timeoutHandle); + this.pendingCaptures.delete(quotedId); + + const text = + msg.message?.conversation ?? + msg.message?.extendedTextMessage?.text ?? + ""; + + pending.resolve(text); + } + + // --------------------------------------------------------------------------- + // Utilities + // --------------------------------------------------------------------------- + + /** + * Returns method-2 quoted-reply capture options if we have a prior message + * to quote. In a full implementation this would retrieve the actual + * WAMessage object from an in-memory store (Baileys' makeInMemoryStore). + * We return an empty object here since Baileys can reconstruct the stub from + * the message key alone when a proper store is wired up. + */ + private async buildQuotedOptions( + _jid: string, + replyToId?: string, + ): Promise[2]> { + if (!replyToId) return undefined; + // Full store integration: return { quoted: storedMessage } + // For now, return undefined — the caller can extend this by injecting a + // message store via the SessionManager. + return undefined; + } +} + +// --------------------------------------------------------------------------- +// Pure helper functions (no adapter state) +// --------------------------------------------------------------------------- + +/** + * Converts a bare phone number or group ID to a WhatsApp JID. + * + * - Already contains "@" → returned as-is + * - Ends with "-" (legacy group format) → appended with "@g.us" + * - Otherwise → appended with "@s.whatsapp.net" + */ +function toJid(target: string): string { + if (target.includes("@")) return target; + if (target.includes("-")) return `${target}@g.us`; + return `${target}@s.whatsapp.net`; +} + +/** + * Parses a raw Baileys WAMessage into the OpenThreads InboundMessage shape. + * Returns null for unsupported / system message types. + */ +function parseInboundMessage(msg: WAMessage): InboundMessage | null { + if (!msg.key.remoteJid || !msg.message) return null; + + const jid = msg.key.remoteJid; + const messageId = msg.key.id ?? ""; + const senderId = msg.key.participant ?? jid.split("@")[0] ?? ""; + const senderName = (msg as { pushName?: string }).pushName ?? senderId; + const timestamp = + typeof msg.messageTimestamp === "number" + ? new Date(msg.messageTimestamp * 1_000) + : new Date(); + + const contentType = getContentType(msg.message); + + let content: InboundMessage["content"]; + + switch (contentType) { + case "conversation": + case "extendedTextMessage": { + const text = + msg.message.conversation ?? + msg.message.extendedTextMessage?.text ?? + ""; + content = { type: "text", text }; + break; + } + + case "imageMessage": + content = { + type: "image", + caption: msg.message.imageMessage?.caption, + }; + break; + + case "videoMessage": + content = { + type: "video", + caption: msg.message.videoMessage?.caption, + }; + break; + + case "audioMessage": + content = { type: "audio" }; + break; + + case "documentMessage": + content = { + type: "document", + filename: msg.message.documentMessage?.fileName ?? undefined, + caption: msg.message.documentMessage?.caption ?? undefined, + }; + break; + + case "stickerMessage": + content = { type: "sticker" }; + break; + + // Button / list responses + case "buttonsResponseMessage": + content = { + type: "text", + text: msg.message.buttonsResponseMessage?.selectedDisplayText ?? "", + }; + break; + + case "listResponseMessage": + content = { + type: "text", + text: + msg.message.listResponseMessage?.title ?? + msg.message.listResponseMessage?.singleSelectReply?.selectedRowId ?? + "", + }; + break; + + default: + // Protocol messages, ephemeral keys, receipts, etc. + return null; + } + + // Extract the quoted message ID to determine the virtual thread. + const quotedId = + msg.message.extendedTextMessage?.contextInfo?.stanzaId ?? + msg.message.imageMessage?.contextInfo?.stanzaId ?? + msg.message.videoMessage?.contextInfo?.stanzaId ?? + msg.message.audioMessage?.contextInfo?.stanzaId ?? + msg.message.documentMessage?.contextInfo?.stanzaId ?? + msg.message.buttonsResponseMessage?.contextInfo?.stanzaId ?? + msg.message.listResponseMessage?.contextInfo?.stanzaId; + + // Virtual thread ID: if this message is a reply, the thread is rooted at the + // quoted message; otherwise the thread root is the conversation JID. + const threadId = quotedId ?? jid; + + return { + id: messageId, + threadId, + channelId: jid, + senderId, + senderName, + content, + replyToId: quotedId, + timestamp, + }; +} + +function buildAuthorizeBody(intent: A2HIntent & { intent: "AUTHORIZE" }): string { + const ctx = intent.context; + const lines: string[] = ["*Authorization Required*"]; + + if (ctx.action) lines.push(`\n*Action:* ${ctx.action}`); + if (ctx.details) lines.push(`*Details:* ${ctx.details}`); + + return lines.join("\n"); +} + +function buildA2HBody(intent: A2HIntent): string { + const ctx = intent.context as Record; + + if (intent.intent === "AUTHORIZE") { + const lines = ["*Authorization Required*"]; + if (ctx.action) lines.push(`\n*Action:* ${ctx.action}`); + if (ctx.details) lines.push(`*Details:* ${ctx.details}`); + return lines.join("\n"); + } + + if (intent.intent === "COLLECT") { + const question = (ctx.question as string | undefined) ?? "Please provide the requested information."; + return `*Question:* ${question}`; + } + + if (intent.intent === "INFORM") { + return buildInformText(intent); + } + + return "Please respond via the link below."; +} + +function buildInformText(intent: A2HIntent): string { + const ctx = intent.context as Record; + const message = (ctx.message as string | undefined) ?? ""; + return message; +} + +function buildFooter(intent: A2HIntent): string { + return `OpenThreads · ${intent.intent}${intent.traceId ? ` · ${intent.traceId.slice(0, 8)}` : ""}`; +} diff --git a/packages/channels/whatsapp/src/__tests__/WhatsAppAdapter.test.ts b/packages/channels/whatsapp/src/__tests__/WhatsAppAdapter.test.ts new file mode 100644 index 0000000..21a9ae8 --- /dev/null +++ b/packages/channels/whatsapp/src/__tests__/WhatsAppAdapter.test.ts @@ -0,0 +1,519 @@ +/** + * WhatsApp adapter conformance tests. + * + * These tests verify the adapter's behaviour at the unit level using a mock + * Baileys socket. Integration tests that require an actual WhatsApp account + * are intentionally excluded from this file. + * + * Run with: bun test + */ + +import { describe, it, expect, beforeEach, mock } from "bun:test"; +import { WhatsAppAdapter } from "../WhatsAppAdapter.js"; +import { WHATSAPP_CAPABILITIES } from "../types.js"; +import type { WhatsAppAdapterOptions } from "../types.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Minimal mock of the Baileys WASocket surface we depend on. */ +function createMockSocket() { + const listeners = new Map void)[]>(); + const sentMessages: Array<{ jid: string; content: unknown; opts?: unknown }> = []; + + const socket = { + user: { id: "15551234567:1@s.whatsapp.net" }, + ev: { + on: (event: string, cb: (...args: unknown[]) => void) => { + if (!listeners.has(event)) listeners.set(event, []); + listeners.get(event)!.push(cb); + }, + removeAllListeners: (event: string) => listeners.delete(event), + emit: (event: string, ...args: unknown[]) => { + listeners.get(event)?.forEach((cb) => cb(...args)); + }, + }, + sendMessage: mock( + async (jid: string, content: unknown, opts?: unknown) => { + sentMessages.push({ jid, content, opts }); + return { key: { id: `msg_${sentMessages.length}`, remoteJid: jid } }; + }, + ), + logout: mock(async () => {}), + _sentMessages: sentMessages, + }; + + return socket; +} + +/** Creates an adapter whose SessionManager is replaced with a controllable mock. */ +function createTestAdapter(overrides: Partial = {}) { + const mockSocket = createMockSocket(); + let socketReadyCb: ((sock: unknown) => void) | null = null; + + const options: WhatsAppAdapterOptions = { + config: { + sessionDir: "/tmp/whatsapp-test-session", + serverBaseUrl: "https://openthreads.test", + logLevel: "silent", + }, + onQRCode: mock(), + onConnected: mock(), + onDisconnected: mock(), + ...overrides, + }; + + const adapter = new WhatsAppAdapter(options); + + // Patch the session manager's internal connect to use our mock socket. + (adapter as unknown as { session: { connect: () => Promise; getSocket: () => unknown; disconnect: () => Promise } }).session = { + connect: async () => { + // Simulate the session emitting onSocketReady with our mock. + socketReadyCb = (adapter as unknown as { attachListeners: (s: unknown) => void }).attachListeners?.bind(adapter) ?? null; + // Directly call the private attachListeners via prototype to wire events. + (WhatsAppAdapter.prototype as unknown as { attachListeners: (s: unknown) => void }) + .attachListeners + ?.call(adapter, mockSocket); + + // Expose the mock socket on the session mock. + (adapter as unknown as { session: { getSocket: () => unknown } }).session.getSocket = () => mockSocket; + }, + getSocket: () => mockSocket, + disconnect: async () => { + await mockSocket.logout(); + }, + }; + + return { adapter, mockSocket, options }; +} + +// --------------------------------------------------------------------------- +// Capabilities +// --------------------------------------------------------------------------- + +describe("WhatsAppAdapter capabilities", () => { + it("reports the correct capabilities object", () => { + const { adapter } = createTestAdapter(); + expect(adapter.capabilities).toEqual(WHATSAPP_CAPABILITIES); + }); + + it("reports type = 'whatsapp'", () => { + const { adapter } = createTestAdapter(); + expect(adapter.type).toBe("whatsapp"); + }); + + it("reports threads = false", () => { + const { adapter } = createTestAdapter(); + expect(adapter.capabilities.threads).toBe(false); + }); + + it("reports buttons = true (limited)", () => { + const { adapter } = createTestAdapter(); + expect(adapter.capabilities.buttons).toBe(true); + }); + + it("reports selectMenus = false", () => { + const { adapter } = createTestAdapter(); + expect(adapter.capabilities.selectMenus).toBe(false); + }); + + it("reports replyMessages = true", () => { + const { adapter } = createTestAdapter(); + expect(adapter.capabilities.replyMessages).toBe(true); + }); + + it("reports dms = true", () => { + const { adapter } = createTestAdapter(); + expect(adapter.capabilities.dms).toBe(true); + }); + + it("reports fileUpload = true", () => { + const { adapter } = createTestAdapter(); + expect(adapter.capabilities.fileUpload).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Outbound messages +// --------------------------------------------------------------------------- + +describe("WhatsAppAdapter sendMessage", () => { + it("sends a text message to the correct JID", async () => { + const { adapter, mockSocket } = createTestAdapter(); + await adapter.initialize(); + + await adapter.sendMessage({ + targetId: "15551234567", + content: { type: "text", text: "Hello, world!" }, + }); + + expect(mockSocket.sendMessage).toHaveBeenCalledTimes(1); + const [jid, content] = (mockSocket.sendMessage as ReturnType).mock.calls[0] as [string, unknown]; + expect(jid).toBe("15551234567@s.whatsapp.net"); + expect((content as { text: string }).text).toBe("Hello, world!"); + }); + + it("returns a SentMessage with id and threadId", async () => { + const { adapter } = createTestAdapter(); + await adapter.initialize(); + + const result = await adapter.sendMessage({ + targetId: "15551234567", + content: { type: "text", text: "Hi" }, + }); + + expect(result).toHaveProperty("id"); + expect(result).toHaveProperty("threadId"); + expect(typeof result.id).toBe("string"); + expect(typeof result.threadId).toBe("string"); + }); + + it("sends a buttons message with at most 3 buttons", async () => { + const { adapter, mockSocket } = createTestAdapter(); + await adapter.initialize(); + + await adapter.sendMessage({ + targetId: "15551234567", + content: { + type: "buttons", + body: "Choose an option", + buttons: [ + { id: "a", label: "One" }, + { id: "b", label: "Two" }, + { id: "c", label: "Three" }, + { id: "d", label: "Four" }, // should be truncated + ], + }, + }); + + const [, content] = (mockSocket.sendMessage as ReturnType).mock.calls[0] as [string, { buttons: unknown[] }]; + expect((content as { buttons: unknown[] }).buttons).toHaveLength(3); + }); + + it("uses @g.us JID for group targets containing a dash", async () => { + const { adapter, mockSocket } = createTestAdapter(); + await adapter.initialize(); + + await adapter.sendMessage({ + targetId: "1234567890-1680000000", + content: { type: "text", text: "Hi group!" }, + }); + + const [jid] = (mockSocket.sendMessage as ReturnType).mock.calls[0] as [string]; + expect(jid).toBe("1234567890-1680000000@g.us"); + }); + + it("passes through targets that already include @", async () => { + const { adapter, mockSocket } = createTestAdapter(); + await adapter.initialize(); + + await adapter.sendMessage({ + targetId: "15551234567@s.whatsapp.net", + content: { type: "text", text: "Hi" }, + }); + + const [jid] = (mockSocket.sendMessage as ReturnType).mock.calls[0] as [string]; + expect(jid).toBe("15551234567@s.whatsapp.net"); + }); + + it("sends multiple content items sequentially", async () => { + const { adapter, mockSocket } = createTestAdapter(); + await adapter.initialize(); + + await adapter.sendMessage({ + targetId: "15551234567", + content: [ + { type: "text", text: "First" }, + { type: "text", text: "Second" }, + ], + }); + + expect(mockSocket.sendMessage).toHaveBeenCalledTimes(2); + }); +}); + +// --------------------------------------------------------------------------- +// A2H — AUTHORIZE +// --------------------------------------------------------------------------- + +describe("WhatsAppAdapter renderA2H — AUTHORIZE", () => { + it("uses buttons for AUTHORIZE with ≤3 options (method 1)", async () => { + const { adapter, mockSocket } = createTestAdapter(); + await adapter.initialize(); + + await adapter.renderA2H( + { + intent: "AUTHORIZE", + context: { + action: "deploy-to-production", + options: ["Approve", "Reject"], + }, + traceId: "trace_001", + }, + { targetId: "15551234567" }, + ); + + const [, content] = (mockSocket.sendMessage as ReturnType).mock.calls[0] as [string, { buttons?: unknown[]; text?: string }]; + // Should use buttons (method 1) + expect(content).toHaveProperty("buttons"); + expect((content as { buttons: unknown[] }).buttons).toHaveLength(2); + }); + + it("falls back to external form for AUTHORIZE with >3 options (method 3)", async () => { + const { adapter, mockSocket } = createTestAdapter(); + await adapter.initialize(); + + await adapter.renderA2H( + { + intent: "AUTHORIZE", + context: { + action: "pick-region", + options: ["us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1"], + }, + traceId: "trace_002", + }, + { targetId: "15551234567" }, + ); + + const [, content] = (mockSocket.sendMessage as ReturnType).mock.calls[0] as [string, { text?: string; buttons?: unknown[] }]; + // Should NOT use buttons — should include a form URL in the text + expect(content).not.toHaveProperty("buttons"); + expect((content as { text: string }).text).toContain("openthreads.test"); + }); + + it("includes the trace ID in the form URL for AUTHORIZE method-3", async () => { + const { adapter, mockSocket } = createTestAdapter(); + await adapter.initialize(); + + await adapter.renderA2H( + { + intent: "AUTHORIZE", + context: { action: "approve", options: ["A", "B", "C", "D"] }, + traceId: "trace_xyz", + }, + { targetId: "15551234567" }, + ); + + const [, content] = (mockSocket.sendMessage as ReturnType).mock.calls[0] as [string, { text: string }]; + expect((content as { text: string }).text).toContain("trace_xyz"); + }); +}); + +// --------------------------------------------------------------------------- +// A2H — COLLECT +// --------------------------------------------------------------------------- + +describe("WhatsAppAdapter renderA2H — COLLECT", () => { + it("always falls back to external form (method 3)", async () => { + const { adapter, mockSocket } = createTestAdapter(); + await adapter.initialize(); + + await adapter.renderA2H( + { + intent: "COLLECT", + context: { question: "What is your shipping address?" }, + traceId: "trace_collect_001", + }, + { targetId: "15551234567" }, + ); + + const [, content] = (mockSocket.sendMessage as ReturnType).mock.calls[0] as [string, { text: string; buttons?: unknown[] }]; + expect(content).not.toHaveProperty("buttons"); + expect((content as { text: string }).text).toContain("openthreads.test"); + }); + + it("sends a plain message when serverBaseUrl is not configured", async () => { + const { adapter, mockSocket } = createTestAdapter({ + config: { + sessionDir: "/tmp/session", + logLevel: "silent", + // no serverBaseUrl + }, + }); + await adapter.initialize(); + + await adapter.renderA2H( + { + intent: "COLLECT", + context: { question: "Your name?" }, + traceId: "trace_no_url", + }, + { targetId: "15551234567" }, + ); + + const [, content] = (mockSocket.sendMessage as ReturnType).mock.calls[0] as [string, { text: string }]; + expect((content as { text: string }).text).toBeTruthy(); + // Should not contain undefined/null URL + expect((content as { text: string }).text).not.toContain("undefined"); + expect((content as { text: string }).text).not.toContain("null"); + }); +}); + +// --------------------------------------------------------------------------- +// Inbound messages +// --------------------------------------------------------------------------- + +describe("WhatsAppAdapter inbound messages", () => { + it("dispatches text messages to the registered handler", async () => { + const { adapter, mockSocket } = createTestAdapter(); + await adapter.initialize(); + + const received: unknown[] = []; + adapter.onInboundMessage(async (msg) => { + received.push(msg); + }); + + // Simulate an inbound text message from Baileys. + mockSocket.ev.emit("messages.upsert", { + type: "notify", + messages: [ + { + key: { + remoteJid: "15559876543@s.whatsapp.net", + id: "msg_inbound_001", + fromMe: false, + }, + message: { conversation: "Hello!" }, + messageTimestamp: 1_700_000_000, + pushName: "Alice", + }, + ], + }); + + // Allow microtask queue to drain. + await Promise.resolve(); + + expect(received).toHaveLength(1); + const msg = received[0] as { content: { type: string; text: string }; senderName: string }; + expect(msg.content.type).toBe("text"); + expect(msg.content.text).toBe("Hello!"); + expect(msg.senderName).toBe("Alice"); + }); + + it("ignores messages sent by the bot (fromMe = true)", async () => { + const { adapter, mockSocket } = createTestAdapter(); + await adapter.initialize(); + + const received: unknown[] = []; + adapter.onInboundMessage(async (msg) => { + received.push(msg); + }); + + mockSocket.ev.emit("messages.upsert", { + type: "notify", + messages: [ + { + key: { + remoteJid: "15559876543@s.whatsapp.net", + id: "msg_outbound", + fromMe: true, + }, + message: { conversation: "This is from us" }, + messageTimestamp: 1_700_000_000, + }, + ], + }); + + await Promise.resolve(); + expect(received).toHaveLength(0); + }); + + it("ignores messages.upsert events with type != notify", async () => { + const { adapter, mockSocket } = createTestAdapter(); + await adapter.initialize(); + + const received: unknown[] = []; + adapter.onInboundMessage(async (msg) => { + received.push(msg); + }); + + mockSocket.ev.emit("messages.upsert", { + type: "append", // not "notify" + messages: [ + { + key: { remoteJid: "15559876543@s.whatsapp.net", id: "m1", fromMe: false }, + message: { conversation: "Hi" }, + messageTimestamp: 1_700_000_000, + }, + ], + }); + + await Promise.resolve(); + expect(received).toHaveLength(0); + }); + + it("extracts threadId from quoted message context", async () => { + const { adapter, mockSocket } = createTestAdapter(); + await adapter.initialize(); + + const received: unknown[] = []; + adapter.onInboundMessage(async (msg) => { + received.push(msg); + }); + + mockSocket.ev.emit("messages.upsert", { + type: "notify", + messages: [ + { + key: { + remoteJid: "15559876543@s.whatsapp.net", + id: "reply_msg", + fromMe: false, + }, + message: { + extendedTextMessage: { + text: "Yes, I agree", + contextInfo: { stanzaId: "original_msg_id" }, + }, + }, + messageTimestamp: 1_700_000_001, + }, + ], + }); + + await Promise.resolve(); + const msg = received[0] as { threadId: string; replyToId: string }; + expect(msg.threadId).toBe("original_msg_id"); + expect(msg.replyToId).toBe("original_msg_id"); + }); +}); + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +describe("WhatsAppAdapter lifecycle", () => { + it("calls logout on destroy", async () => { + const { adapter, mockSocket } = createTestAdapter(); + await adapter.initialize(); + await adapter.destroy(); + expect(mockSocket.logout).toHaveBeenCalledTimes(1); + }); + + it("clears inbound handler on destroy", async () => { + const { adapter, mockSocket } = createTestAdapter(); + await adapter.initialize(); + + const received: unknown[] = []; + adapter.onInboundMessage(async (msg) => { + received.push(msg); + }); + + await adapter.destroy(); + + // After destroy, sending a message should not invoke the handler. + mockSocket.ev.emit("messages.upsert", { + type: "notify", + messages: [ + { + key: { remoteJid: "1@s.whatsapp.net", id: "m", fromMe: false }, + message: { conversation: "Late message" }, + }, + ], + }); + + await Promise.resolve(); + expect(received).toHaveLength(0); + }); +}); diff --git a/packages/channels/whatsapp/src/index.ts b/packages/channels/whatsapp/src/index.ts new file mode 100644 index 0000000..9d19ae9 --- /dev/null +++ b/packages/channels/whatsapp/src/index.ts @@ -0,0 +1,59 @@ +/** + * @openthreads/channel-whatsapp + * + * WhatsApp channel adapter for OpenThreads, built on the Baileys library + * (WhatsApp Web protocol). + * + * ## Quick start + * + * ```ts + * import { WhatsAppAdapter } from "@openthreads/channel-whatsapp"; + * + * const adapter = new WhatsAppAdapter({ + * config: { + * sessionDir: "./whatsapp-session", + * serverBaseUrl: "https://openthreads.example.com", + * }, + * onQRCode: (qr) => { + * // Render QR code in terminal, save as image, or surface in the UI. + * console.log("Scan QR:", qr); + * }, + * onConnected: (phone) => console.log("WhatsApp connected:", phone), + * onDisconnected: (reason) => console.warn("WhatsApp disconnected:", reason), + * }); + * + * await adapter.initialize(); + * + * adapter.onInboundMessage(async (msg) => { + * console.log("Received:", msg); + * }); + * + * // Send a text message + * await adapter.sendMessage({ + * targetId: "15551234567", + * content: { type: "text", text: "Hello from OpenThreads!" }, + * }); + * + * // Render an A2H AUTHORIZE intent as WhatsApp buttons (≤3 options) + * await adapter.renderA2H( + * { + * intent: "AUTHORIZE", + * context: { action: "deploy-to-production", options: ["Approve", "Reject"] }, + * traceId: "ot_trace_abc123", + * }, + * { targetId: "15551234567" }, + * ); + * ``` + */ + +export { WhatsAppAdapter } from "./WhatsAppAdapter.js"; +export { SessionManager } from "./SessionManager.js"; +export { + WHATSAPP_CAPABILITIES, + WHATSAPP_MAX_BUTTONS, +} from "./types.js"; +export type { + WhatsAppConfig, + WhatsAppAdapterOptions, + PendingCapture, +} from "./types.js"; diff --git a/packages/channels/whatsapp/src/types.ts b/packages/channels/whatsapp/src/types.ts new file mode 100644 index 0000000..20fb567 --- /dev/null +++ b/packages/channels/whatsapp/src/types.ts @@ -0,0 +1,112 @@ +import type { ChannelCapabilities } from "@openthreads/core"; + +/** + * Configuration for the WhatsApp adapter. + */ +export interface WhatsAppConfig { + /** + * Directory where Baileys will persist the multi-file auth state (credentials, + * keys, etc.). Must be writable. Each WhatsApp account should use a distinct + * directory so multiple instances can coexist. + */ + sessionDir: string; + + /** + * Base URL of the OpenThreads server, used to build external form links for + * A2H method-3 fallback (e.g. "https://openthreads.example.com"). + * When absent, method-3 messages still send but without a working URL. + */ + serverBaseUrl?: string; + + /** + * Pino log level used for the internal Baileys logger. + * Defaults to "silent" so Baileys does not pollute the application logs. + */ + logLevel?: "trace" | "debug" | "info" | "warn" | "error" | "silent"; + + /** + * Base interval (ms) for the first reconnection attempt. + * Subsequent attempts use exponential backoff capped at 30 s. + * Defaults to 1 000 ms. + */ + reconnectIntervalMs?: number; + + /** + * Maximum number of automatic reconnection attempts before giving up. + * Defaults to 10. + */ + maxReconnectAttempts?: number; + + /** + * Timeout (ms) to wait for a method-2 quoted-reply response before the + * pending capture expires and the caller receives an error. + * Defaults to 300 000 ms (5 minutes). + */ + replyTimeoutMs?: number; +} + +/** + * Constructor options for {@link WhatsAppAdapter}. + */ +export interface WhatsAppAdapterOptions { + config: WhatsAppConfig; + + /** + * Called with the raw QR-code string when the device needs to be paired. + * Consumers can render it as an ASCII QR in the terminal, as an image, or + * expose it via an API endpoint — the adapter is agnostic. + */ + onQRCode?: (qr: string) => void | Promise; + + /** + * Called once the connection reaches the "open" state. + * @param phoneNumber The WhatsApp account number that is now connected. + */ + onConnected?: (phoneNumber: string) => void | Promise; + + /** + * Called whenever the connection closes. + * @param reason Human-readable description of the disconnect reason. + */ + onDisconnected?: (reason: string) => void | Promise; +} + +/** + * WhatsApp channel capabilities as reported by the adapter. + * + * - threads: false — WhatsApp has no native thread concept; the adapter + * emulates virtual threads via quoted-reply chains. + * - buttons: true (limited) — WhatsApp interactive messages support up to + * 3 quick-reply buttons. + * - selectMenus: false — WhatsApp lists are row-based, not + + + + + + )} + + {step === 'credentials' && ( + <> + + + + + + + + )} + + {step === 'test' && ( +
+ {testResult === 'idle' && ( + <> + + Click “Test Connection” to validate your credentials reference before + saving. + + + + )} + {testResult === 'success' && ( + + + Credentials reference looks valid + + + )} + {testResult === 'error' && ( + + Test failed — check your credentials reference + + + )} +
+ )} + + +
+ + {step !== 'test' && ( + + )} +
+ + + ); +} + +function EditChannelModal({ + channel, + onClose, + onUpdated, +}: { + channel: Channel; + onClose: () => void; + onUpdated: (channel: Channel) => void; +}) { + const [form] = Form.useForm(); + const [saving, setSaving] = useState(false); + const [messageApi, contextHolder] = message.useMessage(); + + useEffect(() => { + form.setFieldsValue({ + credentialsRef: channel.credentialsRef, + metadata: channel.metadata ? JSON.stringify(channel.metadata, null, 2) : '', + }); + }, [channel, form]); + + const handleSave = async () => { + setSaving(true); + try { + const values = form.getFieldsValue() as { credentialsRef: string; metadata: string }; + let metadata: Record | undefined; + if (values.metadata) { + try { + metadata = JSON.parse(values.metadata) as Record; + } catch { + messageApi.error('Metadata must be valid JSON'); + setSaving(false); + return; + } + } + const updated = await channelApi.update(channel.id, { + credentialsRef: values.credentialsRef, + ...(metadata !== undefined ? { metadata } : {}), + }); + onUpdated(updated); + onClose(); + } catch (err) { + messageApi.error(err instanceof Error ? err.message : 'Update failed'); + } finally { + setSaving(false); + } + }; + + return ( + <> + {contextHolder} + +
+ + {channel.platform} + + + + + + + +
+
+ + ); +} + +export default function ChannelsPage() { + const [channels, setChannels] = useState([]); + const [loading, setLoading] = useState(true); + const [wizardOpen, setWizardOpen] = useState(false); + const [editChannel, setEditChannel] = useState(null); + const [messageApi, contextHolder] = message.useMessage(); + + const load = useCallback(() => { + setLoading(true); + channelApi + .list() + .then(setChannels) + .catch(() => messageApi.error('Failed to load channels')) + .finally(() => setLoading(false)); + }, [messageApi]); + + useEffect(() => { + load(); + }, [load]); + + const handleDelete = async (id: string) => { + try { + await channelApi.delete(id); + messageApi.success('Channel deleted'); + setChannels((prev) => prev.filter((c) => c.id !== id)); + } catch (err) { + messageApi.error(err instanceof Error ? err.message : 'Delete failed'); + } + }; + + const columns = [ + { + title: 'ID', + dataIndex: 'id', + key: 'id', + render: (id: string) => {id}, + }, + { + title: 'Platform', + dataIndex: 'platform', + key: 'platform', + render: (p: string) => {p}, + }, + { + title: 'Credentials Ref', + dataIndex: 'credentialsRef', + key: 'credentialsRef', + render: (ref: string) => ( + + {ref} + + ), + }, + { + title: 'API Key', + dataIndex: 'apiKey', + key: 'apiKey', + render: (key: string) => , + }, + { + title: 'Status', + key: 'status', + render: () => Unknown} />, + }, + { + title: 'Actions', + key: 'actions', + render: (_: unknown, record: Channel) => ( + + + handleDelete(record.id)} + okText="Delete" + okButtonProps={{ danger: true }} + > + + + + ), + }, + ]; + + return ( + <> + {contextHolder} + + + + Channels + + + + + + + + + + + + setWizardOpen(false)} + onCreated={(channel) => { + messageApi.success(`Channel "${channel.id}" created`); + setChannels((prev) => [...prev, channel]); + }} + /> + + {editChannel && ( + setEditChannel(null)} + onUpdated={(updated) => { + messageApi.success('Channel updated'); + setChannels((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); + }} + /> + )} + + ); +} diff --git a/packages/server/src/app/dashboard/layout.tsx b/packages/server/src/app/dashboard/layout.tsx new file mode 100644 index 0000000..a6d3074 --- /dev/null +++ b/packages/server/src/app/dashboard/layout.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { + ApiOutlined, + BranchesOutlined, + MessageOutlined, + SettingOutlined, + DashboardOutlined, +} from '@ant-design/icons'; +import { ConfigProvider, Layout, Menu, Typography, theme } from 'antd'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import type { ReactNode } from 'react'; + +const { Sider, Content } = Layout; +const { Text } = Typography; + +const NAV_ITEMS = [ + { + key: '/dashboard', + icon: , + label: Overview, + exact: true, + }, + { + key: '/dashboard/channels', + icon: , + label: Channels, + }, + { + key: '/dashboard/routes', + icon: , + label: Routes, + }, + { + key: '/dashboard/threads', + icon: , + label: Threads, + }, + { + key: '/dashboard/settings', + icon: , + label: Settings, + }, +]; + +export default function DashboardLayout({ children }: { children: ReactNode }) { + const pathname = usePathname(); + + const selectedKey = + NAV_ITEMS.find((item) => + item.exact + ? pathname === item.key + : pathname.startsWith(item.key) && item.key !== '/dashboard', + )?.key ?? (pathname === '/dashboard' ? '/dashboard' : ''); + + return ( + + + +
+ + OpenThreads + +
+ ({ key, icon, label }))} + /> + + + + {children} + + + + + ); +} diff --git a/packages/server/src/app/dashboard/page.tsx b/packages/server/src/app/dashboard/page.tsx new file mode 100644 index 0000000..99fa196 --- /dev/null +++ b/packages/server/src/app/dashboard/page.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { ApiOutlined, BranchesOutlined, MessageOutlined } from '@ant-design/icons'; +import { Card, Col, Row, Statistic, Typography } from 'antd'; +import { useEffect, useState } from 'react'; +import { channelApi, routeApi, threadApi } from '@/lib/api-client'; + +const { Title, Text } = Typography; + +export default function DashboardOverview() { + const [channelCount, setChannelCount] = useState(null); + const [routeCount, setRouteCount] = useState(null); + const [threadCount, setThreadCount] = useState(null); + + useEffect(() => { + channelApi.list().then((c) => setChannelCount(c.length)).catch(() => setChannelCount(0)); + routeApi.list().then((r) => setRouteCount(r.length)).catch(() => setRouteCount(0)); + threadApi + .list({ limit: 1000 }) + .then((t) => setThreadCount(t.length)) + .catch(() => setThreadCount(0)); + }, []); + + return ( +
+ + Overview + + OpenThreads management dashboard + + +
+ + } + /> + + + + + } + /> + + + + + } + /> + + + + + ); +} diff --git a/packages/server/src/app/dashboard/routes/RouteFlowCanvas.tsx b/packages/server/src/app/dashboard/routes/RouteFlowCanvas.tsx new file mode 100644 index 0000000..c3d0ddf --- /dev/null +++ b/packages/server/src/app/dashboard/routes/RouteFlowCanvas.tsx @@ -0,0 +1,336 @@ +'use client'; + +/** + * ReactFlow canvas for visualizing routes. + * Channel nodes → Route nodes → Recipient nodes + * + * This file is dynamically imported (no SSR) from the routes page. + */ + +import { + Background, + BackgroundVariant, + Controls, + Handle, + MiniMap, + Panel, + Position, + ReactFlow, + addEdge, + useEdgesState, + useNodesState, + type Connection, + type Edge, + type Node, + type NodeProps, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import { Tag, Tooltip } from 'antd'; +import { useCallback, useEffect } from 'react'; +import type { Channel, Recipient, Route } from '@/lib/api-client'; + +// ─── Node data types ────────────────────────────────────────────────────────── + +interface ChannelNodeData extends Record { + channel: Channel; +} + +interface RecipientNodeData extends Record { + recipient: Recipient; +} + +interface RouteNodeData extends Record { + route: Route; + highlighted: boolean; + onEdit: (route: Route) => void; +} + +// ─── Custom node components ─────────────────────────────────────────────────── + +function ChannelNode({ data }: NodeProps) { + const { channel } = data as ChannelNodeData; + return ( +
+
CHANNEL
+
{channel.id}
+ + {channel.platform} + + +
+ ); +} + +function RecipientNode({ data }: NodeProps) { + const { recipient } = data as RecipientNodeData; + const shortUrl = recipient.webhookUrl.replace(/^https?:\/\//, '').slice(0, 28); + return ( +
+ +
RECIPIENT
+
{recipient.id}
+ +
{shortUrl}…
+
+
+ ); +} + +function RouteNode({ data }: NodeProps) { + const { route, highlighted, onEdit } = data as RouteNodeData; + const criteriaItems: string[] = []; + if (route.criteria.channelId) criteriaItems.push(`ch:${route.criteria.channelId}`); + if (route.criteria.isDm) criteriaItems.push('DM'); + if (route.criteria.isMention) criteriaItems.push('mention'); + if (route.criteria.senderId) criteriaItems.push(`from:${route.criteria.senderId}`); + if (criteriaItems.length === 0) criteriaItems.push('any'); + + return ( +
onEdit(route)} + style={{ + background: highlighted ? '#fffbe6' : '#fff7e6', + border: `2px solid ${highlighted ? '#52c41a' : '#fa8c16'}`, + borderRadius: 8, + padding: '10px 14px', + minWidth: 160, + cursor: 'pointer', + boxShadow: highlighted ? '0 0 0 3px rgba(82,196,26,0.3)' : undefined, + }} + > + +
+ ROUTE P{route.priority} +
+
{route.id}
+
+ {criteriaItems.map((item) => ( + + {item} + + ))} +
+ {!route.enabled && ( + + disabled + + )} + +
+ ); +} + +const NODE_TYPES = { + channel: ChannelNode, + recipient: RecipientNode, + route: RouteNode, +}; + +// ─── Layout helpers ─────────────────────────────────────────────────────────── + +const COL_X = { channel: 0, route: 320, recipient: 650 }; +const ROW_H = 140; +const PADDING_Y = 40; + +function buildGraph( + routes: Route[], + channels: Channel[], + recipients: Recipient[], + highlightedRouteIds: string[], + onEdit: (route: Route) => void, +): { nodes: Node[]; edges: Edge[] } { + const nodes: Node[] = []; + const edges: Edge[] = []; + + channels.forEach((c, i) => { + nodes.push({ + id: `channel-${c.id}`, + type: 'channel', + position: { x: COL_X.channel, y: PADDING_Y + i * ROW_H }, + data: { channel: c }, + }); + }); + + recipients.forEach((r, i) => { + nodes.push({ + id: `recipient-${r.id}`, + type: 'recipient', + position: { x: COL_X.recipient, y: PADDING_Y + i * ROW_H }, + data: { recipient: r }, + }); + }); + + routes.forEach((route, i) => { + nodes.push({ + id: `route-${route.id}`, + type: 'route', + position: { x: COL_X.route, y: PADDING_Y + i * ROW_H }, + data: { + route, + highlighted: highlightedRouteIds.includes(route.id), + onEdit, + }, + }); + + // Edge: channel → route (if criteria.channelId is set) + if (route.criteria.channelId) { + const sourceId = `channel-${route.criteria.channelId}`; + if (nodes.some((n) => n.id === sourceId)) { + edges.push({ + id: `e-ch-${route.id}`, + source: sourceId, + target: `route-${route.id}`, + animated: highlightedRouteIds.includes(route.id), + style: { stroke: '#1677ff', strokeWidth: 2 }, + }); + } + } + + // Edge: route → recipient + const targetId = `recipient-${route.recipientId}`; + if (nodes.some((n) => n.id === targetId)) { + edges.push({ + id: `e-rt-${route.id}`, + source: `route-${route.id}`, + target: targetId, + animated: highlightedRouteIds.includes(route.id), + style: { stroke: '#52c41a', strokeWidth: 2 }, + }); + } + }); + + return { nodes, edges }; +} + +// ─── Main canvas component ─────────────────────────────────────────────────── + +interface RouteFlowCanvasProps { + routes: Route[]; + channels: Channel[]; + recipients: Recipient[]; + highlightedRouteIds: string[]; + onEditRoute: (route: Route) => void; + onCreateRoute: (defaults: Partial) => void; + onDeleteRoute: (id: string) => void; +} + +export default function RouteFlowCanvas({ + routes, + channels, + recipients, + highlightedRouteIds, + onEditRoute, + onCreateRoute, +}: RouteFlowCanvasProps) { + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + const onConnect = useCallback( + (params: Connection) => { + // Dragging from a channel node to a recipient node → open create drawer + if (params.source?.startsWith('channel-') && params.target?.startsWith('recipient-')) { + const channelId = params.source.replace('channel-', ''); + const recipientId = params.target.replace('recipient-', ''); + onCreateRoute({ + criteria: { channelId }, + recipientId, + priority: routes.length * 10 + 10, + }); + } + setEdges((eds) => addEdge(params, eds)); + }, + [routes, onCreateRoute, setEdges], + ); + + useEffect(() => { + const { nodes: n, edges: e } = buildGraph( + routes, + channels, + recipients, + highlightedRouteIds, + onEditRoute, + ); + setNodes(n); + setEdges(e); + }, [routes, channels, recipients, highlightedRouteIds, onEditRoute, setNodes, setEdges]); + + const isEmpty = routes.length === 0 && channels.length === 0 && recipients.length === 0; + + return ( +
+ + + + { + if (n.type === 'channel') return '#1677ff'; + if (n.type === 'recipient') return '#52c41a'; + return '#fa8c16'; + }} + /> + {isEmpty && ( + +
+ Add channels, recipients, and routes to see the flow visualization +
+
+ )} + {!isEmpty && routes.length === 0 && ( + +
+ Drag from a channel node to a recipient node to create a route +
+
+ )} +
+
+ ); +} diff --git a/packages/server/src/app/dashboard/routes/page.tsx b/packages/server/src/app/dashboard/routes/page.tsx new file mode 100644 index 0000000..8ca04c6 --- /dev/null +++ b/packages/server/src/app/dashboard/routes/page.tsx @@ -0,0 +1,520 @@ +'use client'; + +import { + DeleteOutlined, + EditOutlined, + ExperimentOutlined, + PlusOutlined, +} from '@ant-design/icons'; +import { + Alert, + Badge, + Button, + Card, + Checkbox, + Col, + Divider, + Drawer, + Form, + Input, + InputNumber, + message, + Popconfirm, + Row, + Select, + Space, + Tag, + Typography, +} from 'antd'; +import dynamic from 'next/dynamic'; +import { useCallback, useEffect, useState } from 'react'; +import { channelApi, recipientApi, routeApi } from '@/lib/api-client'; +import type { Channel, Recipient, Route, RouteCriteria, CreateRouteInput } from '@/lib/api-client'; + +const { Title, Text } = Typography; + +// Lazy-load the ReactFlow editor to avoid SSR issues +const RouteFlowCanvas = dynamic(() => import('./RouteFlowCanvas'), { + ssr: false, + loading: () => ( +
+ Loading route editor… +
+ ), +}); + +const CRITERIA_LABELS: Record = { + channelId: 'Channel', + groupId: 'Group', + isDm: 'Direct Message', + nativeThreadId: 'Native Thread', + isMention: 'Mention', + senderId: 'Sender', + contentPattern: 'Content Pattern (regex)', +}; + +function criteriaToTags(criteria: RouteCriteria): string[] { + const tags: string[] = []; + if (criteria.channelId) tags.push(`channel:${criteria.channelId}`); + if (criteria.groupId) tags.push(`group:${criteria.groupId}`); + if (criteria.isDm) tags.push('DM'); + if (criteria.isMention) tags.push('mention'); + if (criteria.senderId) tags.push(`sender:${criteria.senderId}`); + if (criteria.contentPattern) tags.push(`pattern:${criteria.contentPattern}`); + if (tags.length === 0) tags.push('any'); + return tags; +} + +function RouteForm({ + initial, + channels, + recipients, + onSave, + onCancel, + saving, +}: { + initial?: Partial; + channels: Channel[]; + recipients: Recipient[]; + onSave: (values: CreateRouteInput) => void; + onCancel: () => void; + saving: boolean; +}) { + const [form] = Form.useForm(); + + useEffect(() => { + if (initial) { + form.setFieldsValue({ + id: initial.id, + priority: initial.priority ?? 10, + recipientId: initial.recipientId, + enabled: initial.enabled ?? true, + channelId: initial.criteria?.channelId, + groupId: initial.criteria?.groupId, + isDm: initial.criteria?.isDm, + isMention: initial.criteria?.isMention, + senderId: initial.criteria?.senderId, + contentPattern: initial.criteria?.contentPattern, + nativeThreadId: initial.criteria?.nativeThreadId, + }); + } else { + form.setFieldsValue({ priority: 10, enabled: true }); + } + }, [initial, form]); + + const handleFinish = (values: Record) => { + const criteria: RouteCriteria = {}; + if (values.channelId) criteria.channelId = values.channelId as string; + if (values.groupId) criteria.groupId = values.groupId as string; + if (values.isDm) criteria.isDm = true; + if (values.isMention) criteria.isMention = true; + if (values.senderId) criteria.senderId = values.senderId as string; + if (values.contentPattern) criteria.contentPattern = values.contentPattern as string; + if (values.nativeThreadId) criteria.nativeThreadId = values.nativeThreadId as string; + + onSave({ + id: values.id as string, + recipientId: values.recipientId as string, + priority: values.priority as number, + enabled: (values.enabled as boolean) ?? true, + criteria, + }); + }; + + return ( +
+ + + + + + + + + + ({ value: c.id, label: `${c.id} (${c.platform})` }))} + /> + + + + + + + +
+ + {CRITERIA_LABELS.isDm} + + + + + {CRITERIA_LABELS.isMention} + + + + + + + + + + + + + + + + +
+ + +
+ + ); +} + +function TestRoutePanel({ + open, + channels, + onClose, + onResult, +}: { + open: boolean; + channels: Channel[]; + onClose: () => void; + onResult: (matchingIds: string[]) => void; +}) { + const [form] = Form.useForm(); + const [testing, setTesting] = useState(false); + const [messageApi, contextHolder] = message.useMessage(); + + const handleTest = async () => { + setTesting(true); + try { + const values = form.getFieldsValue() as Record; + const criteria: Partial = {}; + if (values.channelId) criteria.channelId = values.channelId as string; + if (values.isDm) criteria.isDm = true; + if (values.isMention) criteria.isMention = true; + if (values.senderId) criteria.senderId = values.senderId as string; + + const result = await routeApi.test(criteria); + onResult(result.matchingRouteIds); + if (result.matchingRouteIds.length === 0) { + messageApi.info('No routes matched this message'); + } else { + messageApi.success( + `${result.matchingRouteIds.length} route(s) matched — highlighted in canvas`, + ); + } + } catch (err) { + messageApi.error(err instanceof Error ? err.message : 'Test failed'); + } finally { + setTesting(false); + } + }; + + return ( + <> + {contextHolder} + } + > + Run Test + + } + > + +
+ + + + +
+ + ); +} + +export default function RoutesPage() { + const [routes, setRoutes] = useState([]); + const [channels, setChannels] = useState([]); + const [recipients, setRecipients] = useState([]); + const [loading, setLoading] = useState(true); + const [drawerOpen, setDrawerOpen] = useState(false); + const [editRoute, setEditRoute] = useState(null); + const [saving, setSaving] = useState(false); + const [testPanelOpen, setTestPanelOpen] = useState(false); + const [highlightedRouteIds, setHighlightedRouteIds] = useState([]); + const [newRouteDefaults, setNewRouteDefaults] = useState>({}); + const [messageApi, contextHolder] = message.useMessage(); + + const load = useCallback(() => { + setLoading(true); + Promise.all([routeApi.list(), channelApi.list(), recipientApi.list()]) + .then(([r, c, rec]) => { + setRoutes(r); + setChannels(c); + setRecipients(rec); + }) + .catch(() => messageApi.error('Failed to load data')) + .finally(() => setLoading(false)); + }, [messageApi]); + + useEffect(() => { + load(); + }, [load]); + + const openCreateDrawer = (defaults?: Partial) => { + setEditRoute(null); + setNewRouteDefaults(defaults ?? {}); + setDrawerOpen(true); + }; + + const openEditDrawer = (route: Route) => { + setEditRoute(route); + setNewRouteDefaults({}); + setDrawerOpen(true); + }; + + const handleSave = async (values: CreateRouteInput) => { + setSaving(true); + try { + if (editRoute) { + const updated = await routeApi.update(editRoute.id, { + criteria: values.criteria, + recipientId: values.recipientId, + priority: values.priority, + enabled: values.enabled, + }); + setRoutes((prev) => prev.map((r) => (r.id === updated.id ? updated : r))); + messageApi.success('Route updated'); + } else { + const created = await routeApi.create(values); + setRoutes((prev) => [...prev, created].sort((a, b) => a.priority - b.priority)); + messageApi.success('Route created'); + } + setDrawerOpen(false); + } catch (err) { + messageApi.error(err instanceof Error ? err.message : 'Save failed'); + } finally { + setSaving(false); + } + }; + + const handleDelete = async (id: string) => { + try { + await routeApi.delete(id); + setRoutes((prev) => prev.filter((r) => r.id !== id)); + messageApi.success('Route deleted'); + } catch (err) { + messageApi.error(err instanceof Error ? err.message : 'Delete failed'); + } + }; + + return ( + <> + {contextHolder} + +
+ + Routes + + + + + + + + + + + {/* ReactFlow Canvas */} + + openCreateDrawer(defaults)} + onDeleteRoute={handleDelete} + /> + + + {/* Route List (priority-ordered) */} + + {routes.length === 0 ? ( + + No routes defined. Create one using “New Route”. + + ) : ( + + {routes.map((route, index) => ( + + + + + + {route.id} + {!route.enabled && Disabled} + {criteriaToTags(route.criteria).map((tag) => ( + + {tag} + + ))} + + {route.recipientId} + + + + + + + + {channel.id} + + + {channel.platform} + + + + + + TTL: {ttlLabel(effectiveTtl)} + + {override?.tokenTtlSeconds !== undefined && ( + + override + + )} + + + + Trust: {effectiveTrust ? 'on' : 'off'} + + {override?.trustLayerEnabled !== undefined && ( + + override + + )} + + + + + + {hasOverride && ( + + )} + + + + {override?.tokenTtlSeconds !== undefined && ( + + + + Override TTL: + + + + + + + + + + + + + + + + + + + + + + Current effective settings + +
+ + Token TTL: {ttlLabel(settings.tokenTtlSeconds)} + +
+ + Trust Layer:{' '} + + {settings.trustLayerEnabled ? 'Enabled' : 'Disabled'} + + +
+
+ + + + + Environment variables + +
+ + REPLY_TOKEN_TTL — Token TTL (seconds, overrides DB setting) + +
+ + MANAGEMENT_API_KEY — Management API authentication key + +
+
+ + + + + {/* Per-Channel Overrides */} + + {channels.length === 0 ? ( + + No channels registered. Add channels first to configure per-channel overrides. + + ) : ( + <> + + Override global settings for individual channels. Changes take effect immediately. + + {channels.map((channel) => ( + + ))} + + )} + + + ); +} diff --git a/packages/server/src/app/dashboard/threads/[threadId]/page.tsx b/packages/server/src/app/dashboard/threads/[threadId]/page.tsx new file mode 100644 index 0000000..584986a --- /dev/null +++ b/packages/server/src/app/dashboard/threads/[threadId]/page.tsx @@ -0,0 +1,336 @@ +'use client'; + +import { ArrowLeftOutlined, DownOutlined, UpOutlined } from '@ant-design/icons'; +import { + Badge, + Button, + Card, + Col, + Collapse, + Descriptions, + Row, + Space, + Spin, + Tag, + Timeline, + Typography, +} from 'antd'; +import { useRouter, useParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; +import { threadApi } from '@/lib/api-client'; +import type { Thread, Turn } from '@/lib/api-client'; + +const { Title, Text } = Typography; + +function MessageView({ message }: { message: Record }) { + const isA2H = 'intent' in message; + + if (isA2H) { + const intent = message.intent as string; + const context = message.context as Record | undefined; + return ( +
+ + + + A2H Intent: + + {intent} + + {context && ( +
+            {JSON.stringify(context, null, 2)}
+          
+ )} +
+ ); + } + + const text = message.text as string | undefined; + return ( +
+ {text ?? JSON.stringify(message)} +
+ ); +} + +function TurnCard({ turn }: { turn: Turn }) { + const isInbound = turn.direction === 'inbound'; + const messages = Array.isArray(turn.message) ? turn.message : [turn.message]; + + return ( + + +
+ + + {isInbound ? '\u2190 inbound' : '\u2192 outbound'} + + + {turn.turnId} + + + + + + {new Date(turn.timestamp).toLocaleString()} + + + + +
+ {messages.map((msg, i) => ( + } /> + ))} +
+ + + Raw envelope + + ), + children: ( +
+                {JSON.stringify(turn, null, 2)}
+              
+ ), + }, + ]} + /> + + ); +} + +export default function ThreadDetailPage() { + const router = useRouter(); + const params = useParams<{ threadId: string }>(); + const threadId = params.threadId; + + const [thread, setThread] = useState(null); + const [turns, setTurns] = useState([]); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState>(new Set()); + + useEffect(() => { + if (!threadId) return; + setLoading(true); + Promise.all([threadApi.get(threadId), threadApi.turns(threadId)]) + .then(([t, fetchedTurns]) => { + setThread(t); + setTurns(fetchedTurns); + }) + .catch(() => { + setThread(null); + setTurns([]); + }) + .finally(() => setLoading(false)); + }, [threadId]); + + const toggleExpand = (turnId: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(turnId)) next.delete(turnId); + else next.add(turnId); + return next; + }); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!thread) { + return ( +
+ + + Thread not found. + +
+ ); + } + + return ( + <> + +
+ + + + + Thread Detail + + + + + + + + + {thread.threadId} + + + + {thread.channelId} + + + + {thread.targetId} + + + + {thread.nativeThreadId ? ( + + {thread.nativeThreadId} + + ) : ( + virtual + )} + + + {new Date(thread.createdAt).toLocaleString()} + + + + + + + + + Turn Log{' '} + <Text type="secondary" style={{ fontWeight: 400, fontSize: 14 }}> + ({turns.length} turns, chronological) + </Text> + + + {turns.length === 0 ? ( + + No turns recorded for this thread. + + ) : ( + ({ + key: turn.turnId, + color: turn.direction === 'inbound' ? 'blue' : 'green', + label: ( + + {new Date(turn.timestamp).toLocaleTimeString()} + + ), + children: ( +
+
toggleExpand(turn.turnId)} + > + + + {turn.direction === 'inbound' ? '\u2190 inbound' : '\u2192 outbound'} + + + {turn.turnId} + + {expanded.has(turn.turnId) ? ( + + ) : ( + + )} + +
+ {expanded.has(turn.turnId) && ( +
+ +
+ )} + {!expanded.has(turn.turnId) && ( +
+ {(() => { + const msgs = Array.isArray(turn.message) + ? turn.message + : [turn.message]; + const first = msgs[0] as Record; + if ('intent' in first) return `[A2H: ${first.intent as string}]`; + return (first.text as string) ?? JSON.stringify(first).slice(0, 80); + })()} +
+ )} +
+ ), + }))} + /> + )} + + ); +} diff --git a/packages/server/src/app/dashboard/threads/page.tsx b/packages/server/src/app/dashboard/threads/page.tsx new file mode 100644 index 0000000..ac2a03e --- /dev/null +++ b/packages/server/src/app/dashboard/threads/page.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { SearchOutlined } from '@ant-design/icons'; +import { Button, Card, Col, Input, Row, Select, Space, Table, Tag, Typography } from 'antd'; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useState } from 'react'; +import { channelApi, threadApi } from '@/lib/api-client'; +import type { Channel, Thread } from '@/lib/api-client'; + +const { Title, Text } = Typography; + +export default function ThreadsPage() { + const router = useRouter(); + const [threads, setThreads] = useState([]); + const [channels, setChannels] = useState([]); + const [loading, setLoading] = useState(false); + const [channelFilter, setChannelFilter] = useState(undefined); + const [search, setSearch] = useState(''); + const [searchInput, setSearchInput] = useState(''); + + useEffect(() => { + channelApi + .list() + .then(setChannels) + .catch(() => {}); + }, []); + + const load = useCallback(() => { + setLoading(true); + threadApi + .list({ channelId: channelFilter, search: search || undefined, limit: 100 }) + .then(setThreads) + .catch(() => setThreads([])) + .finally(() => setLoading(false)); + }, [channelFilter, search]); + + useEffect(() => { + load(); + }, [load]); + + const handleSearch = () => { + setSearch(searchInput); + }; + + const columns = [ + { + title: 'Thread ID', + dataIndex: 'threadId', + key: 'threadId', + render: (id: string) => ( + + ), + }, + { + title: 'Channel', + dataIndex: 'channelId', + key: 'channelId', + render: (id: string) => {id}, + }, + { + title: 'Target', + dataIndex: 'targetId', + key: 'targetId', + render: (id: string) => ( + + {id} + + ), + }, + { + title: 'Native Thread', + dataIndex: 'nativeThreadId', + key: 'nativeThreadId', + render: (id: string | null) => + id ? ( + + {id} + + ) : ( + + virtual + + ), + }, + { + title: 'Created', + dataIndex: 'createdAt', + key: 'createdAt', + render: (d: string | Date) => ( + + {new Date(d).toLocaleString()} + + ), + }, + { + title: '', + key: 'actions', + render: (_: unknown, record: Thread) => ( + + ), + }, + ]; + + return ( + <> + +
+ + Threads + + + + + + + setSearchInput(e.target.value)} + onPressEnter={handleSearch} + style={{ width: 300 }} + suffix={ + + + + + +
({ + style: { cursor: 'pointer' }, + onClick: () => router.push(`/dashboard/threads/${record.threadId}`), + })} + /> + + + ); +} diff --git a/packages/server/src/app/form/[formKey]/FormClient.tsx b/packages/server/src/app/form/[formKey]/FormClient.tsx new file mode 100644 index 0000000..8e51f2a --- /dev/null +++ b/packages/server/src/app/form/[formKey]/FormClient.tsx @@ -0,0 +1,523 @@ +'use client'; + +/** + * FormClient — interactive A2H form UI (Ant Design 5). + * + * Renders the appropriate form for each A2H intent type: + * AUTHORIZE → context display + approve/deny buttons + * COLLECT → labeled inputs (text, select, radio, checkbox) based on field schema + * Batch → all intents on a single page, single submit + * + * Handles four display states: idle (form), loading (submitting), success, expired/error. + */ + +import { useState } from 'react'; +import { + Alert, + Button, + Card, + Checkbox, + Descriptions, + Divider, + Form, + Input, + Radio, + Result, + Select, + Space, + Spin, + Tag, + Typography, +} from 'antd'; +import { + CheckCircleOutlined, + ClockCircleOutlined, + CloseCircleOutlined, + ExclamationCircleOutlined, +} from '@ant-design/icons'; + +const { Title, Text } = Typography; + +// ─── Field definition (from COLLECT context) ────────────────────────────────── + +interface CollectField { + name: string; + type: 'text' | 'textarea' | 'number' | 'date' | 'select' | 'multiselect' | 'checkbox'; + label?: string; + required?: boolean; + options?: string[]; + placeholder?: string; +} + +interface A2HIntentData { + intent: string; + context?: Record; + description?: string; +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface FormClientProps { + formKey: string; + intents: A2HIntentData[]; + isBatch: boolean; + status: 'pending' | 'submitted' | 'expired'; + expiresAt: string; +} + +// ─── Main component ─────────────────────────────────────────────────────────── + +export default function FormClient({ formKey, intents, isBatch, status: initialStatus, expiresAt }: FormClientProps) { + const [form] = Form.useForm(); + const [uiState, setUiState] = useState<'idle' | 'loading' | 'success' | 'error'>( + initialStatus === 'submitted' ? 'success' : initialStatus === 'expired' ? 'error' : 'idle', + ); + const [errorMessage, setErrorMessage] = useState(''); + + const expiry = new Date(expiresAt); + const isExpired = initialStatus === 'expired'; + + // ── Submit handler ───────────────────────────────────────────────────────── + + async function handleSubmit(values: Record) { + setUiState('loading'); + setErrorMessage(''); + + try { + // Build the response payload. + // For batch forms, `values` is a flat map keyed as `${intentIndex}_${fieldName}`. + // For single forms, `values` contains the intent's fields directly. + const responses = isBatch + ? buildBatchResponses(values, intents) + : [buildSingleResponse(values, intents[0])]; + + const res = await fetch(`/api/form/${formKey}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ responses }), + }); + + if (res.ok) { + setUiState('success'); + } else { + const body = await res.json().catch(() => ({})); + setErrorMessage((body as { error?: string }).error ?? `Server error (${res.status})`); + setUiState('error'); + } + } catch (err) { + setErrorMessage(err instanceof Error ? err.message : 'Network error. Please try again.'); + setUiState('error'); + } + } + + // ── Render states ────────────────────────────────────────────────────────── + + if (isExpired) { + return ( + + } + title="This form has expired" + subTitle={`The form link expired on ${expiry.toLocaleString()}. Please ask the system to send a new request.`} + /> + + ); + } + + if (uiState === 'success') { + return ( + + } + title="Response submitted" + subTitle="Your response has been recorded and sent back to the system." + /> + + ); + } + + if (uiState === 'error' && initialStatus === 'submitted') { + return ( + + } + title="Already submitted" + subTitle="This form has already been completed." + /> + + ); + } + + return ( + + {/* Expiry notice */} + } + message={`This form expires on ${expiry.toLocaleString()}`} + showIcon + style={{ marginBottom: 24 }} + /> + + {/* Error banner (submission errors) */} + {uiState === 'error' && errorMessage && ( + } + message="Submission failed" + description={errorMessage} + showIcon + closable + onClose={() => { setUiState('idle'); setErrorMessage(''); }} + style={{ marginBottom: 24 }} + /> + )} + + +
+ {intents.map((intent, idx) => ( + + ))} + + + + + + + +
+
+ ); +} + +// ─── Page shell ─────────────────────────────────────────────────────────────── + +function PageShell({ children }: { children: React.ReactNode }) { + return ( +
+
+
+ + OpenThreads + + Human-in-the-Loop Response +
+ {children} +
+
+ ); +} + +// ─── Intent section ─────────────────────────────────────────────────────────── + +interface IntentSectionProps { + intent: A2HIntentData; + intentIndex: number; + isBatch: boolean; +} + +function IntentFormSection({ intent, intentIndex, isBatch }: IntentSectionProps) { + const prefix = isBatch ? `${intentIndex}_` : ''; + + if (intent.intent === 'AUTHORIZE') { + return ; + } + + if (intent.intent === 'COLLECT') { + return ; + } + + // ESCALATE or other intents — show a read-only notice. + return ( + + + + + {intent.description ?? `Intent: ${intent.intent}`} + + + + ); +} + +// ─── AUTHORIZE section ──────────────────────────────────────────────────────── + +interface AuthorizeSectionProps { + intent: A2HIntentData; + prefix: string; + isBatch: boolean; +} + +function AuthorizeSection({ intent, prefix }: AuthorizeSectionProps) { + const ctx = intent.context ?? {}; + const action = (ctx.action as string) ?? ''; + const details = (ctx.details as string) ?? ''; + const evidence = (ctx.evidence as string) ?? ''; + const requestedBy = (ctx.requestedBy as string) ?? ''; + + return ( + + AUTHORIZE + {intent.description ?? action ?? 'Authorization Request'} + + } + style={{ marginBottom: 24 }} + > + {/* Context display */} + + {action && {action}} + {details && {details}} + {evidence && {evidence}} + {requestedBy && {requestedBy}} + + + {/* Approve / Deny radio */} + + + + + + + Approve + + + + + + Deny + + + + + + + {/* Optional comment */} + + + + + ); +} + +// ─── COLLECT section ────────────────────────────────────────────────────────── + +interface CollectSectionProps { + intent: A2HIntentData; + prefix: string; + isBatch: boolean; + intentIndex: number; +} + +function CollectSection({ intent, prefix, isBatch, intentIndex }: CollectSectionProps) { + const ctx = intent.context ?? {}; + const question = (ctx.question as string) ?? intent.description ?? ''; + const rawFields = ctx.fields as CollectField[] | undefined; + const fields: CollectField[] = Array.isArray(rawFields) ? rawFields : []; + + const sectionTitle = isBatch + ? `Question ${intentIndex + 1}${question ? `: ${question}` : ''}` + : (question || 'Collect Information'); + + return ( + + COLLECT + {sectionTitle} + + } + style={{ marginBottom: 24 }} + > + {fields.length === 0 ? ( + // Free-text question with no defined fields. + + + + ) : ( + fields.map((field) => ( + + )) + )} + + ); +} + +// ─── Individual collect field ───────────────────────────────────────────────── + +function CollectFieldInput({ field, prefix }: { field: CollectField; prefix: string }) { + const fieldName = `${prefix}${field.name}`; + const label = field.label ?? field.name; + const required = field.required ?? false; + const rules = required ? [{ required: true, message: `${label} is required` }] : []; + + if (field.type === 'select') { + return ( + + ({ value: o, label: o }))} + /> + + ); + } + + if (field.type === 'checkbox') { + return ( + + ({ value: o, label: o }))} + style={{ display: 'flex', flexDirection: 'column', gap: 8 }} + /> + + ); + } + + if (field.type === 'textarea') { + return ( + + + + ); + } + + if (field.type === 'number') { + return ( + + + + ); + } + + if (field.type === 'date') { + return ( + + + + ); + } + + // Default: text input. + return ( + + + + ); +} + +// ─── Response builders ──────────────────────────────────────────────────────── + +function buildSingleResponse( + values: Record, + intent: A2HIntentData, +): Record { + if (intent.intent === 'AUTHORIZE') { + return { + intent: 'AUTHORIZE', + response: values['decision'] === 'approve', + comment: values['comment'] ?? undefined, + respondedAt: new Date().toISOString(), + }; + } + + if (intent.intent === 'COLLECT') { + const ctx = intent.context ?? {}; + const rawFields = ctx.fields as CollectField[] | undefined; + const fields: CollectField[] = Array.isArray(rawFields) ? rawFields : []; + + if (fields.length === 0) { + return { + intent: 'COLLECT', + response: { answer: values['answer'] }, + respondedAt: new Date().toISOString(), + }; + } + + const response: Record = {}; + for (const field of fields) { + response[field.name] = values[field.name]; + } + + return { intent: 'COLLECT', response, respondedAt: new Date().toISOString() }; + } + + return { intent: intent.intent, response: values, respondedAt: new Date().toISOString() }; +} + +function buildBatchResponses( + values: Record, + intents: A2HIntentData[], +): Record[] { + return intents.map((intent, idx) => { + const prefix = `${idx}_`; + // Extract only keys belonging to this intent's prefix. + const intentValues: Record = {}; + for (const [key, value] of Object.entries(values)) { + if (key.startsWith(prefix)) { + intentValues[key.slice(prefix.length)] = value; + } + } + return buildSingleResponse(intentValues, intent); + }); +} diff --git a/packages/server/src/app/form/[formKey]/page.tsx b/packages/server/src/app/form/[formKey]/page.tsx new file mode 100644 index 0000000..861b876 --- /dev/null +++ b/packages/server/src/app/form/[formKey]/page.tsx @@ -0,0 +1,94 @@ +/** + * GET /form/:formKey — Auto-generated A2H form page. + * + * Renders a temporary web form for A2H reply methods 3 (single intent) and + * 4 (batch of intents). The form key is either `turnId` (method 3) or + * `${turnId}_batch` (method 4). + * + * This server component loads the turn data and passes it to the client + * component which renders the interactive form UI. + */ + +import { notFound } from 'next/navigation'; +import { getTurn, getFormRecord, createFormRecord } from '@/lib/db'; +import FormClient from './FormClient'; + +export const runtime = 'nodejs'; + +type PageProps = { params: Promise<{ formKey: string }> }; + +/** Default form TTL matches the reply token TTL (env: REPLY_TOKEN_TTL, default 24h). */ +function getFormTtlMs(): number { + return Number(process.env.REPLY_TOKEN_TTL ?? 86400) * 1000; +} + +/** Extract base turnId and batch flag from formKey. */ +function parseFormKey(formKey: string): { turnId: string; isBatch: boolean } { + if (formKey.endsWith('_batch')) { + return { turnId: formKey.slice(0, -6), isBatch: true }; + } + return { turnId: formKey, isBatch: false }; +} + +/** Type guard: checks if an item is an A2H message (has an `intent` string field). */ +function isA2HMessage(item: unknown): item is { intent: string; context?: Record; description?: string } { + return ( + typeof item === 'object' && + item !== null && + 'intent' in item && + typeof (item as Record).intent === 'string' + ); +} + +export default async function FormPage({ params }: PageProps) { + const { formKey } = await params; + const { turnId, isBatch } = parseFormKey(formKey); + + // Load (or lazily create) the form record. + let formRecord = await getFormRecord(formKey); + + if (!formRecord) { + // First access: resolve the turn and create the form record. + const turn = await getTurn(turnId); + if (!turn) { + notFound(); + } + + // Extract A2H intents from the turn message. + const messages = Array.isArray(turn.message) ? turn.message : [turn.message]; + const intents = messages.filter(isA2HMessage); + + if (intents.length === 0) { + // This turn has no A2H intents — no form to show. + notFound(); + } + + // Create the form record. + const expiresAt = new Date(new Date(turn.timestamp).getTime() + getFormTtlMs()); + formRecord = await createFormRecord({ + formKey, + turnId, + isBatch, + intents, + status: 'pending', + expiresAt, + }); + } + + const now = new Date(); + const isExpired = formRecord.expiresAt < now; + + return ( + ; + description?: string; + }>} + isBatch={formRecord.isBatch} + status={isExpired ? 'expired' : formRecord.status} + expiresAt={formRecord.expiresAt.toISOString()} + /> + ); +} diff --git a/packages/server/src/app/health/route.ts b/packages/server/src/app/health/route.ts new file mode 100644 index 0000000..934978b --- /dev/null +++ b/packages/server/src/app/health/route.ts @@ -0,0 +1,7 @@ +/** + * GET /health — Health check endpoint (alias of /api/health). + * + * Satisfies the acceptance criteria: "Health check returns 200 with status". + */ + +export { GET } from '../api/health/route'; diff --git a/packages/server/src/app/layout.tsx b/packages/server/src/app/layout.tsx new file mode 100644 index 0000000..87fc8d5 --- /dev/null +++ b/packages/server/src/app/layout.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from 'next'; +import { AntdRegistry } from '@ant-design/nextjs-registry'; + +export const metadata: Metadata = { + title: 'OpenThreads', + description: 'Unified communication channel abstraction with human-in-the-loop support', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/packages/server/src/app/page.tsx b/packages/server/src/app/page.tsx new file mode 100644 index 0000000..f889cb6 --- /dev/null +++ b/packages/server/src/app/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation'; + +export default function Home() { + redirect('/dashboard'); +} diff --git a/packages/server/src/app/send/channel/[channelId]/target/[groupOrUser]/route.ts b/packages/server/src/app/send/channel/[channelId]/target/[groupOrUser]/route.ts new file mode 100644 index 0000000..6dc4bb7 --- /dev/null +++ b/packages/server/src/app/send/channel/[channelId]/target/[groupOrUser]/route.ts @@ -0,0 +1,127 @@ +/** + * POST /send/channel/:channelId/target/:groupOrUser + * + * Recipient inbound — new thread variant. + * Creates a new thread for the given (channel, target) pair and processes + * the message body via the Reply Engine. + * + * Auth: `?token=ot_tk_...` OR `Authorization: Bearer ot_ch_sk_...` + * Body: `{ message: object | object[] }` + * + * Returns 202 Accepted for fire-and-forget messages (no blocking A2H intent). + * Returns 200 with responses for blocking A2H intents. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { + createThread, + createTurn, + createEphemeralToken, + consumeToken, + generateTurnId, + generateThreadId, +} from '@/lib/db'; +import { verifySendAuth } from '@/lib/auth'; +import { sendRateLimit, getClientIp } from '@/lib/rate-limit'; +import { isA2HMessage, hasA2HMessages, normaliseToArray } from '@openthreads/core'; +import type { OpenThreadsMessage } from '@openthreads/core'; + +export const runtime = 'nodejs'; + +type RouteContext = { params: Promise<{ channelId: string; groupOrUser: string }> }; + +export async function POST(request: NextRequest, context: RouteContext): Promise { + const ip = getClientIp(request); + if (!sendRateLimit(ip)) { + return NextResponse.json({ error: 'Too many requests' }, { status: 429 }); + } + + const { channelId, groupOrUser } = await context.params; + + // Validate auth (token or API key) + const auth = await verifySendAuth(request, channelId); + if (!auth.valid) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + + // Parse body + let body: { message?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + if (!body.message) { + return NextResponse.json({ error: 'Missing required field: message' }, { status: 400 }); + } + + try { + // Create new thread + const thread = await createThread({ + threadId: generateThreadId(), + channelId, + targetId: groupOrUser, + nativeThreadId: null, + }); + + // Record the inbound turn + const messages = normaliseToArray(body.message as OpenThreadsMessage | OpenThreadsMessage[]); + const turn = await createTurn({ + turnId: generateTurnId(), + threadId: thread.threadId, + direction: 'inbound', + message: messages, + }); + + // Consume the token if auth was via token + if (auth.method === 'token') { + const tokenParam = request.nextUrl.searchParams.get('token'); + if (tokenParam) await consumeToken(tokenParam); + } + + // Determine if this is blocking (contains A2H intents that need a response) + const isBlocking = hasA2HMessages(messages); + + // Generate a replyTo URL for the next turn + const baseUrl = process.env.OPENTHREADS_BASE_URL ?? `https://${request.headers.get('host')}`; + const replyToken = await createEphemeralToken({ + channelId, + threadId: thread.threadId, + turnId: turn.turnId, + }); + const replyTo = `${baseUrl}/send/channel/${channelId}/target/${groupOrUser}/thread/${thread.threadId}?token=${replyToken.value}`; + + // For fire-and-forget (no blocking A2H), return 202 Accepted. + if (!isBlocking) { + return NextResponse.json( + { + status: 'accepted', + threadId: thread.threadId, + turnId: turn.turnId, + replyTo, + }, + { status: 202 }, + ); + } + + // For blocking A2H intents, return 200 with a synchronous receipt. + // The actual human response will come in via a subsequent POST to replyTo. + // The Reply Engine integration (actual channel adapter call) happens when + // the channel adapter is instantiated and attached to this server. + return NextResponse.json({ + status: 'pending', + threadId: thread.threadId, + turnId: turn.turnId, + replyTo, + intents: messages.filter(isA2HMessage).map((m) => ({ + intent: m.intent, + description: m.description, + traceId: m.traceId, + })), + }); + } catch (err) { + console.error('[send] create thread error:', err); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/packages/server/src/app/send/channel/[channelId]/target/[groupOrUser]/thread/[threadId]/route.ts b/packages/server/src/app/send/channel/[channelId]/target/[groupOrUser]/thread/[threadId]/route.ts new file mode 100644 index 0000000..39bb7e4 --- /dev/null +++ b/packages/server/src/app/send/channel/[channelId]/target/[groupOrUser]/thread/[threadId]/route.ts @@ -0,0 +1,123 @@ +/** + * POST /send/channel/:channelId/target/:groupOrUser/thread/:threadId + * + * Recipient inbound — existing thread variant. + * Processes the message body in the context of an existing thread. + * + * Auth: `?token=ot_tk_...` (scoped to thread) OR `Authorization: Bearer ot_ch_sk_...` + * Body: `{ message: object | object[] }` + * + * Returns 202 Accepted for fire-and-forget (INFORM / Chat SDK messages). + * Returns 200 with pending status for blocking A2H intents. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { + getThread, + createTurn, + createEphemeralToken, + consumeToken, + generateTurnId, +} from '@/lib/db'; +import { verifySendAuth } from '@/lib/auth'; +import { sendRateLimit, getClientIp } from '@/lib/rate-limit'; +import { isA2HMessage, hasA2HMessages, normaliseToArray } from '@openthreads/core'; +import type { OpenThreadsMessage } from '@openthreads/core'; + +export const runtime = 'nodejs'; + +type RouteContext = { + params: Promise<{ channelId: string; groupOrUser: string; threadId: string }>; +}; + +export async function POST(request: NextRequest, context: RouteContext): Promise { + const ip = getClientIp(request); + if (!sendRateLimit(ip)) { + return NextResponse.json({ error: 'Too many requests' }, { status: 429 }); + } + + const { channelId, groupOrUser, threadId } = await context.params; + + // Validate auth (token scoped to this thread, or channel API key) + const auth = await verifySendAuth(request, channelId, threadId); + if (!auth.valid) { + return NextResponse.json({ error: auth.error }, { status: auth.status }); + } + + // Parse body + let body: { message?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + if (!body.message) { + return NextResponse.json({ error: 'Missing required field: message' }, { status: 400 }); + } + + try { + // Verify the thread exists and belongs to this channel/target + const thread = await getThread(threadId); + if (!thread) { + return NextResponse.json({ error: 'Thread not found' }, { status: 404 }); + } + if (thread.channelId !== channelId) { + return NextResponse.json({ error: 'Thread does not belong to this channel' }, { status: 403 }); + } + + // Record the inbound turn + const messages = normaliseToArray(body.message as OpenThreadsMessage | OpenThreadsMessage[]); + const turn = await createTurn({ + turnId: generateTurnId(), + threadId: thread.threadId, + direction: 'inbound', + message: messages, + }); + + // Consume the token if auth was via token + if (auth.method === 'token') { + const tokenParam = request.nextUrl.searchParams.get('token'); + if (tokenParam) await consumeToken(tokenParam); + } + + // Determine if this requires a blocking response + const isBlocking = hasA2HMessages(messages); + + // Generate a replyTo URL for subsequent replies + const baseUrl = process.env.OPENTHREADS_BASE_URL ?? `https://${request.headers.get('host')}`; + const replyToken = await createEphemeralToken({ + channelId, + threadId: thread.threadId, + turnId: turn.turnId, + }); + const replyTo = `${baseUrl}/send/channel/${channelId}/target/${groupOrUser}/thread/${thread.threadId}?token=${replyToken.value}`; + + if (!isBlocking) { + return NextResponse.json( + { + status: 'accepted', + threadId: thread.threadId, + turnId: turn.turnId, + replyTo, + }, + { status: 202 }, + ); + } + + return NextResponse.json({ + status: 'pending', + threadId: thread.threadId, + turnId: turn.turnId, + replyTo, + intents: messages.filter(isA2HMessage).map((m) => ({ + intent: m.intent, + description: m.description, + traceId: m.traceId, + })), + }); + } catch (err) { + console.error('[send/thread] error:', err); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/packages/server/src/app/webhook/[channelId]/route.ts b/packages/server/src/app/webhook/[channelId]/route.ts new file mode 100644 index 0000000..9d54b2a --- /dev/null +++ b/packages/server/src/app/webhook/[channelId]/route.ts @@ -0,0 +1,336 @@ +/** + * POST /webhook/:channelId — Generic inbound webhook receiver. + * + * Receives events from external platforms (Slack, Telegram, Discord, etc.), + * verifies platform signatures, normalizes the event, runs it through the + * Router, and fans out to matched recipients. + * + * Platform-specific signature verification is selected based on channel type. + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { + getChannel, + getThreadByNativeId, + createThread, + createTurn, + findMatchingRoutes, + getRecipient, + generateThreadId, + generateTurnId, +} from '@/lib/db'; +import { webhookRateLimit, getClientIp } from '@/lib/rate-limit'; +import { fanOut } from '@/lib/fanout'; +import type { Recipient } from '@openthreads/core'; + +export const runtime = 'nodejs'; + +type RouteContext = { params: Promise<{ channelId: string }> }; + +export async function POST(request: NextRequest, context: RouteContext): Promise { + const { channelId } = await context.params; + + // Rate limit per channel + if (!webhookRateLimit(channelId)) { + return NextResponse.json({ error: 'Too many requests' }, { status: 429 }); + } + + // Read raw body (needed for signature verification) + const rawBody = await request.text(); + + // Look up channel config + let channel; + try { + channel = await getChannel(channelId); + } catch (err) { + console.error(`[webhook/${channelId}] db error:`, err); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } + + if (!channel) { + return NextResponse.json({ error: 'Channel not found' }, { status: 404 }); + } + + // Platform-specific signature verification + const sigVerified = await verifyWebhookSignature(request, channel.platform, rawBody, channel); + if (!sigVerified) { + console.warn(`[webhook/${channelId}] signature verification failed`); + return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); + } + + // Parse the event body + let event: Record; + try { + event = JSON.parse(rawBody); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } + + // Handle Slack URL verification challenge + if (channel.platform === 'slack' && event.type === 'url_verification') { + return NextResponse.json({ challenge: event.challenge }); + } + + // Normalize the inbound event to an OpenThreads message + const normalized = normalizeInboundEvent(channel.platform, event); + if (!normalized) { + // Acknowledge unknown event types without processing + return NextResponse.json({ ok: true }); + } + + try { + // Find or create a thread for this event + const thread = await resolveThread(channelId, normalized); + + // Record the inbound turn + const turn = await createTurn({ + turnId: generateTurnId(), + threadId: thread.threadId, + direction: 'inbound', + message: normalized.message, + }); + + // Find matching routes and fan out to recipients + const matchingRoutes = await findMatchingRoutes({ + channelId, + isDm: normalized.isDm, + isMention: normalized.isMention, + senderId: normalized.senderId, + }); + + if (matchingRoutes.length === 0) { + return NextResponse.json({ ok: true, routed: 0 }); + } + + // Resolve recipient objects + const recipientIds = [...new Set(matchingRoutes.map((r) => r.recipientId))]; + const recipients: Recipient[] = []; + for (const rid of recipientIds) { + const recipient = await getRecipient(rid); + if (recipient) recipients.push(recipient); + } + + if (recipients.length === 0) { + return NextResponse.json({ ok: true, routed: 0 }); + } + + // Build the outbound envelope + const baseUrl = process.env.OPENTHREADS_BASE_URL ?? `https://${request.headers.get('host')}`; + const replyTo = `${baseUrl}/send/channel/${channelId}/target/${normalized.targetId}/thread/${thread.threadId}`; + + const envelope = { + threadId: thread.threadId, + turnId: turn.turnId, + replyTo, + source: { + channel: channel.platform, + channelId, + sender: { + id: normalized.senderId, + name: normalized.senderName, + }, + }, + message: normalized.message, + }; + + // Fan out concurrently + const results = await fanOut(recipients, envelope); + + const delivered = [...results.values()].filter((r) => r.success).length; + const failed = [...results.values()].filter((r) => !r.success).length; + + return NextResponse.json({ + ok: true, + routed: delivered, + failed, + threadId: thread.threadId, + turnId: turn.turnId, + }); + } catch (err) { + console.error(`[webhook/${channelId}] processing error:`, err); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// ─── Signature verification ─────────────────────────────────────────────────── + +async function verifyWebhookSignature( + request: NextRequest, + platform: string, + rawBody: string, + channel: { credentialsRef: string }, +): Promise { + // In production, credentialsRef would be used to fetch secrets from a vault. + // For now, we support Slack signing secrets and Telegram tokens via env vars. + + if (platform === 'slack') { + return verifySlackSignature(request, rawBody); + } + + if (platform === 'telegram') { + return verifyTelegramSignature(request); + } + + // For other platforms, accept if no specific verification is configured. + // Extend this as new adapters are added. + return true; +} + +async function verifySlackSignature(request: NextRequest, rawBody: string): Promise { + const signingSecret = process.env.SLACK_SIGNING_SECRET; + if (!signingSecret) { + // No secret configured — accept in development, reject in production. + return process.env.NODE_ENV !== 'production'; + } + + const timestamp = request.headers.get('x-slack-request-timestamp'); + const slackSig = request.headers.get('x-slack-signature'); + + if (!timestamp || !slackSig) return false; + + // Reject requests older than 5 minutes to prevent replay attacks. + const age = Math.abs(Date.now() / 1000 - Number(timestamp)); + if (age > 5 * 60) return false; + + const sigBase = `v0:${timestamp}:${rawBody}`; + const key = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(signingSecret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'], + ); + const sigBytes = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(sigBase)); + const hexSig = `v0=${Array.from(new Uint8Array(sigBytes)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('')}`; + + // Constant-time comparison + if (hexSig.length !== slackSig.length) return false; + let diff = 0; + for (let i = 0; i < hexSig.length; i++) { + diff |= hexSig.charCodeAt(i) ^ slackSig.charCodeAt(i); + } + return diff === 0; +} + +function verifyTelegramSignature(request: NextRequest): boolean { + const expectedToken = process.env.TELEGRAM_WEBHOOK_SECRET; + if (!expectedToken) { + return process.env.NODE_ENV !== 'production'; + } + const token = request.headers.get('x-telegram-bot-api-secret-token'); + return token === expectedToken; +} + +// ─── Event normalization ────────────────────────────────────────────────────── + +interface NormalizedEvent { + message: unknown; + senderId: string; + senderName?: string; + targetId: string; + nativeThreadId?: string | null; + isDm: boolean; + isMention: boolean; +} + +function normalizeInboundEvent( + platform: string, + event: Record, +): NormalizedEvent | null { + if (platform === 'slack') { + return normalizeSlackEvent(event); + } + if (platform === 'telegram') { + return normalizeTelegramEvent(event); + } + // Generic fallback for unknown platforms + return normalizeGenericEvent(event); +} + +function normalizeSlackEvent(event: Record): NormalizedEvent | null { + const payload = event.event as Record | undefined; + if (!payload) return null; + + const type = payload.type as string; + if (!['message', 'app_mention'].includes(type)) return null; + + const text = (payload.text as string) ?? ''; + const userId = (payload.user as string) ?? 'unknown'; + const channel = (payload.channel as string) ?? ''; + const threadTs = (payload.thread_ts as string) ?? null; + const isDm = channel.startsWith('D'); + const isMention = type === 'app_mention' || text.includes('<@'); + + return { + message: { text }, + senderId: userId, + targetId: channel, + nativeThreadId: threadTs, + isDm, + isMention, + }; +} + +function normalizeTelegramEvent(event: Record): NormalizedEvent | null { + const message = event.message as Record | undefined; + if (!message) return null; + + const text = (message.text as string) ?? ''; + const from = message.from as Record | undefined; + const chat = message.chat as Record | undefined; + if (!from || !chat) return null; + + const senderId = String(from.id ?? 'unknown'); + const senderName = (from.first_name as string) ?? undefined; + const chatId = String(chat.id ?? ''); + const chatType = (chat.type as string) ?? ''; + const isDm = chatType === 'private'; + + return { + message: { text }, + senderId, + senderName, + targetId: chatId, + nativeThreadId: null, + isDm, + isMention: false, + }; +} + +function normalizeGenericEvent(event: Record): NormalizedEvent | null { + // Accept events with explicit OpenThreads fields + if (!event.senderId || !event.targetId) return null; + + return { + message: event.message ?? { text: String(event.text ?? '') }, + senderId: String(event.senderId), + senderName: event.senderName ? String(event.senderName) : undefined, + targetId: String(event.targetId), + nativeThreadId: event.nativeThreadId ? String(event.nativeThreadId) : null, + isDm: Boolean(event.isDm), + isMention: Boolean(event.isMention), + }; +} + +// ─── Thread resolution ──────────────────────────────────────────────────────── + +async function resolveThread( + channelId: string, + event: NormalizedEvent, +): Promise<{ threadId: string }> { + // If the event has a native thread ID, look for an existing thread + if (event.nativeThreadId) { + const existing = await getThreadByNativeId(channelId, event.nativeThreadId); + if (existing) return existing; + } + + // Create a new thread for this event + return createThread({ + threadId: generateThreadId(), + channelId, + targetId: event.targetId, + nativeThreadId: event.nativeThreadId ?? null, + }); +} diff --git a/packages/server/src/instrumentation.ts b/packages/server/src/instrumentation.ts new file mode 100644 index 0000000..9916521 --- /dev/null +++ b/packages/server/src/instrumentation.ts @@ -0,0 +1,91 @@ +/** + * Next.js instrumentation file. + * + * Called once when the server starts. Responsibilities: + * 1. Graceful shutdown — close MongoDB connections cleanly on SIGTERM/SIGINT. + * 2. OpenTelemetry — initialise tracing when OTEL_EXPORTER_OTLP_ENDPOINT is set. + * + * OpenTelemetry configuration (via environment variables): + * + * OTEL_EXPORTER_OTLP_ENDPOINT OTLP gRPC/HTTP endpoint, e.g. http://otel-collector:4318 + * OTEL_SERVICE_NAME Service name tag (default: "openthreads") + * OTEL_SDK_DISABLED Set to "true" to disable tracing entirely + * + * @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation + * @see https://opentelemetry.io/docs/languages/js/getting-started/nodejs/ + */ + +export async function register(): Promise { + if (process.env.NEXT_RUNTIME !== 'nodejs') return; + + // ── Graceful shutdown ──────────────────────────────────────────────────────── + const { disconnectDb } = await import('./lib/db'); + + async function shutdown(signal: string): Promise { + console.log(JSON.stringify({ level: 'info', message: `received ${signal}, shutting down…`, signal })); + try { + await disconnectDb(); + console.log(JSON.stringify({ level: 'info', message: 'MongoDB connection closed' })); + } catch (err) { + console.error(JSON.stringify({ + level: 'error', + message: 'error closing MongoDB connection', + error: err instanceof Error ? err.message : String(err), + })); + } + process.exit(0); + } + + process.on('SIGTERM', () => { void shutdown('SIGTERM'); }); + process.on('SIGINT', () => { void shutdown('SIGINT'); }); + + // ── OpenTelemetry tracing ─────────────────────────────────────────────────── + if ( + process.env.OTEL_SDK_DISABLED !== 'true' && + process.env.OTEL_EXPORTER_OTLP_ENDPOINT + ) { + await setupOpenTelemetry(); + } +} + +async function setupOpenTelemetry(): Promise { + try { + const { + NodeSDK, + // eslint-disable-next-line @typescript-eslint/no-require-imports + } = await import('@opentelemetry/sdk-node' as string).catch(() => ({ NodeSDK: null })) as { NodeSDK: (new (...args: unknown[]) => { start(): void }) | null }; + + if (!NodeSDK) { + console.warn(JSON.stringify({ + level: 'warn', + message: 'OpenTelemetry SDK not installed. Install @opentelemetry/sdk-node to enable tracing.', + })); + return; + } + + const serviceName = process.env.OTEL_SERVICE_NAME ?? 'openthreads'; + + // NodeSDK auto-instruments HTTP, DNS, MongoDB, etc. via OTEL_NODE_RESOURCE_DETECTORS. + // The exporter endpoint and protocol are read from OTEL_EXPORTER_OTLP_ENDPOINT / + // OTEL_EXPORTER_OTLP_PROTOCOL (defaults to http/protobuf). + const sdk = new NodeSDK({ resource: { attributes: { 'service.name': serviceName } } } as unknown as Parameters[0]); + sdk.start(); + + console.log(JSON.stringify({ + level: 'info', + message: 'OpenTelemetry tracing started', + serviceName, + endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT, + })); + + // Flush spans on shutdown + process.on('SIGTERM', () => { void (sdk as unknown as { shutdown(): Promise }).shutdown(); }); + process.on('SIGINT', () => { void (sdk as unknown as { shutdown(): Promise }).shutdown(); }); + } catch (err) { + console.error(JSON.stringify({ + level: 'error', + message: 'Failed to start OpenTelemetry SDK', + error: err instanceof Error ? err.message : String(err), + })); + } +} diff --git a/packages/server/src/lib/api-client.ts b/packages/server/src/lib/api-client.ts new file mode 100644 index 0000000..7ed3919 --- /dev/null +++ b/packages/server/src/lib/api-client.ts @@ -0,0 +1,166 @@ +/** + * Client-side API helpers for the OpenThreads management dashboard. + * + * In development (no MANAGEMENT_API_KEY set), the server bypasses auth. + * In production, set NEXT_PUBLIC_MANAGEMENT_API_KEY to authenticate. + */ + +import type { Channel, CreateChannelInput } from '@openthreads/core'; +import type { Route, CreateRouteInput, RouteCriteria } from '@openthreads/core'; +import type { Recipient, CreateRecipientInput } from '@openthreads/core'; +import type { Thread } from '@openthreads/core'; +import type { Turn } from '@openthreads/core'; + +// ─── Settings types (mirrored from lib/db to avoid server-only import) ──────── + +export interface ChannelOverride { + tokenTtlSeconds?: number; + trustLayerEnabled?: boolean; +} + +export interface AppSettings { + tokenTtlSeconds: number; + trustLayerEnabled: boolean; + perChannelOverrides: Record; +} + +// Re-export core types for convenience in client components +export type { Channel, Route, RouteCriteria, Recipient, Thread, Turn }; +export type { CreateChannelInput, CreateRouteInput, CreateRecipientInput }; + +function buildHeaders(): HeadersInit { + const h: Record = { 'Content-Type': 'application/json' }; + const key = + typeof window !== 'undefined' + ? (process.env.NEXT_PUBLIC_MANAGEMENT_API_KEY ?? '') + : ''; + if (key) h['Authorization'] = `Bearer ${key}`; + return h; +} + +async function apiFetch(path: string, options?: RequestInit): Promise { + const res = await fetch(path, { + ...options, + headers: { ...buildHeaders(), ...(options?.headers ?? {}) }, + }); + if (res.status === 204) return undefined as T; + const data = await res.json(); + if (!res.ok) throw new Error((data as { error?: string }).error ?? `HTTP ${res.status}`); + return data as T; +} + +// ─── Channels ───────────────────────────────────────────────────────────────── + +export const channelApi = { + list: () => + apiFetch<{ channels: Channel[] }>('/api/channels').then((r) => r.channels), + + get: (id: string) => + apiFetch<{ channel: Channel }>(`/api/channels/${id}`).then((r) => r.channel), + + create: (input: Omit) => + apiFetch<{ channel: Channel }>('/api/channels', { + method: 'POST', + body: JSON.stringify(input), + }).then((r) => r.channel), + + update: (id: string, input: Partial) => + apiFetch<{ channel: Channel }>(`/api/channels/${id}`, { + method: 'PUT', + body: JSON.stringify(input), + }).then((r) => r.channel), + + delete: (id: string) => + apiFetch(`/api/channels/${id}`, { method: 'DELETE' }), +}; + +// ─── Recipients ─────────────────────────────────────────────────────────────── + +export const recipientApi = { + list: () => + apiFetch<{ recipients: Recipient[] }>('/api/recipients').then((r) => r.recipients), + + get: (id: string) => + apiFetch<{ recipient: Recipient }>(`/api/recipients/${id}`).then((r) => r.recipient), + + create: (input: CreateRecipientInput) => + apiFetch<{ recipient: Recipient }>('/api/recipients', { + method: 'POST', + body: JSON.stringify(input), + }).then((r) => r.recipient), + + update: (id: string, input: Partial) => + apiFetch<{ recipient: Recipient }>(`/api/recipients/${id}`, { + method: 'PUT', + body: JSON.stringify(input), + }).then((r) => r.recipient), + + delete: (id: string) => + apiFetch(`/api/recipients/${id}`, { method: 'DELETE' }), +}; + +// ─── Routes ─────────────────────────────────────────────────────────────────── + +export const routeApi = { + list: () => + apiFetch<{ routes: Route[] }>('/api/routes').then((r) => r.routes), + + get: (id: string) => + apiFetch<{ route: Route }>(`/api/routes/${id}`).then((r) => r.route), + + create: (input: CreateRouteInput) => + apiFetch<{ route: Route }>('/api/routes', { + method: 'POST', + body: JSON.stringify(input), + }).then((r) => r.route), + + update: (id: string, input: Partial) => + apiFetch<{ route: Route }>(`/api/routes/${id}`, { + method: 'PUT', + body: JSON.stringify(input), + }).then((r) => r.route), + + delete: (id: string) => + apiFetch(`/api/routes/${id}`, { method: 'DELETE' }), + + test: (criteria: Partial) => + apiFetch<{ matchingRouteIds: string[]; routes: Route[] }>('/api/routes/test', { + method: 'POST', + body: JSON.stringify(criteria), + }), +}; + +// ─── Threads ────────────────────────────────────────────────────────────────── + +export const threadApi = { + list: (params?: { channelId?: string; targetId?: string; search?: string; limit?: number }) => { + const qs = new URLSearchParams(); + if (params?.channelId) qs.set('channelId', params.channelId); + if (params?.targetId) qs.set('targetId', params.targetId); + if (params?.search) qs.set('search', params.search); + if (params?.limit) qs.set('limit', String(params.limit)); + const query = qs.toString() ? `?${qs.toString()}` : ''; + return apiFetch<{ threads: Thread[] }>(`/api/threads${query}`).then((r) => r.threads); + }, + + get: (threadId: string) => + apiFetch<{ thread: Thread }>(`/api/threads/${threadId}`).then((r) => r.thread), + + turns: (threadId: string) => + apiFetch<{ threadId: string; turns: Turn[] }>(`/api/threads/${threadId}/turns`).then( + (r) => r.turns, + ), +}; + +// ─── Settings ───────────────────────────────────────────────────────────────── + +export const settingsApi = { + get: () => + apiFetch<{ settings: AppSettings }>('/api/settings').then((r) => r.settings), + + update: (settings: Partial) => + apiFetch<{ settings: AppSettings }>('/api/settings', { + method: 'PUT', + body: JSON.stringify(settings), + }).then((r) => r.settings), +}; diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts new file mode 100644 index 0000000..af32ec8 --- /dev/null +++ b/packages/server/src/lib/auth.ts @@ -0,0 +1,203 @@ +/** + * Authentication middleware helpers for OpenThreads server. + * + * Three auth mechanisms: + * 1. Ephemeral token — `?token=ot_tk_...` for single-use reply tokens + * 2. Channel API key — `Authorization: Bearer ot_ch_sk_...` for channel access + * 3. Management API key — `Authorization: Bearer ` for admin CRUD + */ + +import type { NextRequest } from 'next/server'; +import { getChannelByApiKey, getValidToken } from './db'; +import type { Channel } from '@openthreads/core'; +import type { TokenDoc } from './db'; + +// ─── Ephemeral token auth ───────────────────────────────────────────────────── + +export interface TokenAuthResult { + valid: true; + token: TokenDoc; + channelId: string; + threadId: string; +} + +export interface TokenAuthFailure { + valid: false; + reason: 'missing_token' | 'invalid_or_expired' | 'channel_mismatch' | 'thread_mismatch'; +} + +export type EphemeralTokenResult = TokenAuthResult | TokenAuthFailure; + +/** + * Validate the `?token=` query parameter from an inbound send request. + * + * Optionally checks that the token is scoped to the given channelId / threadId. + */ +export async function verifyEphemeralToken( + request: NextRequest, + options: { channelId?: string; threadId?: string } = {}, +): Promise { + const value = request.nextUrl.searchParams.get('token'); + if (!value) { + return { valid: false, reason: 'missing_token' }; + } + + const token = await getValidToken(value); + if (!token) { + return { valid: false, reason: 'invalid_or_expired' }; + } + + if (options.channelId && token.channelId !== options.channelId) { + return { valid: false, reason: 'channel_mismatch' }; + } + + if (options.threadId && token.threadId !== options.threadId) { + return { valid: false, reason: 'thread_mismatch' }; + } + + return { valid: true, token, channelId: token.channelId, threadId: token.threadId }; +} + +// ─── Channel API key auth ───────────────────────────────────────────────────── + +export interface ApiKeyAuthResult { + valid: true; + channel: Channel; +} + +export interface ApiKeyAuthFailure { + valid: false; + reason: 'missing_auth' | 'invalid_key' | 'channel_mismatch'; +} + +export type ChannelApiKeyResult = ApiKeyAuthResult | ApiKeyAuthFailure; + +/** + * Validate the `Authorization: Bearer` header as a channel API key. + * + * Optionally checks that the key belongs to the specified channelId. + */ +export async function verifyChannelApiKey( + request: NextRequest, + options: { channelId?: string } = {}, +): Promise { + const authHeader = request.headers.get('authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return { valid: false, reason: 'missing_auth' }; + } + + const apiKey = authHeader.slice(7).trim(); + if (!apiKey) { + return { valid: false, reason: 'missing_auth' }; + } + + const channel = await getChannelByApiKey(apiKey); + if (!channel) { + return { valid: false, reason: 'invalid_key' }; + } + + if (options.channelId && channel.id !== options.channelId) { + return { valid: false, reason: 'channel_mismatch' }; + } + + return { valid: true, channel }; +} + +// ─── Send endpoint combined auth ────────────────────────────────────────────── + +export interface SendAuthResult { + valid: true; + channelId: string; + targetId?: string; + threadId?: string; + /** 'token' if authenticated via ephemeral token, 'apikey' if via channel API key */ + method: 'token' | 'apikey'; +} + +export interface SendAuthFailure { + valid: false; + status: 401; + error: string; +} + +export type SendAuthCheck = SendAuthResult | SendAuthFailure; + +/** + * Check either ephemeral token OR channel API key auth for send endpoints. + * Token is tried first (cheapest), then API key. + */ +export async function verifySendAuth( + request: NextRequest, + channelId: string, + threadId?: string, +): Promise { + // Try ephemeral token first + const tokenResult = await verifyEphemeralToken(request, { channelId, threadId }); + if (tokenResult.valid) { + return { + valid: true, + channelId: tokenResult.channelId, + threadId: tokenResult.threadId, + method: 'token', + }; + } + + // Fall back to channel API key + const apiKeyResult = await verifyChannelApiKey(request, { channelId }); + if (apiKeyResult.valid) { + return { + valid: true, + channelId: apiKeyResult.channel.id, + method: 'apikey', + }; + } + + return { + valid: false, + status: 401, + error: 'Unauthorized: provide a valid ?token= or Authorization: Bearer header', + }; +} + +// ─── Management API auth ────────────────────────────────────────────────────── + +export interface ManagementAuthResult { + valid: true; +} + +export interface ManagementAuthFailure { + valid: false; + reason: 'missing_auth' | 'invalid_key' | 'not_configured'; +} + +export type ManagementAuthCheck = ManagementAuthResult | ManagementAuthFailure; + +/** + * Validate management API key from `Authorization: Bearer` header. + * + * If `MANAGEMENT_API_KEY` env var is not set, all requests are rejected unless + * running in development mode (where auth is bypassed for convenience). + */ +export function verifyManagementAuth(request: NextRequest): ManagementAuthCheck { + const configuredKey = process.env.MANAGEMENT_API_KEY; + + // In development without a configured key, allow all management requests. + if (!configuredKey) { + if (process.env.NODE_ENV === 'development') { + return { valid: true }; + } + return { valid: false, reason: 'not_configured' }; + } + + const authHeader = request.headers.get('authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return { valid: false, reason: 'missing_auth' }; + } + + const key = authHeader.slice(7).trim(); + if (key !== configuredKey) { + return { valid: false, reason: 'invalid_key' }; + } + + return { valid: true }; +} diff --git a/packages/server/src/lib/db.ts b/packages/server/src/lib/db.ts new file mode 100644 index 0000000..3134621 --- /dev/null +++ b/packages/server/src/lib/db.ts @@ -0,0 +1,539 @@ +/** + * MongoDB singleton + typed collection helpers for OpenThreads server. + * + * Uses the core types from @openthreads/core as MongoDB document shapes. + * All CRUD helpers operate against the shared singleton connection. + */ + +import { MongoClient, type Db, type Collection, type Filter, type UpdateFilter } from 'mongodb'; +import type { Channel, CreateChannelInput } from '@openthreads/core'; +import type { Recipient, CreateRecipientInput } from '@openthreads/core'; +import type { Thread, CreateThreadInput } from '@openthreads/core'; +import type { Turn, CreateTurnInput } from '@openthreads/core'; +import type { Route, CreateRouteInput, RouteCriteria } from '@openthreads/core'; +import { + generateThreadId, + generateTurnId, + generateTokenId, + generateChannelSecretKey, +} from '@openthreads/core'; + +// Re-export generators for use in route handlers +export { generateThreadId, generateTurnId, generateTokenId, generateChannelSecretKey }; + +// ─── Token document type ─────────────────────────────────────────────────────── + +export interface TokenDoc { + /** Internal unique ID */ + tokenId: string; + /** The "ot_tk_..." value included in ?token= query param */ + value: string; + channelId: string; + threadId: string; + turnId?: string; + expiresAt: Date; + used: boolean; + createdAt: Date; +} + +// ─── Singleton MongoDB connection ───────────────────────────────────────────── + +let _client: MongoClient | null = null; +let _db: Db | null = null; + +export async function getDb(): Promise { + if (_db) return _db; + + const uri = process.env.MONGODB_URI ?? 'mongodb://localhost:27017'; + const dbName = process.env.MONGODB_DB ?? 'openthreads'; + + _client = new MongoClient(uri, { + maxPoolSize: 10, + minPoolSize: 2, + connectTimeoutMS: 10_000, + serverSelectionTimeoutMS: 10_000, + }); + await _client.connect(); + _db = _client.db(dbName); + return _db; +} + +export async function disconnectDb(): Promise { + if (_client) { + await _client.close(); + _client = null; + _db = null; + } +} + +export async function pingDb(): Promise { + try { + const db = await getDb(); + await db.command({ ping: 1 }); + return true; + } catch { + return false; + } +} + +// ─── Typed collection accessors ─────────────────────────────────────────────── + +async function col(name: string): Promise> { + const db = await getDb(); + return db.collection(name); +} + +// ─── Channels ───────────────────────────────────────────────────────────────── + +export async function createChannel(input: CreateChannelInput): Promise { + const doc: Channel = { + ...input, + apiKey: input.apiKey ?? generateChannelSecretKey(), + }; + const coll = await col('channels'); + await coll.insertOne(doc as unknown as Channel & { _id?: unknown }); + return doc; +} + +export async function getChannel(id: string): Promise { + const coll = await col('channels'); + return (await coll.findOne({ id } as Filter)) as Channel | null; +} + +export async function getChannelByApiKey(apiKey: string): Promise { + const coll = await col('channels'); + return (await coll.findOne({ apiKey } as Filter)) as Channel | null; +} + +export async function updateChannel( + id: string, + updates: Partial, +): Promise { + const coll = await col('channels'); + const result = await coll.findOneAndUpdate( + { id } as Filter, + { $set: updates } as UpdateFilter, + { returnDocument: 'after' }, + ); + return result as Channel | null; +} + +export async function deleteChannel(id: string): Promise { + const coll = await col('channels'); + const result = await coll.deleteOne({ id } as Filter); + return result.deletedCount === 1; +} + +export async function listChannels(): Promise { + const coll = await col('channels'); + return (await coll.find({}).toArray()) as Channel[]; +} + +// ─── Recipients ─────────────────────────────────────────────────────────────── + +export async function createRecipient(input: CreateRecipientInput): Promise { + const coll = await col('recipients'); + await coll.insertOne(input as unknown as Recipient & { _id?: unknown }); + return input; +} + +export async function getRecipient(id: string): Promise { + const coll = await col('recipients'); + return (await coll.findOne({ id } as Filter)) as Recipient | null; +} + +export async function updateRecipient( + id: string, + updates: Partial, +): Promise { + const coll = await col('recipients'); + const result = await coll.findOneAndUpdate( + { id } as Filter, + { $set: updates } as UpdateFilter, + { returnDocument: 'after' }, + ); + return result as Recipient | null; +} + +export async function deleteRecipient(id: string): Promise { + const coll = await col('recipients'); + const result = await coll.deleteOne({ id } as Filter); + return result.deletedCount === 1; +} + +export async function listRecipients(): Promise { + const coll = await col('recipients'); + return (await coll.find({}).toArray()) as Recipient[]; +} + +// ─── Threads ────────────────────────────────────────────────────────────────── + +export async function createThread(input: CreateThreadInput): Promise { + const doc: Thread = { + threadId: input.threadId ?? generateThreadId(), + channelId: input.channelId, + nativeThreadId: input.nativeThreadId, + targetId: input.targetId, + createdAt: input.createdAt ?? new Date(), + }; + const coll = await col('threads'); + await coll.insertOne(doc as unknown as Thread & { _id?: unknown }); + return doc; +} + +export async function getThread(threadId: string): Promise { + const coll = await col('threads'); + return (await coll.findOne({ threadId } as Filter)) as Thread | null; +} + +export async function getThreadByNativeId( + channelId: string, + nativeThreadId: string, +): Promise { + const coll = await col('threads'); + return (await coll.findOne({ channelId, nativeThreadId } as Filter)) as Thread | null; +} + +export async function listThreadsByChannel( + channelId: string, + targetId?: string, +): Promise { + const coll = await col('threads'); + const query: Record = { channelId }; + if (targetId) query['targetId'] = targetId; + return (await coll.find(query as Filter).sort({ createdAt: -1 }).toArray()) as Thread[]; +} + +export async function listThreads(options?: { + channelId?: string; + targetId?: string; + search?: string; + limit?: number; + skip?: number; +}): Promise { + const coll = await col('threads'); + const query: Record = {}; + if (options?.channelId) query['channelId'] = options.channelId; + if (options?.targetId) query['targetId'] = options.targetId; + if (options?.search) { + query['$or'] = [ + { threadId: { $regex: options.search, $options: 'i' } }, + { targetId: { $regex: options.search, $options: 'i' } }, + { channelId: { $regex: options.search, $options: 'i' } }, + ]; + } + let cursor = coll.find(query as Filter).sort({ createdAt: -1 }); + if (options?.skip) cursor = cursor.skip(options.skip); + if (options?.limit) cursor = cursor.limit(options.limit); + return (await cursor.toArray()) as Thread[]; +} + +// ─── Turns ──────────────────────────────────────────────────────────────────── + +export async function createTurn(input: CreateTurnInput): Promise { + const doc: Turn = { + turnId: input.turnId ?? generateTurnId(), + threadId: input.threadId, + direction: input.direction, + message: input.message, + timestamp: input.timestamp ?? new Date(), + }; + const coll = await col('turns'); + await coll.insertOne(doc as unknown as Turn & { _id?: unknown }); + return doc; +} + +export async function getTurn(turnId: string): Promise { + const coll = await col('turns'); + return (await coll.findOne({ turnId } as Filter)) as Turn | null; +} + +export async function listTurnsByThread(threadId: string): Promise { + const coll = await col('turns'); + return (await coll + .find({ threadId } as Filter) + .sort({ timestamp: 1 }) + .toArray()) as Turn[]; +} + +// ─── Routes ─────────────────────────────────────────────────────────────────── + +export async function createRoute(input: CreateRouteInput): Promise { + const coll = await col('routes'); + await coll.insertOne(input as unknown as Route & { _id?: unknown }); + return input; +} + +export async function getRoute(id: string): Promise { + const coll = await col('routes'); + return (await coll.findOne({ id } as Filter)) as Route | null; +} + +export async function updateRoute(id: string, updates: Partial): Promise { + const coll = await col('routes'); + const result = await coll.findOneAndUpdate( + { id } as Filter, + { $set: updates } as UpdateFilter, + { returnDocument: 'after' }, + ); + return result as Route | null; +} + +export async function deleteRoute(id: string): Promise { + const coll = await col('routes'); + const result = await coll.deleteOne({ id } as Filter); + return result.deletedCount === 1; +} + +export async function listRoutes(): Promise { + const coll = await col('routes'); + return (await coll.find({}).sort({ priority: 1 }).toArray()) as Route[]; +} + +export async function findMatchingRoutes(criteria: Partial): Promise { + const coll = await col('routes'); + const conditions: Record[] = [ + { $or: [{ enabled: true }, { enabled: { $exists: false } }] }, + ]; + + if (criteria.channelId) { + conditions.push({ + $or: [ + { 'criteria.channelId': criteria.channelId }, + { 'criteria.channelId': { $exists: false } }, + ], + }); + } + + if (criteria.isDm !== undefined) { + conditions.push({ + $or: [ + { 'criteria.isDm': criteria.isDm }, + { 'criteria.isDm': { $exists: false } }, + ], + }); + } + + if (criteria.isMention !== undefined) { + conditions.push({ + $or: [ + { 'criteria.isMention': criteria.isMention }, + { 'criteria.isMention': { $exists: false } }, + ], + }); + } + + if (criteria.senderId) { + conditions.push({ + $or: [ + { 'criteria.senderId': criteria.senderId }, + { 'criteria.senderId': { $exists: false } }, + ], + }); + } + + const query = conditions.length > 1 ? { $and: conditions } : conditions[0]; + return (await coll + .find(query as Filter) + .sort({ priority: 1 }) + .toArray()) as Route[]; +} + +// ─── Tokens ─────────────────────────────────────────────────────────────────── + +export interface CreateTokenOptions { + channelId: string; + threadId: string; + turnId?: string; + /** TTL in seconds (default: from env or 86400) */ + ttlSeconds?: number; +} + +export async function createEphemeralToken(options: CreateTokenOptions): Promise { + const ttl = options.ttlSeconds ?? Number(process.env.REPLY_TOKEN_TTL ?? 86400); + const value = generateTokenId(); + const doc: TokenDoc = { + tokenId: value, + value, + channelId: options.channelId, + threadId: options.threadId, + turnId: options.turnId, + expiresAt: new Date(Date.now() + ttl * 1000), + used: false, + createdAt: new Date(), + }; + + const coll = await col('tokens'); + await coll.insertOne(doc as unknown as TokenDoc & { _id?: unknown }); + return doc; +} + +export async function getValidToken(value: string): Promise { + const coll = await col('tokens'); + return (await coll.findOne({ + value, + used: false, + expiresAt: { $gt: new Date() }, + } as Filter)) as TokenDoc | null; +} + +export async function consumeToken(value: string): Promise { + const coll = await col('tokens'); + const result = await coll.updateOne( + { value, used: false, expiresAt: { $gt: new Date() } } as Filter, + { $set: { used: true } } as UpdateFilter, + ); + return result.modifiedCount === 1; +} + +// ─── Form Records ───────────────────────────────────────────────────────────── + +/** + * A form record tracks the state of an auto-generated A2H form (methods 3 & 4). + * + * Created lazily on first GET /form/:formKey access. Expires alongside the + * ephemeral token TTL. The `formKey` is the turnId for single intents and + * `${turnId}_batch` for batch (method 4) forms. + */ +export interface FormRecord { + /** Form key: turnId for single intent, `${turnId}_batch` for batch */ + formKey: string; + /** The base turn ID */ + turnId: string; + /** Whether this is a batch form (multiple A2H intents) */ + isBatch: boolean; + /** The A2H intent(s) for this form, as serialized JSON */ + intents: unknown[]; + /** Current form status */ + status: 'pending' | 'submitted'; + /** Human's responses, populated on submission */ + responses?: unknown[]; + /** When the form expires */ + expiresAt: Date; + createdAt: Date; +} + +export async function createFormRecord( + record: Omit, +): Promise { + const doc: FormRecord = { ...record, createdAt: new Date() }; + const coll = await col('forms'); + await coll.insertOne(doc as unknown as FormRecord & { _id?: unknown }); + return doc; +} + +export async function getFormRecord(formKey: string): Promise { + const coll = await col('forms'); + return (await coll.findOne({ formKey } as Filter)) as FormRecord | null; +} + +export async function updateFormRecord( + formKey: string, + updates: Partial>, +): Promise { + const coll = await col('forms'); + const result = await coll.findOneAndUpdate( + { formKey } as Filter, + { $set: updates } as UpdateFilter, + { returnDocument: 'after' }, + ); + return result as FormRecord | null; +} + +// ─── Audit Log ──────────────────────────────────────────────────────────────── + +export interface AuditLogDoc { + id: string; + eventType: string; + turnId: string; + threadId?: string; + channelId?: string; + actorId?: string; + channelMetadata?: Record; + intentType?: string; + traceId?: string; + nonce?: string; + timestamp: Date; + payload?: unknown; +} + +export async function saveAuditEntry(entry: AuditLogDoc): Promise { + const coll = await col('audit_log'); + await coll.insertOne(entry as unknown as AuditLogDoc & { _id?: unknown }); +} + +export async function queryAuditLog(filter: { + turnId?: string; + threadId?: string; + channelId?: string; + eventType?: string; + fromDate?: Date; + toDate?: Date; + limit?: number; + offset?: number; +}): Promise { + const coll = await col('audit_log'); + const query: Record = {}; + + if (filter.turnId) query['turnId'] = filter.turnId; + if (filter.threadId) query['threadId'] = filter.threadId; + if (filter.channelId) query['channelId'] = filter.channelId; + if (filter.eventType) query['eventType'] = filter.eventType; + if (filter.fromDate || filter.toDate) { + const tsFilter: Record = {}; + if (filter.fromDate) tsFilter['$gte'] = filter.fromDate; + if (filter.toDate) tsFilter['$lte'] = filter.toDate; + query['timestamp'] = tsFilter; + } + + let cursor = coll.find(query as Filter).sort({ timestamp: -1 }); + if (filter.offset) cursor = cursor.skip(filter.offset); + cursor = cursor.limit(filter.limit ?? 100); + + return (await cursor.toArray()) as AuditLogDoc[]; +} + +// ─── Ensure indexes ─────────────────────────────────────────────────────────── + +export async function ensureIndexes(): Promise { + const db = await getDb(); + + await Promise.all([ + db.collection('channels').createIndexes([ + { key: { id: 1 }, unique: true, name: 'channels_id_unique' }, + { key: { apiKey: 1 }, sparse: true, unique: true, name: 'channels_apiKey_unique' }, + ]), + db.collection('recipients').createIndexes([ + { key: { id: 1 }, unique: true, name: 'recipients_id_unique' }, + ]), + db.collection('threads').createIndexes([ + { key: { threadId: 1 }, unique: true, name: 'threads_threadId_unique' }, + { key: { channelId: 1, nativeThreadId: 1 }, name: 'threads_channelId_nativeThreadId' }, + { key: { channelId: 1, targetId: 1 }, name: 'threads_channelId_targetId' }, + ]), + db.collection('turns').createIndexes([ + { key: { turnId: 1 }, unique: true, name: 'turns_turnId_unique' }, + { key: { threadId: 1, timestamp: 1 }, name: 'turns_threadId_timestamp' }, + ]), + db.collection('routes').createIndexes([ + { key: { id: 1 }, unique: true, name: 'routes_id_unique' }, + { key: { priority: 1 }, name: 'routes_priority' }, + ]), + db.collection('tokens').createIndexes([ + { key: { value: 1 }, unique: true, name: 'tokens_value_unique' }, + { key: { expiresAt: 1 }, expireAfterSeconds: 0, name: 'tokens_expiresAt_ttl' }, + ]), + db.collection('forms').createIndexes([ + { key: { formKey: 1 }, unique: true, name: 'forms_formKey_unique' }, + { key: { turnId: 1 }, name: 'forms_turnId' }, + { key: { expiresAt: 1 }, expireAfterSeconds: 0, name: 'forms_expiresAt_ttl' }, + ]), + db.collection('audit_log').createIndexes([ + { key: { id: 1 }, unique: true, name: 'audit_log_id_unique' }, + { key: { turnId: 1, timestamp: -1 }, name: 'audit_log_turnId_timestamp' }, + { key: { threadId: 1 }, sparse: true, name: 'audit_log_threadId' }, + { key: { eventType: 1, timestamp: -1 }, name: 'audit_log_eventType_timestamp' }, + { key: { timestamp: -1 }, name: 'audit_log_timestamp' }, + ]), + ]); +} diff --git a/packages/server/src/lib/deduplication.ts b/packages/server/src/lib/deduplication.ts new file mode 100644 index 0000000..1d4c201 --- /dev/null +++ b/packages/server/src/lib/deduplication.ts @@ -0,0 +1,158 @@ +/** + * Idempotent inbound message deduplication. + * + * Prevents the same platform event from being processed more than once when + * a platform retries delivery (e.g., Slack retries events that receive no 2xx + * within 3 seconds; Telegram retries if the bot misses a getUpdates poll). + * + * Usage: + * 1. Call `deduplicationStore.check(key)` with a stable event identifier. + * 2. If it returns `true`, the event is a duplicate — return 200 immediately. + * 3. If it returns `false`, process the event then call `.seen(key)`. + * + * The store is intentionally kept as a simple interface so it can be backed + * by Redis, a database, or the default in-process LRU (for single-instance + * deployments and testing). + */ + +// --------------------------------------------------------------------------- +// Interface +// --------------------------------------------------------------------------- + +export interface DeduplicationStore { + /** + * Returns `true` if the key was previously seen (and is still within TTL). + * Does NOT record the key. + */ + check(key: string): boolean; + /** + * Record `key` as seen. Subsequent `check(key)` calls will return `true` + * until the key's TTL expires. + */ + seen(key: string, ttlMs?: number): void; +} + +// --------------------------------------------------------------------------- +// In-memory LRU-capped store (default, single-process) +// --------------------------------------------------------------------------- + +interface Entry { + expiresAt: number; +} + +/** Default TTL: 1 hour */ +const DEFAULT_TTL_MS = 60 * 60 * 1_000; + +/** Maximum number of keys to track before evicting the oldest. */ +const DEFAULT_MAX_SIZE = 10_000; + +/** + * In-memory deduplication store backed by a Map with LRU-style eviction. + * + * Suitable for single-instance deployments. For multi-instance deployments, + * replace with a Redis-backed implementation that exposes the same interface. + */ +export class InMemoryDeduplicationStore implements DeduplicationStore { + private readonly seen_keys = new Map(); + private readonly maxSize: number; + + constructor(maxSize = DEFAULT_MAX_SIZE) { + this.maxSize = maxSize; + } + + check(key: string): boolean { + const entry = this.seen_keys.get(key); + if (!entry) return false; + + if (Date.now() > entry.expiresAt) { + this.seen_keys.delete(key); + return false; + } + + return true; + } + + seen(key: string, ttlMs = DEFAULT_TTL_MS): void { + // Evict the oldest entry if at capacity. + if (this.seen_keys.size >= this.maxSize) { + const oldest = this.seen_keys.keys().next().value; + if (oldest !== undefined) this.seen_keys.delete(oldest); + } + + this.seen_keys.set(key, { expiresAt: Date.now() + ttlMs }); + } + + /** Returns the number of currently tracked keys (includes expired, pre-eviction). */ + get size(): number { + return this.seen_keys.size; + } + + /** Purge all expired entries. Can be called periodically to reclaim memory. */ + purgeExpired(): number { + const now = Date.now(); + let purged = 0; + for (const [key, entry] of this.seen_keys) { + if (now > entry.expiresAt) { + this.seen_keys.delete(key); + purged++; + } + } + return purged; + } +} + +// --------------------------------------------------------------------------- +// Platform-specific key builders +// --------------------------------------------------------------------------- + +/** + * Build a deduplication key for a Slack event. + * + * Slack includes a unique `event_id` on every event payload. Retries carry + * the same `event_id`, making it ideal as a deduplication key. + */ +export function slackEventKey(eventId: string): string { + return `slack:${eventId}`; +} + +/** + * Build a deduplication key for a Telegram update. + * + * Each Telegram update has a monotonically increasing `update_id` per bot. + */ +export function telegramUpdateKey(botId: string, updateId: number): string { + return `telegram:${botId}:${updateId}`; +} + +/** + * Build a deduplication key for a WhatsApp message. + * + * WhatsApp message IDs are unique per JID (phone/group). + */ +export function whatsappMessageKey(jid: string, messageId: string): string { + return `whatsapp:${jid}:${messageId}`; +} + +/** + * Generic deduplication key from an arbitrary channel + native message ID. + */ +export function genericMessageKey(channelId: string, nativeMessageId: string): string { + return `msg:${channelId}:${nativeMessageId}`; +} + +// --------------------------------------------------------------------------- +// Singleton store (shared across webhook handlers in the same process) +// --------------------------------------------------------------------------- + +let _defaultStore: InMemoryDeduplicationStore | null = null; + +/** + * Returns the process-wide default deduplication store, creating it on first call. + * Suitable for single-process deployments using the in-memory store. + */ +export function getDefaultDeduplicationStore(): InMemoryDeduplicationStore { + if (!_defaultStore) { + _defaultStore = new InMemoryDeduplicationStore(); + } + return _defaultStore; +} diff --git a/packages/server/src/lib/fanout.ts b/packages/server/src/lib/fanout.ts new file mode 100644 index 0000000..b86cc8c --- /dev/null +++ b/packages/server/src/lib/fanout.ts @@ -0,0 +1,146 @@ +/** + * Fan-out: deliver an envelope to a recipient's webhook URL. + * + * Used by both the webhook handler (inbound channel → recipients) + * and the send handler (recipient inbound → response delivery). + */ + +import type { Recipient } from '@openthreads/core'; +import { withRetry, type RetryOptions } from './retry.js'; + +export interface DeliverOptions { + recipient: Recipient; + payload: unknown; + /** Timeout in milliseconds (default: 30s) */ + timeoutMs?: number; +} + +export interface DeliverWithRetryOptions extends DeliverOptions { + /** Retry configuration. Defaults: maxAttempts=3, initialDelayMs=1000, backoffFactor=2 */ + retryOptions?: Partial; +} + +export interface DeliverResult { + success: boolean; + status?: number; + error?: string; +} + +/** + * POST the envelope payload to the recipient's webhook URL. + * + * Uses the recipient's `apiKey` as a Bearer token if configured. + * Returns the HTTP status from the recipient's server. + */ +export async function deliverToRecipient(options: DeliverOptions): Promise { + const { recipient, payload, timeoutMs = 30_000 } = options; + + const headers: Record = { + 'Content-Type': 'application/json', + 'User-Agent': 'OpenThreads/1.0', + }; + + if (recipient.apiKey) { + headers['Authorization'] = `Bearer ${recipient.apiKey}`; + } + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(recipient.webhookUrl, { + method: 'POST', + headers, + body: JSON.stringify(payload), + signal: controller.signal, + }); + + return { success: response.ok, status: response.status }; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + return { success: false, error }; + } finally { + clearTimeout(timer); + } +} + +/** + * POST the envelope payload to the recipient's webhook URL with automatic + * retry on transient failures (network errors, 5xx responses). + * + * Uses exponential backoff — initial 1 s delay, doubling up to 30 s. + * 4xx responses (client errors) are considered non-retryable. + */ +export async function deliverWithRetry( + options: DeliverWithRetryOptions, +): Promise { + const { retryOptions = {}, ...deliverOptions } = options; + + return withRetry( + async () => { + const result = await deliverToRecipient(deliverOptions); + + // Treat 4xx as non-retryable client errors — the caller sent bad data. + if (!result.success && result.status !== undefined && result.status >= 400 && result.status < 500) { + // Signal to withRetry to not retry by throwing a non-retryable sentinel. + const err = new NonRetryableError(`Recipient returned ${result.status}`); + (err as unknown as { result: DeliverResult }).result = result; + throw err; + } + + if (!result.success) { + throw new Error(result.error ?? `Delivery failed (status ${result.status ?? 'unknown'})`); + } + + return result; + }, + { + ...retryOptions, + retryable: (err) => !(err instanceof NonRetryableError), + }, + ).catch((err: unknown) => { + // If the final error wraps a DeliverResult (from a 4xx), return it directly. + if (err instanceof NonRetryableError) { + const wrapped = (err as unknown as { result?: DeliverResult }).result; + if (wrapped) return wrapped; + } + const error = err instanceof Error ? err.message : String(err); + return { success: false, error } as DeliverResult; + }); +} + +/** Sentinel error type used to stop retrying on 4xx responses. */ +class NonRetryableError extends Error { + constructor(message: string) { + super(message); + this.name = 'NonRetryableError'; + } +} + +/** + * Fan out to multiple recipients concurrently. + * Returns a map of recipientId → delivery result. + */ +export async function fanOut( + recipients: Recipient[], + payload: unknown, +): Promise> { + const results = await Promise.allSettled( + recipients.map((r) => deliverToRecipient({ recipient: r, payload })), + ); + + const map = new Map(); + for (let i = 0; i < recipients.length; i++) { + const result = results[i]; + if (result.status === 'fulfilled') { + map.set(recipients[i].id, result.value); + } else { + map.set(recipients[i].id, { + success: false, + error: result.reason instanceof Error ? result.reason.message : String(result.reason), + }); + } + } + + return map; +} diff --git a/packages/server/src/lib/form-registry.ts b/packages/server/src/lib/form-registry.ts new file mode 100644 index 0000000..7debb94 --- /dev/null +++ b/packages/server/src/lib/form-registry.ts @@ -0,0 +1,84 @@ +/** + * Global in-process registry for pending A2H form responses. + * + * When the Reply Engine generates a form URL (methods 3 & 4), it registers + * a pending entry keyed by formKey (turnId or `${turnId}_batch`). The form + * submission API route calls `formRegistry.submit()` to resolve the promise + * and unblock the Reply Engine. + * + * Implemented as a global singleton (via `globalThis`) so it persists across + * hot-reloads in development and is shared across all Next.js API route + * invocations within the same Node.js process. + */ + +export type A2HIntent = 'INFORM' | 'COLLECT' | 'AUTHORIZE' | 'ESCALATE' | 'RESULT'; + +export interface A2HResponse { + intent: A2HIntent; + /** The human's answer. true/false for AUTHORIZE; field map for COLLECT. */ + response: unknown; + /** Optional free-text comment (AUTHORIZE) */ + comment?: string; + /** Timestamp of the human's response */ + respondedAt: string; +} + +interface PendingEntry { + resolve: (response: A2HResponse) => void; + reject: (error: unknown) => void; + intent: A2HIntent; + createdAt: Date; +} + +class FormResponseRegistry { + private readonly pending = new Map(); + + /** + * Register a pending entry for `key`. + * Returns a Promise that resolves when `submit(key, response)` is called. + */ + wait(key: string, intent: A2HIntent): Promise { + return new Promise((resolve, reject) => { + this.pending.set(key, { resolve, reject, intent, createdAt: new Date() }); + }); + } + + /** + * Resolve the pending entry for `key` with the human's response. + * Returns true if a pending entry was found and resolved, false otherwise. + */ + submit(key: string, response: A2HResponse): boolean { + const entry = this.pending.get(key); + if (!entry) return false; + this.pending.delete(key); + entry.resolve(response); + return true; + } + + /** + * Reject the pending entry for `key` (e.g., form expired or cancelled). + */ + cancel(key: string, reason?: unknown): boolean { + const entry = this.pending.get(key); + if (!entry) return false; + this.pending.delete(key); + entry.reject(reason ?? new Error(`Pending form response for key "${key}" was cancelled`)); + return true; + } + + has(key: string): boolean { + return this.pending.has(key); + } + + get size(): number { + return this.pending.size; + } +} + +// Attach to globalThis so it survives module re-evaluation during hot-reload. +const g = globalThis as typeof globalThis & { __otFormRegistry?: FormResponseRegistry }; +if (!g.__otFormRegistry) { + g.__otFormRegistry = new FormResponseRegistry(); +} + +export const formRegistry: FormResponseRegistry = g.__otFormRegistry; diff --git a/packages/server/src/lib/graceful-storage.ts b/packages/server/src/lib/graceful-storage.ts new file mode 100644 index 0000000..796b2ef --- /dev/null +++ b/packages/server/src/lib/graceful-storage.ts @@ -0,0 +1,136 @@ +/** + * Graceful degradation for storage operations. + * + * When the storage layer (MongoDB, etc.) becomes temporarily unavailable, + * it should not cause a complete outage. This module provides helpers that: + * + * 1. Catch storage errors and return a safe fallback value. + * 2. Optionally invoke an `onError` callback for observability. + * 3. Track whether storage is currently healthy. + * + * Usage: + * ```ts + * // Instead of: + * const channel = await db.channels.getById(channelId); + * + * // Use: + * const channel = await withGracefulStorage( + * () => db.channels.getById(channelId), + * null, + * 'channels.getById', + * ); + * if (!channel) { + * return NextResponse.json({ error: 'Storage unavailable' }, { status: 503 }); + * } + * ``` + */ + +// --------------------------------------------------------------------------- +// Core utility +// --------------------------------------------------------------------------- + +export interface GracefulStorageOptions { + /** + * Invoked whenever a storage operation throws. + * Use for logging / alerting. + */ + onError?: (operation: string, error: unknown) => void; +} + +/** + * Execute a storage operation, returning `fallback` if the operation throws. + * + * @param operation A function that performs the storage call. + * @param fallback Value returned when `operation` throws. + * @param label Human-readable label for error logging. Default: 'storage'. + * @param options Optional hooks (e.g., onError callback). + */ +export async function withGracefulStorage( + operation: () => Promise, + fallback: T, + label = 'storage', + options: GracefulStorageOptions = {}, +): Promise { + try { + return await operation(); + } catch (err) { + options.onError?.(label, err); + return fallback; + } +} + +// --------------------------------------------------------------------------- +// StorageHealthMonitor +// --------------------------------------------------------------------------- + +/** + * Tracks the health of the storage layer based on recent operation outcomes. + * + * Call `recordSuccess()` / `recordFailure()` around storage operations. + * `isHealthy()` returns false when the error rate exceeds the threshold in + * the sliding window — at which point callers should return 503 immediately + * rather than attempting (and failing) storage calls. + */ +export class StorageHealthMonitor { + private readonly windowSize: number; + private readonly failureThreshold: number; + private readonly outcomes: boolean[] = []; + + /** + * @param windowSize Number of recent outcomes to track. Default: 20 + * @param failureThreshold Fraction of failures that triggers unhealthy. Default: 0.5 + */ + constructor(windowSize = 20, failureThreshold = 0.5) { + this.windowSize = windowSize; + this.failureThreshold = failureThreshold; + } + + recordSuccess(): void { + this.push(true); + } + + recordFailure(): void { + this.push(false); + } + + /** + * Returns `true` when the storage layer appears healthy. + * Returns `true` when there is not enough history to make a determination. + */ + isHealthy(): boolean { + if (this.outcomes.length < this.windowSize) return true; + + const failures = this.outcomes.filter((ok) => !ok).length; + return failures / this.outcomes.length < this.failureThreshold; + } + + /** Reset the monitor (e.g., after a successful reconnection). */ + reset(): void { + this.outcomes.length = 0; + } + + // Keep the rolling window bounded. + private push(ok: boolean): void { + this.outcomes.push(ok); + if (this.outcomes.length > this.windowSize) { + this.outcomes.shift(); + } + } +} + +// --------------------------------------------------------------------------- +// Singleton monitor +// --------------------------------------------------------------------------- + +let _defaultMonitor: StorageHealthMonitor | null = null; + +/** + * Returns the process-wide default `StorageHealthMonitor`, creating it on + * first call. + */ +export function getDefaultStorageMonitor(): StorageHealthMonitor { + if (!_defaultMonitor) { + _defaultMonitor = new StorageHealthMonitor(); + } + return _defaultMonitor; +} diff --git a/packages/server/src/lib/logger.ts b/packages/server/src/lib/logger.ts new file mode 100644 index 0000000..dd44574 --- /dev/null +++ b/packages/server/src/lib/logger.ts @@ -0,0 +1,119 @@ +/** + * Structured JSON logger for OpenThreads server. + * + * Outputs newline-delimited JSON when LOG_FORMAT=json (default in production) + * or human-readable text when LOG_FORMAT=text (default in development). + * + * Log level is controlled via the LOG_LEVEL environment variable: + * debug | info | warn | error (default: info) + */ + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +const LEVEL_RANK: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +function getConfiguredLevel(): LogLevel { + const env = (process.env.LOG_LEVEL ?? 'info').toLowerCase() as LogLevel; + return env in LEVEL_RANK ? env : 'info'; +} + +function isJsonFormat(): boolean { + const fmt = process.env.LOG_FORMAT ?? (process.env.NODE_ENV === 'production' ? 'json' : 'text'); + return fmt === 'json'; +} + +function shouldLog(level: LogLevel): boolean { + return LEVEL_RANK[level] >= LEVEL_RANK[getConfiguredLevel()]; +} + +// ─── Core log function ──────────────────────────────────────────────────────── + +function log(level: LogLevel, message: string, fields?: Record): void { + if (!shouldLog(level)) return; + + if (isJsonFormat()) { + const entry: Record = { + timestamp: new Date().toISOString(), + level, + message, + ...fields, + }; + const line = JSON.stringify(entry); + if (level === 'error') { + process.stderr.write(line + '\n'); + } else { + process.stdout.write(line + '\n'); + } + } else { + const ts = new Date().toISOString(); + const lvl = level.toUpperCase().padEnd(5); + const extras = fields ? ' ' + Object.entries(fields).map(([k, v]) => `${k}=${String(v)}`).join(' ') : ''; + const line = `[${ts}] [${lvl}] ${message}${extras}`; + if (level === 'error') { + console.error(line); + } else if (level === 'warn') { + console.warn(line); + } else { + console.log(line); + } + } +} + +export const logger = { + debug: (message: string, fields?: Record) => log('debug', message, fields), + info: (message: string, fields?: Record) => log('info', message, fields), + warn: (message: string, fields?: Record) => log('warn', message, fields), + error: (message: string, fields?: Record) => log('error', message, fields), +}; + +// ─── Request logging ────────────────────────────────────────────────────────── + +export interface RequestLogEntry { + timestamp: string; + method: string; + path: string; + status: number; + durationMs: number; + ip?: string; + error?: string; +} + +export function logRequest(entry: RequestLogEntry): void { + const { status, method, path, durationMs, ip, error } = entry; + const level: LogLevel = status >= 500 ? 'error' : status >= 400 ? 'warn' : 'info'; + + log(level, `${method} ${path} ${status}`, { + method, + path, + status, + durationMs, + ...(ip ? { ip } : {}), + ...(error ? { error } : {}), + }); +} + +/** + * Create a request log entry from a Next.js Request + Response. + */ +export function createLogEntry( + request: Request, + status: number, + startedAt: number, + error?: unknown, +): RequestLogEntry { + const url = new URL(request.url); + return { + timestamp: new Date().toISOString(), + method: request.method, + path: url.pathname, + status, + durationMs: Date.now() - startedAt, + ip: request.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? undefined, + error: error instanceof Error ? error.message : error ? String(error) : undefined, + }; +} diff --git a/packages/server/src/lib/metrics.ts b/packages/server/src/lib/metrics.ts new file mode 100644 index 0000000..42febc7 --- /dev/null +++ b/packages/server/src/lib/metrics.ts @@ -0,0 +1,141 @@ +/** + * In-process metrics registry for OpenThreads. + * + * Tracks Prometheus-compatible counters and gauges. Values are exposed via + * GET /api/metrics in the Prometheus text exposition format. + * + * All counters and gauges are process-local. In a multi-instance deployment, + * aggregate across instances using a Prometheus scrape job. + */ + +type MetricType = 'counter' | 'gauge'; + +interface MetricDescriptor { + name: string; + help: string; + type: MetricType; +} + +interface LabelSet { + [label: string]: string; +} + +interface Sample { + labels: LabelSet; + value: number; +} + +class Metric { + private samples = new Map(); + + constructor(readonly descriptor: MetricDescriptor) {} + + private key(labels: LabelSet): string { + return JSON.stringify(Object.fromEntries(Object.entries(labels).sort())); + } + + inc(labels: LabelSet = {}, amount = 1): void { + const k = this.key(labels); + const existing = this.samples.get(k); + if (existing) { + existing.value += amount; + } else { + this.samples.set(k, { labels, value: amount }); + } + } + + set(labels: LabelSet = {}, value: number): void { + const k = this.key(labels); + this.samples.set(k, { labels, value }); + } + + get(labels: LabelSet = {}): number { + return this.samples.get(this.key(labels))?.value ?? 0; + } + + render(): string { + const { name, help, type } = this.descriptor; + const lines: string[] = [ + `# HELP ${name} ${help}`, + `# TYPE ${name} ${type}`, + ]; + for (const { labels, value } of this.samples.values()) { + const labelStr = Object.entries(labels) + .map(([k, v]) => `${k}="${v.replace(/"/g, '\\"')}"`) + .join(','); + lines.push(labelStr ? `${name}{${labelStr}} ${value}` : `${name} ${value}`); + } + return lines.join('\n'); + } +} + +// ─── Registry ───────────────────────────────────────────────────────────────── + +class MetricsRegistry { + private metrics = new Map(); + + private register(descriptor: MetricDescriptor): Metric { + if (!this.metrics.has(descriptor.name)) { + this.metrics.set(descriptor.name, new Metric(descriptor)); + } + return this.metrics.get(descriptor.name)!; + } + + counter(name: string, help: string): Metric { + return this.register({ name, help, type: 'counter' }); + } + + gauge(name: string, help: string): Metric { + return this.register({ name, help, type: 'gauge' }); + } + + render(): string { + return [...this.metrics.values()].map((m) => m.render()).join('\n\n') + '\n'; + } +} + +export const registry = new MetricsRegistry(); + +// ─── Metric definitions ─────────────────────────────────────────────────────── + +/** Total inbound webhook events received, labelled by channel. */ +export const messagesInTotal = registry.counter( + 'openthreads_messages_in_total', + 'Total number of inbound messages received from channels.', +); + +/** Total outbound messages sent to recipients, labelled by channel and status. */ +export const messagesOutTotal = registry.counter( + 'openthreads_messages_out_total', + 'Total number of outbound messages sent to recipients.', +); + +/** Total A2H intents processed, labelled by intent type and method (1-4). */ +export const a2hIntentsTotal = registry.counter( + 'openthreads_a2h_intents_total', + 'Total number of A2H intents processed by the Reply Engine.', +); + +/** Number of currently active (open, not-yet-resolved) threads. */ +export const activeThreadsGauge = registry.gauge( + 'openthreads_active_threads', + 'Number of active (open) threads.', +); + +/** HTTP request duration histogram approximation (p50/p95 via labelled gauges). */ +export const httpRequestDurationMs = registry.counter( + 'openthreads_http_requests_total', + 'Total HTTP requests served, labelled by method, path, and status_class.', +); + +/** Recipient fanout latency total (for computing average). */ +export const fanoutDurationMsTotal = registry.counter( + 'openthreads_fanout_duration_ms_total', + 'Cumulative fanout latency in milliseconds (divide by fanout_count for average).', +); + +/** Total successful fanout deliveries. */ +export const fanoutTotal = registry.counter( + 'openthreads_fanout_total', + 'Total recipient fanout attempts, labelled by status (success|error).', +); diff --git a/packages/server/src/lib/rate-limit.ts b/packages/server/src/lib/rate-limit.ts new file mode 100644 index 0000000..707cfb2 --- /dev/null +++ b/packages/server/src/lib/rate-limit.ts @@ -0,0 +1,84 @@ +/** + * Simple in-memory rate limiter for public endpoints. + * + * Uses a sliding-window counter per key (IP address or channel ID). + * NOT suitable for multi-process deployments — use Redis for production. + */ + +interface Bucket { + count: number; + resetAt: number; +} + +const buckets = new Map(); + +// Clean up expired buckets every 5 minutes to prevent unbounded memory growth. +let cleanupTimer: ReturnType | null = null; + +function startCleanup(): void { + if (cleanupTimer) return; + cleanupTimer = setInterval(() => { + const now = Date.now(); + for (const [key, bucket] of buckets) { + if (bucket.resetAt <= now) { + buckets.delete(key); + } + } + }, 5 * 60 * 1000); + + // Don't block process exit. + if (typeof cleanupTimer === 'object' && 'unref' in cleanupTimer) { + (cleanupTimer as NodeJS.Timeout).unref(); + } +} + +/** + * Attempt to consume one request from the rate limit bucket for `key`. + * + * @param key Identifier for this rate-limit bucket (e.g. IP, channelId) + * @param limit Maximum number of requests allowed in the window + * @param windowMs Window duration in milliseconds + * @returns `true` if the request is allowed, `false` if the limit is exceeded + */ +export function rateLimit(key: string, limit: number, windowMs: number): boolean { + startCleanup(); + + const now = Date.now(); + const bucket = buckets.get(key); + + if (!bucket || bucket.resetAt <= now) { + buckets.set(key, { count: 1, resetAt: now + windowMs }); + return true; + } + + if (bucket.count >= limit) { + return false; + } + + bucket.count += 1; + return true; +} + +/** + * Extract the client IP address from a Next.js request. + * Falls back to 'unknown' if no address can be determined. + */ +export function getClientIp(request: Request): string { + const forwarded = request.headers.get('x-forwarded-for'); + if (forwarded) { + return forwarded.split(',')[0].trim(); + } + return request.headers.get('x-real-ip') ?? 'unknown'; +} + +// ─── Preconfigured limiters ─────────────────────────────────────────────────── + +/** 60 webhook requests per minute per channel */ +export function webhookRateLimit(channelId: string): boolean { + return rateLimit(`webhook:${channelId}`, 60, 60 * 1000); +} + +/** 30 send requests per minute per IP */ +export function sendRateLimit(ip: string): boolean { + return rateLimit(`send:${ip}`, 30, 60 * 1000); +} diff --git a/packages/server/src/lib/retry.ts b/packages/server/src/lib/retry.ts new file mode 100644 index 0000000..d88b89a --- /dev/null +++ b/packages/server/src/lib/retry.ts @@ -0,0 +1,99 @@ +/** + * Exponential backoff retry utility. + * + * Used by the webhook fan-out layer to retry failed deliveries. + */ + +export interface RetryOptions { + /** Maximum number of total attempts (first try + retries). Default: 3 */ + maxAttempts: number; + /** Delay before the second attempt in milliseconds. Default: 1000 */ + initialDelayMs: number; + /** Cap on the computed delay (prevents runaway backoff). Default: 30000 */ + maxDelayMs: number; + /** Multiplier applied to the delay after each attempt. Default: 2 */ + backoffFactor: number; + /** + * Optional predicate — called with the thrown error. + * When it returns `false`, the retry loop stops immediately and the error + * is re-thrown without further attempts. + * Default: always retry. + */ + retryable?: (error: unknown) => boolean; + /** + * Optional callback invoked before each retry (not before the first attempt). + */ + onRetry?: (attempt: number, delayMs: number, error: unknown) => void; +} + +const DEFAULTS: RetryOptions = { + maxAttempts: 3, + initialDelayMs: 1_000, + maxDelayMs: 30_000, + backoffFactor: 2, +}; + +/** + * Execute `fn`, retrying up to `options.maxAttempts` times with exponential + * backoff between attempts. + * + * Resolves with the first successful return value, or rejects with the last + * error if all attempts fail. + * + * @example + * ```ts + * const result = await withRetry(() => fetch(url), { maxAttempts: 5 }); + * ``` + */ +export async function withRetry( + fn: () => Promise, + options: Partial = {}, +): Promise { + const opts: RetryOptions = { ...DEFAULTS, ...options }; + let lastError: unknown; + + for (let attempt = 1; attempt <= opts.maxAttempts; attempt++) { + try { + return await fn(); + } catch (err) { + lastError = err; + + // Check if we should stop retrying for this error type. + if (opts.retryable && !opts.retryable(err)) { + throw err; + } + + // On the final attempt, don't schedule another delay. + if (attempt === opts.maxAttempts) break; + + // Exponential backoff: initialDelayMs * backoffFactor^(attempt-1) + const rawDelay = opts.initialDelayMs * Math.pow(opts.backoffFactor, attempt - 1); + const delayMs = Math.min(rawDelay, opts.maxDelayMs); + + opts.onRetry?.(attempt, delayMs, err); + + await sleep(delayMs); + } + } + + throw lastError; +} + +/** + * Compute the delay for attempt N (1-indexed, first attempt = 1) without + * actually sleeping. Useful for testing and logging. + */ +export function computeRetryDelay( + attempt: number, + options: Partial> = {}, +): number { + const initialDelayMs = options.initialDelayMs ?? DEFAULTS.initialDelayMs; + const maxDelayMs = options.maxDelayMs ?? DEFAULTS.maxDelayMs; + const backoffFactor = options.backoffFactor ?? DEFAULTS.backoffFactor; + const raw = initialDelayMs * Math.pow(backoffFactor, attempt - 1); + return Math.min(raw, maxDelayMs); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/server/src/lib/trust-service.ts b/packages/server/src/lib/trust-service.ts new file mode 100644 index 0000000..41f4165 --- /dev/null +++ b/packages/server/src/lib/trust-service.ts @@ -0,0 +1,98 @@ +/** + * Server-side trust layer singleton. + * + * Instantiates the TrustLayerManager once and attaches it to globalThis so it + * survives hot-reloads in development. Reads configuration from environment + * variables: + * + * TRUST_LAYER_ENABLED=true — enable the trust layer + * TRUST_LAYER_ALGORITHM=ES256 — JWS algorithm (default: ES256) + * TRUST_LAYER_TIMESTAMP_TOLERANCE=300 — seconds (default: 300) + * TRUST_LAYER_NONCE_TTL=3600 — seconds (default: 3600) + * WEBAUTHN_RP_ID=openthreads.host — relying party ID for WebAuthn + * TRUST_DEFAULT_AUTH_METHOD=totp — default auth method (default: totp) + * + * The trust layer is wired with a MongoDB-backed audit storage adapter when + * enabled. All audit log entries are written to the `audit_log` collection. + */ + +import type { AuditLogEntry, AuditLogFilter, AuditStorageAdapter } from '@openthreads/trust'; +import { TrustLayerManager } from '@openthreads/trust'; +import { saveAuditEntry, queryAuditLog, type AuditLogDoc } from './db'; + +// ─── MongoDB audit storage adapter ─────────────────────────────────────────── + +class MongoAuditStorage implements AuditStorageAdapter { + async saveAuditEntry(entry: AuditLogEntry): Promise { + await saveAuditEntry(entry as unknown as AuditLogDoc); + } + + async queryAuditLog(filter: AuditLogFilter): Promise { + const docs = await queryAuditLog({ + turnId: filter.turnId, + threadId: filter.threadId, + channelId: filter.channelId, + eventType: filter.eventType, + fromDate: filter.fromDate, + toDate: filter.toDate, + limit: filter.limit, + offset: filter.offset, + }); + return docs as unknown as AuditLogEntry[]; + } +} + +// ─── Singleton ──────────────────────────────────────────────────────────────── + +type TrustServiceGlobal = typeof globalThis & { + __otTrustService?: TrustLayerManager; + __otTrustServiceInit?: Promise; +}; + +const g = globalThis as TrustServiceGlobal; + +export function getTrustEnabled(): boolean { + return process.env.TRUST_LAYER_ENABLED === 'true'; +} + +async function createTrustService(): Promise { + const enabled = getTrustEnabled(); + const algorithm = (process.env.TRUST_LAYER_ALGORITHM ?? 'ES256') as 'ES256' | 'RS256' | 'PS256'; + const toleranceSecs = Number(process.env.TRUST_LAYER_TIMESTAMP_TOLERANCE ?? 300); + const nonceTtlSecs = Number(process.env.TRUST_LAYER_NONCE_TTL ?? 3600); + const rpId = process.env.WEBAUTHN_RP_ID ?? 'localhost'; + const defaultMethod = (process.env.TRUST_DEFAULT_AUTH_METHOD ?? 'totp') as + | 'webauthn' + | 'totp' + | 'sms_otp'; + + const storage = enabled ? new MongoAuditStorage() : undefined; + + return TrustLayerManager.create( + { + enabled, + jwsAlgorithm: algorithm, + timestampToleranceSecs: toleranceSecs, + nonceTtlSecs, + }, + storage, + { defaultMethod, rpId }, + ); +} + +/** + * Get the server-wide TrustLayerManager singleton. + * Initialises it on first call. + */ +export async function getTrustService(): Promise { + if (g.__otTrustService) return g.__otTrustService; + + if (!g.__otTrustServiceInit) { + g.__otTrustServiceInit = createTrustService().then((svc) => { + g.__otTrustService = svc; + return svc; + }); + } + + return g.__otTrustServiceInit; +} diff --git a/packages/server/tests/deduplication.test.ts b/packages/server/tests/deduplication.test.ts new file mode 100644 index 0000000..40129ae --- /dev/null +++ b/packages/server/tests/deduplication.test.ts @@ -0,0 +1,165 @@ +/** + * Unit tests for the message deduplication store and helpers. + */ + +import { describe, it, expect, beforeEach } from 'bun:test'; +import { + InMemoryDeduplicationStore, + slackEventKey, + telegramUpdateKey, + whatsappMessageKey, + genericMessageKey, + getDefaultDeduplicationStore, +} from '../src/lib/deduplication.js'; + +// --------------------------------------------------------------------------- +// InMemoryDeduplicationStore — basic operations +// --------------------------------------------------------------------------- + +describe('InMemoryDeduplicationStore — check / seen', () => { + let store: InMemoryDeduplicationStore; + + beforeEach(() => { + store = new InMemoryDeduplicationStore(); + }); + + it('check returns false for unseen keys', () => { + expect(store.check('key_001')).toBe(false); + }); + + it('check returns true after seen() is called', () => { + store.seen('key_001'); + expect(store.check('key_001')).toBe(true); + }); + + it('check returns false for a different key', () => { + store.seen('key_001'); + expect(store.check('key_002')).toBe(false); + }); + + it('check returns false for an expired key', async () => { + store.seen('key_ttl', 1); // 1ms TTL — expires almost immediately + await new Promise((r) => setTimeout(r, 10)); + expect(store.check('key_ttl')).toBe(false); + }); + + it('seen() with the same key is idempotent', () => { + store.seen('key_dup'); + store.seen('key_dup'); + expect(store.check('key_dup')).toBe(true); + expect(store.size).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// InMemoryDeduplicationStore — LRU eviction +// --------------------------------------------------------------------------- + +describe('InMemoryDeduplicationStore — LRU eviction', () => { + it('evicts the oldest key when maxSize is reached', () => { + const small = new InMemoryDeduplicationStore(3); + + small.seen('k1'); + small.seen('k2'); + small.seen('k3'); + expect(small.size).toBe(3); + + // Adding a 4th key should evict k1 + small.seen('k4'); + expect(small.size).toBe(3); + expect(small.check('k1')).toBe(false); // evicted + expect(small.check('k2')).toBe(true); + expect(small.check('k3')).toBe(true); + expect(small.check('k4')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// InMemoryDeduplicationStore — purgeExpired +// --------------------------------------------------------------------------- + +describe('InMemoryDeduplicationStore — purgeExpired', () => { + it('removes expired entries and returns the count', async () => { + const store = new InMemoryDeduplicationStore(); + + store.seen('valid', 60_000); // 60s — will not expire + store.seen('expired_a', 1); // 1ms — will expire + store.seen('expired_b', 1); // 1ms — will expire + + await new Promise((r) => setTimeout(r, 10)); + + const purged = store.purgeExpired(); + expect(purged).toBe(2); + expect(store.size).toBe(1); + expect(store.check('valid')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Key builders +// --------------------------------------------------------------------------- + +describe('Key builders', () => { + it('slackEventKey produces a namespaced key', () => { + expect(slackEventKey('Ev01234ABCDE')).toBe('slack:Ev01234ABCDE'); + }); + + it('telegramUpdateKey produces a namespaced key', () => { + expect(telegramUpdateKey('bot_123456789', 42)).toBe('telegram:bot_123456789:42'); + }); + + it('whatsappMessageKey produces a namespaced key', () => { + const key = whatsappMessageKey('15551234567@s.whatsapp.net', 'msg_abc123'); + expect(key).toBe('whatsapp:15551234567@s.whatsapp.net:msg_abc123'); + }); + + it('genericMessageKey produces a namespaced key', () => { + expect(genericMessageKey('my-channel', 'native_msg_001')).toBe('msg:my-channel:native_msg_001'); + }); +}); + +// --------------------------------------------------------------------------- +// Deduplication flow simulation +// --------------------------------------------------------------------------- + +describe('Deduplication flow', () => { + it('correctly deduplicates a Slack event delivered twice', () => { + const store = new InMemoryDeduplicationStore(); + const key = slackEventKey('Ev01234ABCDE'); + + // First delivery + const firstTime = store.check(key); + store.seen(key); + + // Second delivery (Slack retry) + const secondTime = store.check(key); + + expect(firstTime).toBe(false); // not a duplicate + expect(secondTime).toBe(true); // duplicate — skip processing + }); + + it('correctly deduplicates a Telegram update delivered twice', () => { + const store = new InMemoryDeduplicationStore(); + const key = telegramUpdateKey('bot_987', 100); + + expect(store.check(key)).toBe(false); + store.seen(key); + expect(store.check(key)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Singleton store +// --------------------------------------------------------------------------- + +describe('getDefaultDeduplicationStore', () => { + it('returns the same instance on repeated calls', () => { + const a = getDefaultDeduplicationStore(); + const b = getDefaultDeduplicationStore(); + expect(a).toBe(b); + }); + + it('instance is an InMemoryDeduplicationStore', () => { + expect(getDefaultDeduplicationStore()).toBeInstanceOf(InMemoryDeduplicationStore); + }); +}); diff --git a/packages/server/tests/fanout.test.ts b/packages/server/tests/fanout.test.ts new file mode 100644 index 0000000..af1c768 --- /dev/null +++ b/packages/server/tests/fanout.test.ts @@ -0,0 +1,232 @@ +/** + * Unit tests for the fan-out delivery layer including retry behaviour. + */ + +import { describe, it, expect, mock, beforeEach, afterEach } from 'bun:test'; +import type { Recipient } from '@openthreads/core'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeRecipient(overrides: Partial = {}): Recipient { + return { + id: 'recipient-001', + webhookUrl: 'https://example.com/webhook', + apiKey: 'test-api-key', + ...overrides, + }; +} + +// Keep a reference to the original global fetch so we can restore it. +const originalFetch = globalThis.fetch; + +function mockFetch(responses: Array<{ status: number; ok: boolean; body?: string }>) { + let callIndex = 0; + globalThis.fetch = mock(async () => { + const resp = responses[callIndex] ?? responses[responses.length - 1]; + callIndex++; + return new Response(resp.body ?? '{}', { status: resp.status }); + }) as typeof fetch; +} + +// --------------------------------------------------------------------------- +// deliverToRecipient +// --------------------------------------------------------------------------- + +describe('deliverToRecipient', () => { + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('returns success:true for 2xx responses', async () => { + mockFetch([{ status: 200, ok: true }]); + + const { deliverToRecipient } = await import('../src/lib/fanout.js'); + const result = await deliverToRecipient({ + recipient: makeRecipient(), + payload: { message: 'test' }, + }); + + expect(result.success).toBe(true); + expect(result.status).toBe(200); + }); + + it('returns success:false for 5xx responses', async () => { + mockFetch([{ status: 503, ok: false }]); + + const { deliverToRecipient } = await import('../src/lib/fanout.js'); + const result = await deliverToRecipient({ + recipient: makeRecipient(), + payload: { message: 'test' }, + }); + + expect(result.success).toBe(false); + expect(result.status).toBe(503); + }); + + it('includes Authorization header when apiKey is provided', async () => { + const calls: Array<[RequestInfo | URL, RequestInit | undefined]> = []; + globalThis.fetch = mock(async (input, init) => { + calls.push([input as RequestInfo | URL, init]); + return new Response('{}', { status: 200 }); + }) as typeof fetch; + + const { deliverToRecipient } = await import('../src/lib/fanout.js'); + await deliverToRecipient({ + recipient: makeRecipient({ apiKey: 'my-key' }), + payload: {}, + }); + + const headers = calls[0]?.[1]?.headers as Record; + expect(headers?.['Authorization']).toBe('Bearer my-key'); + }); + + it('returns success:false and error message on network failure', async () => { + globalThis.fetch = mock(async () => { + throw new Error('ECONNREFUSED'); + }) as typeof fetch; + + const { deliverToRecipient } = await import('../src/lib/fanout.js'); + const result = await deliverToRecipient({ + recipient: makeRecipient(), + payload: {}, + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('ECONNREFUSED'); + }); +}); + +// --------------------------------------------------------------------------- +// deliverWithRetry +// --------------------------------------------------------------------------- + +describe('deliverWithRetry', () => { + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('returns success on first attempt', async () => { + mockFetch([{ status: 200, ok: true }]); + + const { deliverWithRetry } = await import('../src/lib/fanout.js'); + const result = await deliverWithRetry({ + recipient: makeRecipient(), + payload: {}, + retryOptions: { maxAttempts: 3, initialDelayMs: 1 }, + }); + + expect(result.success).toBe(true); + expect((globalThis.fetch as ReturnType).mock.calls.length).toBe(1); + }); + + it('retries on 5xx and succeeds on second attempt', async () => { + mockFetch([ + { status: 503, ok: false }, + { status: 200, ok: true }, + ]); + + const { deliverWithRetry } = await import('../src/lib/fanout.js'); + const result = await deliverWithRetry({ + recipient: makeRecipient(), + payload: {}, + retryOptions: { maxAttempts: 3, initialDelayMs: 1 }, + }); + + expect(result.success).toBe(true); + expect((globalThis.fetch as ReturnType).mock.calls.length).toBe(2); + }); + + it('does NOT retry on 4xx (non-retryable)', async () => { + mockFetch([ + { status: 401, ok: false }, + { status: 200, ok: true }, // should never be reached + ]); + + const { deliverWithRetry } = await import('../src/lib/fanout.js'); + const result = await deliverWithRetry({ + recipient: makeRecipient(), + payload: {}, + retryOptions: { maxAttempts: 3, initialDelayMs: 1 }, + }); + + expect(result.success).toBe(false); + expect(result.status).toBe(401); + // Only one call — no retries on 4xx + expect((globalThis.fetch as ReturnType).mock.calls.length).toBe(1); + }); + + it('returns failure after exhausting all retries', async () => { + mockFetch([ + { status: 503, ok: false }, + { status: 503, ok: false }, + { status: 503, ok: false }, + ]); + + const { deliverWithRetry } = await import('../src/lib/fanout.js'); + const result = await deliverWithRetry({ + recipient: makeRecipient(), + payload: {}, + retryOptions: { maxAttempts: 3, initialDelayMs: 1 }, + }); + + expect(result.success).toBe(false); + expect((globalThis.fetch as ReturnType).mock.calls.length).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// fanOut +// --------------------------------------------------------------------------- + +describe('fanOut', () => { + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('delivers to all recipients concurrently', async () => { + let calls = 0; + globalThis.fetch = mock(async () => { + calls++; + return new Response('{}', { status: 200 }); + }) as typeof fetch; + + const { fanOut } = await import('../src/lib/fanout.js'); + const recipients: Recipient[] = [ + makeRecipient({ id: 'r1', webhookUrl: 'https://r1.example.com/webhook' }), + makeRecipient({ id: 'r2', webhookUrl: 'https://r2.example.com/webhook' }), + makeRecipient({ id: 'r3', webhookUrl: 'https://r3.example.com/webhook' }), + ]; + + const results = await fanOut(recipients, { message: 'test' }); + + expect(calls).toBe(3); + expect(results.get('r1')?.success).toBe(true); + expect(results.get('r2')?.success).toBe(true); + expect(results.get('r3')?.success).toBe(true); + }); + + it('records individual failures without affecting other deliveries', async () => { + let callCount = 0; + globalThis.fetch = mock(async (input) => { + callCount++; + const url = typeof input === 'string' ? input : (input as Request).url; + if (url.includes('r2')) { + return new Response('{}', { status: 500 }); + } + return new Response('{}', { status: 200 }); + }) as typeof fetch; + + const { fanOut } = await import('../src/lib/fanout.js'); + const recipients: Recipient[] = [ + makeRecipient({ id: 'r1', webhookUrl: 'https://r1.example.com/webhook' }), + makeRecipient({ id: 'r2', webhookUrl: 'https://r2.example.com/webhook' }), + ]; + + const results = await fanOut(recipients, {}); + + expect(results.get('r1')?.success).toBe(true); + expect(results.get('r2')?.success).toBe(false); + }); +}); diff --git a/packages/server/tests/graceful-storage.test.ts b/packages/server/tests/graceful-storage.test.ts new file mode 100644 index 0000000..a679ce4 --- /dev/null +++ b/packages/server/tests/graceful-storage.test.ts @@ -0,0 +1,172 @@ +/** + * Unit tests for the graceful storage degradation utilities. + */ + +import { describe, it, expect, mock } from 'bun:test'; +import { + withGracefulStorage, + StorageHealthMonitor, + getDefaultStorageMonitor, +} from '../src/lib/graceful-storage.js'; + +// --------------------------------------------------------------------------- +// withGracefulStorage +// --------------------------------------------------------------------------- + +describe('withGracefulStorage', () => { + it('returns the operation result when it succeeds', async () => { + const result = await withGracefulStorage( + async () => ({ id: 'ch_1' }), + null, + ); + expect(result).toEqual({ id: 'ch_1' }); + }); + + it('returns the fallback when the operation throws', async () => { + const result = await withGracefulStorage( + async () => { throw new Error('MongoDB unavailable'); }, + null, + ); + expect(result).toBeNull(); + }); + + it('calls onError with the label and error when the operation throws', async () => { + const errors: Array<{ label: string; error: unknown }> = []; + + await withGracefulStorage( + async () => { throw new Error('connection refused'); }, + [], + 'channels.list', + { onError: (label, err) => errors.push({ label, error: err }) }, + ); + + expect(errors).toHaveLength(1); + expect(errors[0].label).toBe('channels.list'); + expect((errors[0].error as Error).message).toBe('connection refused'); + }); + + it('does NOT call onError when the operation succeeds', async () => { + const onError = mock((_l: string, _e: unknown) => {}); + + await withGracefulStorage( + async () => 'ok', + 'fallback', + 'operation', + { onError }, + ); + + expect(onError).not.toHaveBeenCalled(); + }); + + it('returns fallback even when fallback is undefined', async () => { + const result = await withGracefulStorage( + async () => { throw new Error('err'); }, + undefined, + ); + expect(result).toBeUndefined(); + }); + + it('returns an empty array as fallback for list operations', async () => { + const result = await withGracefulStorage( + async () => { throw new Error('err'); }, + [], + ); + expect(result).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// StorageHealthMonitor +// --------------------------------------------------------------------------- + +describe('StorageHealthMonitor — basic health tracking', () => { + it('reports healthy when no outcomes have been recorded', () => { + const monitor = new StorageHealthMonitor(); + expect(monitor.isHealthy()).toBe(true); + }); + + it('reports healthy when all outcomes are successes', () => { + const monitor = new StorageHealthMonitor(5, 0.5); + for (let i = 0; i < 5; i++) monitor.recordSuccess(); + expect(monitor.isHealthy()).toBe(true); + }); + + it('reports unhealthy when failure rate exceeds threshold', () => { + const monitor = new StorageHealthMonitor(4, 0.5); + // 3 failures, 1 success → 75% failure rate > 50% threshold + monitor.recordFailure(); + monitor.recordFailure(); + monitor.recordFailure(); + monitor.recordSuccess(); + expect(monitor.isHealthy()).toBe(false); + }); + + it('reports healthy when failure rate is below threshold', () => { + const monitor = new StorageHealthMonitor(4, 0.5); + // 1 failure, 3 success → 25% failure rate < 50% threshold + monitor.recordFailure(); + monitor.recordSuccess(); + monitor.recordSuccess(); + monitor.recordSuccess(); + expect(monitor.isHealthy()).toBe(true); + }); + + it('reset() clears all outcomes and reports healthy', () => { + const monitor = new StorageHealthMonitor(4, 0.5); + monitor.recordFailure(); + monitor.recordFailure(); + monitor.recordFailure(); + monitor.recordFailure(); + expect(monitor.isHealthy()).toBe(false); + + monitor.reset(); + expect(monitor.isHealthy()).toBe(true); + }); + + it('sliding window: old outcomes fall off as new ones come in', () => { + const monitor = new StorageHealthMonitor(4, 0.5); + + // Fill with failures + monitor.recordFailure(); + monitor.recordFailure(); + monitor.recordFailure(); + monitor.recordFailure(); + expect(monitor.isHealthy()).toBe(false); + + // Push successes — old failures slide out + monitor.recordSuccess(); + monitor.recordSuccess(); + monitor.recordSuccess(); + monitor.recordSuccess(); + expect(monitor.isHealthy()).toBe(true); + }); +}); + +describe('StorageHealthMonitor — not enough data', () => { + it('reports healthy when fewer outcomes than windowSize exist', () => { + const monitor = new StorageHealthMonitor(10, 0.3); + + // Only 3 outcomes — below windowSize of 10 — should be healthy regardless + monitor.recordFailure(); + monitor.recordFailure(); + monitor.recordFailure(); + + expect(monitor.isHealthy()).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// getDefaultStorageMonitor +// --------------------------------------------------------------------------- + +describe('getDefaultStorageMonitor', () => { + it('returns the same instance on repeated calls', () => { + const a = getDefaultStorageMonitor(); + const b = getDefaultStorageMonitor(); + expect(a).toBe(b); + }); + + it('instance is a StorageHealthMonitor', () => { + expect(getDefaultStorageMonitor()).toBeInstanceOf(StorageHealthMonitor); + }); +}); diff --git a/packages/server/tests/retry.test.ts b/packages/server/tests/retry.test.ts new file mode 100644 index 0000000..fe6c130 --- /dev/null +++ b/packages/server/tests/retry.test.ts @@ -0,0 +1,171 @@ +/** + * Unit tests for the exponential backoff retry utility. + */ + +import { describe, it, expect, mock } from 'bun:test'; +import { withRetry, computeRetryDelay } from '../src/lib/retry.js'; + +// --------------------------------------------------------------------------- +// computeRetryDelay +// --------------------------------------------------------------------------- + +describe('computeRetryDelay', () => { + it('returns initialDelayMs for attempt 1', () => { + expect(computeRetryDelay(1, { initialDelayMs: 1000 })).toBe(1000); + }); + + it('doubles the delay for attempt 2 (default backoffFactor=2)', () => { + expect(computeRetryDelay(2, { initialDelayMs: 1000 })).toBe(2000); + }); + + it('quadruples the delay for attempt 3', () => { + expect(computeRetryDelay(3, { initialDelayMs: 1000 })).toBe(4000); + }); + + it('caps at maxDelayMs', () => { + expect( + computeRetryDelay(10, { initialDelayMs: 1000, maxDelayMs: 5000 }), + ).toBe(5000); + }); + + it('uses custom backoffFactor', () => { + // backoffFactor = 3: 1000, 3000, 9000... + expect(computeRetryDelay(2, { initialDelayMs: 1000, backoffFactor: 3 })).toBe(3000); + }); +}); + +// --------------------------------------------------------------------------- +// withRetry — success paths +// --------------------------------------------------------------------------- + +describe('withRetry — success paths', () => { + it('returns the result immediately when the first attempt succeeds', async () => { + const fn = mock(async () => 'ok'); + + const result = await withRetry(fn, { maxAttempts: 3 }); + + expect(result).toBe('ok'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('retries and returns the result when the second attempt succeeds', async () => { + let calls = 0; + const fn = mock(async () => { + if (++calls < 2) throw new Error('transient'); + return 'recovered'; + }); + + const result = await withRetry(fn, { + maxAttempts: 3, + initialDelayMs: 1, + }); + + expect(result).toBe('recovered'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('retries up to maxAttempts times', async () => { + let calls = 0; + const fn = mock(async () => { + calls++; + if (calls < 3) throw new Error('transient'); + return 'final'; + }); + + const result = await withRetry(fn, { + maxAttempts: 3, + initialDelayMs: 1, + }); + + expect(result).toBe('final'); + expect(fn).toHaveBeenCalledTimes(3); + }); +}); + +// --------------------------------------------------------------------------- +// withRetry — failure paths +// --------------------------------------------------------------------------- + +describe('withRetry — failure paths', () => { + it('throws after maxAttempts when all attempts fail', async () => { + const fn = mock(async () => { + throw new Error('always fails'); + }); + + await expect( + withRetry(fn, { maxAttempts: 3, initialDelayMs: 1 }), + ).rejects.toThrow('always fails'); + + expect(fn).toHaveBeenCalledTimes(3); + }); + + it('throws immediately when retryable returns false', async () => { + let calls = 0; + const fn = mock(async () => { + calls++; + throw new Error('non-retryable'); + }); + + await expect( + withRetry(fn, { + maxAttempts: 5, + initialDelayMs: 1, + retryable: () => false, + }), + ).rejects.toThrow('non-retryable'); + + // Should have called only once — no retries + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('calls onRetry callback before each retry', async () => { + const retryCalls: Array<{ attempt: number; delayMs: number }> = []; + + let calls = 0; + await expect( + withRetry( + async () => { + if (++calls <= 2) throw new Error('fail'); + return 'done'; + }, + { + maxAttempts: 3, + initialDelayMs: 1, + onRetry: (attempt, delayMs) => retryCalls.push({ attempt, delayMs }), + }, + ), + ).resolves.toBe('done'); + + expect(retryCalls).toHaveLength(2); + expect(retryCalls[0].attempt).toBe(1); + expect(retryCalls[1].attempt).toBe(2); + }); + + it('throws the last error (not the first) when all attempts fail', async () => { + let calls = 0; + await expect( + withRetry( + async () => { + throw new Error(`attempt ${++calls}`); + }, + { maxAttempts: 3, initialDelayMs: 1 }, + ), + ).rejects.toThrow('attempt 3'); + }); +}); + +// --------------------------------------------------------------------------- +// withRetry — defaults +// --------------------------------------------------------------------------- + +describe('withRetry — defaults', () => { + it('uses maxAttempts=3 by default', async () => { + let calls = 0; + await expect( + // Override initialDelayMs to 1ms so the test doesn't actually wait 3s + withRetry(async () => { calls++; throw new Error('fail'); }, { initialDelayMs: 1 }), + ).rejects.toThrow(); + + expect(calls).toBe(3); + }); +}); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 0000000..fa110c7 --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "preserve", + "incremental": true, + "composite": false, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/storage/mongodb/docker-compose.test.yml b/packages/storage/mongodb/docker-compose.test.yml new file mode 100644 index 0000000..36bef80 --- /dev/null +++ b/packages/storage/mongodb/docker-compose.test.yml @@ -0,0 +1,18 @@ +version: '3.9' + +services: + mongodb: + image: mongo:7 + ports: + - "27018:27017" + environment: + MONGO_INITDB_DATABASE: openthreads_test + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + tmpfs: + # Use tmpfs for speed in CI — data is ephemeral anyway + - /data/db diff --git a/packages/storage/mongodb/package.json b/packages/storage/mongodb/package.json new file mode 100644 index 0000000..54f0254 --- /dev/null +++ b/packages/storage/mongodb/package.json @@ -0,0 +1,33 @@ +{ + "name": "@openthreads/storage-mongodb", + "version": "0.0.1", + "private": false, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "test": "bun test" + }, + "peerDependencies": { + "mongodb": ">=6.0.0" + }, + "devDependencies": { + "mongodb": "^6.0.0", + "vite": "*", + "vite-plugin-dts": "*", + "typescript": "*" + }, + "dependencies": { + "@openthreads/core": "workspace:*" + } +} diff --git a/packages/storage/mongodb/src/MongoStorageAdapter.ts b/packages/storage/mongodb/src/MongoStorageAdapter.ts new file mode 100644 index 0000000..91421e0 --- /dev/null +++ b/packages/storage/mongodb/src/MongoStorageAdapter.ts @@ -0,0 +1,415 @@ +import { + MongoClient, + type Collection, + type Db, + type Document, + type Filter, + type UpdateFilter, +} from 'mongodb'; +import type { StorageAdapter } from '@openthreads/core'; +import type { + Channel, + ChannelInput, + Recipient, + RecipientInput, + Thread, + ThreadInput, + Turn, + TurnInput, + Route, + RouteCriteria, + RouteInput, + Token, + TokenInput, +} from '@openthreads/core'; +import { + ensureChannelsIndexes, + ensureRecipientsIndexes, + ensureThreadsIndexes, + ensureTurnsIndexes, + ensureRoutesIndexes, + ensureTokensIndexes, +} from './indexes.js'; + +export interface MongoStorageAdapterOptions { + /** MongoDB connection URI (e.g. "mongodb://localhost:27017") */ + uri: string; + /** Database name to use (default: "openthreads") */ + dbName?: string; + /** Maximum number of connections in the pool (default: 10) */ + maxPoolSize?: number; + /** Minimum number of connections in the pool (default: 2) */ + minPoolSize?: number; + /** Connection timeout in ms (default: 10000) */ + connectTimeoutMS?: number; + /** Server selection timeout in ms (default: 10000) */ + serverSelectionTimeoutMS?: number; +} + +/** + * MongoDB implementation of the OpenThreads StorageAdapter. + * + * Features: + * - Connection pooling via the native mongodb driver + * - All required indexes created on connect() + * - TTL index on tokens.expiresAt for automatic expiry + * - Graceful shutdown via disconnect() + */ +export class MongoStorageAdapter implements StorageAdapter { + private client: MongoClient; + private db: Db | null = null; + private readonly dbName: string; + private connected = false; + + constructor(private readonly options: MongoStorageAdapterOptions) { + this.dbName = options.dbName ?? 'openthreads'; + this.client = new MongoClient(options.uri, { + maxPoolSize: options.maxPoolSize ?? 10, + minPoolSize: options.minPoolSize ?? 2, + connectTimeoutMS: options.connectTimeoutMS ?? 10_000, + serverSelectionTimeoutMS: options.serverSelectionTimeoutMS ?? 10_000, + }); + } + + // ─── Lifecycle ───────────────────────────────────────────────────────────── + + async connect(): Promise { + if (this.connected) return; + await this.client.connect(); + this.db = this.client.db(this.dbName); + await this.ensureAllIndexes(); + this.connected = true; + } + + async disconnect(): Promise { + if (!this.connected) return; + await this.client.close(); + this.db = null; + this.connected = false; + } + + async ping(): Promise { + try { + await this.getDb().command({ ping: 1 }); + return true; + } catch { + return false; + } + } + + // ─── Channels ────────────────────────────────────────────────────────────── + + async createChannel(input: ChannelInput): Promise { + const now = new Date(); + const doc: Channel = { ...input, createdAt: now, updatedAt: now }; + await this.channels().insertOne(doc as unknown as Document); + return doc; + } + + async getChannel(channelId: string): Promise { + return (await this.channels().findOne( + { channelId } as Filter + )) as Channel | null; + } + + async getChannelByApiKey(apiKey: string): Promise { + return (await this.channels().findOne( + { apiKey } as Filter + )) as Channel | null; + } + + async updateChannel(channelId: string, updates: Partial): Promise { + const result = await this.channels().findOneAndUpdate( + { channelId } as Filter, + { $set: { ...updates, updatedAt: new Date() } } as UpdateFilter, + { returnDocument: 'after' } + ); + return result as Channel | null; + } + + async deleteChannel(channelId: string): Promise { + const result = await this.channels().deleteOne({ channelId } as Filter); + return result.deletedCount === 1; + } + + async listChannels(filter?: { active?: boolean; type?: string }): Promise { + const query: Record = {}; + if (filter?.active !== undefined) query['active'] = filter.active; + if (filter?.type !== undefined) query['type'] = filter.type; + return (await this.channels().find(query as Filter).toArray()) as Channel[]; + } + + // ─── Recipients ──────────────────────────────────────────────────────────── + + async createRecipient(input: RecipientInput): Promise { + const now = new Date(); + const doc: Recipient = { ...input, createdAt: now, updatedAt: now }; + await this.recipients().insertOne(doc as unknown as Document); + return doc; + } + + async getRecipient(recipientId: string): Promise { + return (await this.recipients().findOne( + { recipientId } as Filter + )) as Recipient | null; + } + + async updateRecipient( + recipientId: string, + updates: Partial + ): Promise { + const result = await this.recipients().findOneAndUpdate( + { recipientId } as Filter, + { $set: { ...updates, updatedAt: new Date() } } as UpdateFilter, + { returnDocument: 'after' } + ); + return result as Recipient | null; + } + + async deleteRecipient(recipientId: string): Promise { + const result = await this.recipients().deleteOne({ recipientId } as Filter); + return result.deletedCount === 1; + } + + async listRecipients(filter?: { active?: boolean }): Promise { + const query: Record = {}; + if (filter?.active !== undefined) query['active'] = filter.active; + return (await this.recipients().find(query as Filter).toArray()) as Recipient[]; + } + + // ─── Threads ─────────────────────────────────────────────────────────────── + + async createThread(input: ThreadInput): Promise { + const now = new Date(); + const doc: Thread = { ...input, createdAt: now, updatedAt: now }; + await this.threads().insertOne(doc as unknown as Document); + return doc; + } + + async getThread(threadId: string): Promise { + return (await this.threads().findOne( + { threadId } as Filter + )) as Thread | null; + } + + async getThreadByNativeId(channelId: string, nativeThreadId: string): Promise { + return (await this.threads().findOne( + { channelId, nativeThreadId } as Filter + )) as Thread | null; + } + + async getMainThread(channelId: string, targetId: string): Promise { + return (await this.threads().findOne( + { channelId, targetId, isMain: true } as Filter + )) as Thread | null; + } + + async updateThread(threadId: string, updates: Partial): Promise { + const result = await this.threads().findOneAndUpdate( + { threadId } as Filter, + { $set: { ...updates, updatedAt: new Date() } } as UpdateFilter, + { returnDocument: 'after' } + ); + return result as Thread | null; + } + + async deleteThread(threadId: string): Promise { + const result = await this.threads().deleteOne({ threadId } as Filter); + return result.deletedCount === 1; + } + + async listThreadsByChannel( + channelId: string, + limit = 50, + offset = 0 + ): Promise { + return (await this.threads() + .find({ channelId } as Filter) + .sort({ createdAt: -1 }) + .skip(offset) + .limit(limit) + .toArray()) as Thread[]; + } + + // ─── Turns ───────────────────────────────────────────────────────────────── + + async createTurn(input: TurnInput): Promise { + const now = new Date(); + const doc: Turn = { ...input, createdAt: now, updatedAt: now }; + await this.turns().insertOne(doc as unknown as Document); + return doc; + } + + async getTurn(turnId: string): Promise { + return (await this.turns().findOne( + { turnId } as Filter + )) as Turn | null; + } + + async getTurnsForThread(threadId: string, limit = 100, offset = 0): Promise { + return (await this.turns() + .find({ threadId } as Filter) + .sort({ timestamp: 1 }) + .skip(offset) + .limit(limit) + .toArray()) as Turn[]; + } + + async updateTurn(turnId: string, updates: Partial): Promise { + const result = await this.turns().findOneAndUpdate( + { turnId } as Filter, + { $set: { ...updates, updatedAt: new Date() } } as UpdateFilter, + { returnDocument: 'after' } + ); + return result as Turn | null; + } + + // ─── Routes ──────────────────────────────────────────────────────────────── + + async createRoute(input: RouteInput): Promise { + const now = new Date(); + const doc: Route = { ...input, createdAt: now, updatedAt: now }; + await this.routes().insertOne(doc as unknown as Document); + return doc; + } + + async getRoute(routeId: string): Promise { + return (await this.routes().findOne( + { routeId } as Filter + )) as Route | null; + } + + async findMatchingRoutes(criteria: Partial): Promise { + // Build a query that matches routes where each defined criteria field matches. + // A route field being undefined/absent means "match any" (not stored = wildcard). + const conditions: Filter[] = [{ active: true }]; + + const criteriaFields = [ + 'channelId', + 'channelType', + 'targetId', + 'threadId', + 'senderId', + 'isDM', + ] as const; + + for (const field of criteriaFields) { + const value = criteria[field]; + if (value !== undefined) { + // Match routes that either explicitly match this value OR have no criteria for this field + conditions.push({ + $or: [ + { [`criteria.${field}`]: value }, + { [`criteria.${field}`]: { $exists: false } }, + { [`criteria.${field}`]: null }, + ], + } as unknown as Filter); + } + } + + return (await this.routes() + .find({ $and: conditions } as Filter) + .sort({ priority: 1 }) + .toArray()) as Route[]; + } + + async updateRoute(routeId: string, updates: Partial): Promise { + const result = await this.routes().findOneAndUpdate( + { routeId } as Filter, + { $set: { ...updates, updatedAt: new Date() } } as UpdateFilter, + { returnDocument: 'after' } + ); + return result as Route | null; + } + + async deleteRoute(routeId: string): Promise { + const result = await this.routes().deleteOne({ routeId } as Filter); + return result.deletedCount === 1; + } + + async listRoutes(filter?: { active?: boolean; recipientId?: string }): Promise { + const query: Record = {}; + if (filter?.active !== undefined) query['active'] = filter.active; + if (filter?.recipientId !== undefined) query['recipientId'] = filter.recipientId; + return (await this.routes() + .find(query as Filter) + .sort({ priority: 1 }) + .toArray()) as Route[]; + } + + // ─── Tokens ──────────────────────────────────────────────────────────────── + + async createToken(input: TokenInput): Promise { + const doc: Token = { ...input, createdAt: new Date() }; + await this.tokens().insertOne(doc as unknown as Document); + return doc; + } + + async getTokenByValue(value: string): Promise { + return (await this.tokens().findOne( + { value, used: false, expiresAt: { $gt: new Date() } } as Filter + )) as Token | null; + } + + async consumeToken(value: string): Promise { + const result = await this.tokens().updateOne( + { value, used: false, expiresAt: { $gt: new Date() } } as Filter, + { $set: { used: true } } as UpdateFilter + ); + return result.modifiedCount === 1; + } + + async deleteExpiredTokens(): Promise { + const result = await this.tokens().deleteMany( + { expiresAt: { $lte: new Date() } } as Filter + ); + return result.deletedCount; + } + + // ─── Private helpers ─────────────────────────────────────────────────────── + + private getDb(): Db { + if (!this.db) { + throw new Error( + 'MongoStorageAdapter: not connected. Call connect() before using the adapter.' + ); + } + return this.db; + } + + private channels(): Collection { + return this.getDb().collection('channels'); + } + + private recipients(): Collection { + return this.getDb().collection('recipients'); + } + + private threads(): Collection { + return this.getDb().collection('threads'); + } + + private turns(): Collection { + return this.getDb().collection('turns'); + } + + private routes(): Collection { + return this.getDb().collection('routes'); + } + + private tokens(): Collection { + return this.getDb().collection('tokens'); + } + + private async ensureAllIndexes(): Promise { + const db = this.getDb(); + await Promise.all([ + ensureChannelsIndexes(db.collection('channels')), + ensureRecipientsIndexes(db.collection('recipients')), + ensureThreadsIndexes(db.collection('threads')), + ensureTurnsIndexes(db.collection('turns')), + ensureRoutesIndexes(db.collection('routes')), + ensureTokensIndexes(db.collection('tokens')), + ]); + } +} diff --git a/packages/storage/mongodb/src/adapter.ts b/packages/storage/mongodb/src/adapter.ts new file mode 100644 index 0000000..7ba53c1 --- /dev/null +++ b/packages/storage/mongodb/src/adapter.ts @@ -0,0 +1,30 @@ +import type { MongoClient, Db } from 'mongodb' +import type { StorageAdapter, Thread, Channel, Route } from '@openthreads/core' + +export class MongoDBStorageAdapter implements StorageAdapter { + private db: Db + + constructor(client: MongoClient, dbName?: string) { + this.db = client.db(dbName) + } + + async getThread(threadId: string): Promise { + const doc = await this.db.collection('threads').findOne({ threadId }) + return doc ?? null + } + + async createThread(thread: Omit): Promise { + const doc: Thread = { ...thread, createdAt: new Date() } + await this.db.collection('threads').insertOne(doc) + return doc + } + + async getChannel(channelId: string): Promise { + const doc = await this.db.collection('channels').findOne({ channelId }) + return doc ?? null + } + + async getRoutes(channelId: string): Promise { + return this.db.collection('routes').find({ channelId }).toArray() + } +} diff --git a/packages/storage/mongodb/src/index.test.ts b/packages/storage/mongodb/src/index.test.ts new file mode 100644 index 0000000..825599d --- /dev/null +++ b/packages/storage/mongodb/src/index.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from 'bun:test' + +describe('@openthreads/storage-mongodb', () => { + it('exports MongoDBStorageAdapter', async () => { + const { MongoDBStorageAdapter } = await import('./index') + expect(MongoDBStorageAdapter).toBeDefined() + }) +}) diff --git a/packages/storage/mongodb/src/index.ts b/packages/storage/mongodb/src/index.ts new file mode 100644 index 0000000..c55db40 --- /dev/null +++ b/packages/storage/mongodb/src/index.ts @@ -0,0 +1,4 @@ +// @openthreads/storage-mongodb +// MongoDB implementation of the StorageAdapter interface + +export { MongoDBStorageAdapter } from './adapter' diff --git a/packages/storage/mongodb/src/indexes.ts b/packages/storage/mongodb/src/indexes.ts new file mode 100644 index 0000000..49ead21 --- /dev/null +++ b/packages/storage/mongodb/src/indexes.ts @@ -0,0 +1,67 @@ +import type { Collection, IndexDescription } from 'mongodb'; + +/** + * All index definitions for the OpenThreads MongoDB collections. + * Call ensureIndexes() once at startup or during migration. + */ + +export async function ensureThreadsIndexes(collection: Collection): Promise { + await collection.createIndexes([ + // Unique lookup by threadId + { key: { threadId: 1 }, unique: true, name: 'threads_threadId_unique' }, + // Look up thread by native platform thread ID within a channel + { key: { channelId: 1, nativeThreadId: 1 }, name: 'threads_channelId_nativeThreadId' }, + // Look up the main thread for a channel+target pair + { key: { channelId: 1, targetId: 1 }, name: 'threads_channelId_targetId' }, + ] as IndexDescription[]); +} + +export async function ensureTurnsIndexes(collection: Collection): Promise { + await collection.createIndexes([ + // Unique lookup by turnId + { key: { turnId: 1 }, unique: true, name: 'turns_turnId_unique' }, + // Chronological listing of turns within a thread + { key: { threadId: 1, timestamp: 1 }, name: 'turns_threadId_timestamp' }, + ] as IndexDescription[]); +} + +export async function ensureRoutesIndexes(collection: Collection): Promise { + await collection.createIndexes([ + // Efficient matching queries against criteria fields + { key: { 'criteria.channelId': 1 }, sparse: true, name: 'routes_criteria_channelId' }, + { key: { 'criteria.channelType': 1 }, sparse: true, name: 'routes_criteria_channelType' }, + { key: { 'criteria.targetId': 1 }, sparse: true, name: 'routes_criteria_targetId' }, + { key: { 'criteria.senderId': 1 }, sparse: true, name: 'routes_criteria_senderId' }, + // Priority ordering for route evaluation + { key: { active: 1, priority: 1 }, name: 'routes_active_priority' }, + // Lookup by recipient + { key: { recipientId: 1 }, name: 'routes_recipientId' }, + ] as IndexDescription[]); +} + +export async function ensureTokensIndexes(collection: Collection): Promise { + await collection.createIndexes([ + // Unique lookup by token value + { key: { value: 1 }, unique: true, name: 'tokens_value_unique' }, + // TTL index — MongoDB automatically deletes expired token documents + { key: { expiresAt: 1 }, expireAfterSeconds: 0, name: 'tokens_expiresAt_ttl' }, + // Scoped lookups + { key: { channelId: 1 }, name: 'tokens_channelId' }, + { key: { threadId: 1 }, name: 'tokens_threadId' }, + ] as IndexDescription[]); +} + +export async function ensureChannelsIndexes(collection: Collection): Promise { + await collection.createIndexes([ + // Unique lookup by channelId (also _id, but keep explicit index for clarity) + { key: { channelId: 1 }, unique: true, name: 'channels_channelId_unique' }, + // Quick lookup by API key + { key: { apiKey: 1 }, sparse: true, unique: true, name: 'channels_apiKey_unique' }, + ] as IndexDescription[]); +} + +export async function ensureRecipientsIndexes(collection: Collection): Promise { + await collection.createIndexes([ + { key: { recipientId: 1 }, unique: true, name: 'recipients_recipientId_unique' }, + ] as IndexDescription[]); +} diff --git a/packages/storage/mongodb/src/migrations/migrate.ts b/packages/storage/mongodb/src/migrations/migrate.ts new file mode 100644 index 0000000..bc0190e --- /dev/null +++ b/packages/storage/mongodb/src/migrations/migrate.ts @@ -0,0 +1,66 @@ +import { MongoClient } from 'mongodb'; +import { + ensureChannelsIndexes, + ensureRecipientsIndexes, + ensureThreadsIndexes, + ensureTurnsIndexes, + ensureRoutesIndexes, + ensureTokensIndexes, +} from '../indexes.js'; +import { seedDatabase } from './seed.js'; + +export interface MigrateOptions { + uri: string; + dbName?: string; + seed?: boolean; +} + +/** + * Run all migrations against the target MongoDB instance. + * Creates all collections (implicitly) and their indexes. + * + * Safe to run repeatedly — MongoDB's createIndex is idempotent for identical index specs. + */ +export async function migrate(options: MigrateOptions): Promise { + const client = new MongoClient(options.uri); + const dbName = options.dbName ?? 'openthreads'; + + try { + console.log(`[migrate] connecting to ${options.uri} / ${dbName} ...`); + await client.connect(); + const db = client.db(dbName); + + console.log('[migrate] ensuring indexes ...'); + await Promise.all([ + ensureChannelsIndexes(db.collection('channels')), + ensureRecipientsIndexes(db.collection('recipients')), + ensureThreadsIndexes(db.collection('threads')), + ensureTurnsIndexes(db.collection('turns')), + ensureRoutesIndexes(db.collection('routes')), + ensureTokensIndexes(db.collection('tokens')), + ]); + console.log('[migrate] indexes: ok'); + + if (options.seed) { + console.log('[migrate] seeding initial data ...'); + await seedDatabase(db); + console.log('[migrate] seed: ok'); + } + + console.log('[migrate] done'); + } finally { + await client.close(); + } +} + +// CLI entry-point: bun packages/storage/mongodb/src/migrations/migrate.ts +if (import.meta.url === `file://${process.argv[1]}`) { + const uri = process.env['MONGODB_URI'] ?? 'mongodb://localhost:27017'; + const dbName = process.env['MONGODB_DB'] ?? 'openthreads'; + const seed = process.env['SEED'] === 'true'; + + migrate({ uri, dbName, seed }).catch((err) => { + console.error('[migrate] error:', err); + process.exit(1); + }); +} diff --git a/packages/storage/mongodb/src/migrations/seed.ts b/packages/storage/mongodb/src/migrations/seed.ts new file mode 100644 index 0000000..91c11cd --- /dev/null +++ b/packages/storage/mongodb/src/migrations/seed.ts @@ -0,0 +1,83 @@ +import type { Db } from 'mongodb'; + +/** + * Seed the database with initial data for development and testing. + * Safe to run multiple times (idempotent — uses upserts). + */ +export async function seedDatabase(db: Db): Promise { + await seedChannels(db); + await seedRecipients(db); + await seedRoutes(db); +} + +async function seedChannels(db: Db): Promise { + const channels = db.collection('channels'); + const now = new Date(); + + const defaultChannel = { + channelId: 'example-slack', + type: 'slack', + name: 'Example Slack Workspace', + config: { + botToken: 'xoxb-example-token', + signingSecret: 'example-signing-secret', + }, + active: false, + createdAt: now, + updatedAt: now, + }; + + await channels.updateOne( + { channelId: defaultChannel.channelId }, + { $setOnInsert: defaultChannel }, + { upsert: true } + ); + + console.log('[seed] channels: done'); +} + +async function seedRecipients(db: Db): Promise { + const recipients = db.collection('recipients'); + const now = new Date(); + + const defaultRecipient = { + recipientId: 'example-recipient', + name: 'Example Recipient', + webhookUrl: 'https://example.com/webhook', + active: false, + createdAt: now, + updatedAt: now, + }; + + await recipients.updateOne( + { recipientId: defaultRecipient.recipientId }, + { $setOnInsert: defaultRecipient }, + { upsert: true } + ); + + console.log('[seed] recipients: done'); +} + +async function seedRoutes(db: Db): Promise { + const routes = db.collection('routes'); + const now = new Date(); + + const catchAllRoute = { + routeId: 'catch-all', + name: 'Catch-All Route', + criteria: {}, + recipientId: 'example-recipient', + priority: 999, + active: false, + createdAt: now, + updatedAt: now, + }; + + await routes.updateOne( + { routeId: catchAllRoute.routeId }, + { $setOnInsert: catchAllRoute }, + { upsert: true } + ); + + console.log('[seed] routes: done'); +} diff --git a/packages/storage/mongodb/src/types.ts b/packages/storage/mongodb/src/types.ts new file mode 100644 index 0000000..0407f2f --- /dev/null +++ b/packages/storage/mongodb/src/types.ts @@ -0,0 +1,16 @@ +/** + * Internal MongoDB document types. + * We store all entities with MongoDB's native `_id` field instead of a duplicate + * string id, so we map the domain `*Id` field to `_id` at the persistence layer. + */ +import type { WithId } from 'mongodb'; + +/** Strip MongoDB's _id from a document shape */ +export type WithoutId = Omit; + +/** Base fields present on every stored document */ +export interface BaseDocument { + _id: string; + createdAt: Date; + updatedAt: Date; +} diff --git a/packages/storage/mongodb/tests/integration/MongoStorageAdapter.test.ts b/packages/storage/mongodb/tests/integration/MongoStorageAdapter.test.ts new file mode 100644 index 0000000..782f7fe --- /dev/null +++ b/packages/storage/mongodb/tests/integration/MongoStorageAdapter.test.ts @@ -0,0 +1,420 @@ +/** + * Integration tests for MongoStorageAdapter. + * + * Prerequisites: + * docker compose -f docker-compose.test.yml up -d + * + * Run: + * bun test tests/integration + * + * Environment: + * MONGODB_URI — default: mongodb://localhost:27018 + * MONGODB_DB — default: openthreads_test + */ +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test'; +import { MongoStorageAdapter } from '../../src/MongoStorageAdapter.js'; +import type { + ChannelInput, + RecipientInput, + ThreadInput, + TurnInput, + RouteInput, + TokenInput, +} from '@openthreads/core'; + +const MONGODB_URI = process.env['MONGODB_URI'] ?? 'mongodb://localhost:27018'; +const MONGODB_DB = process.env['MONGODB_DB'] ?? 'openthreads_test'; + +let adapter: MongoStorageAdapter; + +beforeAll(async () => { + adapter = new MongoStorageAdapter({ uri: MONGODB_URI, dbName: MONGODB_DB }); + await adapter.connect(); +}); + +afterAll(async () => { + await adapter.disconnect(); +}); + +// ─── Channels ────────────────────────────────────────────────────────────────── + +describe('channels', () => { + const channelInput: ChannelInput = { + channelId: 'test-slack', + type: 'slack', + name: 'Test Slack', + config: { botToken: 'xoxb-test' }, + apiKey: 'ot_ch_sk_test1', + active: true, + }; + + beforeEach(async () => { + await adapter.deleteChannel(channelInput.channelId); + }); + + test('createChannel and getChannel', async () => { + const created = await adapter.createChannel(channelInput); + expect(created.channelId).toBe(channelInput.channelId); + expect(created.createdAt).toBeInstanceOf(Date); + + const fetched = await adapter.getChannel(channelInput.channelId); + expect(fetched).not.toBeNull(); + expect(fetched?.name).toBe(channelInput.name); + }); + + test('getChannelByApiKey', async () => { + await adapter.createChannel(channelInput); + const fetched = await adapter.getChannelByApiKey('ot_ch_sk_test1'); + expect(fetched?.channelId).toBe(channelInput.channelId); + }); + + test('updateChannel', async () => { + await adapter.createChannel(channelInput); + const updated = await adapter.updateChannel(channelInput.channelId, { name: 'Updated Slack' }); + expect(updated?.name).toBe('Updated Slack'); + }); + + test('deleteChannel', async () => { + await adapter.createChannel(channelInput); + const deleted = await adapter.deleteChannel(channelInput.channelId); + expect(deleted).toBe(true); + expect(await adapter.getChannel(channelInput.channelId)).toBeNull(); + }); + + test('listChannels with filter', async () => { + await adapter.createChannel(channelInput); + const active = await adapter.listChannels({ active: true }); + expect(active.some(c => c.channelId === channelInput.channelId)).toBe(true); + + const inactive = await adapter.listChannels({ active: false }); + expect(inactive.some(c => c.channelId === channelInput.channelId)).toBe(false); + }); +}); + +// ─── Recipients ──────────────────────────────────────────────────────────────── + +describe('recipients', () => { + const recipientInput: RecipientInput = { + recipientId: 'test-agent-1', + name: 'Test Agent', + webhookUrl: 'https://example.com/webhook', + active: true, + }; + + beforeEach(async () => { + await adapter.deleteRecipient(recipientInput.recipientId); + }); + + test('createRecipient and getRecipient', async () => { + const created = await adapter.createRecipient(recipientInput); + expect(created.recipientId).toBe(recipientInput.recipientId); + + const fetched = await adapter.getRecipient(recipientInput.recipientId); + expect(fetched?.webhookUrl).toBe(recipientInput.webhookUrl); + }); + + test('updateRecipient', async () => { + await adapter.createRecipient(recipientInput); + const updated = await adapter.updateRecipient(recipientInput.recipientId, { + webhookUrl: 'https://example.com/new-webhook', + }); + expect(updated?.webhookUrl).toBe('https://example.com/new-webhook'); + }); + + test('deleteRecipient', async () => { + await adapter.createRecipient(recipientInput); + expect(await adapter.deleteRecipient(recipientInput.recipientId)).toBe(true); + expect(await adapter.getRecipient(recipientInput.recipientId)).toBeNull(); + }); +}); + +// ─── Threads ─────────────────────────────────────────────────────────────────── + +describe('threads', () => { + const threadInput: ThreadInput = { + threadId: 'ot_thr_test001', + channelId: 'test-slack', + nativeThreadId: 'slack-ts-12345', + targetId: 'C0123', + isMain: false, + }; + + beforeEach(async () => { + await adapter.deleteThread(threadInput.threadId); + }); + + test('createThread and getThread', async () => { + const created = await adapter.createThread(threadInput); + expect(created.threadId).toBe(threadInput.threadId); + + const fetched = await adapter.getThread(threadInput.threadId); + expect(fetched?.channelId).toBe(threadInput.channelId); + }); + + test('getThreadByNativeId', async () => { + await adapter.createThread(threadInput); + const fetched = await adapter.getThreadByNativeId( + threadInput.channelId, + threadInput.nativeThreadId! + ); + expect(fetched?.threadId).toBe(threadInput.threadId); + }); + + test('getMainThread', async () => { + const mainThreadInput: ThreadInput = { + threadId: 'ot_thr_main_test', + channelId: 'test-slack', + targetId: 'C9999', + isMain: true, + }; + await adapter.deleteThread(mainThreadInput.threadId); + await adapter.createThread(mainThreadInput); + + const fetched = await adapter.getMainThread('test-slack', 'C9999'); + expect(fetched?.threadId).toBe(mainThreadInput.threadId); + await adapter.deleteThread(mainThreadInput.threadId); + }); + + test('updateThread', async () => { + await adapter.createThread(threadInput); + const updated = await adapter.updateThread(threadInput.threadId, { + recipientId: 'agent-1', + }); + expect(updated?.recipientId).toBe('agent-1'); + }); + + test('deleteThread', async () => { + await adapter.createThread(threadInput); + expect(await adapter.deleteThread(threadInput.threadId)).toBe(true); + expect(await adapter.getThread(threadInput.threadId)).toBeNull(); + }); + + test('listThreadsByChannel', async () => { + await adapter.createThread(threadInput); + const threads = await adapter.listThreadsByChannel(threadInput.channelId); + expect(threads.some(t => t.threadId === threadInput.threadId)).toBe(true); + }); +}); + +// ─── Turns ───────────────────────────────────────────────────────────────────── + +describe('turns', () => { + const turnInput: TurnInput = { + turnId: 'ot_turn_test001', + threadId: 'ot_thr_test001', + inbound: { + message: { text: 'Hello, world!' }, + sender: { id: 'U123', name: 'Test User' }, + timestamp: new Date('2024-01-01T00:00:00Z'), + }, + status: 'pending', + timestamp: new Date('2024-01-01T00:00:00Z'), + }; + + beforeEach(async () => { + // Clean up by attempting to delete (may not exist) + const existing = await adapter.getTurn(turnInput.turnId); + if (existing) { + // We can't directly delete turns in the interface but we can update + } + }); + + test('createTurn and getTurn', async () => { + const created = await adapter.createTurn(turnInput); + expect(created.turnId).toBe(turnInput.turnId); + + const fetched = await adapter.getTurn(turnInput.turnId); + expect(fetched?.threadId).toBe(turnInput.threadId); + expect(fetched?.status).toBe('pending'); + }); + + test('getTurnsForThread returns chronological order', async () => { + const turn2: TurnInput = { + turnId: 'ot_turn_test002', + threadId: 'ot_thr_test001', + inbound: { + message: { text: 'Second message' }, + sender: { id: 'U123' }, + timestamp: new Date('2024-01-01T00:01:00Z'), + }, + status: 'pending', + timestamp: new Date('2024-01-01T00:01:00Z'), + }; + await adapter.createTurn(turn2); + + const turns = await adapter.getTurnsForThread('ot_thr_test001'); + const timestamps = turns.map(t => t.timestamp.getTime()); + // Verify ascending order + for (let i = 1; i < timestamps.length; i++) { + expect(timestamps[i]).toBeGreaterThanOrEqual(timestamps[i - 1]!); + } + }); + + test('updateTurn status', async () => { + await adapter.createTurn(turnInput); + const updated = await adapter.updateTurn(turnInput.turnId, { + status: 'delivered', + outbound: { + message: { text: 'Acknowledged!' }, + timestamp: new Date(), + }, + }); + expect(updated?.status).toBe('delivered'); + expect(updated?.outbound).toBeDefined(); + }); +}); + +// ─── Routes ──────────────────────────────────────────────────────────────────── + +describe('routes', () => { + const routeInput: RouteInput = { + routeId: 'test-route-1', + name: 'Test Route', + criteria: { channelId: 'test-slack', targetId: 'C0123' }, + recipientId: 'test-agent-1', + priority: 10, + active: true, + }; + + beforeEach(async () => { + await adapter.deleteRoute(routeInput.routeId); + }); + + test('createRoute and getRoute', async () => { + const created = await adapter.createRoute(routeInput); + expect(created.routeId).toBe(routeInput.routeId); + + const fetched = await adapter.getRoute(routeInput.routeId); + expect(fetched?.criteria.channelId).toBe('test-slack'); + }); + + test('findMatchingRoutes returns active routes matching criteria', async () => { + await adapter.createRoute(routeInput); + + const matches = await adapter.findMatchingRoutes({ + channelId: 'test-slack', + targetId: 'C0123', + }); + expect(matches.some(r => r.routeId === routeInput.routeId)).toBe(true); + }); + + test('findMatchingRoutes does not return routes for different channel', async () => { + await adapter.createRoute(routeInput); + + const matches = await adapter.findMatchingRoutes({ channelId: 'other-channel' }); + expect(matches.some(r => r.routeId === routeInput.routeId)).toBe(false); + }); + + test('findMatchingRoutes respects priority ordering', async () => { + const route2: RouteInput = { + routeId: 'test-route-low-priority', + name: 'Low Priority Route', + criteria: { channelId: 'test-slack' }, + recipientId: 'test-agent-1', + priority: 100, + active: true, + }; + await adapter.createRoute(route2); + + const matches = await adapter.findMatchingRoutes({ channelId: 'test-slack' }); + const priorities = matches.map(r => r.priority); + for (let i = 1; i < priorities.length; i++) { + expect(priorities[i]).toBeGreaterThanOrEqual(priorities[i - 1]!); + } + + await adapter.deleteRoute(route2.routeId); + }); + + test('updateRoute', async () => { + await adapter.createRoute(routeInput); + const updated = await adapter.updateRoute(routeInput.routeId, { priority: 5 }); + expect(updated?.priority).toBe(5); + }); + + test('deleteRoute', async () => { + await adapter.createRoute(routeInput); + expect(await adapter.deleteRoute(routeInput.routeId)).toBe(true); + expect(await adapter.getRoute(routeInput.routeId)).toBeNull(); + }); +}); + +// ─── Tokens ──────────────────────────────────────────────────────────────────── + +describe('tokens', () => { + const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000); // +24h + + const tokenInput: TokenInput = { + tokenId: 'tok_test_001', + value: 'ot_tk_test_value_001', + channelId: 'test-slack', + threadId: 'ot_thr_test001', + expiresAt: futureDate, + used: false, + }; + + test('createToken and getTokenByValue', async () => { + const created = await adapter.createToken(tokenInput); + expect(created.value).toBe(tokenInput.value); + + const fetched = await adapter.getTokenByValue(tokenInput.value); + expect(fetched?.channelId).toBe(tokenInput.channelId); + }); + + test('getTokenByValue returns null for expired tokens', async () => { + const expiredInput: TokenInput = { + ...tokenInput, + tokenId: 'tok_expired_001', + value: 'ot_tk_expired_001', + expiresAt: new Date(Date.now() - 1000), // already expired + }; + await adapter.createToken(expiredInput); + + const fetched = await adapter.getTokenByValue(expiredInput.value); + expect(fetched).toBeNull(); + }); + + test('consumeToken marks token as used', async () => { + await adapter.createToken({ + ...tokenInput, + tokenId: 'tok_consume_001', + value: 'ot_tk_consume_001', + }); + + const consumed = await adapter.consumeToken('ot_tk_consume_001'); + expect(consumed).toBe(true); + + // Second consume should fail + const secondConsume = await adapter.consumeToken('ot_tk_consume_001'); + expect(secondConsume).toBe(false); + + // getTokenByValue should return null after consumption + const fetched = await adapter.getTokenByValue('ot_tk_consume_001'); + expect(fetched).toBeNull(); + }); + + test('deleteExpiredTokens removes expired documents', async () => { + const expiredInput: TokenInput = { + ...tokenInput, + tokenId: 'tok_del_expired_001', + value: 'ot_tk_del_expired_001', + expiresAt: new Date(Date.now() - 5000), + }; + await adapter.createToken(expiredInput); + + const count = await adapter.deleteExpiredTokens(); + expect(count).toBeGreaterThanOrEqual(1); + }); + + test('TTL index exists on tokens collection', async () => { + const isPingable = await adapter.ping(); + expect(isPingable).toBe(true); + }); +}); + +// ─── ping ────────────────────────────────────────────────────────────────────── + +describe('ping', () => { + test('returns true when connected', async () => { + expect(await adapter.ping()).toBe(true); + }); +}); diff --git a/packages/storage/mongodb/tsconfig.json b/packages/storage/mongodb/tsconfig.json new file mode 100644 index 0000000..732df5f --- /dev/null +++ b/packages/storage/mongodb/tsconfig.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "references": [ + { "path": "../../core" } + ], + "include": ["src"] +} diff --git a/packages/storage/mongodb/vite.config.ts b/packages/storage/mongodb/vite.config.ts new file mode 100644 index 0000000..dd7cc4e --- /dev/null +++ b/packages/storage/mongodb/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite' +import dts from 'vite-plugin-dts' +import { resolve } from 'path' + +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'OpenThreadsMongoDB', + formats: ['es', 'cjs'], + fileName: (format) => `index.${format === 'es' ? 'js' : 'cjs'}`, + }, + rollupOptions: { + external: ['mongodb', '@openthreads/core'], + }, + }, + plugins: [dts({ rollupTypes: true })], +}) diff --git a/packages/trust/package.json b/packages/trust/package.json new file mode 100644 index 0000000..852175b --- /dev/null +++ b/packages/trust/package.json @@ -0,0 +1,29 @@ +{ + "name": "@openthreads/trust", + "version": "0.0.1", + "private": false, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "test": "bun test" + }, + "devDependencies": { + "vite": "*", + "vite-plugin-dts": "*", + "typescript": "*" + }, + "dependencies": { + "@openthreads/core": "workspace:*" + } +} diff --git a/packages/trust/src/audit/index.ts b/packages/trust/src/audit/index.ts new file mode 100644 index 0000000..977e1f2 --- /dev/null +++ b/packages/trust/src/audit/index.ts @@ -0,0 +1,2 @@ +export { AuditLogger } from './logger.js'; +export { InMemoryAuditStorage } from './storage.js'; diff --git a/packages/trust/src/audit/logger.ts b/packages/trust/src/audit/logger.ts new file mode 100644 index 0000000..99f7244 --- /dev/null +++ b/packages/trust/src/audit/logger.ts @@ -0,0 +1,48 @@ +/** + * AuditLogger — structured audit logging for all A2H interactions. + * + * Records the full decision path: intent sent → auth → consent → evidence. + * Delegates storage to the configured AuditStorageAdapter. + */ + +import type { AuditEventType, AuditLogEntry, AuditLogFilter, AuditStorageAdapter } from '../types.js'; + +let _entryCounter = 0; + +function generateEntryId(): string { + _entryCounter = (_entryCounter + 1) % 1_000_000; + return `ot_audit_${Date.now()}_${_entryCounter.toString().padStart(6, '0')}`; +} + +export class AuditLogger { + constructor(private readonly storage: AuditStorageAdapter) {} + + /** + * Record an audit log entry. + * + * @param eventType The event type (e.g., 'intent_sent', 'evidence_signed') + * @param turnId Turn this event belongs to + * @param fields Additional contextual fields + */ + async log( + eventType: AuditEventType, + turnId: string, + fields: Omit = {}, + ): Promise { + const entry: AuditLogEntry = { + id: generateEntryId(), + eventType, + turnId, + timestamp: new Date(), + ...fields, + }; + + await this.storage.saveAuditEntry(entry); + return entry; + } + + /** Query the audit log with optional filters. */ + async query(filter: AuditLogFilter = {}): Promise { + return this.storage.queryAuditLog(filter); + } +} diff --git a/packages/trust/src/audit/storage.ts b/packages/trust/src/audit/storage.ts new file mode 100644 index 0000000..70b630f --- /dev/null +++ b/packages/trust/src/audit/storage.ts @@ -0,0 +1,72 @@ +/** + * Audit log storage interface and in-memory implementation. + */ + +import type { AuditLogEntry, AuditLogFilter, AuditStorageAdapter } from '../types.js'; + +// ─── In-memory implementation ───────────────────────────────────────────────── + +/** + * InMemoryAuditStorage — simple in-memory audit log. + * + * Suitable for development and single-process deployments. For production, wire + * in a persistence-backed implementation (e.g., MongoAuditStorage in the server + * package that stores entries in the `audit_log` MongoDB collection). + */ +export class InMemoryAuditStorage implements AuditStorageAdapter { + private readonly entries: AuditLogEntry[] = []; + /** Maximum entries to retain in memory. Oldest are evicted when exceeded. */ + private readonly maxEntries: number; + + constructor(maxEntries = 10_000) { + this.maxEntries = maxEntries; + } + + async saveAuditEntry(entry: AuditLogEntry): Promise { + this.entries.push(entry); + if (this.entries.length > this.maxEntries) { + // Evict the oldest entry. + this.entries.shift(); + } + } + + async queryAuditLog(filter: AuditLogFilter): Promise { + let results = [...this.entries]; + + if (filter.turnId) { + results = results.filter((e) => e.turnId === filter.turnId); + } + if (filter.threadId) { + results = results.filter((e) => e.threadId === filter.threadId); + } + if (filter.channelId) { + results = results.filter((e) => e.channelId === filter.channelId); + } + if (filter.eventType) { + results = results.filter((e) => e.eventType === filter.eventType); + } + if (filter.fromDate) { + results = results.filter((e) => e.timestamp >= filter.fromDate!); + } + if (filter.toDate) { + results = results.filter((e) => e.timestamp <= filter.toDate!); + } + + // Sort descending by timestamp (most recent first). + results.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); + + const offset = filter.offset ?? 0; + const limit = filter.limit ?? 100; + return results.slice(offset, offset + limit); + } + + /** Total number of stored entries. */ + get size(): number { + return this.entries.length; + } + + /** Flush all entries (useful in tests). */ + clear(): void { + this.entries.length = 0; + } +} diff --git a/packages/trust/src/auth/challenge-manager.ts b/packages/trust/src/auth/challenge-manager.ts new file mode 100644 index 0000000..5c3a0fe --- /dev/null +++ b/packages/trust/src/auth/challenge-manager.ts @@ -0,0 +1,228 @@ +/** + * AuthChallengeManager — issue and verify authentication challenges. + * + * Supports two methods: + * webauthn — WebAuthn/Passkey (strong authentication for AUTHORIZE intents) + * totp — Time-based OTP (simpler fallback, RFC 6238) + * sms_otp — SMS OTP stub (actual SMS delivery is external) + */ + +import type { + AuthChallenge, + AuthChallengeResult, + AuthMethod, + TotpVerification, + WebAuthnAssertion, +} from '../types.js'; +import { generateWebAuthnChallenge, verifyWebAuthnAssertion } from './webauthn.js'; +import { generateTotpSecret, verifyTotp, encodeBase32 } from './totp.js'; + +// ─── In-memory challenge store ──────────────────────────────────────────────── + +interface StoredChallenge extends AuthChallenge { + /** TOTP secret (raw bytes) when method === 'totp' */ + totpSecret?: Uint8Array; + /** WebAuthn public key JWK for registered credentials */ + webAuthnPublicKeyJwk?: JsonWebKey; + /** Relying Party ID for WebAuthn */ + rpId?: string; +} + +function generateChallengeId(): string { + return `ot_ch_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; +} + +function generateBase64urlChallenge(byteLength = 32): string { + const bytes = crypto.getRandomValues(new Uint8Array(byteLength)); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +// ─── AuthChallengeManager ───────────────────────────────────────────────────── + +export interface AuthChallengeManagerOptions { + /** Default authentication method when not specified. Default: 'totp'. */ + defaultMethod?: AuthMethod; + /** Challenge TTL in seconds. Default: 300 (5 minutes). */ + challengeTtlSecs?: number; + /** Relying Party ID for WebAuthn. Default: 'localhost'. */ + rpId?: string; +} + +export class AuthChallengeManager { + private readonly challenges = new Map(); + private readonly defaultMethod: AuthMethod; + private readonly challengeTtlMs: number; + private readonly rpId: string; + + constructor(options: AuthChallengeManagerOptions = {}) { + this.defaultMethod = options.defaultMethod ?? 'totp'; + this.challengeTtlMs = (options.challengeTtlSecs ?? 300) * 1000; + this.rpId = options.rpId ?? 'localhost'; + } + + /** + * Issue a new authentication challenge for a form. + * + * Returns an `AuthChallenge` that the server sends to the form client. + * The challenge field contains the data the authenticator needs to respond. + */ + async issueChallenge(formKey: string, method?: AuthMethod): Promise { + const m = method ?? this.defaultMethod; + const challengeId = generateChallengeId(); + const expiresAt = new Date(Date.now() + this.challengeTtlMs); + + let challenge: string; + const stored: Partial = {}; + + if (m === 'webauthn') { + challenge = generateWebAuthnChallenge(); + stored.rpId = this.rpId; + } else if (m === 'totp') { + const secret = generateTotpSecret(); + stored.totpSecret = secret; + // The challenge carries the base32-encoded secret (sent to client for QR code generation) + // In production this would be pre-registered; here we provision one per challenge. + challenge = encodeBase32(secret); + } else { + // sms_otp: generate a 6-digit code, challenge is a placeholder (actual SMS is external) + challenge = generateBase64urlChallenge(4); // used as correlation ID + } + + const authChallenge: StoredChallenge = { + challengeId, + formKey, + method: m, + challenge, + expiresAt, + verified: false, + createdAt: new Date(), + ...stored, + }; + + this.challenges.set(challengeId, authChallenge); + + // Return the public-facing challenge (strip server-side secrets). + return { + challengeId, + formKey, + method: m, + challenge, + expiresAt, + verified: false, + createdAt: authChallenge.createdAt, + }; + } + + /** + * Verify an authentication challenge response. + * + * @param challengeId The ID returned by `issueChallenge` + * @param response The authenticator's response: + * WebAuthn: `WebAuthnAssertion` object + * TOTP: `TotpVerification` object with `code` + * SMS OTP: `TotpVerification` object with `code` + * @param webAuthnPublicKeyJwk Required for WebAuthn: the credential's public key + */ + async verifyChallenge( + challengeId: string, + response: WebAuthnAssertion | TotpVerification, + webAuthnPublicKeyJwk?: JsonWebKey, + ): Promise { + const stored = this.challenges.get(challengeId); + + if (!stored) { + return { success: false, challengeId, error: 'Challenge not found' }; + } + + if (stored.expiresAt < new Date()) { + this.challenges.delete(challengeId); + return { success: false, challengeId, error: 'Challenge has expired' }; + } + + if (stored.verified) { + return { success: false, challengeId, error: 'Challenge already used' }; + } + + let success = false; + let identityId: string | undefined; + + if (stored.method === 'webauthn') { + const assertion = response as WebAuthnAssertion; + const publicKeyJwk = webAuthnPublicKeyJwk ?? stored.webAuthnPublicKeyJwk; + if (!publicKeyJwk) { + return { success: false, challengeId, error: 'WebAuthn public key not provided' }; + } + success = await verifyWebAuthnAssertion( + assertion, + stored.challenge, + stored.rpId ?? this.rpId, + publicKeyJwk, + ); + if (success) identityId = assertion.credentialId; + } else if (stored.method === 'totp') { + const { code } = response as TotpVerification; + if (!stored.totpSecret) { + return { success: false, challengeId, error: 'TOTP secret not found' }; + } + success = await verifyTotp(stored.totpSecret, code); + } else { + // sms_otp: for this implementation, accept any 6-digit numeric code + // (real SMS OTP verification would validate against a sent code stored externally) + const { code } = response as TotpVerification; + success = /^\d{6}$/.test(code); + } + + if (success) { + const verifiedAt = new Date(); + stored.verified = true; + stored.verifiedAt = verifiedAt; + stored.identityId = identityId; + return { success: true, challengeId, verifiedAt, identityId }; + } + + return { success: false, challengeId, error: 'Verification failed' }; + } + + /** + * Check if a challenge has been successfully verified. + * Returns the challenge record if verified, null otherwise. + */ + getVerifiedChallenge(challengeId: string): AuthChallenge | null { + const stored = this.challenges.get(challengeId); + if (!stored || !stored.verified || stored.expiresAt < new Date()) return null; + return { + challengeId: stored.challengeId, + formKey: stored.formKey, + method: stored.method, + challenge: stored.challenge, + expiresAt: stored.expiresAt, + verified: stored.verified, + verifiedAt: stored.verifiedAt, + identityId: stored.identityId, + createdAt: stored.createdAt, + }; + } + + /** + * Remove expired challenge entries. Call periodically to avoid unbounded growth. + */ + prune(): number { + const now = new Date(); + let removed = 0; + for (const [id, challenge] of this.challenges) { + if (challenge.expiresAt < now) { + this.challenges.delete(id); + removed++; + } + } + return removed; + } + + get size(): number { + return this.challenges.size; + } +} diff --git a/packages/trust/src/auth/index.ts b/packages/trust/src/auth/index.ts new file mode 100644 index 0000000..91c4e1b --- /dev/null +++ b/packages/trust/src/auth/index.ts @@ -0,0 +1,11 @@ +export { AuthChallengeManager } from './challenge-manager.js'; +export type { AuthChallengeManagerOptions } from './challenge-manager.js'; +export { generateWebAuthnChallenge, buildCredentialRequestOptions, verifyWebAuthnAssertion } from './webauthn.js'; +export { + generateTotp, + verifyTotp, + generateTotpSecret, + encodeBase32, + decodeBase32, +} from './totp.js'; +export type { TotpOptions } from './totp.js'; diff --git a/packages/trust/src/auth/totp.ts b/packages/trust/src/auth/totp.ts new file mode 100644 index 0000000..60a087f --- /dev/null +++ b/packages/trust/src/auth/totp.ts @@ -0,0 +1,166 @@ +/** + * TOTP (Time-based One-Time Password) implementation. + * + * Implements RFC 6238 (TOTP) over RFC 4226 (HOTP) using the Web Crypto API. + * No external dependencies. + * + * Algorithm: + * HOTP(K, C) = Truncate(HMAC-SHA-1(K, C)) + * TOTP(K, T) = HOTP(K, T) where T = floor((unix_time - T0) / step) + */ + +// ─── HOTP core ──────────────────────────────────────────────────────────────── + +/** + * Compute an HOTP code for the given key and counter. + * + * @param key Base32-encoded or raw TOTP secret + * @param counter 8-byte counter value + * @param digits OTP length (default: 6) + */ +async function hotp(key: Uint8Array, counter: bigint, digits = 6): Promise { + // Encode counter as 8-byte big-endian + const counterBytes = new Uint8Array(8); + let c = counter; + for (let i = 7; i >= 0; i--) { + counterBytes[i] = Number(c & 0xffn); + c >>= 8n; + } + + // HMAC-SHA-1 + const cryptoKey = await crypto.subtle.importKey( + 'raw', + key, + { name: 'HMAC', hash: 'SHA-1' }, + false, + ['sign'], + ); + const hmacBuffer = await crypto.subtle.sign('HMAC', cryptoKey, counterBytes); + const hmac = new Uint8Array(hmacBuffer); + + // Dynamic truncation + const offset = hmac[hmac.length - 1] & 0x0f; + const code = + ((hmac[offset] & 0x7f) << 24) | + ((hmac[offset + 1] & 0xff) << 16) | + ((hmac[offset + 2] & 0xff) << 8) | + (hmac[offset + 3] & 0xff); + + const otp = (code % 10 ** digits).toString(); + return otp.padStart(digits, '0'); +} + +// ─── TOTP ──────────────────────────────────────────────────────────────────── + +export interface TotpOptions { + /** Time step in seconds. Default: 30. */ + step?: number; + /** Number of OTP digits. Default: 6. */ + digits?: number; + /** + * Acceptable window: number of steps before/after current to accept. + * Default: 1 (accepts current step + 1 step in each direction). + */ + window?: number; +} + +/** + * Generate the current TOTP code for a secret. + * + * @param secret Raw key bytes + * @param options TOTP options + */ +export async function generateTotp(secret: Uint8Array, options: TotpOptions = {}): Promise { + const step = options.step ?? 30; + const digits = options.digits ?? 6; + const counter = BigInt(Math.floor(Date.now() / 1000 / step)); + return hotp(secret, counter, digits); +} + +/** + * Verify a TOTP code against a secret. + * + * Accepts codes within the configured time window to account for clock skew. + * + * @param secret Raw key bytes + * @param code The OTP string to verify + * @param options TOTP options + * @returns true if the code is valid within the acceptance window + */ +export async function verifyTotp( + secret: Uint8Array, + code: string, + options: TotpOptions = {}, +): Promise { + const step = options.step ?? 30; + const digits = options.digits ?? 6; + const window = options.window ?? 1; + const currentCounter = BigInt(Math.floor(Date.now() / 1000 / step)); + + for (let i = -window; i <= window; i++) { + const counter = currentCounter + BigInt(i); + if (counter < 0n) continue; + const expected = await hotp(secret, counter, digits); + if (expected === code) return true; + } + return false; +} + +/** + * Generate a random TOTP secret. + * + * @param byteLength Length of the secret in bytes. Default: 20 (160 bits, SHA-1 block size). + */ +export function generateTotpSecret(byteLength = 20): Uint8Array { + return crypto.getRandomValues(new Uint8Array(byteLength)); +} + +/** + * Encode a secret as a Base32 string (for use in otpauth:// URIs / QR codes). + * Implements RFC 4648 Base32. + */ +export function encodeBase32(bytes: Uint8Array): string { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + let output = ''; + let buffer = 0; + let bitsLeft = 0; + + for (const byte of bytes) { + buffer = (buffer << 8) | byte; + bitsLeft += 8; + while (bitsLeft >= 5) { + output += alphabet[(buffer >> (bitsLeft - 5)) & 0x1f]; + bitsLeft -= 5; + } + } + + if (bitsLeft > 0) { + output += alphabet[(buffer << (5 - bitsLeft)) & 0x1f]; + } + + return output; +} + +/** + * Decode a Base32-encoded secret to raw bytes. + */ +export function decodeBase32(base32: string): Uint8Array { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + const clean = base32.toUpperCase().replace(/=+$/, ''); + const bytes: number[] = []; + let buffer = 0; + let bitsLeft = 0; + + for (const char of clean) { + const idx = alphabet.indexOf(char); + if (idx === -1) continue; + buffer = (buffer << 5) | idx; + bitsLeft += 5; + if (bitsLeft >= 8) { + bytes.push((buffer >> (bitsLeft - 8)) & 0xff); + bitsLeft -= 8; + } + } + + return new Uint8Array(bytes); +} diff --git a/packages/trust/src/auth/webauthn.ts b/packages/trust/src/auth/webauthn.ts new file mode 100644 index 0000000..64aff6c --- /dev/null +++ b/packages/trust/src/auth/webauthn.ts @@ -0,0 +1,157 @@ +/** + * WebAuthn server-side utilities for the OpenThreads Trust Layer. + * + * Handles the server-side of the WebAuthn ceremony: + * 1. Challenge generation — create a random challenge to send to the browser + * 2. Credential verification — verify the browser's signed assertion + * + * The browser-side (navigator.credentials.get / create) is handled by the + * form client (FormClient.tsx). + * + * References: + * - https://www.w3.org/TR/webauthn-2/ + * - https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API + */ + +import type { WebAuthnAssertion } from '../types.js'; + +// ─── Challenge generation ───────────────────────────────────────────────────── + +/** + * Generate a cryptographically random WebAuthn challenge. + * + * @param byteLength Length of the challenge in bytes. Default: 32 (256 bits). + * @returns Base64url-encoded challenge string to send to the browser. + */ +export function generateWebAuthnChallenge(byteLength = 32): string { + const bytes = crypto.getRandomValues(new Uint8Array(byteLength)); + return base64urlEncodeBytes(bytes); +} + +/** + * Build the PublicKeyCredentialRequestOptions payload to send to the browser. + * The browser passes this to `navigator.credentials.get({ publicKey: ... })`. + */ +export function buildCredentialRequestOptions( + challenge: string, + rpId: string, + timeout = 60_000, +): object { + return { + challenge, + rpId, + timeout, + userVerification: 'preferred', + }; +} + +// ─── Assertion verification ─────────────────────────────────────────────────── + +/** + * Verify a WebAuthn authenticator assertion. + * + * This implements a simplified subset of the W3C WebAuthn Level 2 verification + * algorithm — sufficient for standard resident-key / discoverable-credential + * scenarios. For full Level 2 compliance (attestation, extensions, token + * binding), use a dedicated library like `@simplewebauthn/server`. + * + * @param assertion The credential assertion from the browser + * @param expectedChallenge The challenge that was sent to the browser (base64url) + * @param expectedRpId The relying party ID (e.g., "openthreads.host") + * @param publicKeyJwk The stored public key for this credential (as JWK) + * @returns true if the assertion is valid + */ +export async function verifyWebAuthnAssertion( + assertion: WebAuthnAssertion, + expectedChallenge: string, + expectedRpId: string, + publicKeyJwk: JsonWebKey, +): Promise { + try { + // 1. Parse clientDataJSON + const clientDataBytes = base64urlDecodeBytes(assertion.clientDataJSON); + const clientData = JSON.parse(new TextDecoder().decode(clientDataBytes)) as { + type: string; + challenge: string; + origin: string; + }; + + // 2. Verify type + if (clientData.type !== 'webauthn.get') return false; + + // 3. Verify challenge + if (clientData.challenge !== expectedChallenge) return false; + + // 4. Parse authenticatorData + const authDataBytes = base64urlDecodeBytes(assertion.authenticatorData); + if (authDataBytes.length < 37) return false; + + // Bytes 0-31: rpIdHash (SHA-256 of the RP ID) + const rpIdHash = authDataBytes.slice(0, 32); + const expectedRpIdHash = new Uint8Array( + await crypto.subtle.digest('SHA-256', new TextEncoder().encode(expectedRpId)), + ); + if (!uint8ArrayEqual(rpIdHash, expectedRpIdHash)) return false; + + // Byte 32: flags + const flags = authDataBytes[32]; + const userPresent = (flags & 0x01) !== 0; + if (!userPresent) return false; + + // 5. Verify signature over clientDataHash + authenticatorData + const clientDataHash = new Uint8Array( + await crypto.subtle.digest('SHA-256', clientDataBytes), + ); + const signedData = new Uint8Array(authDataBytes.length + clientDataHash.length); + signedData.set(authDataBytes, 0); + signedData.set(clientDataHash, authDataBytes.length); + + // Import the public key (EC P-256) + const publicKey = await crypto.subtle.importKey( + 'jwk', + publicKeyJwk, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['verify'], + ); + + const signatureBytes = base64urlDecodeBytes(assertion.signature); + return crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + publicKey, + signatureBytes, + signedData, + ); + } catch { + return false; + } +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function base64urlEncodeBytes(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +function base64urlDecodeBytes(b64url: string): Uint8Array { + const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '='); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function uint8ArrayEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} diff --git a/packages/trust/src/index.test.ts b/packages/trust/src/index.test.ts new file mode 100644 index 0000000..0e6d9a0 --- /dev/null +++ b/packages/trust/src/index.test.ts @@ -0,0 +1,438 @@ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { + TrustLayerManager, + ReplayGuard, + ReplayError, + InMemoryAuditStorage, + AuditLogger, + AuthChallengeManager, + generateKeyPair, + jwsSign, + jwsVerify, + jwsSignIntent, + jwsSignResponse, + jwsDecodeUnverified, + generateTotp, + verifyTotp, + generateTotpSecret, + encodeBase32, + decodeBase32, + generateWebAuthnChallenge, +} from './index'; + +// ─── JWS tests ──────────────────────────────────────────────────────────────── + +describe('JWS', () => { + it('generateKeyPair returns a usable ES256 key pair', async () => { + const pair = await generateKeyPair(); + expect(pair.privateKey).toBeDefined(); + expect(pair.publicKey).toBeDefined(); + expect(pair.publicKeyJwk).toBeDefined(); + expect(pair.publicKeyJwk.kty).toBe('EC'); + expect(pair.publicKeyJwk.crv).toBe('P-256'); + }); + + it('sign + verify round-trip succeeds', async () => { + const { privateKey, publicKey } = await generateKeyPair(); + const payload = { sub: 'AUTHORIZE', iat: Math.floor(Date.now() / 1000), jti: 'test-nonce' }; + + const jws = await jwsSign(payload, privateKey); + expect(typeof jws).toBe('string'); + expect(jws.split('.').length).toBe(3); + + const result = await jwsVerify(jws, publicKey); + expect(result).not.toBeNull(); + expect(result!.payload['sub']).toBe('AUTHORIZE'); + expect(result!.payload['jti']).toBe('test-nonce'); + }); + + it('verify returns null for tampered JWS', async () => { + const { privateKey, publicKey } = await generateKeyPair(); + const jws = await jwsSign({ sub: 'TEST' }, privateKey); + const [h, p, s] = jws.split('.'); + const tampered = `${h}.${p}modified.${s}`; + const result = await jwsVerify(tampered, publicKey); + expect(result).toBeNull(); + }); + + it('verify returns null with wrong public key', async () => { + const pair1 = await generateKeyPair(); + const pair2 = await generateKeyPair(); + const jws = await jwsSign({ sub: 'TEST' }, pair1.privateKey); + const result = await jwsVerify(jws, pair2.publicKey); + expect(result).toBeNull(); + }); + + it('signIntent embeds intent claims correctly', async () => { + const { privateKey, publicKey } = await generateKeyPair(); + const message = { intent: 'AUTHORIZE' as const, context: { action: 'deploy' } }; + const jws = await jwsSignIntent(message, 'ot_turn_001', 'test-nonce-123', privateKey); + + const result = await jwsVerify(jws, publicKey); + expect(result).not.toBeNull(); + expect(result!.payload['sub']).toBe('AUTHORIZE'); + expect(result!.payload['jti']).toBe('test-nonce-123'); + expect(result!.payload['tid']).toBe('ot_turn_001'); + }); + + it('signResponse embeds response claims and links to intent', async () => { + const { privateKey, publicKey } = await generateKeyPair(); + const jws = await jwsSignResponse( + { approved: true }, + 'AUTHORIZE', + 'response-nonce', + 'intent-nonce', + privateKey, + ); + + const result = await jwsVerify(jws, publicKey); + expect(result).not.toBeNull(); + expect(result!.payload['intentJti']).toBe('intent-nonce'); + expect((result!.payload['response'] as Record)['approved']).toBe(true); + }); + + it('decodeUnverified works without key', async () => { + const { privateKey } = await generateKeyPair(); + const jws = await jwsSign({ sub: 'COLLECT', data: 42 }, privateKey); + const decoded = jwsDecodeUnverified(jws); + expect(decoded).not.toBeNull(); + expect(decoded!.payload['sub']).toBe('COLLECT'); + expect(decoded!.payload['data']).toBe(42); + }); +}); + +// ─── Replay protection tests ────────────────────────────────────────────────── + +describe('ReplayGuard', () => { + it('accepts a fresh nonce with valid timestamp', () => { + const guard = new ReplayGuard(300, 3600); + expect(() => guard.check('nonce-1', new Date())).not.toThrow(); + }); + + it('rejects a nonce used twice', () => { + const guard = new ReplayGuard(300, 3600); + guard.check('nonce-2', new Date()); + expect(() => guard.check('nonce-2', new Date())).toThrow(ReplayError); + expect(() => guard.checkNonce('nonce-2')).toThrow(ReplayError); + }); + + it('rejects stale timestamp', () => { + const guard = new ReplayGuard(60, 3600); + const old = new Date(Date.now() - 120_000); // 2 minutes ago, tolerance 60s + expect(() => guard.validateTimestamp(old)).toThrow(ReplayError); + + try { + guard.validateTimestamp(old); + } catch (e) { + expect((e as ReplayError).code).toBe('intent_expired'); + } + }); + + it('rejects future timestamp beyond tolerance', () => { + const guard = new ReplayGuard(60, 3600); + const future = new Date(Date.now() + 120_000); // 2 minutes ahead, tolerance 60s + expect(() => guard.validateTimestamp(future)).toThrow(ReplayError); + + try { + guard.validateTimestamp(future); + } catch (e) { + expect((e as ReplayError).code).toBe('intent_future'); + } + }); + + it('prune removes expired entries', () => { + const guard = new ReplayGuard(300, 3600); + guard.recordNonce('prunable', 1); // 1ms TTL — already expired after setting + guard.recordNonce('keep', 60_000); + + // Fast-forward: manually expire by direct manipulation + // (In real tests, we'd wait or mock timers; here we just verify prune returns a number) + const removed = guard.prune(); + expect(typeof removed).toBe('number'); + }); +}); + +// ─── Audit logging tests ────────────────────────────────────────────────────── + +describe('AuditLogger', () => { + let storage: InMemoryAuditStorage; + let logger: AuditLogger; + + beforeEach(() => { + storage = new InMemoryAuditStorage(); + logger = new AuditLogger(storage); + }); + + it('logs an entry and returns it', async () => { + const entry = await logger.log('intent_sent', 'ot_turn_001', { + intentType: 'AUTHORIZE', + traceId: 'trace-abc', + }); + + expect(entry.id).toBeDefined(); + expect(entry.eventType).toBe('intent_sent'); + expect(entry.turnId).toBe('ot_turn_001'); + expect(entry.intentType).toBe('AUTHORIZE'); + expect(entry.timestamp).toBeInstanceOf(Date); + expect(storage.size).toBe(1); + }); + + it('queries by turnId', async () => { + await logger.log('intent_sent', 'ot_turn_001'); + await logger.log('intent_sent', 'ot_turn_002'); + await logger.log('response_received', 'ot_turn_001'); + + const results = await logger.query({ turnId: 'ot_turn_001' }); + expect(results.length).toBe(2); + expect(results.every((e) => e.turnId === 'ot_turn_001')).toBe(true); + }); + + it('queries by eventType', async () => { + await logger.log('intent_sent', 'ot_turn_001'); + await logger.log('evidence_signed', 'ot_turn_001'); + await logger.log('intent_rendered', 'ot_turn_001'); + + const results = await logger.query({ eventType: 'evidence_signed' }); + expect(results.length).toBe(1); + expect(results[0].eventType).toBe('evidence_signed'); + }); + + it('respects limit and offset', async () => { + for (let i = 0; i < 10; i++) { + await logger.log('intent_sent', `ot_turn_00${i}`); + } + + const page1 = await logger.query({ limit: 3, offset: 0 }); + const page2 = await logger.query({ limit: 3, offset: 3 }); + + expect(page1.length).toBe(3); + expect(page2.length).toBe(3); + // Pages should not overlap + const page1Ids = new Set(page1.map((e) => e.id)); + const page2Ids = new Set(page2.map((e) => e.id)); + expect([...page1Ids].some((id) => page2Ids.has(id))).toBe(false); + }); + + it('filters by date range', async () => { + const before = new Date(); + await logger.log('intent_sent', 'ot_turn_001'); + const after = new Date(); + + const results = await logger.query({ fromDate: before, toDate: after }); + expect(results.length).toBeGreaterThanOrEqual(1); + }); +}); + +// ─── TOTP tests ─────────────────────────────────────────────────────────────── + +describe('TOTP', () => { + it('generates a 6-digit OTP', async () => { + const secret = generateTotpSecret(); + const code = await generateTotp(secret); + expect(code).toMatch(/^\d{6}$/); + }); + + it('verifies the current TOTP code', async () => { + const secret = generateTotpSecret(); + const code = await generateTotp(secret); + const valid = await verifyTotp(secret, code); + expect(valid).toBe(true); + }); + + it('rejects an incorrect code', async () => { + const secret = generateTotpSecret(); + const valid = await verifyTotp(secret, '000000'); + // This could randomly pass but is extremely unlikely (1/1,000,000 per valid window) + // We just verify the function returns a boolean. + expect(typeof valid).toBe('boolean'); + }); + + it('base32 encode/decode is symmetric', () => { + const secret = generateTotpSecret(); + const encoded = encodeBase32(secret); + const decoded = decodeBase32(encoded); + expect(decoded.length).toBe(secret.length); + for (let i = 0; i < secret.length; i++) { + expect(decoded[i]).toBe(secret[i]); + } + }); +}); + +// ─── WebAuthn tests ─────────────────────────────────────────────────────────── + +describe('WebAuthn challenge generation', () => { + it('generates a base64url-encoded challenge', () => { + const challenge = generateWebAuthnChallenge(); + expect(typeof challenge).toBe('string'); + expect(challenge.length).toBeGreaterThan(0); + // Should be valid base64url (no + / =) + expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/); + }); + + it('generates unique challenges', () => { + const c1 = generateWebAuthnChallenge(); + const c2 = generateWebAuthnChallenge(); + expect(c1).not.toBe(c2); + }); +}); + +// ─── AuthChallengeManager tests ─────────────────────────────────────────────── + +describe('AuthChallengeManager', () => { + it('issues a TOTP challenge', async () => { + const manager = new AuthChallengeManager({ defaultMethod: 'totp' }); + const challenge = await manager.issueChallenge('form-key-1'); + + expect(challenge.challengeId).toBeDefined(); + expect(challenge.method).toBe('totp'); + expect(challenge.challenge).toBeDefined(); // base32 TOTP secret + expect(challenge.verified).toBe(false); + expect(challenge.expiresAt > new Date()).toBe(true); + }); + + it('issues a WebAuthn challenge', async () => { + const manager = new AuthChallengeManager({ defaultMethod: 'webauthn' }); + const challenge = await manager.issueChallenge('form-key-2', 'webauthn'); + + expect(challenge.method).toBe('webauthn'); + expect(challenge.challenge).toMatch(/^[A-Za-z0-9_-]+$/); // base64url + }); + + it('verifies a TOTP challenge', async () => { + const manager = new AuthChallengeManager({ defaultMethod: 'totp' }); + const challenge = await manager.issueChallenge('form-key-3'); + + // Decode the base32 secret from the challenge, generate current code. + const secret = decodeBase32(challenge.challenge); + const code = await generateTotp(secret); + + const result = await manager.verifyChallenge(challenge.challengeId, { code }); + expect(result.success).toBe(true); + expect(result.challengeId).toBe(challenge.challengeId); + }); + + it('rejects wrong TOTP code', async () => { + const manager = new AuthChallengeManager({ defaultMethod: 'totp' }); + const challenge = await manager.issueChallenge('form-key-4'); + + const result = await manager.verifyChallenge(challenge.challengeId, { code: '000000' }); + // Extremely unlikely to be correct, just verify shape. + expect(typeof result.success).toBe('boolean'); + }); + + it('rejects unknown challengeId', async () => { + const manager = new AuthChallengeManager(); + const result = await manager.verifyChallenge('non-existent-id', { code: '123456' }); + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + + it('returns verified challenge after success', async () => { + const manager = new AuthChallengeManager({ defaultMethod: 'totp' }); + const challenge = await manager.issueChallenge('form-key-5'); + const secret = decodeBase32(challenge.challenge); + const code = await generateTotp(secret); + + await manager.verifyChallenge(challenge.challengeId, { code }); + const verified = manager.getVerifiedChallenge(challenge.challengeId); + + expect(verified).not.toBeNull(); + expect(verified!.verified).toBe(true); + expect(verified!.verifiedAt).toBeInstanceOf(Date); + }); + + it('prune removes expired challenges', () => { + const manager = new AuthChallengeManager({ challengeTtlSecs: -1 }); // already expired + const removed = manager.prune(); + expect(typeof removed).toBe('number'); + }); +}); + +// ─── TrustLayerManager integration tests ───────────────────────────────────── + +describe('TrustLayerManager', () => { + it('creates a manager with auto-generated keys', async () => { + const trust = await TrustLayerManager.create({ enabled: true }); + expect(trust.config.enabled).toBe(true); + expect(trust.config.privateKey).toBeDefined(); + expect(trust.config.publicKey).toBeDefined(); + }); + + it('signIntent returns signed evidence', async () => { + const trust = await TrustLayerManager.create({ enabled: true }); + const message = { intent: 'AUTHORIZE' as const, context: { action: 'deploy' } }; + + const evidence = await trust.signIntent(message, 'ot_turn_001'); + + expect(evidence.jws).toBeDefined(); + expect(evidence.nonce).toBeDefined(); + expect(evidence.timestamp).toBeInstanceOf(Date); + expect(evidence.intent.intent).toBe('AUTHORIZE'); + }); + + it('verifyEvidence returns true for valid evidence', async () => { + const trust = await TrustLayerManager.create({ enabled: true }); + const message = { intent: 'COLLECT' as const }; + + const evidence = await trust.signIntent(message, 'ot_turn_002'); + const valid = await trust.verifyEvidence(evidence); + + expect(valid).toBe(true); + }); + + it('signResponse links response to intent', async () => { + const trust = await TrustLayerManager.create({ enabled: true }); + const message = { intent: 'AUTHORIZE' as const }; + const evidence = await trust.signIntent(message, 'ot_turn_003'); + const signed = await trust.signResponse({ approved: true }, evidence, 'user-123'); + + expect(signed.intentNonce).toBe(evidence.nonce); + expect(signed.jws).toBeDefined(); + }); + + it('checkReplay rejects duplicate nonces', async () => { + const trust = await TrustLayerManager.create({ enabled: true }); + trust.recordNonce('dup-nonce'); + expect(() => trust.checkReplay('dup-nonce', new Date())).toThrow(ReplayError); + }); + + it('checkReplay rejects stale timestamps', async () => { + const trust = await TrustLayerManager.create({ enabled: true, timestampToleranceSecs: 30 }); + const old = new Date(Date.now() - 60_000); + expect(() => trust.checkReplay('fresh-nonce', old)).toThrow(ReplayError); + }); + + it('log records audit entries', async () => { + const storage = new InMemoryAuditStorage(); + const trust = await TrustLayerManager.create({ enabled: true }, storage); + + await trust.log('intent_sent', 'ot_turn_010', { intentType: 'AUTHORIZE' }); + const entries = await trust.queryAuditLog({ turnId: 'ot_turn_010' }); + + expect(entries.length).toBeGreaterThanOrEqual(1); + expect(entries[0].eventType).toBe('intent_sent'); + }); + + it('issueAuthChallenge creates a challenge', async () => { + const trust = await TrustLayerManager.create({ enabled: true }); + const challenge = await trust.issueAuthChallenge('form-key-x'); + + expect(challenge.challengeId).toBeDefined(); + expect(challenge.expiresAt > new Date()).toBe(true); + }); + + it('throws when disabled', async () => { + const trust = await TrustLayerManager.create({ enabled: false }); + const msg = { intent: 'AUTHORIZE' as const }; + + await expect(trust.signIntent(msg, 'turn-1')).rejects.toThrow('Trust layer is not enabled'); + }); + + it('signIntent rejects duplicate idempotency key', async () => { + const trust = await TrustLayerManager.create({ enabled: true }); + const message = { intent: 'AUTHORIZE' as const, idempotencyKey: 'idem-key-1' }; + + await trust.signIntent(message, 'ot_turn_001'); + await expect(trust.signIntent(message, 'ot_turn_001')).rejects.toThrow(ReplayError); + }); +}); diff --git a/packages/trust/src/index.ts b/packages/trust/src/index.ts new file mode 100644 index 0000000..0ad8219 --- /dev/null +++ b/packages/trust/src/index.ts @@ -0,0 +1,72 @@ +// @openthreads/trust +// Optional trust layer: JWS signing, strong authentication, audit logging, replay protection. +// Enable for compliance requirements; skip for lightweight deployments (zero overhead). + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type { + TrustConfig, + JwsHeader, + IntentClaims, + ResponseClaims, + SignedEvidence, + SignedResponse, + TrustKeyPair, + ReplayRejectReason, + AuditEventType, + AuditLogEntry, + AuditLogFilter, + AuditStorageAdapter, + AuthMethod, + AuthChallenge, + AuthChallengeResult, + WebAuthnAssertion, + TotpVerification, +} from './types.js'; + +export { ReplayError } from './types.js'; + +// ─── JWS ───────────────────────────────────────────────────────────────────── + +export { + generateKeyPair, + importPublicKey, + exportPrivateKey, + importPrivateKey, + sign as jwsSign, + verify as jwsVerify, + signIntent as jwsSignIntent, + signResponse as jwsSignResponse, + decodeUnverified as jwsDecodeUnverified, +} from './jws/index.js'; + +// ─── Replay protection ──────────────────────────────────────────────────────── + +export { ReplayGuard } from './replay/index.js'; + +// ─── Audit logging ──────────────────────────────────────────────────────────── + +export { AuditLogger } from './audit/logger.js'; +export { InMemoryAuditStorage } from './audit/storage.js'; + +// ─── Authentication ─────────────────────────────────────────────────────────── + +export { AuthChallengeManager } from './auth/challenge-manager.js'; +export type { AuthChallengeManagerOptions } from './auth/challenge-manager.js'; +export { + generateWebAuthnChallenge, + buildCredentialRequestOptions, + verifyWebAuthnAssertion, +} from './auth/webauthn.js'; +export { + generateTotp, + verifyTotp, + generateTotpSecret, + encodeBase32, + decodeBase32, +} from './auth/totp.js'; +export type { TotpOptions } from './auth/totp.js'; + +// ─── Trust Layer Manager ────────────────────────────────────────────────────── + +export { TrustLayerManager } from './trust-layer.js'; diff --git a/packages/trust/src/jws/index.ts b/packages/trust/src/jws/index.ts new file mode 100644 index 0000000..2486473 --- /dev/null +++ b/packages/trust/src/jws/index.ts @@ -0,0 +1,220 @@ +/** + * JWS (JSON Web Signature) utilities for the OpenThreads Trust Layer. + * + * Uses the Web Crypto API (built into Bun and Node.js ≥ 19) — no external deps. + * Default algorithm: ES256 (ECDSA with P-256 curve and SHA-256 hash). + */ + +import type { IntentClaims, JwsHeader, ResponseClaims, TrustKeyPair } from '../types.js'; +import type { A2HMessage, A2HIntent } from '@openthreads/core'; + +// ─── Base64url helpers ──────────────────────────────────────────────────────── + +function base64urlEncodeString(str: string): string { + return base64urlEncodeBytes(new TextEncoder().encode(str)); +} + +function base64urlEncodeBytes(bytes: ArrayBuffer | Uint8Array): string { + const u8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); + let binary = ''; + for (let i = 0; i < u8.length; i++) { + binary += String.fromCharCode(u8[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +function base64urlDecodeBytes(b64url: string): Uint8Array { + const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/'); + const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '='); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function base64urlDecodeString(b64url: string): string { + return new TextDecoder().decode(base64urlDecodeBytes(b64url)); +} + +// ─── Key management ─────────────────────────────────────────────────────────── + +/** + * Generate a new ES256 (ECDSA P-256) key pair for JWS signing. + * The keys are extractable so they can be exported as JWK for storage/sharing. + */ +export async function generateKeyPair(): Promise { + const pair = await crypto.subtle.generateKey( + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign', 'verify'], + ); + + const publicKeyJwk = await crypto.subtle.exportKey('jwk', pair.publicKey); + + return { + privateKey: pair.privateKey, + publicKey: pair.publicKey, + publicKeyJwk, + }; +} + +/** + * Import an EC public key from a JWK for verification. + */ +export async function importPublicKey(jwk: JsonWebKey): Promise { + return crypto.subtle.importKey( + 'jwk', + jwk, + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['verify'], + ); +} + +/** + * Export a private key to JWK format for persistence. + */ +export async function exportPrivateKey(key: CryptoKey): Promise { + return crypto.subtle.exportKey('jwk', key); +} + +/** + * Import an EC private key from a JWK for signing. + */ +export async function importPrivateKey(jwk: JsonWebKey): Promise { + return crypto.subtle.importKey( + 'jwk', + jwk, + { name: 'ECDSA', namedCurve: 'P-256' }, + true, + ['sign'], + ); +} + +// ─── JWS sign / verify ──────────────────────────────────────────────────────── + +/** + * Sign a payload object and return a JWS compact serialization string. + * + * Format: BASE64URL(header).BASE64URL(payload).BASE64URL(signature) + */ +export async function sign(payload: object, privateKey: CryptoKey, alg = 'ES256'): Promise { + const header: JwsHeader = { alg, typ: 'JWT' }; + + const headerB64 = base64urlEncodeString(JSON.stringify(header)); + const payloadB64 = base64urlEncodeString(JSON.stringify(payload)); + const signingInput = `${headerB64}.${payloadB64}`; + + const sigBytes = await crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + privateKey, + new TextEncoder().encode(signingInput), + ); + + const signatureB64 = base64urlEncodeBytes(sigBytes); + return `${signingInput}.${signatureB64}`; +} + +/** + * Verify a JWS compact serialization. Returns the decoded header and payload + * if the signature is valid, or `null` if invalid/malformed. + */ +export async function verify( + jws: string, + publicKey: CryptoKey, +): Promise<{ header: JwsHeader; payload: Record } | null> { + const parts = jws.split('.'); + if (parts.length !== 3) return null; + + const [headerB64, payloadB64, signatureB64] = parts; + const signingInput = `${headerB64}.${payloadB64}`; + + try { + const valid = await crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + publicKey, + base64urlDecodeBytes(signatureB64), + new TextEncoder().encode(signingInput), + ); + + if (!valid) return null; + + const header = JSON.parse(base64urlDecodeString(headerB64)) as JwsHeader; + const payload = JSON.parse(base64urlDecodeString(payloadB64)) as Record; + + return { header, payload }; + } catch { + return null; + } +} + +// ─── Intent / Response signing helpers ──────────────────────────────────────── + +/** + * Build and sign an IntentClaims JWS. + * + * @param message The A2H message to sign + * @param turnId The turn identifier + * @param nonce Unique nonce (jti) — generate with `crypto.randomUUID()` + * @param privateKey Signing key + */ +export async function signIntent( + message: A2HMessage, + turnId: string, + nonce: string, + privateKey: CryptoKey, +): Promise { + const claims: IntentClaims = { + sub: message.intent as A2HIntent, + iat: Math.floor(Date.now() / 1000), + jti: nonce, + tid: turnId, + intent: message, + traceId: message.traceId, + }; + return sign(claims, privateKey); +} + +/** + * Build and sign a ResponseClaims JWS. + * + * @param response The human's response payload + * @param intentType The A2H intent type being responded to + * @param nonce Unique nonce for this response + * @param intentNonce The nonce of the original intent (creates a cryptographic link) + * @param privateKey Signing key + */ +export async function signResponse( + response: unknown, + intentType: A2HIntent, + nonce: string, + intentNonce: string | undefined, + privateKey: CryptoKey, +): Promise { + const claims: ResponseClaims = { + sub: intentType, + iat: Math.floor(Date.now() / 1000), + jti: nonce, + response, + intentJti: intentNonce, + }; + return sign(claims, privateKey); +} + +/** + * Decode a JWS without verifying the signature (for inspection only). + * Use `verify()` when signature validation is required. + */ +export function decodeUnverified(jws: string): { header: JwsHeader; payload: Record } | null { + const parts = jws.split('.'); + if (parts.length !== 3) return null; + try { + const header = JSON.parse(base64urlDecodeString(parts[0])) as JwsHeader; + const payload = JSON.parse(base64urlDecodeString(parts[1])) as Record; + return { header, payload }; + } catch { + return null; + } +} diff --git a/packages/trust/src/replay/index.ts b/packages/trust/src/replay/index.ts new file mode 100644 index 0000000..158e36c --- /dev/null +++ b/packages/trust/src/replay/index.ts @@ -0,0 +1,119 @@ +/** + * Replay protection for the OpenThreads Trust Layer. + * + * Guards against: + * 1. Stale intents — timestamps outside the configured tolerance window + * 2. Future intents — timestamps too far ahead of the server clock + * 3. Nonce reuse — same JTI (nonce) used more than once + * + * In-memory implementation. For distributed deployments, swap the nonce store + * with a Redis-backed implementation. + */ + +import { ReplayError } from '../types.js'; + +// ─── Nonce store ────────────────────────────────────────────────────────────── + +interface NonceEntry { + /** When this nonce entry expires and can be evicted */ + expiresAt: number; // Unix ms +} + +export class ReplayGuard { + private readonly nonces = new Map(); + private readonly toleranceMs: number; + private readonly nonceTtlMs: number; + + /** + * @param toleranceSecs Max age (and future skew) for intent timestamps. Default: 300 (5 min). + * @param nonceTtlSecs How long nonces are remembered. Default: 3600 (1h). + */ + constructor(toleranceSecs = 300, nonceTtlSecs = 3600) { + this.toleranceMs = toleranceSecs * 1000; + this.nonceTtlMs = nonceTtlSecs * 1000; + } + + /** + * Validate that a timestamp is within the acceptable window. + * Throws `ReplayError` if the timestamp is stale or too far in the future. + */ + validateTimestamp(timestamp: Date): void { + const now = Date.now(); + const ts = timestamp.getTime(); + + if (ts < now - this.toleranceMs) { + throw new ReplayError( + 'intent_expired', + `Intent timestamp is too old. Age: ${Math.round((now - ts) / 1000)}s, tolerance: ${Math.round(this.toleranceMs / 1000)}s`, + ); + } + + if (ts > now + this.toleranceMs) { + throw new ReplayError( + 'intent_future', + `Intent timestamp is too far in the future. Skew: ${Math.round((ts - now) / 1000)}s, tolerance: ${Math.round(this.toleranceMs / 1000)}s`, + ); + } + } + + /** + * Check if a nonce has already been seen. + * Throws `ReplayError` if the nonce has been used before. + */ + checkNonce(nonce: string): void { + const entry = this.nonces.get(nonce); + if (entry) { + if (entry.expiresAt > Date.now()) { + throw new ReplayError('nonce_reused', `Nonce "${nonce}" has already been used`); + } + // Entry is expired — clean it up. + this.nonces.delete(nonce); + } + } + + /** + * Record a nonce as used. Should be called after a successful replay check. + * The nonce is remembered for `nonceTtlMs` milliseconds. + */ + recordNonce(nonce: string, ttlMs?: number): void { + const expiresAt = Date.now() + (ttlMs ?? this.nonceTtlMs); + this.nonces.set(nonce, { expiresAt }); + + // Schedule lazy eviction. + const ttl = ttlMs ?? this.nonceTtlMs; + if (typeof setTimeout !== 'undefined') { + setTimeout(() => this.nonces.delete(nonce), ttl); + } + } + + /** + * Full replay check: validate timestamp and check nonce. + * On success, records the nonce. + * Throws `ReplayError` on any violation. + */ + check(nonce: string, timestamp: Date): void { + this.validateTimestamp(timestamp); + this.checkNonce(nonce); + this.recordNonce(nonce); + } + + /** + * Remove all expired nonce entries. Call periodically to avoid unbounded growth. + */ + prune(): number { + const now = Date.now(); + let removed = 0; + for (const [nonce, entry] of this.nonces) { + if (entry.expiresAt <= now) { + this.nonces.delete(nonce); + removed++; + } + } + return removed; + } + + /** Number of currently tracked nonces. */ + get size(): number { + return this.nonces.size; + } +} diff --git a/packages/trust/src/trust-layer.ts b/packages/trust/src/trust-layer.ts new file mode 100644 index 0000000..2e02357 --- /dev/null +++ b/packages/trust/src/trust-layer.ts @@ -0,0 +1,313 @@ +/** + * TrustLayerManager — the main entry point for the OpenThreads Trust Layer. + * + * Ties together JWS signing, replay protection, audit logging, and auth challenges + * into a single cohesive interface. Designed to be instantiated once as a singleton. + * + * When `config.enabled` is false, all methods are no-ops (zero overhead for + * lightweight deployments). + * + * @example + * ```ts + * const trust = await TrustLayerManager.create({ enabled: true }); + * + * // In the reply engine hook: + * const evidence = await trust.signIntent(message, turnId); + * await trust.log('intent_sent', turnId, { intentType: message.intent }); + * + * // In the form API route: + * const challenge = await trust.issueAuthChallenge(formKey, 'webauthn'); + * const result = await trust.verifyAuthChallenge(challengeId, assertion); + * ``` + */ + +import type { + AuditLogEntry, + AuditLogFilter, + AuditStorageAdapter, + AuthChallenge, + AuthChallengeResult, + AuthMethod, + SignedEvidence, + SignedResponse, + TotpVerification, + TrustConfig, + WebAuthnAssertion, + AuditEventType, +} from './types.js'; +import type { A2HMessage, A2HIntent } from '@openthreads/core'; +import { generateKeyPair, signIntent as jwsSignIntent, signResponse as jwsSignResponse, verify as jwsVerify } from './jws/index.js'; +import { ReplayGuard } from './replay/index.js'; +import { AuditLogger } from './audit/logger.js'; +import { InMemoryAuditStorage } from './audit/storage.js'; +import { AuthChallengeManager } from './auth/challenge-manager.js'; +import type { AuthChallengeManagerOptions } from './auth/challenge-manager.js'; + +export class TrustLayerManager { + readonly config: Required; + + private readonly replayGuard: ReplayGuard; + private readonly auditLogger: AuditLogger; + private readonly authManager: AuthChallengeManager; + + private constructor( + config: Required, + storage: AuditStorageAdapter, + authOptions?: AuthChallengeManagerOptions, + ) { + this.config = config; + this.replayGuard = new ReplayGuard( + config.timestampToleranceSecs, + config.nonceTtlSecs, + ); + this.auditLogger = new AuditLogger(storage); + this.authManager = new AuthChallengeManager(authOptions); + } + + /** + * Create and initialise a TrustLayerManager. + * + * If no keys are provided in the config, an ephemeral ES256 key pair is + * generated. Pass pre-generated keys for persistence across restarts. + * + * @param config Trust layer configuration + * @param storage Audit log storage adapter (defaults to InMemoryAuditStorage) + * @param authOptions AuthChallengeManager options (rpId, default method, etc.) + */ + static async create( + config: TrustConfig, + storage?: AuditStorageAdapter, + authOptions?: AuthChallengeManagerOptions, + ): Promise { + let privateKey = config.privateKey; + let publicKey = config.publicKey; + + if (!privateKey || !publicKey) { + const pair = await generateKeyPair(); + privateKey = pair.privateKey; + publicKey = pair.publicKey; + } + + const full: Required = { + enabled: config.enabled, + jwsAlgorithm: config.jwsAlgorithm ?? 'ES256', + privateKey, + publicKey, + timestampToleranceSecs: config.timestampToleranceSecs ?? 300, + nonceTtlSecs: config.nonceTtlSecs ?? 3600, + }; + + return new TrustLayerManager(full, storage ?? new InMemoryAuditStorage(), authOptions); + } + + // ─── JWS signing ──────────────────────────────────────────────────────────── + + /** + * Sign an A2H intent and return signed evidence. + * + * Also records the nonce to prevent replay, and emits an 'evidence_signed' + * audit log entry. + * + * @throws `ReplayError` if `message.idempotencyKey` was already processed + */ + async signIntent(message: A2HMessage, turnId: string): Promise { + this.assertEnabled(); + + const nonce = crypto.randomUUID(); + const timestamp = new Date(); + + // If the intent carries an idempotency key, treat it as the nonce check. + if (message.idempotencyKey) { + this.replayGuard.checkNonce(message.idempotencyKey); + this.replayGuard.recordNonce(message.idempotencyKey); + } + + this.replayGuard.recordNonce(nonce); + + const jws = await jwsSignIntent(message, turnId, nonce, this.config.privateKey); + + await this.auditLogger.log('evidence_signed', turnId, { + intentType: message.intent as A2HIntent, + nonce, + traceId: message.traceId, + payload: { action: 'intent_signed', algorithm: this.config.jwsAlgorithm }, + }); + + return { intent: message, turnId, jws, timestamp, nonce }; + } + + /** + * Sign the human's response, cryptographically binding it to the original intent. + * + * @param response The human's response payload + * @param evidence The signed evidence from `signIntent` + * @param actorId Optional identity of the human responder + */ + async signResponse( + response: unknown, + evidence: SignedEvidence, + actorId?: string, + ): Promise { + this.assertEnabled(); + + const nonce = crypto.randomUUID(); + const timestamp = new Date(); + + this.replayGuard.recordNonce(nonce); + + const jws = await jwsSignResponse( + response, + evidence.intent.intent as A2HIntent, + nonce, + evidence.nonce, + this.config.privateKey, + ); + + await this.auditLogger.log('evidence_signed', evidence.turnId, { + intentType: evidence.intent.intent as A2HIntent, + traceId: evidence.intent.traceId, + nonce, + actorId, + payload: { action: 'response_signed', intentNonce: evidence.nonce }, + }); + + return { response, jws, timestamp, nonce, intentNonce: evidence.nonce }; + } + + /** + * Verify a piece of signed evidence. Returns true if the JWS is valid. + */ + async verifyEvidence(evidence: SignedEvidence): Promise { + this.assertEnabled(); + const result = await jwsVerify(evidence.jws, this.config.publicKey); + return result !== null; + } + + // ─── Replay protection ─────────────────────────────────────────────────────── + + /** + * Check a nonce + timestamp pair for replay attacks. + * Records the nonce on success. + * @throws `ReplayError` on violation + */ + checkReplay(nonce: string, timestamp: Date): void { + this.assertEnabled(); + this.replayGuard.check(nonce, timestamp); + } + + /** + * Validate only the timestamp (without nonce check). + * @throws `ReplayError` if timestamp is outside the tolerance window + */ + validateTimestamp(timestamp: Date): void { + this.assertEnabled(); + this.replayGuard.validateTimestamp(timestamp); + } + + /** + * Manually record a nonce as used (e.g., for idempotency key tracking). + */ + recordNonce(nonce: string, ttlSecs?: number): void { + this.assertEnabled(); + this.replayGuard.recordNonce(nonce, ttlSecs ? ttlSecs * 1000 : undefined); + } + + // ─── Authentication challenges ─────────────────────────────────────────────── + + /** + * Issue an auth challenge that must be completed before form submission. + * + * @param formKey The form key the challenge is tied to + * @param method Authentication method (default: configured defaultMethod) + */ + async issueAuthChallenge(formKey: string, method?: AuthMethod): Promise { + this.assertEnabled(); + const challenge = await this.authManager.issueChallenge(formKey, method); + + await this.auditLogger.log('auth_challenge_issued', formKey, { + payload: { challengeId: challenge.challengeId, method: challenge.method }, + }); + + return challenge; + } + + /** + * Verify an auth challenge response. + * + * @param challengeId ID returned by `issueAuthChallenge` + * @param response Authenticator response + * @param webAuthnPublicKeyJwk Required for WebAuthn verification + */ + async verifyAuthChallenge( + challengeId: string, + response: WebAuthnAssertion | TotpVerification, + webAuthnPublicKeyJwk?: JsonWebKey, + ): Promise { + this.assertEnabled(); + + const result = await this.authManager.verifyChallenge( + challengeId, + response, + webAuthnPublicKeyJwk, + ); + + const eventType: AuditEventType = result.success + ? 'auth_challenge_completed' + : 'auth_challenge_failed'; + + // Use challengeId as turnId proxy since we don't always have the turnId here. + await this.auditLogger.log(eventType, challengeId, { + actorId: result.identityId, + payload: { challengeId, success: result.success, error: result.error }, + }); + + return result; + } + + /** + * Check if a challenge has been verified (for pre-submission validation). + */ + getVerifiedChallenge(challengeId: string): AuthChallenge | null { + return this.authManager.getVerifiedChallenge(challengeId); + } + + // ─── Audit logging ─────────────────────────────────────────────────────────── + + /** + * Record an audit log entry directly. + * Can be called from reply engine hooks or form route handlers. + */ + async log( + eventType: AuditEventType, + turnId: string, + fields: Omit = {}, + ): Promise { + return this.auditLogger.log(eventType, turnId, fields); + } + + /** + * Query the audit log. + */ + async queryAuditLog(filter: AuditLogFilter = {}): Promise { + return this.auditLogger.query(filter); + } + + // ─── Maintenance ───────────────────────────────────────────────────────────── + + /** + * Prune expired nonces and auth challenges. + * Call periodically (e.g., every 5 minutes) in long-running processes. + */ + prune(): void { + this.replayGuard.prune(); + this.authManager.prune(); + } + + // ─── Internal ──────────────────────────────────────────────────────────────── + + private assertEnabled(): void { + if (!this.config.enabled) { + throw new Error('Trust layer is not enabled'); + } + } +} diff --git a/packages/trust/src/types.ts b/packages/trust/src/types.ts new file mode 100644 index 0000000..e413773 --- /dev/null +++ b/packages/trust/src/types.ts @@ -0,0 +1,234 @@ +import type { A2HIntent, A2HMessage } from '@openthreads/core'; + +// ─── Trust configuration ─────────────────────────────────────────────────────── + +export interface TrustConfig { + /** Whether the trust layer is active. When false, no trust logic runs. */ + enabled: boolean; + /** JWS signing algorithm. Default: 'ES256' (ECDSA P-256 + SHA-256). */ + jwsAlgorithm?: 'ES256' | 'RS256' | 'PS256'; + /** Pre-generated private key for signing. If absent, one is auto-generated. */ + privateKey?: CryptoKey; + /** Pre-generated public key for verification. */ + publicKey?: CryptoKey; + /** + * Acceptable time skew in seconds for replay protection. + * Intents with timestamps older than this are rejected. Default: 300 (5 min). + */ + timestampToleranceSecs?: number; + /** + * How long a nonce is remembered after use (seconds). Default: 3600 (1h). + * Should be at least 2x timestampToleranceSecs. + */ + nonceTtlSecs?: number; +} + +// ─── JWS / Signing ──────────────────────────────────────────────────────────── + +/** JWS header claims */ +export interface JwsHeader { + /** Algorithm, e.g. 'ES256' */ + alg: string; + /** Always 'JWT' for our purposes */ + typ: 'JWT'; + /** Optional key ID */ + kid?: string; +} + +/** Claims embedded in a signed A2H intent JWS */ +export interface IntentClaims { + /** Intent type (sub = subject) */ + sub: A2HIntent; + /** Issued-at timestamp (Unix seconds) */ + iat: number; + /** JWT ID / nonce — used for replay protection */ + jti: string; + /** Turn identifier */ + tid: string; + /** Full A2H message payload */ + intent: A2HMessage; + /** Optional trace/correlation ID */ + traceId?: string; +} + +/** Claims embedded in a signed response JWS */ +export interface ResponseClaims { + /** Intent type */ + sub: A2HIntent; + /** Issued-at timestamp (Unix seconds) */ + iat: number; + /** JWT ID / nonce */ + jti: string; + /** Human's response payload */ + response: unknown; + /** Nonce of the parent intent JWS (links response → intent) */ + intentJti?: string; +} + +/** Result of signing an A2H intent */ +export interface SignedEvidence { + /** The original A2H message that was signed */ + intent: A2HMessage; + /** Turn identifier this evidence is bound to */ + turnId: string; + /** JWS compact serialization: base64url(header).base64url(payload).base64url(sig) */ + jws: string; + /** When the evidence was signed */ + timestamp: Date; + /** Nonce embedded in the JWS (jti claim) — use for replay checks */ + nonce: string; +} + +/** Result of signing the human's response */ +export interface SignedResponse { + /** The human's response payload */ + response: unknown; + /** JWS compact serialization */ + jws: string; + /** When the response was signed */ + timestamp: Date; + /** Nonce embedded in the JWS */ + nonce: string; + /** Nonce of the originating intent (binds response → intent) */ + intentNonce?: string; +} + +/** Generated key pair for JWS operations */ +export interface TrustKeyPair { + privateKey: CryptoKey; + publicKey: CryptoKey; + /** JWK representation of the public key for export/sharing */ + publicKeyJwk: JsonWebKey; +} + +// ─── Replay protection ───────────────────────────────────────────────────────── + +export type ReplayRejectReason = 'intent_expired' | 'intent_future' | 'nonce_reused'; + +/** Thrown when a replay attack is detected */ +export class ReplayError extends Error { + constructor( + public readonly code: ReplayRejectReason, + message: string, + ) { + super(message); + this.name = 'ReplayError'; + } +} + +// ─── Audit logging ───────────────────────────────────────────────────────────── + +export type AuditEventType = + | 'intent_sent' + | 'intent_rendered' + | 'auth_challenge_issued' + | 'auth_challenge_completed' + | 'auth_challenge_failed' + | 'response_received' + | 'evidence_signed' + | 'replay_rejected'; + +/** Structured audit log entry recording a single A2H lifecycle event */ +export interface AuditLogEntry { + /** Unique entry ID */ + id: string; + /** Event type */ + eventType: AuditEventType; + /** Turn this event belongs to */ + turnId: string; + /** Thread this turn belongs to (when known) */ + threadId?: string; + /** Channel this interaction happened in */ + channelId?: string; + /** Actor who triggered the event (human user ID, agent ID, etc.) */ + actorId?: string; + /** Channel-specific metadata (platform, target ID, etc.) */ + channelMetadata?: Record; + /** A2H intent type involved */ + intentType?: A2HIntent; + /** Trace/correlation ID from the A2H message */ + traceId?: string; + /** Nonce associated with the JWS (for evidence tracing) */ + nonce?: string; + /** When the event occurred */ + timestamp: Date; + /** Arbitrary event-specific payload */ + payload?: unknown; +} + +/** Filter for querying the audit log */ +export interface AuditLogFilter { + turnId?: string; + threadId?: string; + channelId?: string; + eventType?: AuditEventType; + fromDate?: Date; + toDate?: Date; + /** Maximum number of results (default: 100) */ + limit?: number; + /** Skip N results (for pagination) */ + offset?: number; +} + +/** Abstract storage interface for audit log entries */ +export interface AuditStorageAdapter { + /** Persist an audit log entry */ + saveAuditEntry(entry: AuditLogEntry): Promise; + /** Query audit log entries with optional filters */ + queryAuditLog(filter: AuditLogFilter): Promise; +} + +// ─── Authentication challenge ────────────────────────────────────────────────── + +export type AuthMethod = 'webauthn' | 'totp' | 'sms_otp'; + +/** An issued authentication challenge that must be completed before form submission */ +export interface AuthChallenge { + /** Unique challenge ID */ + challengeId: string; + /** Form key this challenge is tied to */ + formKey: string; + /** Authentication method */ + method: AuthMethod; + /** Base64url-encoded challenge bytes sent to the authenticator */ + challenge: string; + /** When this challenge expires */ + expiresAt: Date; + /** Whether the challenge has been verified */ + verified: boolean; + /** When the challenge was verified (if verified) */ + verifiedAt?: Date; + /** Identity linked to the verified credential */ + identityId?: string; + /** When the challenge was created */ + createdAt: Date; +} + +/** Result of verifying an auth challenge */ +export interface AuthChallengeResult { + success: boolean; + challengeId: string; + verifiedAt?: Date; + identityId?: string; + error?: string; +} + +/** WebAuthn credential assertion sent by the browser */ +export interface WebAuthnAssertion { + /** base64url credential ID */ + credentialId: string; + /** base64url authenticatorData */ + authenticatorData: string; + /** base64url clientDataJSON */ + clientDataJSON: string; + /** base64url signature */ + signature: string; + /** base64url user handle (optional) */ + userHandle?: string; +} + +/** TOTP verification request */ +export interface TotpVerification { + /** 6-digit TOTP code */ + code: string; +} diff --git a/packages/trust/tsconfig.json b/packages/trust/tsconfig.json new file mode 100644 index 0000000..77c920c --- /dev/null +++ b/packages/trust/tsconfig.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "references": [ + { "path": "../core" } + ], + "include": ["src"] +} diff --git a/packages/trust/vite.config.ts b/packages/trust/vite.config.ts new file mode 100644 index 0000000..dbc47bb --- /dev/null +++ b/packages/trust/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite' +import dts from 'vite-plugin-dts' +import { resolve } from 'path' + +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'OpenThreadsTrust', + formats: ['es', 'cjs'], + fileName: (format) => `index.${format === 'es' ? 'js' : 'cjs'}`, + }, + rollupOptions: { + external: ['@openthreads/core'], + }, + }, + plugins: [dts({ rollupTypes: true })], +}) diff --git a/scripts/seed.ts b/scripts/seed.ts new file mode 100644 index 0000000..ba45726 --- /dev/null +++ b/scripts/seed.ts @@ -0,0 +1,108 @@ +/** + * Seed script for development data. + * + * Creates sample channels, routes, and threads in MongoDB. + * + * Usage: + * bun scripts/seed.ts + * + * Requires MONGODB_URI to be set in .env or as an environment variable. + */ + +import { MongoClient } from 'mongodb' + +const MONGODB_URI = + process.env.MONGODB_URI ?? 'mongodb://openthreads:openthreads@localhost:27017/openthreads' + +async function seed() { + const client = new MongoClient(MONGODB_URI) + + try { + console.log('Connecting to MongoDB...') + await client.connect() + const db = client.db() + + // --- Channels --- + console.log('Seeding channels...') + await db.collection('channels').deleteMany({}) + await db.collection('channels').insertMany([ + { + channelId: 'channel_slack_main', + type: 'slack', + name: 'Main Slack Bot', + config: { + botToken: 'xoxb-replace-with-real-token', + signingSecret: 'replace-with-real-signing-secret', + }, + createdAt: new Date(), + }, + { + channelId: 'channel_telegram_main', + type: 'telegram', + name: 'Main Telegram Bot', + config: { + botToken: 'replace-with-real-telegram-bot-token', + }, + createdAt: new Date(), + }, + ]) + + // --- Routes --- + console.log('Seeding routes...') + await db.collection('routes').deleteMany({}) + await db.collection('routes').insertMany([ + { + routeId: 'route_default_slack', + name: 'Default Slack Route', + channelId: 'channel_slack_main', + recipient: { + webhookUrl: 'http://localhost:8080/webhook', + }, + filters: {}, + createdAt: new Date(), + }, + { + routeId: 'route_default_telegram', + name: 'Default Telegram Route', + channelId: 'channel_telegram_main', + recipient: { + webhookUrl: 'http://localhost:8080/webhook', + }, + filters: {}, + createdAt: new Date(), + }, + ]) + + // --- Threads --- + console.log('Seeding threads...') + await db.collection('threads').deleteMany({}) + await db.collection('threads').insertMany([ + { + threadId: 'ot_thr_sample001', + channelId: 'channel_slack_main', + nativeThreadId: 'T_SAMPLE_001', + turns: [], + createdAt: new Date(), + }, + { + threadId: 'ot_thr_sample002', + channelId: 'channel_telegram_main', + nativeThreadId: null, + turns: [], + createdAt: new Date(), + }, + ]) + + console.log('✓ Seed completed successfully') + console.log(' - 2 channels created') + console.log(' - 2 routes created') + console.log(' - 2 threads created') + } catch (error) { + console.error('Seed failed:', error) + process.exit(1) + } finally { + await client.close() + } +} + +seed() diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..74fc444 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "composite": true, + "isolatedModules": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true + }, + "exclude": ["node_modules", "dist"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d598375 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true + } +}