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:
jmcqueen 2026-07-06 11:10:44 -04:00
commit d0a0dda2b8
37 changed files with 17500 additions and 0 deletions

35
.env.example Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

44
package.json Normal file
View 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"
}
}

View 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");

View 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
View 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)
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

View 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');
};

View 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();
}

View 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
View 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
View 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
View 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
View 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;

View 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}`;
}

View 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();

View 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;

View 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;
}
}
}

View 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
View 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
View 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';

View 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
View 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');
});
});

View 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
View 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
View 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'],
},
});