Initial commit: Sendi 2.0 SMS gateway (Webex + Twilio)
Sendi is a Webex to Twilio SMS gateway written in TypeScript that runs directly on Node 22.18+ via native type-stripping (no bundler/transpiler). Each Webex space represents one external SMS contact; inbound MMS is proxied both ways, with delivery-status cards for outbound sends. Highlights: - Express 5 HTTP surface for Twilio /sms and /callback (signature-validated) - webex-node-bot-framework in websocket transport mode for bot control plane - Prisma 6 + SQLite via better-sqlite3 driver adapter - Zod-validated env with fail-fast startup and pino secret redaction - Pino logging: pretty in dev, structured JSON in prod - 13 vitest unit tests (phone normalization, Twilio signature validation, card action dispatch) - Native --require preload patches Node 22's read-only globalThis.navigator so @webex/internal-media-core (transitive) can load Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
d0a0dda2b8
37 changed files with 17500 additions and 0 deletions
35
.env.example
Normal file
35
.env.example
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# --- Webex ---
|
||||
# Bot access token from https://developer.webex.com (My Webex Apps -> your bot)
|
||||
WEBEX_TOKEN=your_webex_bot_token_here
|
||||
# Bot email (the @webex.bot address). Used for self-membership and identification.
|
||||
WEBEX_BOT_EMAIL=yourbot@webex.bot
|
||||
|
||||
# --- HTTP server (Express) ---
|
||||
# Port the Express app listens on (Twilio webhooks + media serving)
|
||||
PORT=1337
|
||||
# Public base URL where this server is reachable from the internet.
|
||||
# Used for Twilio statusCallback and outbound MMS media URLs.
|
||||
WEBEX_PUBLIC_URL=https://your-domain.example/sendi
|
||||
# Optional override for Twilio status callback. Defaults to ${WEBEX_PUBLIC_URL}/callback.
|
||||
# CALLBACK_URL=https://your-domain.example/sendi/callback
|
||||
|
||||
# --- Twilio ---
|
||||
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
TWILIO_AUTH_TOKEN=replace_me
|
||||
# E.164 number used as the default FROM when creating new contact spaces
|
||||
# (until the create-contact card lets the user pick one).
|
||||
TWILIO_DEFAULT_FROM=+12892067080
|
||||
|
||||
# --- Database ---
|
||||
# SQLite (default, fine for low-volume self-host)
|
||||
DATABASE_URL=file:./prisma/dev.db
|
||||
# Postgres example for later:
|
||||
# DATABASE_URL=postgresql://user:pass@localhost:5432/sendi?schema=public
|
||||
|
||||
# --- Admin / misc ---
|
||||
ADMIN_EMAIL=you@example.com
|
||||
|
||||
# --- Runtime ---
|
||||
# 'development' enables pino-pretty; 'production' emits JSON logs
|
||||
NODE_ENV=development
|
||||
LOG_LEVEL=info
|
||||
39
.gitignore
vendored
Normal file
39
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Secrets — NEVER commit
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Runtime data
|
||||
prisma/dev.db
|
||||
prisma/dev.db-journal
|
||||
prisma/*.db
|
||||
prisma/*.db-journal
|
||||
|
||||
# Twilio/Webex media drops (user content)
|
||||
media/
|
||||
!media/.gitkeep
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
|
||||
# Legacy artifacts from sendi v1 — contains real phone numbers and old screenshots
|
||||
legacy/
|
||||
15663
package-lock.json
generated
Normal file
15663
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
44
package.json
Normal file
44
package.json
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"name": "sendi",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "node --watch --require ./scripts/navigator-patch.cjs src/index.ts",
|
||||
"start": "node --require ./scripts/navigator-patch.cjs src/index.ts",
|
||||
"build": "tsc",
|
||||
"start:built": "node --require ./scripts/navigator-patch.cjs dist/index.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"migrate-old": "node legacy/migrate-old-data.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.18.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-better-sqlite3": "^6.19.3",
|
||||
"@prisma/client": "^6.19.3",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^5.2.1",
|
||||
"pino": "^9.6.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"twilio": "^5.13.1",
|
||||
"webex-node-bot-framework": "^2.5.1",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.13.10",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"prisma": "^6.19.3",
|
||||
"supertest": "^7.0.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.2.6"
|
||||
}
|
||||
}
|
||||
110
prisma/migrations/20260626221023_init/migration.sql
Normal file
110
prisma/migrations/20260626221023_init/migration.sql
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "Space" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"roomId" TEXT NOT NULL,
|
||||
"smsPhone" TEXT NOT NULL,
|
||||
"wbxTmPhone" TEXT NOT NULL,
|
||||
"smsName" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Contact" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"phone" TEXT NOT NULL,
|
||||
"blocked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"blockReason" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Message" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"sid" TEXT,
|
||||
"roomId" TEXT NOT NULL,
|
||||
"from" TEXT NOT NULL,
|
||||
"body" TEXT NOT NULL,
|
||||
"mediaUrls" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "Message_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Space" ("roomId") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "BulkJob" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"parentId" TEXT NOT NULL,
|
||||
"senderEmail" TEXT NOT NULL,
|
||||
"text" TEXT NOT NULL,
|
||||
"files" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"analytics" JSONB,
|
||||
"sentAt" DATETIME,
|
||||
"completedAt" DATETIME,
|
||||
"spaceId" TEXT,
|
||||
CONSTRAINT "BulkJob_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Survey" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"roomId" TEXT NOT NULL,
|
||||
"question" TEXT NOT NULL,
|
||||
"endDate" DATETIME NOT NULL,
|
||||
"responses" TEXT NOT NULL,
|
||||
"completed" BOOLEAN NOT NULL DEFAULT false,
|
||||
CONSTRAINT "Survey_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Space" ("roomId") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "OutboundRouting" (
|
||||
"email" TEXT NOT NULL PRIMARY KEY,
|
||||
"phoneNumber" TEXT NOT NULL,
|
||||
"groups" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "InboundRouting" (
|
||||
"phoneNumber" TEXT NOT NULL PRIMARY KEY,
|
||||
"site" TEXT NOT NULL,
|
||||
"people" TEXT NOT NULL,
|
||||
"keywords" JSONB,
|
||||
"outOfOffice" JSONB
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "BlockNumber" (
|
||||
"phoneNumber" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PendingMessage" (
|
||||
"roomId" TEXT NOT NULL PRIMARY KEY,
|
||||
"text" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "PendingMessage_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Space" ("roomId") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Space_roomId_key" ON "Space"("roomId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Space_smsPhone_wbxTmPhone_idx" ON "Space"("smsPhone", "wbxTmPhone");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Contact_phone_key" ON "Contact"("phone");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Message_sid_key" ON "Message"("sid");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Message_roomId_idx" ON "Message"("roomId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Message_createdAt_idx" ON "Message"("createdAt");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
108
prisma/schema.prisma
Normal file
108
prisma/schema.prisma
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["driverAdapters"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model Space {
|
||||
id String @id @default(uuid())
|
||||
roomId String @unique
|
||||
smsPhone String
|
||||
wbxTmPhone String
|
||||
smsName String
|
||||
title String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
messages Message[]
|
||||
bulkJobs BulkJob[]
|
||||
surveys Survey[]
|
||||
pendingMessages PendingMessage[]
|
||||
|
||||
@@index([smsPhone, wbxTmPhone])
|
||||
}
|
||||
|
||||
model Contact {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
phone String @unique
|
||||
blocked Boolean @default(false)
|
||||
blockReason String?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model Message {
|
||||
id String @id @default(uuid())
|
||||
sid String? @unique
|
||||
roomId String
|
||||
from String // person email or phone
|
||||
body String
|
||||
mediaUrls String // JSON stringified array e.g. '["url1", "url2"]'
|
||||
status String // sent, delivered, failed, undelivered, etc.
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
space Space? @relation(fields: [roomId], references: [roomId], onDelete: Cascade)
|
||||
|
||||
@@index([roomId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model BulkJob {
|
||||
id String @id @default(uuid())
|
||||
parentId String
|
||||
senderEmail String
|
||||
text String
|
||||
files String // JSON stringified array of file URLs/paths
|
||||
status String @default("pending")
|
||||
analytics Json?
|
||||
sentAt DateTime?
|
||||
completedAt DateTime?
|
||||
spaceId String?
|
||||
space Space? @relation(fields: [spaceId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Survey {
|
||||
id String @id @default(uuid())
|
||||
roomId String
|
||||
question String
|
||||
endDate DateTime
|
||||
responses String // JSON stringified array of {phone, response}
|
||||
completed Boolean @default(false)
|
||||
|
||||
space Space? @relation(fields: [roomId], references: [roomId], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model OutboundRouting {
|
||||
email String @id
|
||||
phoneNumber String
|
||||
groups String // JSON stringified array e.g. '["ottawa", "hazleton"]'
|
||||
}
|
||||
|
||||
model InboundRouting {
|
||||
phoneNumber String @id
|
||||
site String
|
||||
people String // JSON stringified array
|
||||
keywords Json?
|
||||
outOfOffice Json?
|
||||
}
|
||||
|
||||
model BlockNumber {
|
||||
phoneNumber String @id
|
||||
name String
|
||||
reason String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// Tracks SMS sends that are waiting for the user to upload an attachment in Webex.
|
||||
// Used by the PendingMessageStore service. One row per room (overwrites previous).
|
||||
model PendingMessage {
|
||||
roomId String @id
|
||||
text String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
space Space? @relation(fields: [roomId], references: [roomId], onDelete: Cascade)
|
||||
}
|
||||
34
scripts/navigator-patch.cjs
Normal file
34
scripts/navigator-patch.cjs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// scripts/navigator-patch.cjs
|
||||
//
|
||||
// Workaround for Node 22 + @webex/internal-media-core.
|
||||
//
|
||||
// The bot framework transitively depends on `@webex/internal-media-core`, whose
|
||||
// pre-compiled CJS bundle does `commonjsGlobal.navigator = { ... }` at import time.
|
||||
// Node 22+ exposes `globalThis.navigator` as a non-writable property, so that
|
||||
// assignment throws:
|
||||
//
|
||||
// TypeError: Cannot set property navigator of #<Object> which has only a getter
|
||||
//
|
||||
// `--require` runs synchronously before any ESM resolution, which is the only
|
||||
// hook early enough to fix the descriptor before the framework loads.
|
||||
//
|
||||
// We make the property writable + configurable, preserving any existing value
|
||||
// so anything that reads from it later still works.
|
||||
'use strict';
|
||||
|
||||
try {
|
||||
const desc = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
|
||||
if (desc && desc.writable !== true) {
|
||||
const current = 'value' in desc ? desc.value : (desc.get ? desc.get() : undefined);
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
value: current,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// If the patch can't apply, log loudly so the bot startup failure is at
|
||||
// least traceable; the framework import will then fail with the original error.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[navigator-patch] failed to make globalThis.navigator writable:', err && err.message);
|
||||
}
|
||||
33
src/app.ts
Normal file
33
src/app.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// src/app.ts
|
||||
// Express application factory. Stays IO-free so it's easy to mount in tests.
|
||||
|
||||
import express, { type Express } from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
import healthRouter from './routes/health.ts';
|
||||
import { buildTwilioRouter } from './routes/twilio.ts';
|
||||
import { errorHandler } from './middleware/errorHandler.ts';
|
||||
import { requestLog } from './middleware/requestLog.ts';
|
||||
import { MEDIA_DIR, ensureMediaDir } from './services/MediaService.ts';
|
||||
import type { WebexFramework } from './bot/index.ts';
|
||||
|
||||
export function createApp(bot: WebexFramework): Express {
|
||||
ensureMediaDir();
|
||||
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
|
||||
app.use(requestLog);
|
||||
|
||||
// Capture the raw body so Twilio signature validation can run on it.
|
||||
const rawCapture = (req: any, _res: unknown, buf: Buffer) => { req.rawBody = buf.toString('utf8'); };
|
||||
app.use(bodyParser.urlencoded({ extended: true, verify: rawCapture }));
|
||||
app.use(bodyParser.json({ verify: rawCapture }));
|
||||
|
||||
app.use('/media', express.static(MEDIA_DIR));
|
||||
app.use('/', healthRouter);
|
||||
app.use('/', buildTwilioRouter(bot as any));
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
return app;
|
||||
}
|
||||
224
src/bot/cardActions.ts
Normal file
224
src/bot/cardActions.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
// src/bot/cardActions.ts
|
||||
import type { WebexBot, WebexFramework, WebexTrigger } from 'webex-node-bot-framework';
|
||||
import prisma from '../services/PrismaService.ts';
|
||||
import env from '../config/env.ts';
|
||||
import logger from '../services/Logger.ts';
|
||||
import { WebexService } from '../services/WebexService.ts';
|
||||
import { CardBuilder } from '../services/CardBuilder.ts';
|
||||
import { pendingMessageStore } from '../services/PendingMessageStore.ts';
|
||||
import { sendViaTwilio } from './sendMessage.ts';
|
||||
import { toE164 } from '../lib/phone.ts';
|
||||
|
||||
type Handler = (bot: WebexBot, trigger: WebexTrigger, inputs: Record<string, any>) => Promise<void>;
|
||||
|
||||
const handlers: Record<string, Handler> = {
|
||||
async sendMessage(bot, trigger, inputs) {
|
||||
const roomId = trigger.attachmentAction!.roomId;
|
||||
const messageText = (inputs.wbxTmText ?? '').trim();
|
||||
const wantsAttachment = inputs.attachment === 'yes';
|
||||
|
||||
if (!messageText) {
|
||||
await bot.say('Please enter a message.');
|
||||
return;
|
||||
}
|
||||
|
||||
const space = await prisma.space.findUnique({ where: { roomId } });
|
||||
if (!space) {
|
||||
await bot.say('Could not find this contact.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (wantsAttachment) {
|
||||
await pendingMessageStore.set(roomId, { roomId, text: messageText, spaceId: space.id });
|
||||
await WebexService.sendCard(roomId, CardBuilder.attachmentPromptCard(), 'Upload Attachment');
|
||||
return;
|
||||
}
|
||||
|
||||
await sendViaTwilio(messageText, space, bot);
|
||||
},
|
||||
|
||||
async updateContact(bot, trigger, inputs) {
|
||||
const roomId = trigger.attachmentAction!.roomId;
|
||||
const newName = (inputs.newName ?? '').trim();
|
||||
if (!newName) {
|
||||
await bot.say('Please enter a new name.');
|
||||
return;
|
||||
}
|
||||
|
||||
const space = await prisma.space.findUnique({ where: { roomId } });
|
||||
if (!space) {
|
||||
await bot.say('Could not find this space.');
|
||||
return;
|
||||
}
|
||||
|
||||
const newTitle = `${newName} ${space.smsPhone}`;
|
||||
try {
|
||||
await prisma.space.update({
|
||||
where: { roomId },
|
||||
data: { smsName: newName, title: newTitle },
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Failed to update contact name');
|
||||
await bot.say('Failed to update contact name. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`https://webexapis.com/v1/rooms/${space.roomId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.WEBEX_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ title: newTitle }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
logger.warn({ status: response.status }, 'Webex room rename failed');
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.warn({ err: err?.message ?? err, roomId: space.roomId }, 'Webex room rename threw');
|
||||
}
|
||||
|
||||
await bot.say(`Contact name updated to **${newName}**`);
|
||||
},
|
||||
|
||||
async createNewContact(_bot, trigger) {
|
||||
await WebexService.sendCard(
|
||||
trigger.attachmentAction!.roomId,
|
||||
CardBuilder.createNewContactCard(),
|
||||
'Create New Contact',
|
||||
);
|
||||
},
|
||||
|
||||
async addContact(bot, trigger, inputs) {
|
||||
const smsName = (inputs.smsName ?? '').trim();
|
||||
const smsPhoneRaw = (inputs.smsPhone ?? '').trim();
|
||||
if (!smsName || !smsPhoneRaw) {
|
||||
await bot.say('Please provide both a name and phone number.');
|
||||
return;
|
||||
}
|
||||
|
||||
let smsPhone: string;
|
||||
try {
|
||||
smsPhone = toE164(smsPhoneRaw);
|
||||
} catch {
|
||||
await bot.say('Please enter a valid phone number (E.164 or 10-digit NANP).');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!env.TWILIO_DEFAULT_FROM) {
|
||||
await bot.say('TWILIO_DEFAULT_FROM is not configured. Set it in `.env` (E.164 format) and restart.');
|
||||
logger.error('Cannot create contact: TWILIO_DEFAULT_FROM unset');
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await prisma.space.findFirst({ where: { smsPhone } });
|
||||
if (existing) {
|
||||
await bot.say(`A contact with this phone number already exists (${existing.title}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const title = `${smsName} ${smsPhone}`;
|
||||
const newRoom = await bot.webex.rooms.create({ title });
|
||||
|
||||
try {
|
||||
if (trigger.personId) {
|
||||
await bot.webex.memberships.create({ roomId: newRoom.id, personId: trigger.personId });
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.debug({ err: err?.message ?? err }, 'Creator may already be in space');
|
||||
}
|
||||
|
||||
try {
|
||||
await bot.webex.memberships.create({ roomId: newRoom.id, personEmail: env.WEBEX_BOT_EMAIL });
|
||||
} catch (err: any) {
|
||||
logger.debug({ err: err?.message ?? err }, 'Bot may already be in space');
|
||||
}
|
||||
|
||||
await prisma.space.create({
|
||||
data: {
|
||||
roomId: newRoom.id,
|
||||
smsPhone,
|
||||
wbxTmPhone: env.TWILIO_DEFAULT_FROM,
|
||||
smsName,
|
||||
title,
|
||||
},
|
||||
});
|
||||
|
||||
await bot.say(`New contact space created!\n\n**${title}**`);
|
||||
await WebexService.sendCard(newRoom.id, CardBuilder.sendMessageCard(), 'Send your first message');
|
||||
},
|
||||
|
||||
async confirmDeleteContact(bot, trigger) {
|
||||
const roomId = trigger.attachmentAction!.roomId;
|
||||
const space = await prisma.space.findUnique({ where: { roomId } });
|
||||
if (!space) {
|
||||
try { await bot.say('Space not found.'); } catch { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await bot.webex.rooms.remove(space.roomId);
|
||||
logger.info({ roomId: space.roomId, title: space.title }, 'Webex space deleted');
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'Webex space delete failed (may already be gone)');
|
||||
}
|
||||
// Cascade deletes will remove Message/PendingMessage/Survey rows automatically.
|
||||
await prisma.space.delete({ where: { roomId: space.roomId } });
|
||||
logger.info({ roomId: space.roomId, title: space.title }, 'Contact fully deleted');
|
||||
},
|
||||
|
||||
async deleteContact(bot, trigger) {
|
||||
const roomId = trigger.attachmentAction!.roomId;
|
||||
const space = await prisma.space.findUnique({ where: { roomId } });
|
||||
if (!space) {
|
||||
await bot.say('This command only works inside a contact space.');
|
||||
return;
|
||||
}
|
||||
await WebexService.sendCard(roomId, CardBuilder.confirmDeleteCard(space.title), 'Confirm Deletion');
|
||||
},
|
||||
|
||||
async sendMessageHelp(_bot, trigger) {
|
||||
await WebexService.sendCard(
|
||||
trigger.attachmentAction!.roomId,
|
||||
CardBuilder.sendMessageCard(),
|
||||
'Send Message',
|
||||
);
|
||||
},
|
||||
|
||||
async cancel(bot, trigger) {
|
||||
await bot.say('Message cancelled.');
|
||||
await pendingMessageStore.delete(trigger.attachmentAction!.roomId);
|
||||
},
|
||||
};
|
||||
|
||||
export function registerCardActions(framework: WebexFramework): void {
|
||||
framework.on('attachmentAction', async (bot, trigger) => {
|
||||
const action = trigger.attachmentAction;
|
||||
if (!action) return;
|
||||
const inputs = (action.inputs ?? {}) as Record<string, any>;
|
||||
const cardType: string | undefined = inputs.cardType;
|
||||
|
||||
// Best-effort cleanup of the submitted card so the UI stays tidy.
|
||||
try { await bot.webex.messages.remove(action.messageId); } catch { /* ignore */ }
|
||||
|
||||
if (!cardType) {
|
||||
logger.warn({ roomId: action.roomId }, 'attachmentAction without cardType');
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = handlers[cardType];
|
||||
if (!handler) {
|
||||
logger.warn({ cardType, roomId: action.roomId }, 'Unknown cardType');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await handler(bot, trigger, inputs);
|
||||
} catch (err) {
|
||||
logger.error({ err, cardType, roomId: action.roomId }, 'cardAction handler failed');
|
||||
try { await bot.say('Something went wrong handling that action.'); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const __testing = { handlers };
|
||||
50
src/bot/commands.ts
Normal file
50
src/bot/commands.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// src/bot/commands.ts
|
||||
import type { WebexFramework } from 'webex-node-bot-framework';
|
||||
import prisma from '../services/PrismaService.ts';
|
||||
import { WebexService } from '../services/WebexService.ts';
|
||||
import { CardBuilder } from '../services/CardBuilder.ts';
|
||||
|
||||
/** Register `hears(...)` command handlers on the bot framework. */
|
||||
export function registerCommands(framework: WebexFramework): void {
|
||||
framework.hears(/send message/i, async (_bot, trigger) => {
|
||||
await WebexService.sendCard(
|
||||
trigger.message.roomId,
|
||||
CardBuilder.sendMessageCard(),
|
||||
'Send a message to this contact',
|
||||
);
|
||||
});
|
||||
|
||||
framework.hears(/update contact|change name/i, async (bot, trigger) => {
|
||||
const space = await prisma.space.findUnique({ where: { roomId: trigger.message.roomId } });
|
||||
if (!space) {
|
||||
await bot.say('This command only works in contact spaces.');
|
||||
return;
|
||||
}
|
||||
await WebexService.sendCard(
|
||||
trigger.message.roomId,
|
||||
CardBuilder.updateContactCard(space.smsName || ''),
|
||||
'Update Contact Name',
|
||||
);
|
||||
});
|
||||
|
||||
framework.hears(/delete contact|remove contact/i, async (bot, trigger) => {
|
||||
const space = await prisma.space.findUnique({ where: { roomId: trigger.message.roomId } });
|
||||
if (!space) {
|
||||
await bot.say('This command only works inside a contact space.');
|
||||
return;
|
||||
}
|
||||
await WebexService.sendCard(
|
||||
trigger.message.roomId,
|
||||
CardBuilder.confirmDeleteCard(space.title || 'this contact'),
|
||||
'Confirm Deletion',
|
||||
);
|
||||
});
|
||||
|
||||
framework.hears(/help|what can i do/i, async (_bot, trigger) => {
|
||||
await WebexService.sendCard(
|
||||
trigger.message.roomId,
|
||||
CardBuilder.helpCard(),
|
||||
'Sendi Help - Choose an action',
|
||||
);
|
||||
});
|
||||
}
|
||||
44
src/bot/files.ts
Normal file
44
src/bot/files.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// src/bot/files.ts
|
||||
import type { WebexFramework } from 'webex-node-bot-framework';
|
||||
import prisma from '../services/PrismaService.ts';
|
||||
import logger from '../services/Logger.ts';
|
||||
import { WebexService } from '../services/WebexService.ts';
|
||||
import { pendingMessageStore } from '../services/PendingMessageStore.ts';
|
||||
import { sendViaTwilio } from './sendMessage.ts';
|
||||
|
||||
/**
|
||||
* Handle file uploads in Webex. If the user has a pending SMS waiting for an
|
||||
* attachment in this room, forward the uploaded file(s) along with it.
|
||||
*/
|
||||
export function registerFiles(framework: WebexFramework): void {
|
||||
framework.on('files', async (bot, trigger) => {
|
||||
const roomId = trigger.message.roomId;
|
||||
const pending = await pendingMessageStore.get(roomId);
|
||||
if (!pending) return;
|
||||
|
||||
logger.info({ roomId }, 'Attachment received for pending message');
|
||||
await bot.say('Processing attachment...');
|
||||
|
||||
try {
|
||||
const mediaUrls: string[] = [];
|
||||
for (const fileUrl of trigger.message.files ?? []) {
|
||||
const publicUrl = await WebexService.getFile(fileUrl);
|
||||
mediaUrls.push(publicUrl);
|
||||
}
|
||||
|
||||
const space = await prisma.space.findUnique({ where: { roomId } });
|
||||
if (!space) {
|
||||
await bot.say('Could not find this contact space.');
|
||||
await pendingMessageStore.delete(roomId);
|
||||
return;
|
||||
}
|
||||
|
||||
await sendViaTwilio(pending.text, space, bot, mediaUrls);
|
||||
await pendingMessageStore.delete(roomId);
|
||||
} catch (err) {
|
||||
logger.error({ err, roomId }, 'Failed to process attachment');
|
||||
await bot.say('Failed to process attachment. Please try again.');
|
||||
await pendingMessageStore.delete(roomId);
|
||||
}
|
||||
});
|
||||
}
|
||||
30
src/bot/index.ts
Normal file
30
src/bot/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// src/bot/index.ts
|
||||
import Framework, { type WebexFramework, type WebexBot, type WebexSdk } from 'webex-node-bot-framework';
|
||||
import env from '../config/env.ts';
|
||||
import logger from '../services/Logger.ts';
|
||||
import { registerCommands } from './commands.ts';
|
||||
import { registerCardActions } from './cardActions.ts';
|
||||
import { registerFiles } from './files.ts';
|
||||
|
||||
export type { WebexFramework, WebexBot, WebexSdk };
|
||||
|
||||
let framework: WebexFramework | null = null;
|
||||
|
||||
export function createBot(): WebexFramework {
|
||||
if (framework) return framework;
|
||||
|
||||
// Websocket transport (no `webhookUrl` / `port`): the framework opens an
|
||||
// outbound connection to Webex, so we don't need to expose a public webhook
|
||||
// for bot events. Twilio webhooks still flow through Express.
|
||||
framework = new Framework({ token: env.WEBEX_TOKEN });
|
||||
|
||||
framework.on('initialized', () => {
|
||||
logger.info('Webex bot framework initialized');
|
||||
});
|
||||
|
||||
registerCommands(framework);
|
||||
registerCardActions(framework);
|
||||
registerFiles(framework);
|
||||
|
||||
return framework;
|
||||
}
|
||||
46
src/bot/sendMessage.ts
Normal file
46
src/bot/sendMessage.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// src/bot/sendMessage.ts
|
||||
import type { WebexBot } from 'webex-node-bot-framework';
|
||||
import prisma from '../services/PrismaService.ts';
|
||||
import logger from '../services/Logger.ts';
|
||||
import { TwilioService } from '../services/Twilioservice.ts';
|
||||
|
||||
interface SpaceLite {
|
||||
roomId: string;
|
||||
smsPhone: string;
|
||||
smsName: string;
|
||||
wbxTmPhone: string;
|
||||
}
|
||||
|
||||
/** Send an SMS via Twilio and persist the message log. */
|
||||
export async function sendViaTwilio(
|
||||
text: string,
|
||||
space: SpaceLite,
|
||||
bot: WebexBot,
|
||||
mediaUrls: string[] = [],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const message = await TwilioService.sendMessage({
|
||||
to: space.smsPhone,
|
||||
from: space.wbxTmPhone,
|
||||
body: text,
|
||||
mediaUrl: mediaUrls.length > 0 ? mediaUrls : undefined,
|
||||
});
|
||||
|
||||
await prisma.message.create({
|
||||
data: {
|
||||
sid: message.sid,
|
||||
roomId: space.roomId,
|
||||
from: space.wbxTmPhone,
|
||||
body: text,
|
||||
status: 'sent',
|
||||
mediaUrls: JSON.stringify(mediaUrls),
|
||||
},
|
||||
});
|
||||
|
||||
const displayName = space.smsName && space.smsName !== 'Unknown' ? space.smsName : 'the contact';
|
||||
await bot.say(`Message sent to **${displayName}** (${space.smsPhone})`);
|
||||
} catch (err: any) {
|
||||
logger.error({ err, to: space.smsPhone }, 'Twilio send failed');
|
||||
await bot.say(`Failed to send: ${err?.message ?? err}`);
|
||||
}
|
||||
}
|
||||
52
src/config/env.ts
Normal file
52
src/config/env.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// src/config/env.ts
|
||||
import { z } from 'zod';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
LOG_LEVEL: z
|
||||
.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent'])
|
||||
.default('info'),
|
||||
|
||||
WEBEX_TOKEN: z.string().min(1, 'WEBEX_TOKEN is required'),
|
||||
WEBEX_BOT_EMAIL: z.string().email(),
|
||||
WEBEX_PUBLIC_URL: z.string().url().default('http://localhost:1337'),
|
||||
|
||||
PORT: z.coerce.number().int().positive().default(1337),
|
||||
|
||||
TWILIO_ACCOUNT_SID: z.string().regex(/^AC[a-f0-9]{32}$/i, 'TWILIO_ACCOUNT_SID must look like AC + 32 hex chars'),
|
||||
TWILIO_AUTH_TOKEN: z.string().min(1),
|
||||
// E.164 number used as the default Twilio FROM when creating a new contact
|
||||
// space if no per-space override is provided. e.g. +12892067080
|
||||
TWILIO_DEFAULT_FROM: z
|
||||
.string()
|
||||
.regex(/^\+[1-9]\d{6,14}$/, 'TWILIO_DEFAULT_FROM must be E.164, e.g. +12892067080')
|
||||
.optional(),
|
||||
|
||||
DATABASE_URL: z.string().default('file:./prisma/dev.db'),
|
||||
|
||||
ADMIN_EMAIL: z.string().email().optional(),
|
||||
|
||||
CALLBACK_URL: z.string().url().optional(),
|
||||
});
|
||||
|
||||
type Env = z.infer<typeof envSchema> & { CALLBACK_URL: string };
|
||||
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues
|
||||
.map((i) => ` - ${i.path.join('.') || '(root)'}: ${i.message}`)
|
||||
.join('\n');
|
||||
// Fail fast, before anything else boots.
|
||||
console.error(`Invalid environment configuration:\n${issues}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const env: Env = {
|
||||
...parsed.data,
|
||||
CALLBACK_URL: parsed.data.CALLBACK_URL ?? `${parsed.data.WEBEX_PUBLIC_URL}/callback`,
|
||||
};
|
||||
|
||||
export default env;
|
||||
38
src/index.ts
Normal file
38
src/index.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// src/index.ts — entrypoint: wire the bot + HTTP app, start them, handle shutdown.
|
||||
|
||||
import env from './config/env.ts';
|
||||
import logger from './services/Logger.ts';
|
||||
import prisma from './services/PrismaService.ts';
|
||||
import { createBot } from './bot/index.ts';
|
||||
import { createApp } from './app.ts';
|
||||
|
||||
async function main() {
|
||||
const bot = createBot();
|
||||
await bot.start();
|
||||
|
||||
const app = createApp(bot);
|
||||
const server = app.listen(env.PORT, () => {
|
||||
logger.info({ port: env.PORT, publicUrl: env.WEBEX_PUBLIC_URL }, 'Sendi listening');
|
||||
});
|
||||
|
||||
let shuttingDown = false;
|
||||
async function shutdown(signal: string) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
logger.info({ signal }, 'Shutting down');
|
||||
try { await new Promise<void>((r) => server.close(() => r())); } catch (e) { logger.warn({ err: e }, 'HTTP close failed'); }
|
||||
try { await bot.stop(); } catch (e) { logger.warn({ err: e }, 'Bot stop failed'); }
|
||||
try { await prisma.$disconnect(); } catch (e) { logger.warn({ err: e }, 'Prisma disconnect failed'); }
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||
process.on('unhandledRejection', (reason) => logger.error({ err: reason }, 'Unhandled rejection'));
|
||||
process.on('uncaughtException', (err) => logger.error({ err }, 'Uncaught exception'));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
logger.error({ err }, 'Fatal startup error');
|
||||
process.exit(1);
|
||||
});
|
||||
15
src/lib/asyncHandler.ts
Normal file
15
src/lib/asyncHandler.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// src/lib/asyncHandler.ts
|
||||
import type { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
|
||||
/**
|
||||
* Wraps an async Express handler so any thrown error / rejected promise
|
||||
* is forwarded to `next()` (and thus to our centralized error middleware).
|
||||
*/
|
||||
export function asyncHandler<
|
||||
Req extends Request = Request,
|
||||
Res extends Response = Response,
|
||||
>(fn: (req: Req, res: Res, next: NextFunction) => Promise<unknown>): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req as Req, res as Res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
47
src/lib/phone.ts
Normal file
47
src/lib/phone.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// src/lib/phone.ts
|
||||
|
||||
/**
|
||||
* Normalize a user-entered phone number to E.164.
|
||||
*
|
||||
* Rules (best-effort, no country lookup):
|
||||
* - Strip everything except digits and a leading `+`.
|
||||
* - If no `+`, assume North America (`+1`) when the digit count is 10.
|
||||
* - If already has `+`, just clean and return.
|
||||
* - Throws if the result doesn't look like a valid E.164 number
|
||||
* (`+` followed by 7-15 digits with non-zero leading digit).
|
||||
*/
|
||||
export function toE164(input: string): string {
|
||||
if (typeof input !== 'string') {
|
||||
throw new TypeError('phone input must be a string');
|
||||
}
|
||||
|
||||
let s = input.trim();
|
||||
let hasPlus = s.startsWith('+');
|
||||
s = s.replace(/[^\d]/g, '');
|
||||
|
||||
if (!hasPlus && s.length === 10) {
|
||||
// Likely NANP number missing the country code
|
||||
s = '1' + s;
|
||||
hasPlus = true;
|
||||
} else if (!hasPlus && s.length === 11 && s.startsWith('1')) {
|
||||
hasPlus = true;
|
||||
}
|
||||
|
||||
const result = `+${s}`;
|
||||
if (!/^\+[1-9]\d{6,14}$/.test(result)) {
|
||||
throw new RangeError(`Cannot normalize "${input}" to E.164`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Returns true if a string is already a valid E.164 number. */
|
||||
export function isE164(input: unknown): input is string {
|
||||
return typeof input === 'string' && /^\+[1-9]\d{6,14}$/.test(input);
|
||||
}
|
||||
|
||||
/** Format E.164 for display, e.g. +12892067080 -> (289) 206-7080 for NANP, else raw. */
|
||||
export function formatForDisplay(e164: string): string {
|
||||
const m = /^\+1(\d{3})(\d{3})(\d{4})$/.exec(e164);
|
||||
if (m) return `(${m[1]}) ${m[2]}-${m[3]}`;
|
||||
return e164;
|
||||
}
|
||||
11
src/middleware/errorHandler.ts
Normal file
11
src/middleware/errorHandler.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// src/middleware/errorHandler.ts
|
||||
import type { ErrorRequestHandler } from 'express';
|
||||
import logger from '../services/Logger.ts';
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
|
||||
logger.error({ err, path: req.path, method: req.method }, 'Express handler error');
|
||||
if (res.headersSent) {
|
||||
return;
|
||||
}
|
||||
res.status(500).send('Internal Server Error');
|
||||
};
|
||||
29
src/middleware/requestLog.ts
Normal file
29
src/middleware/requestLog.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// src/middleware/requestLog.ts
|
||||
// Lightweight access log. We don't pull pino-http because we want our redact
|
||||
// rules + child logger fields to apply consistently, and this is plenty.
|
||||
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import logger from '../services/Logger.ts';
|
||||
|
||||
const SKIP_PATHS = new Set(['/healthz']);
|
||||
|
||||
export function requestLog(req: Request, res: Response, next: NextFunction): void {
|
||||
if (SKIP_PATHS.has(req.path)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
res.on('finish', () => {
|
||||
logger.info(
|
||||
{
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
status: res.statusCode,
|
||||
durationMs: Date.now() - start,
|
||||
ip: req.ip,
|
||||
},
|
||||
'request',
|
||||
);
|
||||
});
|
||||
next();
|
||||
}
|
||||
39
src/middleware/twilioSignature.ts
Normal file
39
src/middleware/twilioSignature.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
// src/middleware/twilioSignature.ts
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import twilio from 'twilio';
|
||||
import env from '../config/env.ts';
|
||||
import logger from '../services/Logger.ts';
|
||||
|
||||
/**
|
||||
* Verify that a request was actually signed by Twilio using our auth token.
|
||||
*
|
||||
* Twilio computes the signature over the FULL public URL (path included)
|
||||
* plus the sorted POST params. When we sit behind a reverse proxy under a path
|
||||
* prefix (e.g. `/sendi`), we must reconstruct that prefix when validating.
|
||||
*/
|
||||
export function twilioSignature(req: Request, res: Response, next: NextFunction): void {
|
||||
const signature = req.header('X-Twilio-Signature');
|
||||
if (!signature) {
|
||||
logger.warn({ path: req.path }, 'Twilio webhook rejected: missing signature');
|
||||
res.status(403).send('Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const base = env.WEBEX_PUBLIC_URL.replace(/\/$/, '');
|
||||
const url = `${base}${req.originalUrl}`;
|
||||
|
||||
const isValid = twilio.validateRequest(
|
||||
env.TWILIO_AUTH_TOKEN,
|
||||
signature,
|
||||
url,
|
||||
(req.body ?? {}) as Record<string, string>,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
logger.warn({ path: req.path, url }, 'Twilio webhook rejected: bad signature');
|
||||
res.status(403).send('Forbidden');
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
14
src/routes/health.ts
Normal file
14
src/routes/health.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// src/routes/health.ts
|
||||
import { Router } from 'express';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (_req, res) => {
|
||||
res.type('text/plain').send('Sendi is alive');
|
||||
});
|
||||
|
||||
router.get('/healthz', (_req, res) => {
|
||||
res.status(200).json({ ok: true, ts: new Date().toISOString() });
|
||||
});
|
||||
|
||||
export default router;
|
||||
118
src/routes/twilio.ts
Normal file
118
src/routes/twilio.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// src/routes/twilio.ts
|
||||
import { Router } from 'express';
|
||||
import prisma from '../services/PrismaService.ts';
|
||||
import logger from '../services/Logger.ts';
|
||||
import { WebexService } from '../services/WebexService.ts';
|
||||
import { CardBuilder } from '../services/CardBuilder.ts';
|
||||
import { downloadTwilioMedia, publicMediaUrl } from '../services/MediaService.ts';
|
||||
import { asyncHandler } from '../lib/asyncHandler.ts';
|
||||
import { twilioSignature } from '../middleware/twilioSignature.ts';
|
||||
import type { WebexBot } from '../bot/index.ts';
|
||||
|
||||
const TWIML_EMPTY = '<?xml version="1.0" encoding="UTF-8"?><Response></Response>';
|
||||
|
||||
export function buildTwilioRouter(bot: WebexBot): Router {
|
||||
const router = Router();
|
||||
|
||||
// -------- Status callback (delivered / failed) --------
|
||||
router.post(
|
||||
'/callback',
|
||||
twilioSignature,
|
||||
asyncHandler(async (req, res) => {
|
||||
const sid = req.body.SmsSid as string | undefined;
|
||||
const status = (req.body.SmsStatus as string | undefined) ?? 'unknown';
|
||||
logger.info({ sid, status }, 'Delivery callback');
|
||||
|
||||
res.type('application/xml').status(200).send(TWIML_EMPTY);
|
||||
if (!sid) return;
|
||||
|
||||
const messageLog = await prisma.message.findUnique({
|
||||
where: { sid },
|
||||
include: { space: true },
|
||||
});
|
||||
if (!messageLog?.space) return;
|
||||
|
||||
// Persist the latest delivery status
|
||||
try {
|
||||
await prisma.message.update({ where: { sid }, data: { status } });
|
||||
} catch (err) {
|
||||
logger.warn({ err, sid }, 'Failed to persist delivery status');
|
||||
}
|
||||
|
||||
const card = CardBuilder.deliveryStatusCard(status, messageLog.body);
|
||||
try {
|
||||
await WebexService.sendCard(messageLog.space.roomId, card, `Delivery: ${status}`);
|
||||
} catch (err) {
|
||||
logger.error({ err, roomId: messageLog.space.roomId }, 'Failed to post delivery card');
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------- Inbound SMS / MMS --------
|
||||
router.post(
|
||||
'/sms',
|
||||
twilioSignature,
|
||||
asyncHandler(async (req, res) => {
|
||||
const smsPhone = req.body.From as string;
|
||||
const wbxTmPhone = req.body.To as string;
|
||||
const smsText = (req.body.Body as string | undefined) ?? '';
|
||||
const numMedia = parseInt((req.body.NumMedia as string | undefined) ?? '0', 10);
|
||||
|
||||
logger.info({ from: smsPhone, to: wbxTmPhone, numMedia }, 'Inbound SMS');
|
||||
|
||||
res.type('application/xml').status(200).send(TWIML_EMPTY);
|
||||
|
||||
const space = await prisma.space.findFirst({
|
||||
where: { smsPhone, wbxTmPhone },
|
||||
});
|
||||
if (!space) {
|
||||
logger.info({ smsPhone, wbxTmPhone }, 'No matching space for inbound SMS');
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist the inbound message
|
||||
try {
|
||||
await prisma.message.create({
|
||||
data: {
|
||||
roomId: space.roomId,
|
||||
from: smsPhone,
|
||||
body: smsText,
|
||||
mediaUrls: '[]',
|
||||
status: 'received',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn({ err, roomId: space.roomId }, 'Failed to persist inbound SMS');
|
||||
}
|
||||
|
||||
// Forward attachments to Webex
|
||||
for (let i = 0; i < numMedia; i++) {
|
||||
const mediaUrl = req.body[`MediaUrl${i}`] as string | undefined;
|
||||
if (!mediaUrl) continue;
|
||||
try {
|
||||
const filename = await downloadTwilioMedia(mediaUrl);
|
||||
await bot.webex.messages.create({
|
||||
roomId: space.roomId,
|
||||
files: [publicMediaUrl(filename)],
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ err, mediaUrl }, 'Failed to forward inbound media to Webex');
|
||||
}
|
||||
}
|
||||
|
||||
const card = CardBuilder.incomingMessageCard({
|
||||
from: space.smsName,
|
||||
text: smsText,
|
||||
numMedia,
|
||||
});
|
||||
|
||||
try {
|
||||
await WebexService.sendCard(space.roomId, card, `Incoming from ${smsPhone}`);
|
||||
} catch (err) {
|
||||
logger.error({ err, roomId: space.roomId }, 'Failed to post inbound card');
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
142
src/services/CardBuilder.ts
Normal file
142
src/services/CardBuilder.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
// src/services/CardBuilder.ts
|
||||
// All Adaptive Card payloads used by the bot. Keep these as pure functions
|
||||
// returning plain objects so they're easy to snapshot in tests.
|
||||
|
||||
const ADAPTIVE_CARD_SCHEMA = 'http://adaptivecards.io/schemas/adaptive-card.json';
|
||||
const VERSION = '1.3';
|
||||
|
||||
export type AdaptiveCard = {
|
||||
type: 'AdaptiveCard';
|
||||
$schema: string;
|
||||
version: string;
|
||||
body: Array<Record<string, unknown>>;
|
||||
actions?: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
function card(body: AdaptiveCard['body'], actions?: AdaptiveCard['actions']): AdaptiveCard {
|
||||
return { type: 'AdaptiveCard', $schema: ADAPTIVE_CARD_SCHEMA, version: VERSION, body, actions };
|
||||
}
|
||||
|
||||
export const CardBuilder = {
|
||||
sendMessageCard(): AdaptiveCard {
|
||||
return card(
|
||||
[
|
||||
{ type: 'TextBlock', text: 'Send Message', size: 'Large', weight: 'Bolder', color: 'Accent' },
|
||||
{ type: 'TextBlock', text: 'Type your message below:', size: 'Medium', weight: 'Lighter', separator: true },
|
||||
{ type: 'Input.Text', placeholder: 'Write your message here...', isMultiline: true, id: 'wbxTmText', height: 'stretch' },
|
||||
{ type: 'Input.Toggle', title: 'Include Attachment', id: 'attachment', valueOn: 'yes', valueOff: 'no' },
|
||||
],
|
||||
[
|
||||
{ type: 'Action.Submit', title: 'Send', style: 'positive', data: { cardType: 'sendMessage' } },
|
||||
{ type: 'Action.Submit', title: 'Cancel', style: 'destructive', data: { cardType: 'cancel' } },
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
attachmentPromptCard(): AdaptiveCard {
|
||||
return card([
|
||||
{ type: 'TextBlock', text: 'Upload Attachment', size: 'Medium', weight: 'Bolder' },
|
||||
{
|
||||
type: 'TextBlock',
|
||||
wrap: true,
|
||||
text:
|
||||
'To attach a file:\n\n' +
|
||||
'1. Click **Reply** to this message\n' +
|
||||
'2. Mention the bot and attach your file using the paperclip icon\n' +
|
||||
'3. Send the message\n\n' +
|
||||
'You can attach images, PDFs, documents, etc.',
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
helpCard(): AdaptiveCard {
|
||||
return card(
|
||||
[
|
||||
{ type: 'TextBlock', text: 'Sendi Help', size: 'Large', weight: 'Bolder', color: 'Accent' },
|
||||
{ type: 'TextBlock', text: 'What would you like to do?', size: 'Medium', weight: 'Lighter', separator: true },
|
||||
],
|
||||
[
|
||||
{ type: 'Action.Submit', title: 'Create New Contact', data: { cardType: 'createNewContact' } },
|
||||
{ type: 'Action.Submit', title: 'Send Message', data: { cardType: 'sendMessageHelp' } },
|
||||
{ type: 'Action.Submit', title: 'Delete Contact', data: { cardType: 'deleteContact' } },
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
createNewContactCard(): AdaptiveCard {
|
||||
return card(
|
||||
[
|
||||
{ type: 'TextBlock', text: 'Create New Contact', size: 'Large', weight: 'Bolder', color: 'Accent' },
|
||||
{ type: 'TextBlock', text: 'Contact Name', separator: true },
|
||||
{ type: 'Input.Text', placeholder: 'Full name or nickname', id: 'smsName' },
|
||||
{ type: 'TextBlock', text: 'Phone Number', separator: true },
|
||||
{ type: 'Input.Text', placeholder: '+14129990624', id: 'smsPhone' },
|
||||
],
|
||||
[
|
||||
{ type: 'Action.Submit', title: 'Create Contact', style: 'positive', data: { cardType: 'addContact' } },
|
||||
{ type: 'Action.Submit', title: 'Cancel', style: 'destructive', data: { cardType: 'cancel' } },
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
updateContactCard(currentName: string): AdaptiveCard {
|
||||
return card(
|
||||
[
|
||||
{ type: 'TextBlock', text: 'Update Contact Name', size: 'Medium', weight: 'Bolder' },
|
||||
{ type: 'TextBlock', text: `Current name: ${currentName || 'Unknown'}`, separator: true },
|
||||
{ type: 'Input.Text', placeholder: 'New contact name', id: 'newName', value: currentName || '' },
|
||||
],
|
||||
[
|
||||
{ type: 'Action.Submit', title: 'Update Name', data: { cardType: 'updateContact' } },
|
||||
{ type: 'Action.Submit', title: 'Cancel', style: 'destructive', data: { cardType: 'cancel' } },
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
confirmDeleteCard(title: string): AdaptiveCard {
|
||||
return card(
|
||||
[
|
||||
{ type: 'TextBlock', text: 'Delete Contact', size: 'Medium', weight: 'Bolder', color: 'Attention' },
|
||||
{ type: 'TextBlock', text: `Are you sure you want to permanently delete:\n\n**${title}**?`, wrap: true },
|
||||
{
|
||||
type: 'TextBlock',
|
||||
text: 'This will delete the Webex space and all message history.',
|
||||
size: 'Small',
|
||||
color: 'Attention',
|
||||
},
|
||||
],
|
||||
[
|
||||
{ type: 'Action.Submit', title: 'Yes, Delete', style: 'destructive', data: { cardType: 'confirmDeleteContact' } },
|
||||
{ type: 'Action.Submit', title: 'Cancel', data: { cardType: 'cancel' } },
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
incomingMessageCard(args: { from: string; text: string; numMedia: number }): AdaptiveCard {
|
||||
const body: AdaptiveCard['body'] = [
|
||||
{ type: 'TextBlock', text: `Incoming from ${args.from || 'Unknown'}`, size: 'Medium', weight: 'Bolder' },
|
||||
{ type: 'TextBlock', text: args.text || '(No text message)', wrap: true, separator: true },
|
||||
];
|
||||
if (args.numMedia > 0) {
|
||||
body.push({
|
||||
type: 'TextBlock',
|
||||
text: args.numMedia === 1 ? '1 attachment received' : `${args.numMedia} attachments received`,
|
||||
weight: 'Bolder',
|
||||
separator: true,
|
||||
});
|
||||
}
|
||||
return card(body, [{ type: 'Action.Submit', title: 'Reply', data: { cardType: 'replyMessage' } }]);
|
||||
},
|
||||
|
||||
deliveryStatusCard(status: string, body: string): AdaptiveCard {
|
||||
let title = 'Message Sent';
|
||||
let color = 'Default';
|
||||
if (status === 'delivered') { title = 'Message Delivered'; color = 'Good'; }
|
||||
else if (status === 'failed' || status === 'undelivered') { title = 'Message Failed'; color = 'Attention'; }
|
||||
return card([
|
||||
{ type: 'TextBlock', text: title, size: 'Medium', weight: 'Bolder', color },
|
||||
{ type: 'TextBlock', text: body || '(message content not stored)', wrap: true, separator: true },
|
||||
{ type: 'TextBlock', text: `Status: ${status}`, size: 'Small', color: 'Accent' },
|
||||
]);
|
||||
},
|
||||
};
|
||||
33
src/services/Logger.ts
Normal file
33
src/services/Logger.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// src/services/Logger.ts
|
||||
import pino, { type LoggerOptions } from 'pino';
|
||||
import env from '../config/env.ts';
|
||||
|
||||
const isDev = env.NODE_ENV !== 'production';
|
||||
|
||||
const options: LoggerOptions = {
|
||||
level: env.LOG_LEVEL,
|
||||
base: { service: 'sendi' },
|
||||
redact: {
|
||||
paths: [
|
||||
'req.headers.authorization',
|
||||
'headers.authorization',
|
||||
'*.WEBEX_TOKEN',
|
||||
'*.TWILIO_AUTH_TOKEN',
|
||||
'WEBEX_TOKEN',
|
||||
'TWILIO_AUTH_TOKEN',
|
||||
],
|
||||
censor: '[REDACTED]',
|
||||
},
|
||||
};
|
||||
|
||||
const logger = isDev
|
||||
? pino({
|
||||
...options,
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: { colorize: true, translateTime: 'SYS:standard', ignore: 'pid,hostname' },
|
||||
},
|
||||
})
|
||||
: pino(options);
|
||||
|
||||
export default logger;
|
||||
53
src/services/MediaService.ts
Normal file
53
src/services/MediaService.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// src/services/MediaService.ts
|
||||
// Local filesystem cache for inbound (Twilio MMS) and outbound (Webex file upload)
|
||||
// attachments. The HTTP server exposes `/media/<filename>` so Twilio can fetch
|
||||
// outbound media URLs we generate from Webex uploads.
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import env from '../config/env.ts';
|
||||
import logger from './Logger.ts';
|
||||
|
||||
export const MEDIA_DIR = path.resolve(process.cwd(), 'media');
|
||||
|
||||
export function ensureMediaDir(): void {
|
||||
if (!fs.existsSync(MEDIA_DIR)) {
|
||||
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
||||
logger.info({ dir: MEDIA_DIR }, 'Created media directory');
|
||||
}
|
||||
}
|
||||
|
||||
function filenameFromContentDisposition(header: string | null, fallback: string): string {
|
||||
if (!header) return fallback;
|
||||
const match = /filename[^;=\n]*=([^;\n]*)/i.exec(header);
|
||||
if (!match || !match[1]) return fallback;
|
||||
return match[1].replace(/['"]/g, '').trim() || fallback;
|
||||
}
|
||||
|
||||
/** Download an inbound Twilio MMS attachment and return its local filename (not the full path). */
|
||||
export async function downloadTwilioMedia(mediaUrl: string): Promise<string> {
|
||||
ensureMediaDir();
|
||||
|
||||
const auth = Buffer.from(`${env.TWILIO_ACCOUNT_SID}:${env.TWILIO_AUTH_TOKEN}`).toString('base64');
|
||||
const response = await fetch(mediaUrl, { headers: { Authorization: `Basic ${auth}` } });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Twilio media download failed (${response.status}) for ${mediaUrl}`);
|
||||
}
|
||||
|
||||
const filename = filenameFromContentDisposition(
|
||||
response.headers.get('content-disposition'),
|
||||
`twilio-${Date.now()}.bin`,
|
||||
);
|
||||
const filePath = path.join(MEDIA_DIR, filename);
|
||||
const buf = Buffer.from(await response.arrayBuffer());
|
||||
fs.writeFileSync(filePath, buf);
|
||||
|
||||
logger.info({ filename, bytes: buf.length }, 'Twilio media saved');
|
||||
return filename;
|
||||
}
|
||||
|
||||
/** Build the public URL for a locally-saved media file (used as Twilio outbound mediaUrl). */
|
||||
export function publicMediaUrl(filename: string): string {
|
||||
return `${env.WEBEX_PUBLIC_URL.replace(/\/$/, '')}/media/${filename}`;
|
||||
}
|
||||
56
src/services/PendingMessageStore.ts
Normal file
56
src/services/PendingMessageStore.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// src/services/PendingMessageStore.ts
|
||||
//
|
||||
// Tracks SMS sends that are waiting on the user to upload an attachment.
|
||||
// Behind an interface so the storage backend can be swapped (DB, Redis, etc.)
|
||||
// without touching call sites.
|
||||
|
||||
export interface PendingMessage {
|
||||
roomId: string;
|
||||
text: string;
|
||||
spaceId: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface PendingMessageStore {
|
||||
set(roomId: string, msg: Omit<PendingMessage, 'createdAt'>): Promise<void>;
|
||||
get(roomId: string): Promise<PendingMessage | undefined>;
|
||||
delete(roomId: string): Promise<void>;
|
||||
/** Drop entries older than `maxAgeMs`. Returns the number removed. */
|
||||
sweep(maxAgeMs: number): Promise<number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory implementation. Loses state on process restart. Suitable for
|
||||
* single-instance deployments. Swap for a DB-backed impl when horizontal
|
||||
* scaling or restart-resilience matter.
|
||||
*/
|
||||
export class InMemoryPendingMessageStore implements PendingMessageStore {
|
||||
private readonly store = new Map<string, PendingMessage>();
|
||||
|
||||
async set(roomId: string, msg: Omit<PendingMessage, 'createdAt'>): Promise<void> {
|
||||
this.store.set(roomId, { ...msg, createdAt: new Date() });
|
||||
}
|
||||
|
||||
async get(roomId: string): Promise<PendingMessage | undefined> {
|
||||
return this.store.get(roomId);
|
||||
}
|
||||
|
||||
async delete(roomId: string): Promise<void> {
|
||||
this.store.delete(roomId);
|
||||
}
|
||||
|
||||
async sweep(maxAgeMs: number): Promise<number> {
|
||||
const cutoff = Date.now() - maxAgeMs;
|
||||
let removed = 0;
|
||||
for (const [roomId, entry] of this.store) {
|
||||
if (entry.createdAt.getTime() < cutoff) {
|
||||
this.store.delete(roomId);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
// Default singleton. Swap the assignment to use a different backend.
|
||||
export const pendingMessageStore: PendingMessageStore = new InMemoryPendingMessageStore();
|
||||
21
src/services/PrismaService.ts
Normal file
21
src/services/PrismaService.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// src/services/PrismaService.ts
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { PrismaBetterSQLite3 } from '@prisma/adapter-better-sqlite3';
|
||||
|
||||
import env from '../config/env.ts';
|
||||
import logger from './Logger.ts';
|
||||
|
||||
const sqlitePath = env.DATABASE_URL.replace(/^file:/, '');
|
||||
|
||||
const adapter = new PrismaBetterSQLite3({ url: sqlitePath });
|
||||
|
||||
export const prisma = new PrismaClient({
|
||||
adapter,
|
||||
log: ['warn', 'error'],
|
||||
});
|
||||
|
||||
prisma.$connect()
|
||||
.then(() => logger.info({ db: sqlitePath }, 'Prisma connected'))
|
||||
.catch((err) => logger.error({ err }, 'Prisma failed to connect'));
|
||||
|
||||
export default prisma;
|
||||
34
src/services/Twilioservice.ts
Normal file
34
src/services/Twilioservice.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// src/services/TwilioService.ts
|
||||
import twilio from 'twilio';
|
||||
import env from '../config/env.ts';
|
||||
import logger from './Logger.ts';
|
||||
|
||||
const client = twilio(env.TWILIO_ACCOUNT_SID, env.TWILIO_AUTH_TOKEN);
|
||||
|
||||
export class TwilioService {
|
||||
static client = client;
|
||||
|
||||
static async sendMessage(params: {
|
||||
to: string;
|
||||
from: string;
|
||||
body: string;
|
||||
mediaUrl?: string[];
|
||||
}) {
|
||||
try {
|
||||
const message = await client.messages.create({
|
||||
body: params.body,
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
mediaUrl: params.mediaUrl,
|
||||
statusCallback: env.CALLBACK_URL,
|
||||
});
|
||||
|
||||
logger.info({ sid: message.sid, to: params.to }, 'SMS sent via Twilio');
|
||||
return message;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, to: params.to }, 'Failed to send SMS');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
99
src/services/WebexService.ts
Normal file
99
src/services/WebexService.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// src/services/WebexService.ts
|
||||
// Uses Node 18+ built-in global `fetch` and built-in FormData/Blob.
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import env from '../config/env.ts';
|
||||
import logger from './Logger.ts';
|
||||
|
||||
const MEDIA_DIR = path.join(process.cwd(), 'media');
|
||||
if (!fs.existsSync(MEDIA_DIR)) fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
||||
|
||||
export class WebexService {
|
||||
static async sendCard(roomId: string, card: any, fallbackText: string, parentId?: string) {
|
||||
const body: any = {
|
||||
roomId,
|
||||
markdown: fallbackText,
|
||||
attachments: [{ contentType: 'application/vnd.microsoft.card.adaptive', content: card }],
|
||||
};
|
||||
|
||||
if (parentId) body.parentId = parentId;
|
||||
|
||||
try {
|
||||
const response = await fetch('https://webexapis.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.WEBEX_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Webex API error ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as any;
|
||||
logger.info({ roomId, messageId: data?.id }, 'Adaptive Card sent successfully');
|
||||
return data;
|
||||
} catch (error: any) {
|
||||
logger.error({ error, roomId }, 'Failed to send Adaptive Card');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Download file from Webex and save locally
|
||||
static async getFile(fileUrl: string): Promise<string> {
|
||||
try {
|
||||
const response = await fetch(fileUrl, {
|
||||
headers: { Authorization: `Bearer ${env.WEBEX_TOKEN}` },
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Download failed: ${response.status}`);
|
||||
|
||||
const disposition = response.headers.get('content-disposition') || '';
|
||||
let filename = `attachment-${Date.now()}.unknown`;
|
||||
|
||||
const match = disposition.match(/filename[^;=\n]*=([^;\n]*)/i);
|
||||
if (match && match[1]) {
|
||||
filename = match[1].replace(/['"]/g, '').trim();
|
||||
}
|
||||
|
||||
const filePath = path.join(MEDIA_DIR, filename);
|
||||
|
||||
// Fixed: Use arrayBuffer() instead of deprecated buffer()
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
fs.writeFileSync(filePath, Buffer.from(arrayBuffer));
|
||||
|
||||
// Return public URL for Twilio
|
||||
const publicUrl = `${env.WEBEX_PUBLIC_URL}/media/${filename}`;
|
||||
|
||||
logger.info({ publicUrl, filename }, 'File downloaded and ready for Twilio');
|
||||
return publicUrl;
|
||||
|
||||
} catch (error) {
|
||||
logger.error({ error, fileUrl }, 'Failed to download Webex file');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: Send file to a person (for archives, etc.)
|
||||
static async sendFileToPerson(email: string, filePath: string, message: string) {
|
||||
const form = new FormData();
|
||||
form.append('toPersonEmail', email);
|
||||
form.append('markdown', message);
|
||||
// Read into memory (these are typically small archive files); for larger
|
||||
// payloads we should swap in a streaming uploader.
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
const fileBlob = new Blob([new Uint8Array(fileBuffer)]);
|
||||
form.append('files', fileBlob, path.basename(filePath));
|
||||
|
||||
const response = await fetch('https://webexapis.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${env.WEBEX_TOKEN}` },
|
||||
body: form,
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
58
src/types/webex-node-bot-framework.d.ts
vendored
Normal file
58
src/types/webex-node-bot-framework.d.ts
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// src/types/webex-node-bot-framework.d.ts
|
||||
// Minimal hand-rolled types for the small surface of the framework we actually use.
|
||||
// The real module is `module.exports = Framework`; with `esModuleInterop` enabled,
|
||||
// the default import is bridged to that value at runtime.
|
||||
|
||||
declare module 'webex-node-bot-framework' {
|
||||
type AnyFn = (...args: any[]) => any;
|
||||
|
||||
export interface WebexSdk {
|
||||
messages: {
|
||||
create(options: Record<string, unknown>): Promise<{ id: string;[k: string]: unknown }>;
|
||||
remove(messageId: string): Promise<void>;
|
||||
};
|
||||
rooms: {
|
||||
create(options: { title: string }): Promise<{ id: string; title: string }>;
|
||||
remove(roomId: string): Promise<void>;
|
||||
};
|
||||
memberships: {
|
||||
create(options: { roomId: string; personId?: string; personEmail?: string }): Promise<unknown>;
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface WebexBot {
|
||||
webex: WebexSdk;
|
||||
say(message: string): Promise<unknown>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface WebexTrigger {
|
||||
personId?: string;
|
||||
personEmail?: string;
|
||||
message: { roomId: string; text?: string; files?: string[];[k: string]: any };
|
||||
attachmentAction?: {
|
||||
roomId: string;
|
||||
messageId: string;
|
||||
inputs?: Record<string, any>;
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface WebexFramework {
|
||||
start(): Promise<unknown>;
|
||||
stop(): Promise<unknown>;
|
||||
on(event: 'initialized', cb: () => void): WebexFramework;
|
||||
on(event: 'attachmentAction', cb: (bot: WebexBot, trigger: WebexTrigger) => unknown): WebexFramework;
|
||||
on(event: 'files', cb: (bot: WebexBot, trigger: WebexTrigger) => unknown): WebexFramework;
|
||||
on(event: string, cb: AnyFn): WebexFramework;
|
||||
hears(pattern: RegExp | string, cb: (bot: WebexBot, trigger: WebexTrigger) => unknown): WebexFramework;
|
||||
}
|
||||
|
||||
interface FrameworkConstructor {
|
||||
new(opts: { token: string;[k: string]: unknown }): WebexFramework;
|
||||
}
|
||||
|
||||
const Framework: FrameworkConstructor;
|
||||
export default Framework;
|
||||
}
|
||||
15
tests/setup.ts
Normal file
15
tests/setup.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// tests/setup.ts
|
||||
// Inject dummy environment values BEFORE any application code runs, so that
|
||||
// importing `../src/config/env` succeeds without a real .env on disk.
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.LOG_LEVEL = 'silent';
|
||||
process.env.PORT = '13337';
|
||||
process.env.WEBEX_TOKEN = 'test-token';
|
||||
process.env.WEBEX_BOT_EMAIL = 'testbot@webex.bot';
|
||||
process.env.WEBEX_PUBLIC_URL = 'https://example.test/sendi';
|
||||
process.env.CALLBACK_URL = 'https://example.test/sendi/callback';
|
||||
process.env.TWILIO_ACCOUNT_SID = 'AC' + '0'.repeat(32);
|
||||
process.env.TWILIO_AUTH_TOKEN = 'test-auth-token';
|
||||
process.env.TWILIO_DEFAULT_FROM = '+15551234567';
|
||||
process.env.DATABASE_URL = 'file:./prisma/dev.db';
|
||||
24
tests/unit/cardActions.test.ts
Normal file
24
tests/unit/cardActions.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { __testing } from '../../src/bot/cardActions.ts';
|
||||
|
||||
describe('cardAction handlers registry', () => {
|
||||
it('registers a handler for every cardType emitted by CardBuilder', () => {
|
||||
const expected = [
|
||||
'sendMessage',
|
||||
'updateContact',
|
||||
'createNewContact',
|
||||
'addContact',
|
||||
'confirmDeleteContact',
|
||||
'deleteContact',
|
||||
'sendMessageHelp',
|
||||
'cancel',
|
||||
];
|
||||
for (const cardType of expected) {
|
||||
expect(__testing.handlers[cardType]).toBeTypeOf('function');
|
||||
}
|
||||
});
|
||||
|
||||
it('does not register handlers for unknown cardTypes', () => {
|
||||
expect(__testing.handlers['nonexistent']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
51
tests/unit/phone.test.ts
Normal file
51
tests/unit/phone.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { toE164, isE164, formatForDisplay } from '../../src/lib/phone.ts';
|
||||
|
||||
describe('toE164', () => {
|
||||
it('normalizes 10-digit NANP', () => {
|
||||
expect(toE164('289-206-7080')).toBe('+12892067080');
|
||||
expect(toE164('(289) 206-7080')).toBe('+12892067080');
|
||||
expect(toE164('289.206.7080')).toBe('+12892067080');
|
||||
expect(toE164('2892067080')).toBe('+12892067080');
|
||||
});
|
||||
|
||||
it('normalizes 11-digit NANP starting with 1', () => {
|
||||
expect(toE164('12892067080')).toBe('+12892067080');
|
||||
expect(toE164('1-289-206-7080')).toBe('+12892067080');
|
||||
});
|
||||
|
||||
it('preserves +-prefixed E.164', () => {
|
||||
expect(toE164('+442071838750')).toBe('+442071838750');
|
||||
expect(toE164('+1 289 206 7080')).toBe('+12892067080');
|
||||
});
|
||||
|
||||
it('throws on inputs that cannot be normalized', () => {
|
||||
expect(() => toE164('abc')).toThrow();
|
||||
expect(() => toE164('123')).toThrow();
|
||||
expect(() => toE164('')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isE164', () => {
|
||||
it('recognizes valid E.164', () => {
|
||||
expect(isE164('+12892067080')).toBe(true);
|
||||
expect(isE164('+442071838750')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid inputs', () => {
|
||||
expect(isE164('2892067080')).toBe(false);
|
||||
expect(isE164('+0123456789')).toBe(false);
|
||||
expect(isE164(undefined)).toBe(false);
|
||||
expect(isE164(12345)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatForDisplay', () => {
|
||||
it('formats NANP numbers', () => {
|
||||
expect(formatForDisplay('+12892067080')).toBe('(289) 206-7080');
|
||||
});
|
||||
|
||||
it('returns non-NANP numbers unchanged', () => {
|
||||
expect(formatForDisplay('+442071838750')).toBe('+442071838750');
|
||||
});
|
||||
});
|
||||
51
tests/unit/twilioSignature.test.ts
Normal file
51
tests/unit/twilioSignature.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import express from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
import twilio from 'twilio';
|
||||
import request from 'supertest';
|
||||
|
||||
import { twilioSignature } from '../../src/middleware/twilioSignature.ts';
|
||||
|
||||
const FAKE_AUTH_TOKEN = process.env.TWILIO_AUTH_TOKEN!;
|
||||
const FAKE_PUBLIC = process.env.WEBEX_PUBLIC_URL!.replace(/\/$/, '');
|
||||
|
||||
function makeApp() {
|
||||
const app = express();
|
||||
app.use(bodyParser.urlencoded({ extended: true }));
|
||||
app.post('/sms', twilioSignature, (_req, res) => {
|
||||
res.status(200).send('ok');
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('twilioSignature middleware', () => {
|
||||
it('rejects requests with no X-Twilio-Signature', async () => {
|
||||
const app = makeApp();
|
||||
const res = await request(app).post('/sms').send({ From: '+15551234567', Body: 'hi' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects requests with a bad signature', async () => {
|
||||
const app = makeApp();
|
||||
const res = await request(app)
|
||||
.post('/sms')
|
||||
.set('X-Twilio-Signature', 'definitely-not-real')
|
||||
.send({ From: '+15551234567', Body: 'hi' });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('accepts a properly signed request', async () => {
|
||||
const app = makeApp();
|
||||
const params = { From: '+15551234567', To: '+15557654321', Body: 'hi' };
|
||||
const url = `${FAKE_PUBLIC}/sms`;
|
||||
const signature = twilio.getExpectedTwilioSignature(FAKE_AUTH_TOKEN, url, params);
|
||||
|
||||
const res = await request(app)
|
||||
.post('/sms')
|
||||
.set('X-Twilio-Signature', signature)
|
||||
.type('form')
|
||||
.send(params);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.text).toBe('ok');
|
||||
});
|
||||
});
|
||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowJs": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"noEmit": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
10
vitest.config.ts
Normal file
10
vitest.config.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['tests/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
setupFiles: ['tests/setup.ts'],
|
||||
reporters: ['default'],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue