Packaging
- Drop stale `main: dist/index.js` from package.json (no build workflow)
- Move `prisma` to dependencies and add `postinstall: prisma generate`
so a fresh `npm ci` on the server produces a runnable client
- Add `prisma:deploy` script for `prisma migrate deploy`
- Rename src/services/Twilioservice.ts -> TwilioService.ts (case fix;
invisible on macOS APFS, would crash on Linux)
Node version guards
- .nvmrc (22.23.1) so `nvm use` picks the right runtime
- .npmrc `engine-strict=true` so npm respects `engines` on install
- Runtime guard in scripts/navigator-patch.cjs that fails fast with an
actionable message on Node < 22.18 (native .ts stripping requirement)
Twilio error taxonomy (src/lib/twilioErrors.ts)
- `classifyTwilioError(err)` for send-time exceptions, mapping known
Twilio REST codes (20003/20429/20500/21211..21614) plus HTTP-status
and Node errno fallback to `{ kind: retryable|terminal, category }`
- `classifyDeliveryFailure(code)` for the 30xxx delivery-status family
- `sendViaTwilio` persists a `send_error[_retryable]` Message row on
failure and surfaces the classified message to the user
- `/callback` extracts ErrorCode and passes classified failure info to
`deliveryStatusCard`, which renders the code + retry-hint inline
Persistent PendingMessageStore
- New PrismaPendingMessageStore (upsert-based) becomes the default
singleton; InMemoryPendingMessageStore retained for tests
- `startPendingMessageSweeper()` runs hourly, drops entries >24h old,
unref()s its timer, and is disabled under NODE_ENV=test
- Wired into the shutdown handler in src/index.ts
Tests (39 total, up from 13)
- Integration: tests/integration/twilioRoutes.test.ts hits /sms and
/callback through the real createApp() with a stub bot, real Twilio
signatures, plus /healthz
- Unit: twilioErrors.test.ts (14), pendingMessageStore.test.ts (5)
- typecheck clean
Co-authored-by: Cursor <cursoragent@cursor.com>
67 lines
2.2 KiB
TypeScript
67 lines
2.2 KiB
TypeScript
// 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';
|
|
import { classifyTwilioError } from '../lib/twilioErrors.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) {
|
|
const classified = classifyTwilioError(err);
|
|
logger.error(
|
|
{ err, to: space.smsPhone, category: classified.category, kind: classified.kind, code: classified.code },
|
|
'Twilio send failed',
|
|
);
|
|
|
|
try {
|
|
await prisma.message.create({
|
|
data: {
|
|
roomId: space.roomId,
|
|
from: space.wbxTmPhone,
|
|
body: text,
|
|
status: classified.kind === 'retryable' ? 'send_error_retryable' : 'send_error',
|
|
mediaUrls: JSON.stringify(mediaUrls),
|
|
},
|
|
});
|
|
} catch (persistErr) {
|
|
logger.warn({ err: persistErr }, 'Failed to persist send failure');
|
|
}
|
|
|
|
const prefix = classified.kind === 'retryable' ? 'Temporary send failure' : 'Send failed';
|
|
await bot.say(`${prefix}: ${classified.userMessage}`);
|
|
}
|
|
}
|