From f5ac9523d868235517f85877bfc1742e487335d6 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Fri, 27 Feb 2026 20:05:16 +0000 Subject: [PATCH] feat(sms): add Twilio SMS integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds end-to-end SMS support via Twilio, following the same registry/AsyncLocalStorage/credential-resolution patterns as the email integration. ## Backend - `src/sms/` — provider interface, registry, AsyncLocalStorage scoping, integration credential resolution, and Twilio adapter - `src/gadgets/sms/SendSms` — LLMist gadget that agents call to send SMS - `src/router/adapters/twilio.ts` — inbound webhook handler with Twilio signature validation (`POST /twilio/webhook/:projectId`) - SMS provider scoped in all three agent execution paths: pm/webhook-handler, github/webhook-handler, manual-runner - Pre-execution integration validation for agents that declare `sms` as a required integration ## Database - Migration 0020: extends `chk_integration_category_provider` to include `sms/twilio`, and `chk_integration_credential_role` to include `account_sid`, `auth_token`, `phone_number` - `getAllProjectIdsWithSmsIntegration()` in settingsRepository (mirrors email equivalent, ready for future SMS scheduler) ## API / CLI - `integrationsDiscovery.verifyTwilio` tRPC mutation — validates Account SID + Auth Token against the Twilio API - `projects.integrations.*` and `integrationCredentials.*` routers updated to accept `sms` category - CLI `integration-credentials`, `integration-credential-set`, `integration-credential-rm` commands updated to include `sms` ## Dashboard UI - `TwilioWizard` — 3-step accordion (credentials → verify → save) with inline credential creator, copy-to-clipboard webhook URL panel, and edit-mode pre-verification - `IntegrationForm` — new SMS tab wired to TwilioWizard ## Tests - Unit tests for SMS context, TwilioSmsProvider, TwilioIntegration, webhook handler, SendSms gadget, settingsRepository, and CLI commands - 206 test files, all passing Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 21 +- package-lock.json | 125 ++++- package.json | 1 + src/agents/definitions/schema.ts | 2 +- src/api/routers/integrationsDiscovery.ts | 31 ++ src/api/routers/projects.ts | 12 +- src/cli/dashboard/projects/override-rm.ts | 6 +- src/cli/dashboard/projects/override-set.ts | 6 +- src/cli/dashboard/projects/overrides.ts | 8 +- src/config/integrationRoles.ts | 10 +- .../0020_sms_twilio_integration.sql | 44 ++ src/db/migrations/meta/_journal.json | 7 + src/db/repositories/settingsRepository.ts | 9 + src/gadgets/index.ts | 3 + src/gadgets/sms/SendSms.ts | 29 + src/gadgets/sms/core/sendSms.ts | 17 + src/gadgets/sms/index.ts | 1 + src/pm/webhook-handler.ts | 11 +- src/router/adapters/twilio.ts | 64 +++ src/router/index.ts | 4 + src/sms/context.ts | 29 + src/sms/index.ts | 23 + src/sms/integration.ts | 49 ++ src/sms/provider.ts | 24 + src/sms/registry.ts | 34 ++ src/sms/twilio/adapter.ts | 27 + src/sms/twilio/index.ts | 8 + src/sms/twilio/integration.ts | 40 ++ src/sms/types.ts | 19 + src/triggers/github/webhook-handler.ts | 7 +- src/triggers/shared/integration-validation.ts | 17 + src/triggers/shared/manual-runner.ts | 17 +- .../projects/integration-credentials.test.ts | 11 +- .../repositories/settingsRepository.test.ts | 20 + tests/unit/gadgets/sms/core/sendSms.test.ts | 52 ++ tests/unit/router/adapters/twilio.test.ts | 119 ++++ tests/unit/sms/context.test.ts | 62 +++ tests/unit/sms/twilio/adapter.test.ts | 64 +++ tests/unit/sms/twilio/integration.test.ts | 93 ++++ .../components/projects/integration-form.tsx | 19 +- web/src/components/projects/twilio-wizard.tsx | 511 ++++++++++++++++++ 41 files changed, 1617 insertions(+), 39 deletions(-) create mode 100644 src/db/migrations/0020_sms_twilio_integration.sql create mode 100644 src/gadgets/sms/SendSms.ts create mode 100644 src/gadgets/sms/core/sendSms.ts create mode 100644 src/gadgets/sms/index.ts create mode 100644 src/router/adapters/twilio.ts create mode 100644 src/sms/context.ts create mode 100644 src/sms/index.ts create mode 100644 src/sms/integration.ts create mode 100644 src/sms/provider.ts create mode 100644 src/sms/registry.ts create mode 100644 src/sms/twilio/adapter.ts create mode 100644 src/sms/twilio/index.ts create mode 100644 src/sms/twilio/integration.ts create mode 100644 src/sms/types.ts create mode 100644 tests/unit/gadgets/sms/core/sendSms.test.ts create mode 100644 tests/unit/router/adapters/twilio.test.ts create mode 100644 tests/unit/sms/context.test.ts create mode 100644 tests/unit/sms/twilio/adapter.test.ts create mode 100644 tests/unit/sms/twilio/integration.test.ts create mode 100644 web/src/components/projects/twilio-wizard.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 1de3eb97..04459c88 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,8 +93,8 @@ CASCADE stores all project configuration in PostgreSQL (Supabase). The `config/p - `organizations` - Organization definitions (multi-tenant support) - `cascade_defaults` - Global defaults per org (model, iterations, timeouts, budget) - `projects` - Per-project config (repo, base branch, budget, backend) -- `project_integrations` - Integration configs per project with `category` (pm/scm), `provider` (trello/jira/github), `config` JSONB, and `triggers` JSONB. One PM + one SCM per project (enforced by unique constraint) -- `integration_credentials` - Links integration roles to org-scoped credential rows (e.g., `api_key` → credential #5). Roles are provider-specific: trello has `api_key`/`token`, jira has `email`/`api_token`, github has `implementer_token`/`reviewer_token` +- `project_integrations` - Integration configs per project with `category` (pm/scm/email/sms), `provider` (trello/jira/github/imap/gmail/twilio), `config` JSONB, and `triggers` JSONB. One PM + one SCM per project (enforced by unique constraint) +- `integration_credentials` - Links integration roles to org-scoped credential rows (e.g., `api_key` → credential #5). Roles are provider-specific: trello has `api_key`/`token`, jira has `email`/`api_token`, github has `implementer_token`/`reviewer_token`, twilio has `account_sid`/`auth_token`/`phone_number` - `agent_configs` - Per-agent-type overrides (model, iterations, backend, prompt), scoped globally, per-org, or per-project - `credentials` - Org-scoped credentials (API keys, tokens) - `users` - Dashboard users (email, bcrypt password hash, org-scoped) @@ -193,6 +193,23 @@ const openrouterKey = await getOrgCredential(projectId, 'OPENROUTER_API_KEY'); Role definitions and env-var-key mappings are in `src/config/integrationRoles.ts`. +### Twilio SMS Integration + +CASCADE supports sending and receiving SMS via Twilio. Configure per-project in the dashboard (Project Settings > Integrations > SMS tab) or CLI: + +```bash +cascade credentials create --name "Twilio Account SID" --key TWILIO_ACCOUNT_SID --value ACxxx... --default +cascade credentials create --name "Twilio Auth Token" --key TWILIO_AUTH_TOKEN --value xxx... --default +cascade credentials create --name "Twilio Phone Number" --key TWILIO_PHONE_NUMBER --value +15550000001 --default +cascade projects integration-credential-set --category sms --role account_sid --credential-id 5 +cascade projects integration-credential-set --category sms --role auth_token --credential-id 6 +cascade projects integration-credential-set --category sms --role phone_number --credential-id 7 +``` + +**Inbound webhook**: `POST /twilio/webhook/:projectId` — configure this URL in the Twilio console under *Phone Numbers → Manage → Active Numbers → [your number] → Messaging → A Message Comes In*. The handler validates the Twilio signature and logs incoming messages (agent triggering will be added with a future `sms-responder` agent). + +**Outbound SMS**: Agents use the `SendSms` gadget. SMS credentials are scoped automatically during agent execution (mirrors email integration). + ### Review Agent Trigger Modes The review agent supports three independent trigger modes via the `reviewTrigger` config in the SCM integration triggers. **All modes default to `false`** — existing behavior is preserved via a legacy fallback. diff --git a/package-lock.json b/package-lock.json index 93982de2..2c5a76b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "open": "^11.0.0", "pg": "^8.18.0", "trello.js": "^1.2.8", + "twilio": "^5.12.2", "zangief": "latest", "zod": "^3.24.1" }, @@ -5365,6 +5366,12 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "license": "MIT", @@ -7671,6 +7678,28 @@ "node": "*" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, "node_modules/jwa": { "version": "2.0.1", "license": "MIT", @@ -8011,17 +8040,46 @@ "version": "4.2.0", "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, "node_modules/lodash.isarguments": { "version": "3.1.0", "license": "MIT" }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, "node_modules/lodash.isequal": { "version": "4.5.0", "license": "MIT" }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", - "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, "node_modules/lodash.kebabcase": { @@ -8039,6 +8097,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/lodash.snakecase": { "version": "4.1.1", "dev": true, @@ -9330,6 +9394,13 @@ "dev": true, "license": "MIT" }, + "node_modules/scmp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz", + "integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==", + "deprecated": "Just use Node.js's crypto.timingSafeEqual()", + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.3", "license": "ISC", @@ -9967,6 +10038,58 @@ "version": "0.14.5", "license": "Unlicense" }, + "node_modules/twilio": { + "version": "5.12.2", + "resolved": "https://registry.npmjs.org/twilio/-/twilio-5.12.2.tgz", + "integrity": "sha512-yjTH04Ig0Z3PAxIXhwrto0IJC4Gv7lBDQQ9f4/P9zJhnxVdd+3tENqBMJOtdmmRags3X0jl2IGKEQefCEpJE9g==", + "license": "MIT", + "dependencies": { + "axios": "^1.12.0", + "dayjs": "^1.11.9", + "https-proxy-agent": "^5.0.0", + "jsonwebtoken": "^9.0.2", + "qs": "^6.14.1", + "scmp": "^2.1.0", + "xmlbuilder": "^13.0.2" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/twilio/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/twilio/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/twilio/node_modules/xmlbuilder": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz", + "integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, "node_modules/type-fest": { "version": "0.21.3", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", diff --git a/package.json b/package.json index b0676208..67207898 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "open": "^11.0.0", "pg": "^8.18.0", "trello.js": "^1.2.8", + "twilio": "^5.12.2", "zangief": "latest", "zod": "^3.24.1" }, diff --git a/src/agents/definitions/schema.ts b/src/agents/definitions/schema.ts index cda2b39b..408a3c77 100644 --- a/src/agents/definitions/schema.ts +++ b/src/agents/definitions/schema.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; // ============================================================================ // Integration categories (aligned with integrationRoles.ts) -export const IntegrationCategorySchema = z.enum(['pm', 'scm', 'email']); +export const IntegrationCategorySchema = z.enum(['pm', 'scm', 'email', 'sms']); // Integration requirements schema (REQUIRED field) const IntegrationsSchema = z diff --git a/src/api/routers/integrationsDiscovery.ts b/src/api/routers/integrationsDiscovery.ts index ee5f6327..e2234888 100644 --- a/src/api/routers/integrationsDiscovery.ts +++ b/src/api/routers/integrationsDiscovery.ts @@ -1,6 +1,7 @@ import { TRPCError } from '@trpc/server'; import { and, eq } from 'drizzle-orm'; import { ImapFlow } from 'imapflow'; +import twilio from 'twilio'; import { z } from 'zod'; import { getDb } from '../../db/client.js'; import { decryptCredential, encryptCredential } from '../../db/crypto.js'; @@ -472,6 +473,36 @@ export const integrationsDiscoveryRouter = router({ } }), + /** + * Verify Twilio credentials by fetching the account details. + */ + verifyTwilio: protectedProcedure + .input( + z.object({ + accountSidCredentialId: z.number(), + authTokenCredentialId: z.number(), + }), + ) + .mutation(async ({ ctx, input }) => { + logger.debug('integrationsDiscovery.verifyTwilio called', { orgId: ctx.effectiveOrgId }); + + const [accountSid, authToken] = await Promise.all([ + resolveCredentialValue(input.accountSidCredentialId, ctx.effectiveOrgId), + resolveCredentialValue(input.authTokenCredentialId, ctx.effectiveOrgId), + ]); + + try { + const client = twilio(accountSid, authToken); + const account = await client.api.accounts(accountSid).fetch(); + return { friendlyName: account.friendlyName, status: account.status }; + } catch (err) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Failed to verify Twilio credentials: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }), + /** * Verify IMAP connection with password auth. */ diff --git a/src/api/routers/projects.ts b/src/api/routers/projects.ts index e772637a..2551968b 100644 --- a/src/api/routers/projects.ts +++ b/src/api/routers/projects.ts @@ -121,7 +121,7 @@ export const projectsRouter = router({ .input( z.object({ projectId: z.string(), - category: z.enum(['pm', 'scm', 'email']), + category: z.enum(['pm', 'scm', 'email', 'sms']), provider: z.string().min(1), config: z.record(z.unknown()), triggers: z.record(z.boolean()).optional(), @@ -142,7 +142,7 @@ export const projectsRouter = router({ .input( z.object({ projectId: z.string(), - category: z.enum(['pm', 'scm', 'email']), + category: z.enum(['pm', 'scm', 'email', 'sms']), triggers: z.record(z.union([z.boolean(), z.string().nullable(), z.record(z.boolean())])), }), ) @@ -152,7 +152,7 @@ export const projectsRouter = router({ }), delete: protectedProcedure - .input(z.object({ projectId: z.string(), category: z.enum(['pm', 'scm']) })) + .input(z.object({ projectId: z.string(), category: z.enum(['pm', 'scm', 'email', 'sms']) })) .mutation(async ({ ctx, input }) => { await verifyProjectOwnership(input.projectId, ctx.effectiveOrgId); await deleteProjectIntegration(input.projectId, input.category); @@ -162,7 +162,7 @@ export const projectsRouter = router({ // Integration Credentials integrationCredentials: router({ list: protectedProcedure - .input(z.object({ projectId: z.string(), category: z.enum(['pm', 'scm', 'email']) })) + .input(z.object({ projectId: z.string(), category: z.enum(['pm', 'scm', 'email', 'sms']) })) .query(async ({ ctx, input }) => { await verifyProjectOwnership(input.projectId, ctx.effectiveOrgId); const integration = await getIntegrationByProjectAndCategory( @@ -177,7 +177,7 @@ export const projectsRouter = router({ .input( z.object({ projectId: z.string(), - category: z.enum(['pm', 'scm', 'email']), + category: z.enum(['pm', 'scm', 'email', 'sms']), role: z.string().min(1), credentialId: z.number(), }), @@ -202,7 +202,7 @@ export const projectsRouter = router({ .input( z.object({ projectId: z.string(), - category: z.enum(['pm', 'scm', 'email']), + category: z.enum(['pm', 'scm', 'email', 'sms']), role: z.string().min(1), }), ) diff --git a/src/cli/dashboard/projects/override-rm.ts b/src/cli/dashboard/projects/override-rm.ts index 4e68116a..728be0fc 100644 --- a/src/cli/dashboard/projects/override-rm.ts +++ b/src/cli/dashboard/projects/override-rm.ts @@ -13,9 +13,9 @@ export default class ProjectsIntegrationCredentialRm extends DashboardCommand { static override flags = { ...DashboardCommand.baseFlags, category: Flags.string({ - description: 'Integration category (pm, scm, or email)', + description: 'Integration category (pm, scm, email, or sms)', required: true, - options: ['pm', 'scm', 'email'], + options: ['pm', 'scm', 'email', 'sms'], }), role: Flags.string({ description: 'Credential role to unlink (e.g. api_key, token, implementer_token)', @@ -29,7 +29,7 @@ export default class ProjectsIntegrationCredentialRm extends DashboardCommand { try { await this.client.projects.integrationCredentials.remove.mutate({ projectId: args.id, - category: flags.category as 'pm' | 'scm' | 'email', + category: flags.category as 'pm' | 'scm' | 'email' | 'sms', role: flags.role, }); diff --git a/src/cli/dashboard/projects/override-set.ts b/src/cli/dashboard/projects/override-set.ts index 2c315c11..744f345c 100644 --- a/src/cli/dashboard/projects/override-set.ts +++ b/src/cli/dashboard/projects/override-set.ts @@ -13,9 +13,9 @@ export default class ProjectsIntegrationCredentialSet extends DashboardCommand { static override flags = { ...DashboardCommand.baseFlags, category: Flags.string({ - description: 'Integration category (pm, scm, or email)', + description: 'Integration category (pm, scm, email, or sms)', required: true, - options: ['pm', 'scm', 'email'], + options: ['pm', 'scm', 'email', 'sms'], }), role: Flags.string({ description: 'Credential role (e.g. api_key, token, implementer_token)', @@ -30,7 +30,7 @@ export default class ProjectsIntegrationCredentialSet extends DashboardCommand { try { await this.client.projects.integrationCredentials.set.mutate({ projectId: args.id, - category: flags.category as 'pm' | 'scm' | 'email', + category: flags.category as 'pm' | 'scm' | 'email' | 'sms', role: flags.role, credentialId: flags['credential-id'], }); diff --git a/src/cli/dashboard/projects/overrides.ts b/src/cli/dashboard/projects/overrides.ts index 5fb6e72e..5190809a 100644 --- a/src/cli/dashboard/projects/overrides.ts +++ b/src/cli/dashboard/projects/overrides.ts @@ -13,8 +13,8 @@ export default class ProjectsIntegrationCredentials extends DashboardCommand { static override flags = { ...DashboardCommand.baseFlags, category: Flags.string({ - description: 'Filter by integration category (pm, scm, or email)', - options: ['pm', 'scm', 'email'], + description: 'Filter by integration category (pm, scm, email, or sms)', + options: ['pm', 'scm', 'email', 'sms'], }), }; @@ -23,8 +23,8 @@ export default class ProjectsIntegrationCredentials extends DashboardCommand { try { const categories = flags.category - ? [flags.category as 'pm' | 'scm' | 'email'] - : (['pm', 'scm', 'email'] as const); + ? [flags.category as 'pm' | 'scm' | 'email' | 'sms'] + : (['pm', 'scm', 'email', 'sms'] as const); const allCreds: Array> = []; diff --git a/src/config/integrationRoles.ts b/src/config/integrationRoles.ts index df865755..e25c76a2 100644 --- a/src/config/integrationRoles.ts +++ b/src/config/integrationRoles.ts @@ -1,5 +1,5 @@ -export type IntegrationCategory = 'pm' | 'scm' | 'email'; -export type IntegrationProvider = 'trello' | 'jira' | 'github' | 'imap' | 'gmail'; +export type IntegrationCategory = 'pm' | 'scm' | 'email' | 'sms'; +export type IntegrationProvider = 'trello' | 'jira' | 'github' | 'imap' | 'gmail' | 'twilio'; export const PROVIDER_CATEGORY: Record = { trello: 'pm', @@ -7,6 +7,7 @@ export const PROVIDER_CATEGORY: Record github: 'scm', imap: 'email', gmail: 'email', + twilio: 'sms', }; export interface CredentialRoleDef { @@ -44,4 +45,9 @@ export const PROVIDER_CREDENTIAL_ROLES: Record return rows.map((r) => r.projectId); } +export async function getAllProjectIdsWithSmsIntegration(): Promise { + const db = getDb(); + const rows = await db + .select({ projectId: projectIntegrations.projectId }) + .from(projectIntegrations) + .where(eq(projectIntegrations.category, 'sms')); + return rows.map((r) => r.projectId); +} + // ============================================================================ // Integration Credentials // ============================================================================ diff --git a/src/gadgets/index.ts b/src/gadgets/index.ts index e24d3d4f..cd8edc36 100644 --- a/src/gadgets/index.ts +++ b/src/gadgets/index.ts @@ -15,3 +15,6 @@ export { GetPRDetails, GetPRComments, ReplyToReviewComment } from './github/inde // Email gadgets export { SendEmail, SearchEmails, ReadEmail, ReplyToEmail } from './email/index.js'; + +// SMS gadgets +export { SendSms } from './sms/index.js'; diff --git a/src/gadgets/sms/SendSms.ts b/src/gadgets/sms/SendSms.ts new file mode 100644 index 00000000..beb7e04c --- /dev/null +++ b/src/gadgets/sms/SendSms.ts @@ -0,0 +1,29 @@ +import { Gadget, z } from 'llmist'; +import { sendSms } from './core/sendSms.js'; + +export class SendSms extends Gadget({ + name: 'SendSms', + description: + 'Send an SMS message via Twilio. Use this to communicate with users via text message.', + timeoutMs: 30000, + schema: z.object({ + to: z.string().describe('Recipient phone number in E.164 format (e.g. +15551234567)'), + body: z.string().min(1).max(1600).describe('SMS message body (max 1600 characters)'), + }), + examples: [ + { + params: { + to: '+15551234567', + body: 'Your request has been processed.', + }, + comment: 'Send a simple status SMS', + }, + ], +}) { + override async execute(params: this['params']): Promise { + return sendSms({ + to: params.to, + body: params.body, + }); + } +} diff --git a/src/gadgets/sms/core/sendSms.ts b/src/gadgets/sms/core/sendSms.ts new file mode 100644 index 00000000..117f6a7a --- /dev/null +++ b/src/gadgets/sms/core/sendSms.ts @@ -0,0 +1,17 @@ +import { getSmsProvider } from '../../../sms/context.js'; +import type { SendSmsOptions } from '../../../sms/types.js'; +import { logger } from '../../../utils/logging.js'; + +export async function sendSms(options: SendSmsOptions): Promise { + try { + const result = await getSmsProvider().sendSms(options); + return `SMS sent successfully to ${options.to} (SID: ${result.sid}, status: ${result.status})`; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.error('SMS send failed', { + to: options.to, + error: message, + }); + return `Error sending SMS: ${message}`; + } +} diff --git a/src/gadgets/sms/index.ts b/src/gadgets/sms/index.ts new file mode 100644 index 00000000..0ac274bd --- /dev/null +++ b/src/gadgets/sms/index.ts @@ -0,0 +1 @@ +export { SendSms } from './SendSms.js'; diff --git a/src/pm/webhook-handler.ts b/src/pm/webhook-handler.ts index 1a4ad0fc..bf0af358 100644 --- a/src/pm/webhook-handler.ts +++ b/src/pm/webhook-handler.ts @@ -10,6 +10,7 @@ import { withEmailIntegration } from '../email/index.js'; import { withGitHubToken } from '../github/client.js'; import { getPersonaToken } from '../github/personas.js'; +import { withSmsIntegration } from '../sms/index.js'; import type { TriggerRegistry } from '../triggers/registry.js'; import { runAgentExecutionPipeline } from '../triggers/shared/agent-execution.js'; import { processNextQueuedWebhook } from '../triggers/shared/webhook-queue.js'; @@ -54,10 +55,12 @@ async function executeAgent( try { await integration.withCredentials(project.id, () => withEmailIntegration(project.id, () => - withGitHubToken(githubToken, () => - runAgentExecutionPipeline(result, project, config, { - logLabel: `${integration.type} agent`, - }), + withSmsIntegration(project.id, () => + withGitHubToken(githubToken, () => + runAgentExecutionPipeline(result, project, config, { + logLabel: `${integration.type} agent`, + }), + ), ), ), ); diff --git a/src/router/adapters/twilio.ts b/src/router/adapters/twilio.ts new file mode 100644 index 00000000..92e5a9d7 --- /dev/null +++ b/src/router/adapters/twilio.ts @@ -0,0 +1,64 @@ +/** + * Twilio webhook handler — receives inbound SMS messages. + * + * URL: POST /twilio/webhook/:projectId + * + * Validates the Twilio signature, logs the incoming message, + * and returns empty TwiML. No agent is triggered yet — that will + * be wired up when a dedicated sms-responder agent is written. + */ + +import type { Context } from 'hono'; +import twilio from 'twilio'; +import { getIntegrationCredentialOrNull } from '../../config/provider.js'; +import { logger } from '../../utils/logging.js'; + +function getPublicUrl(c: Context): string { + const parsed = new URL(c.req.url); + const proto = c.req.header('X-Forwarded-Proto') ?? parsed.protocol.replace(':', ''); + const host = c.req.header('X-Forwarded-Host') ?? c.req.header('Host') ?? parsed.host; + const path = parsed.pathname + parsed.search; + return `${proto}://${host}${path}`; +} + +export async function handleTwilioWebhook(c: Context): Promise { + const projectId = c.req.param('projectId'); + + // Parse URL-encoded POST body from Twilio + const body = await c.req.parseBody(); + + const signature = c.req.header('X-Twilio-Signature') ?? ''; + const url = getPublicUrl(c); + + // Resolve auth_token for signature validation + const authToken = await getIntegrationCredentialOrNull(projectId, 'sms', 'auth_token'); + if (!authToken) { + logger.warn('[TwilioWebhook] No auth_token configured for project', { projectId }); + return c.text('Forbidden', 403); + } + + // Filter out File entries — validateRequest only accepts string values + const stringBody = Object.fromEntries( + Object.entries(body).filter((kv): kv is [string, string] => typeof kv[1] === 'string'), + ); + + // Validate Twilio signature + const isValid = twilio.validateRequest(authToken, signature, url, stringBody); + if (!isValid) { + logger.warn('[TwilioWebhook] Invalid signature', { projectId }); + return c.text('Forbidden', 403); + } + + logger.info('[TwilioWebhook] Incoming SMS', { + projectId, + messageSid: stringBody.MessageSid, + from: stringBody.From, + to: stringBody.To, + body: stringBody.Body, + }); + + // Future: check integration triggers.agentType and submit a dashboard job + + c.header('Content-Type', 'text/xml'); + return c.text('', 200); +} diff --git a/src/router/index.ts b/src/router/index.ts index 4884069f..1fd7650a 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -13,6 +13,7 @@ import { logger } from '../utils/logging.js'; import { GitHubRouterAdapter, injectEventType } from './adapters/github.js'; import { JiraRouterAdapter } from './adapters/jira.js'; import { TrelloRouterAdapter } from './adapters/trello.js'; +import { handleTwilioWebhook } from './adapters/twilio.js'; import { startEmailScheduler, stopEmailScheduler } from './email-scheduler.js'; import { getQueueStats } from './queue.js'; import { processRouterWebhook } from './webhook-processor.js'; @@ -121,6 +122,9 @@ app.post( }), ); +// Twilio SMS webhook handler +app.post('/twilio/webhook/:projectId', handleTwilioWebhook); + // Graceful shutdown async function shutdown(signal: string): Promise { logger.info('Received shutdown signal', { signal }); diff --git a/src/sms/context.ts b/src/sms/context.ts new file mode 100644 index 00000000..925d7f72 --- /dev/null +++ b/src/sms/context.ts @@ -0,0 +1,29 @@ +/** + * AsyncLocalStorage-based scoping for the active SmsProvider. + * + * Webhook handlers / integration wrappers call withSmsProvider(provider, fn) + * to make the provider available to all downstream gadget code via getSmsProvider(). + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { SmsProvider } from './provider.js'; + +const smsProviderStore = new AsyncLocalStorage(); + +export function withSmsProvider(provider: SmsProvider, fn: () => Promise): Promise { + return smsProviderStore.run(provider, fn); +} + +export function getSmsProvider(): SmsProvider { + const provider = smsProviderStore.getStore(); + if (!provider) { + throw new Error( + 'No SmsProvider in scope. Wrap the call with withSmsProvider() or ensure per-project SMS credentials are set in the database.', + ); + } + return provider; +} + +export function getSmsProviderOrNull(): SmsProvider | null { + return smsProviderStore.getStore() ?? null; +} diff --git a/src/sms/index.ts b/src/sms/index.ts new file mode 100644 index 00000000..45e90a42 --- /dev/null +++ b/src/sms/index.ts @@ -0,0 +1,23 @@ +/** + * SMS module barrel. + * + * Registers all provider adapters at import time (mirrors email/index.ts). + * Consumers import from here to get both the public API and side-effect registration. + */ + +import './twilio/index.js'; + +// Provider scoping +export { getSmsProvider, getSmsProviderOrNull, withSmsProvider } from './context.js'; + +// Integration credential resolution +export { hasSmsIntegration, withSmsIntegration } from './integration.js'; + +// Registry (for advanced use cases) +export { smsRegistry } from './registry.js'; + +// Interfaces +export type { SmsIntegration, SmsProvider } from './provider.js'; + +// Types +export type { SendSmsOptions, SendSmsResult, SmsCredentials } from './types.js'; diff --git a/src/sms/integration.ts b/src/sms/integration.ts new file mode 100644 index 00000000..7a73c0fa --- /dev/null +++ b/src/sms/integration.ts @@ -0,0 +1,49 @@ +/** + * SMS integration — credential resolution and scoping via the registry. + * + * Delegates to the registered SmsIntegration for the project's configured + * SMS provider. Adding a new provider requires only a new class + one registry + * line in index.ts — no changes here. + */ + +import { getIntegrationProvider } from '../db/repositories/credentialsRepository.js'; +import { logger } from '../utils/logging.js'; +import { smsRegistry } from './registry.js'; + +/** + * Run a function with an SmsProvider in scope for the given project. + * + * If no SMS integration is configured (or the provider is unknown), runs + * fn() without establishing a provider scope — gadgets will fail with a clear + * error if they try to call getSmsProvider(). + */ +export async function withSmsIntegration(projectId: string, fn: () => Promise): Promise { + try { + const providerType = await getIntegrationProvider(projectId, 'sms'); + if (!providerType) return fn(); + const integration = smsRegistry.getOrNull(providerType); + if (!integration) return fn(); + return integration.withCredentials(projectId, fn); + } catch (error) { + logger.warn('Failed to resolve SMS integration, running without SMS credentials', { + projectId, + error: error instanceof Error ? error.message : String(error), + }); + return fn(); + } +} + +/** + * Check if SMS integration is configured and credentials are present. + */ +export async function hasSmsIntegration(projectId: string): Promise { + try { + const providerType = await getIntegrationProvider(projectId, 'sms'); + if (!providerType) return false; + const integration = smsRegistry.getOrNull(providerType); + if (!integration) return false; + return integration.hasCredentials(projectId); + } catch { + return false; + } +} diff --git a/src/sms/provider.ts b/src/sms/provider.ts new file mode 100644 index 00000000..419afb8d --- /dev/null +++ b/src/sms/provider.ts @@ -0,0 +1,24 @@ +/** + * SmsProvider and SmsIntegration interfaces. + * + * SmsProvider — the runtime object that performs SMS operations + * for a specific auth method / vendor. + * + * SmsIntegration — knows how to resolve credentials for a project + * and scope an SmsProvider via AsyncLocalStorage. + */ + +import type { SendSmsOptions, SendSmsResult } from './types.js'; + +export interface SmsProvider { + readonly type: string; // 'twilio' + sendSms(options: SendSmsOptions): Promise; +} + +export interface SmsIntegration { + readonly type: string; // matches project_integrations.provider + /** Resolve credentials from DB and run fn inside provider scope */ + withCredentials(projectId: string, fn: () => Promise): Promise; + /** True if all required credentials are present */ + hasCredentials(projectId: string): Promise; +} diff --git a/src/sms/registry.ts b/src/sms/registry.ts new file mode 100644 index 00000000..c5409948 --- /dev/null +++ b/src/sms/registry.ts @@ -0,0 +1,34 @@ +/** + * SmsIntegrationRegistry — singleton that holds all registered SMS integrations. + * + * Populated at import time by each integration module. Gadgets and webhook + * handlers use `smsRegistry.getOrNull(type)` to obtain the integration instance + * without provider-specific branching. + */ + +import type { SmsIntegration } from './provider.js'; + +class SmsIntegrationRegistry { + private integrations = new Map(); + + register(integration: SmsIntegration): void { + this.integrations.set(integration.type, integration); + } + + get(type: string): SmsIntegration { + const integration = this.integrations.get(type); + if (!integration) { + throw new Error( + `Unknown SMS integration type: '${type}'. Registered: ${[...this.integrations.keys()].join(', ')}`, + ); + } + return integration; + } + + getOrNull(type: string): SmsIntegration | null { + return this.integrations.get(type) ?? null; + } +} + +/** Singleton registry, populated at import time */ +export const smsRegistry = new SmsIntegrationRegistry(); diff --git a/src/sms/twilio/adapter.ts b/src/sms/twilio/adapter.ts new file mode 100644 index 00000000..74111c47 --- /dev/null +++ b/src/sms/twilio/adapter.ts @@ -0,0 +1,27 @@ +/** + * TwilioSmsProvider — sends SMS messages via the Twilio REST API. + */ + +import twilio from 'twilio'; +import type { SmsProvider } from '../provider.js'; +import type { SendSmsOptions, SendSmsResult, SmsCredentials } from '../types.js'; + +export class TwilioSmsProvider implements SmsProvider { + readonly type = 'twilio'; + private client: ReturnType; + private fromNumber: string; + + constructor(creds: SmsCredentials) { + this.client = twilio(creds.accountSid, creds.authToken); + this.fromNumber = creds.phoneNumber; + } + + async sendSms(options: SendSmsOptions): Promise { + const msg = await this.client.messages.create({ + to: options.to, + from: this.fromNumber, + body: options.body, + }); + return { sid: msg.sid, status: msg.status }; + } +} diff --git a/src/sms/twilio/index.ts b/src/sms/twilio/index.ts new file mode 100644 index 00000000..b2511b56 --- /dev/null +++ b/src/sms/twilio/index.ts @@ -0,0 +1,8 @@ +/** + * Registers TwilioIntegration into the SmsIntegrationRegistry at import time. + */ + +import { smsRegistry } from '../registry.js'; +import { TwilioIntegration } from './integration.js'; + +smsRegistry.register(new TwilioIntegration()); diff --git a/src/sms/twilio/integration.ts b/src/sms/twilio/integration.ts new file mode 100644 index 00000000..3f966b59 --- /dev/null +++ b/src/sms/twilio/integration.ts @@ -0,0 +1,40 @@ +/** + * TwilioIntegration — resolves Twilio credentials from the DB + * and scopes a TwilioSmsProvider for the duration of the callback. + */ + +import { getIntegrationCredentialOrNull } from '../../config/provider.js'; +import { withSmsProvider } from '../context.js'; +import type { SmsIntegration } from '../provider.js'; +import { TwilioSmsProvider } from './adapter.js'; + +export class TwilioIntegration implements SmsIntegration { + readonly type = 'twilio'; + + async withCredentials(projectId: string, fn: () => Promise): Promise { + const creds = await this.resolveCredentials(projectId); + if (!creds) { + return fn(); + } + return withSmsProvider(new TwilioSmsProvider(creds), fn); + } + + async hasCredentials(projectId: string): Promise { + const creds = await this.resolveCredentials(projectId); + return creds !== null; + } + + private async resolveCredentials(projectId: string) { + const [accountSid, authToken, phoneNumber] = await Promise.all([ + getIntegrationCredentialOrNull(projectId, 'sms', 'account_sid'), + getIntegrationCredentialOrNull(projectId, 'sms', 'auth_token'), + getIntegrationCredentialOrNull(projectId, 'sms', 'phone_number'), + ]); + + if (!accountSid || !authToken || !phoneNumber) { + return null; + } + + return { accountSid, authToken, phoneNumber }; + } +} diff --git a/src/sms/types.ts b/src/sms/types.ts new file mode 100644 index 00000000..e30e7080 --- /dev/null +++ b/src/sms/types.ts @@ -0,0 +1,19 @@ +/** + * SMS domain types for the Twilio SMS integration. + */ + +export interface SmsCredentials { + accountSid: string; + authToken: string; + phoneNumber: string; +} + +export interface SendSmsOptions { + to: string; + body: string; +} + +export interface SendSmsResult { + sid: string; + status: string; +} diff --git a/src/triggers/github/webhook-handler.ts b/src/triggers/github/webhook-handler.ts index 57b4bee3..534eca3a 100644 --- a/src/triggers/github/webhook-handler.ts +++ b/src/triggers/github/webhook-handler.ts @@ -7,6 +7,7 @@ import { getPersonaToken, resolvePersonaIdentities } from '../../github/personas import { withPMCredentials } from '../../pm/context.js'; import { createPMProvider, pmRegistry, withPMProvider } from '../../pm/index.js'; import { extractGitHubContext, generateAckMessage } from '../../router/ackMessageGenerator.js'; +import { withSmsIntegration } from '../../sms/index.js'; import type { AgentResult, CascadeConfig, @@ -153,8 +154,10 @@ async function executeGitHubAgent( () => withPMProvider(pmProvider, () => withEmailIntegration(project.id, () => - withGitHubToken(githubToken, () => - runAgentExecutionPipeline(result, project, config, executionConfig), + withSmsIntegration(project.id, () => + withGitHubToken(githubToken, () => + runAgentExecutionPipeline(result, project, config, executionConfig), + ), ), ), ), diff --git a/src/triggers/shared/integration-validation.ts b/src/triggers/shared/integration-validation.ts index dcca43a3..b1ae35e9 100644 --- a/src/triggers/shared/integration-validation.ts +++ b/src/triggers/shared/integration-validation.ts @@ -12,6 +12,7 @@ import { hasEmailIntegration } from '../../email/index.js'; import { hasScmIntegration, hasScmPersonaToken } from '../../github/integration.js'; import { getPersonaForAgentType } from '../../github/personas.js'; import { hasPmIntegration } from '../../pm/integration.js'; +import { hasSmsIntegration } from '../../sms/index.js'; import { logger } from '../../utils/logging.js'; export interface ValidationError { @@ -90,6 +91,20 @@ async function validateEmailIntegration( return null; } +async function validateSmsIntegration( + projectId: string, + agentType: string, +): Promise { + const hasSms = await hasSmsIntegration(projectId); + if (!hasSms) { + return { + category: 'sms', + message: `Agent '${agentType}' requires SMS integration (Twilio), but none is configured.`, + }; + } + return null; +} + // ============================================================================ // Main validation function // ============================================================================ @@ -112,6 +127,8 @@ export async function validateIntegrations( return validateScmIntegration(projectId, agentType); case 'email': return validateEmailIntegration(projectId, agentType); + case 'sms': + return validateSmsIntegration(projectId, agentType); default: return null; } diff --git a/src/triggers/shared/manual-runner.ts b/src/triggers/shared/manual-runner.ts index a66fc3b2..8a54abb9 100644 --- a/src/triggers/shared/manual-runner.ts +++ b/src/triggers/shared/manual-runner.ts @@ -6,6 +6,7 @@ import { getEmailProviderOrNull, withEmailIntegration } from '../../email/index. import type { EmailSearchCriteria } from '../../email/index.js'; import { withPMCredentials } from '../../pm/context.js'; import { createPMProvider, pmRegistry, withPMProvider } from '../../pm/index.js'; +import { withSmsIntegration } from '../../sms/index.js'; import type { AgentInput, CascadeConfig, ProjectConfig } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import { formatValidationErrors, validateIntegrations } from './integration-validation.js'; @@ -165,13 +166,15 @@ export async function triggerManualRun( (t) => pmRegistry.getOrNull(t), () => withPMProvider(pmProvider, () => - withEmailIntegration(project.id, async () => { - if (input.agentType === 'email-joke') { - const shouldRun = await prefetchEmailsForJokeAgent(agentInput, input.projectId); - if (!shouldRun) return undefined; - } - return runAgent(input.agentType, agentInput); - }), + withEmailIntegration(project.id, () => + withSmsIntegration(project.id, async () => { + if (input.agentType === 'email-joke') { + const shouldRun = await prefetchEmailsForJokeAgent(agentInput, input.projectId); + if (!shouldRun) return undefined; + } + return runAgent(input.agentType, agentInput); + }), + ), ), ); if (result !== undefined) { diff --git a/tests/unit/cli/dashboard/projects/integration-credentials.test.ts b/tests/unit/cli/dashboard/projects/integration-credentials.test.ts index 2fc798e9..1279fa88 100644 --- a/tests/unit/cli/dashboard/projects/integration-credentials.test.ts +++ b/tests/unit/cli/dashboard/projects/integration-credentials.test.ts @@ -51,14 +51,14 @@ describe('ProjectsIntegrationCredentials (overrides)', () => { mockLoadConfig.mockReturnValue(baseConfig); }); - it('queries pm, scm, and email categories by default', async () => { + it('queries pm, scm, email, and sms categories by default', async () => { const client = makeClient(); mockCreateDashboardClient.mockReturnValue(client); const cmd = new ProjectsIntegrationCredentials(['my-project'], oclifConfig as never); await cmd.run(); - expect(client.projects.integrationCredentials.list.query).toHaveBeenCalledTimes(3); + expect(client.projects.integrationCredentials.list.query).toHaveBeenCalledTimes(4); expect(client.projects.integrationCredentials.list.query).toHaveBeenCalledWith({ projectId: 'my-project', category: 'pm', @@ -71,6 +71,10 @@ describe('ProjectsIntegrationCredentials (overrides)', () => { projectId: 'my-project', category: 'email', }); + expect(client.projects.integrationCredentials.list.query).toHaveBeenCalledWith({ + projectId: 'my-project', + category: 'sms', + }); }); it('queries only email when --category email is passed', async () => { @@ -140,7 +144,8 @@ describe('ProjectsIntegrationCredentials (overrides)', () => { (client.projects.integrationCredentials.list.query as ReturnType) .mockResolvedValueOnce([]) // pm .mockResolvedValueOnce([]) // scm - .mockResolvedValueOnce(creds); // email + .mockResolvedValueOnce(creds) // email + .mockResolvedValueOnce([]); // sms mockCreateDashboardClient.mockReturnValue(client); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); diff --git a/tests/unit/db/repositories/settingsRepository.test.ts b/tests/unit/db/repositories/settingsRepository.test.ts index a4158b91..b1ccd92a 100644 --- a/tests/unit/db/repositories/settingsRepository.test.ts +++ b/tests/unit/db/repositories/settingsRepository.test.ts @@ -13,6 +13,7 @@ import { deleteProject, deleteProjectIntegration, getAllProjectIdsWithEmailIntegration, + getAllProjectIdsWithSmsIntegration, getCascadeDefaults, getOrganization, getProjectFull, @@ -293,6 +294,25 @@ describe('settingsRepository', () => { }); }); + describe('getAllProjectIdsWithSmsIntegration', () => { + it('returns projectIds for all SMS integrations', async () => { + mockDb.chain.where.mockResolvedValueOnce([{ projectId: 'proj-3' }, { projectId: 'proj-4' }]); + + const result = await getAllProjectIdsWithSmsIntegration(); + + expect(result).toEqual(['proj-3', 'proj-4']); + expect(mockDb.db.select).toHaveBeenCalledTimes(1); + }); + + it('returns empty array when no SMS integrations exist', async () => { + mockDb.chain.where.mockResolvedValueOnce([]); + + const result = await getAllProjectIdsWithSmsIntegration(); + + expect(result).toEqual([]); + }); + }); + // ============================================================================ // Agent Configs // ============================================================================ diff --git a/tests/unit/gadgets/sms/core/sendSms.test.ts b/tests/unit/gadgets/sms/core/sendSms.test.ts new file mode 100644 index 00000000..14310ff4 --- /dev/null +++ b/tests/unit/gadgets/sms/core/sendSms.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Hoist mocks before imports +const { mockGetSmsProvider } = vi.hoisted(() => ({ + mockGetSmsProvider: vi.fn(), +})); + +vi.mock('../../../../../src/sms/context.js', () => ({ + getSmsProvider: mockGetSmsProvider, +})); + +import { sendSms } from '../../../../../src/gadgets/sms/core/sendSms.js'; + +describe('sendSms core function', () => { + const mockSendSms = vi.fn(); + + beforeEach(() => { + vi.resetAllMocks(); + mockGetSmsProvider.mockReturnValue({ sendSms: mockSendSms }); + }); + + it('returns success message on successful send', async () => { + mockSendSms.mockResolvedValue({ sid: 'SM123abc', status: 'queued' }); + + const result = await sendSms({ to: '+15551234567', body: 'Hello' }); + + expect(result).toContain('+15551234567'); + expect(result).toContain('SM123abc'); + expect(result).toContain('queued'); + expect(result).toContain('successfully'); + }); + + it('returns error message on failure', async () => { + mockSendSms.mockRejectedValue(new Error('Twilio error: invalid number')); + + const result = await sendSms({ to: 'bad', body: 'test' }); + + expect(result).toContain('Error sending SMS'); + expect(result).toContain('Twilio error: invalid number'); + }); + + it('calls getSmsProvider().sendSms with correct options', async () => { + mockSendSms.mockResolvedValue({ sid: 'SM999', status: 'sent' }); + + await sendSms({ to: '+15550000001', body: 'Test message' }); + + expect(mockSendSms).toHaveBeenCalledWith({ + to: '+15550000001', + body: 'Test message', + }); + }); +}); diff --git a/tests/unit/router/adapters/twilio.test.ts b/tests/unit/router/adapters/twilio.test.ts new file mode 100644 index 00000000..8f456214 --- /dev/null +++ b/tests/unit/router/adapters/twilio.test.ts @@ -0,0 +1,119 @@ +import { Hono } from 'hono'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Hoist mocks before imports +const { mockGetIntegrationCredentialOrNull, mockValidateRequest } = vi.hoisted(() => ({ + mockGetIntegrationCredentialOrNull: vi.fn(), + mockValidateRequest: vi.fn(), +})); + +vi.mock('../../../../src/config/provider.js', () => ({ + getIntegrationCredentialOrNull: mockGetIntegrationCredentialOrNull, +})); + +vi.mock('twilio', () => ({ + default: Object.assign(vi.fn(), { + validateRequest: mockValidateRequest, + }), +})); + +import { handleTwilioWebhook } from '../../../../src/router/adapters/twilio.js'; + +function buildApp() { + const app = new Hono(); + app.post('/twilio/webhook/:projectId', handleTwilioWebhook); + return app; +} + +function makeFormBody(fields: Record): string { + return new URLSearchParams(fields).toString(); +} + +const VALID_BODY = { + MessageSid: 'SM123', + From: '+15551234567', + To: '+15550000001', + Body: 'Hello!', +}; + +describe('handleTwilioWebhook', () => { + let app: ReturnType; + + beforeEach(() => { + vi.resetAllMocks(); + app = buildApp(); + }); + + it('returns 403 when no auth_token is configured', async () => { + mockGetIntegrationCredentialOrNull.mockResolvedValue(null); + + const res = await app.request('/twilio/webhook/project-1', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Twilio-Signature': 'sig', + }, + body: makeFormBody(VALID_BODY), + }); + + expect(res.status).toBe(403); + expect(await res.text()).toBe('Forbidden'); + }); + + it('returns 403 when signature validation fails', async () => { + mockGetIntegrationCredentialOrNull.mockResolvedValue('auth-token-secret'); + mockValidateRequest.mockReturnValue(false); + + const res = await app.request('/twilio/webhook/project-1', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Twilio-Signature': 'bad-sig', + }, + body: makeFormBody(VALID_BODY), + }); + + expect(res.status).toBe(403); + expect(await res.text()).toBe('Forbidden'); + }); + + it('returns 200 with empty TwiML when signature is valid', async () => { + mockGetIntegrationCredentialOrNull.mockResolvedValue('auth-token-secret'); + mockValidateRequest.mockReturnValue(true); + + const res = await app.request('/twilio/webhook/project-1', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Twilio-Signature': 'valid-sig', + }, + body: makeFormBody(VALID_BODY), + }); + + expect(res.status).toBe(200); + const text = await res.text(); + expect(text).toContain(''); + expect(text).toContain(' { + mockGetIntegrationCredentialOrNull.mockResolvedValue('my-auth-token'); + mockValidateRequest.mockReturnValue(true); + + await app.request('http://localhost/twilio/webhook/project-2', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Twilio-Signature': 'expected-sig', + }, + body: makeFormBody(VALID_BODY), + }); + + expect(mockValidateRequest).toHaveBeenCalledWith( + 'my-auth-token', + 'expected-sig', + expect.stringContaining('/twilio/webhook/project-2'), + expect.objectContaining({ MessageSid: 'SM123' }), + ); + }); +}); diff --git a/tests/unit/sms/context.test.ts b/tests/unit/sms/context.test.ts new file mode 100644 index 00000000..cb7add05 --- /dev/null +++ b/tests/unit/sms/context.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getSmsProvider, getSmsProviderOrNull, withSmsProvider } from '../../../src/sms/context.js'; +import type { SmsProvider } from '../../../src/sms/provider.js'; + +function makeMockProvider(type = 'twilio'): SmsProvider { + return { + type, + sendSms: vi.fn().mockResolvedValue({ sid: 'SM1', status: 'queued' }), + }; +} + +describe('SMS AsyncLocalStorage context', () => { + describe('getSmsProvider', () => { + it('throws when called outside a withSmsProvider scope', () => { + expect(() => getSmsProvider()).toThrow('No SmsProvider in scope'); + }); + }); + + describe('getSmsProviderOrNull', () => { + it('returns null when called outside a withSmsProvider scope', () => { + expect(getSmsProviderOrNull()).toBeNull(); + }); + }); + + describe('withSmsProvider', () => { + it('makes the provider available inside the callback', async () => { + const provider = makeMockProvider(); + const result = await withSmsProvider(provider, async () => getSmsProvider()); + expect(result).toBe(provider); + }); + + it('getSmsProviderOrNull returns provider inside scope', async () => { + const provider = makeMockProvider(); + const result = await withSmsProvider(provider, async () => getSmsProviderOrNull()); + expect(result).toBe(provider); + }); + + it('does not leak the provider outside the callback', async () => { + const provider = makeMockProvider(); + await withSmsProvider(provider, async () => { + // inside — provider is available + expect(getSmsProvider()).toBe(provider); + }); + // outside — provider is gone + expect(getSmsProviderOrNull()).toBeNull(); + }); + + it('scopes are independent (nested withSmsProvider uses inner provider)', async () => { + const outer = makeMockProvider('outer-twilio'); + const inner = makeMockProvider('inner-twilio'); + + await withSmsProvider(outer, async () => { + expect(getSmsProvider().type).toBe('outer-twilio'); + await withSmsProvider(inner, async () => { + expect(getSmsProvider().type).toBe('inner-twilio'); + }); + // Back to outer scope + expect(getSmsProvider().type).toBe('outer-twilio'); + }); + }); + }); +}); diff --git a/tests/unit/sms/twilio/adapter.test.ts b/tests/unit/sms/twilio/adapter.test.ts new file mode 100644 index 00000000..c33c2d9d --- /dev/null +++ b/tests/unit/sms/twilio/adapter.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Hoist mocks before imports +const { mockCreate, mockTwilio } = vi.hoisted(() => { + const mockCreate = vi.fn(); + const mockTwilio = vi.fn(() => ({ + messages: { create: mockCreate }, + })); + return { mockCreate, mockTwilio }; +}); + +vi.mock('twilio', () => ({ + default: mockTwilio, +})); + +import { TwilioSmsProvider } from '../../../../src/sms/twilio/adapter.js'; + +describe('TwilioSmsProvider', () => { + const creds = { + accountSid: 'ACtest123', + authToken: 'token456', + phoneNumber: '+15550000001', + }; + + let provider: TwilioSmsProvider; + + beforeEach(() => { + vi.resetAllMocks(); + // Re-configure the factory mock after reset + mockTwilio.mockReturnValue({ messages: { create: mockCreate } }); + provider = new TwilioSmsProvider(creds); + }); + + it('has type "twilio"', () => { + expect(provider.type).toBe('twilio'); + }); + + it('creates a Twilio client with the provided credentials', () => { + expect(mockTwilio).toHaveBeenCalledWith('ACtest123', 'token456'); + }); + + describe('sendSms', () => { + it('calls messages.create with correct params and returns sid/status', async () => { + mockCreate.mockResolvedValue({ sid: 'SM123', status: 'queued' }); + + const result = await provider.sendSms({ to: '+15551234567', body: 'Hello world' }); + + expect(mockCreate).toHaveBeenCalledWith({ + to: '+15551234567', + from: '+15550000001', + body: 'Hello world', + }); + expect(result).toEqual({ sid: 'SM123', status: 'queued' }); + }); + + it('propagates errors from the Twilio client', async () => { + mockCreate.mockRejectedValue(new Error('Invalid phone number')); + + await expect(provider.sendSms({ to: 'bad-number', body: 'Hi' })).rejects.toThrow( + 'Invalid phone number', + ); + }); + }); +}); diff --git a/tests/unit/sms/twilio/integration.test.ts b/tests/unit/sms/twilio/integration.test.ts new file mode 100644 index 00000000..3f37c13d --- /dev/null +++ b/tests/unit/sms/twilio/integration.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Hoist mocks before imports +const { mockGetIntegrationCredentialOrNull } = vi.hoisted(() => ({ + mockGetIntegrationCredentialOrNull: vi.fn(), +})); + +vi.mock('../../../../src/config/provider.js', () => ({ + getIntegrationCredentialOrNull: mockGetIntegrationCredentialOrNull, +})); + +vi.mock('twilio', () => ({ + default: vi.fn(() => ({ + messages: { create: vi.fn() }, + })), +})); + +import { TwilioIntegration } from '../../../../src/sms/twilio/integration.js'; + +describe('TwilioIntegration', () => { + let integration: TwilioIntegration; + + beforeEach(() => { + vi.resetAllMocks(); + integration = new TwilioIntegration(); + }); + + it('has type "twilio"', () => { + expect(integration.type).toBe('twilio'); + }); + + describe('hasCredentials', () => { + it('returns true when all three credentials are present', async () => { + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce('ACtest123'); + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce('token456'); + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce('+15550000001'); + + const result = await integration.hasCredentials('project-1'); + expect(result).toBe(true); + }); + + it('returns false when account_sid is missing', async () => { + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce(null); + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce('token456'); + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce('+15550000001'); + + const result = await integration.hasCredentials('project-1'); + expect(result).toBe(false); + }); + + it('returns false when auth_token is missing', async () => { + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce('ACtest123'); + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce(null); + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce('+15550000001'); + + const result = await integration.hasCredentials('project-1'); + expect(result).toBe(false); + }); + + it('returns false when phone_number is missing', async () => { + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce('ACtest123'); + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce('token456'); + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce(null); + + const result = await integration.hasCredentials('project-1'); + expect(result).toBe(false); + }); + }); + + describe('withCredentials', () => { + it('calls fn() directly when credentials are missing', async () => { + mockGetIntegrationCredentialOrNull.mockResolvedValue(null); + + const fn = vi.fn().mockResolvedValue('result'); + const result = await integration.withCredentials('project-1', fn); + + expect(fn).toHaveBeenCalledOnce(); + expect(result).toBe('result'); + }); + + it('scopes provider and calls fn() when credentials are present', async () => { + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce('ACtest123'); + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce('token456'); + mockGetIntegrationCredentialOrNull.mockResolvedValueOnce('+15550000001'); + + const fn = vi.fn().mockResolvedValue('ok'); + const result = await integration.withCredentials('project-1', fn); + + expect(fn).toHaveBeenCalledOnce(); + expect(result).toBe('ok'); + }); + }); +}); diff --git a/web/src/components/projects/integration-form.tsx b/web/src/components/projects/integration-form.tsx index 9ece60c9..aa309bc9 100644 --- a/web/src/components/projects/integration-form.tsx +++ b/web/src/components/projects/integration-form.tsx @@ -5,8 +5,9 @@ import { CheckCircle, Loader2, XCircle } from 'lucide-react'; import { useEffect, useState } from 'react'; import { EmailWizard } from './email-wizard.js'; import { PMWizard } from './pm-wizard.js'; +import { TwilioWizard } from './twilio-wizard.js'; -type IntegrationCategory = 'pm' | 'scm' | 'email'; +type IntegrationCategory = 'pm' | 'scm' | 'email' | 'sms'; interface CredentialOption { id: number; @@ -358,6 +359,9 @@ export function IntegrationForm({ projectId }: { projectId: string }) { const emailCredsQuery = useQuery( trpc.projects.integrationCredentials.list.queryOptions({ projectId, category: 'email' }), ); + const smsCredsQuery = useQuery( + trpc.projects.integrationCredentials.list.queryOptions({ projectId, category: 'sms' }), + ); const [activeTab, setActiveTab] = useState('pm'); @@ -383,6 +387,9 @@ export function IntegrationForm({ projectId }: { projectId: string }) { const emailCredMap = buildCredentialMap( emailCredsQuery.data as Array<{ role: string; credentialId: number }>, ); + const smsCredMap = buildCredentialMap( + smsCredsQuery.data as Array<{ role: string; credentialId: number }>, + ); return (
@@ -405,6 +412,12 @@ export function IntegrationForm({ projectId }: { projectId: string }) { activeTab={activeTab} onClick={() => setActiveTab('email')} /> + setActiveTab('sms')} + />
{activeTab === 'pm' && ( @@ -431,6 +444,10 @@ export function IntegrationForm({ projectId }: { projectId: string }) { initialCredentials={emailCredMap} /> )} + + {activeTab === 'sms' && ( + + )} ); } diff --git a/web/src/components/projects/twilio-wizard.tsx b/web/src/components/projects/twilio-wizard.tsx new file mode 100644 index 00000000..dd318d10 --- /dev/null +++ b/web/src/components/projects/twilio-wizard.tsx @@ -0,0 +1,511 @@ +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + Check, + CheckCircle, + ChevronDown, + ChevronRight, + Copy, + Loader2, + MessageSquare, + Plus, + XCircle, +} from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +// ============================================================================ +// Types +// ============================================================================ + +interface CredentialOption { + id: number; + name: string; + envVarKey: string; + value: string; +} + +interface WizardState { + accountSidCredentialId: number | null; + authTokenCredentialId: number | null; + phoneNumberCredentialId: number | null; + verifiedName: string | null; + verifyError: string | null; +} + +// ============================================================================ +// Wizard Step Shell +// ============================================================================ + +function WizardStep({ + stepNumber, + title, + status, + isOpen, + onToggle, + children, +}: { + stepNumber: number; + title: string; + status: 'pending' | 'complete' | 'active'; + isOpen: boolean; + onToggle: () => void; + children: React.ReactNode; +}) { + const statusClasses = + status === 'complete' + ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' + : status === 'active' + ? 'bg-primary text-primary-foreground' + : 'bg-muted text-muted-foreground'; + + return ( +
+ + {isOpen &&
{children}
} +
+ ); +} + +// ============================================================================ +// Inline Credential Creator +// ============================================================================ + +function InlineCredentialCreator({ + onCreated, + suggestedKey, +}: { + onCreated: (id: number) => void; + suggestedKey?: string; +}) { + const [isOpen, setIsOpen] = useState(false); + const [name, setName] = useState(''); + const [envVarKey, setEnvVarKey] = useState(suggestedKey ?? ''); + const [value, setValue] = useState(''); + const queryClient = useQueryClient(); + + const createMutation = useMutation({ + mutationFn: () => + trpcClient.credentials.create.mutate({ name, envVarKey, value, isDefault: false }), + onSuccess: async (result) => { + await queryClient.invalidateQueries({ + queryKey: trpc.credentials.list.queryOptions().queryKey, + }); + onCreated((result as { id: number }).id); + setIsOpen(false); + setName(''); + setEnvVarKey(suggestedKey ?? ''); + setValue(''); + }, + }); + + if (!isOpen) { + return ( + + ); + } + + return ( +
+
+ setName(e.target.value)} + placeholder="Name" + className="flex-1" + /> + setEnvVarKey(e.target.value.toUpperCase())} + placeholder="ENV_VAR_KEY" + className="flex-1" + /> +
+ setValue(e.target.value)} + placeholder="Secret value" + type="password" + /> +
+ + +
+
+ ); +} + +// ============================================================================ +// Credential Select +// ============================================================================ + +function CredentialSelect({ + label, + value, + onChange, + credentials, + suggestedKey, +}: { + label: string; + value: number | null; + onChange: (id: number | null) => void; + credentials: CredentialOption[]; + suggestedKey: string; +}) { + return ( +
+ + + +
+ ); +} + +// ============================================================================ +// Webhook URL Panel +// ============================================================================ + +function WebhookUrlPanel({ projectId }: { projectId: string }) { + const url = `${window.location.origin}/twilio/webhook/${projectId}`; + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + void navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+

Webhook URL

+

+ Configure this URL in your Twilio console under{' '} + + Phone Numbers → Manage → Active Numbers → [your number] → Messaging → A Message Comes In + + . +

+
+ {url} + +
+
+ ); +} + +// ============================================================================ +// Main TwilioWizard Component +// ============================================================================ + +export function TwilioWizard({ + projectId, + initialCredentials, +}: { + projectId: string; + initialCredentials?: Map; +}) { + const queryClient = useQueryClient(); + const credentialsQuery = useQuery(trpc.credentials.list.queryOptions()); + const orgCredentials = (credentialsQuery.data ?? []) as CredentialOption[]; + + const [state, setState] = useState({ + accountSidCredentialId: null, + authTokenCredentialId: null, + phoneNumberCredentialId: null, + verifiedName: null, + verifyError: null, + }); + const [openSteps, setOpenSteps] = useState>(new Set([1])); + const initDoneRef = useRef(false); + + // Initialize from existing integration + useEffect(() => { + if (initDoneRef.current || !initialCredentials || initialCredentials.size === 0) return; + initDoneRef.current = true; + setState((prev) => ({ + ...prev, + accountSidCredentialId: initialCredentials.get('account_sid') ?? null, + authTokenCredentialId: initialCredentials.get('auth_token') ?? null, + phoneNumberCredentialId: initialCredentials.get('phone_number') ?? null, + verifiedName: '(existing integration)', + })); + setOpenSteps(new Set([1, 2, 3])); + }, [initialCredentials]); + + const toggleStep = (step: number) => { + setOpenSteps((prev) => { + const next = new Set(prev); + if (next.has(step)) next.delete(step); + else next.add(step); + return next; + }); + }; + + const step1Complete = + !!state.accountSidCredentialId && + !!state.authTokenCredentialId && + !!state.phoneNumberCredentialId; + const step2Complete = !!state.verifiedName; + + const getStatus = (stepNum: number, complete: boolean): 'pending' | 'complete' | 'active' => { + if (complete) return 'complete'; + if (openSteps.has(stepNum)) return 'active'; + return 'pending'; + }; + + // Verify Twilio credentials + const verifyMutation = useMutation({ + mutationFn: async () => { + if (!state.accountSidCredentialId || !state.authTokenCredentialId) { + throw new Error('Account SID and Auth Token are required'); + } + return trpcClient.integrationsDiscovery.verifyTwilio.mutate({ + accountSidCredentialId: state.accountSidCredentialId, + authTokenCredentialId: state.authTokenCredentialId, + }); + }, + onSuccess: (result) => { + setState((prev) => ({ + ...prev, + verifiedName: result.friendlyName, + verifyError: null, + })); + setOpenSteps((prev) => new Set([...prev, 3])); + }, + onError: (err) => { + setState((prev) => ({ + ...prev, + verifiedName: null, + verifyError: err instanceof Error ? err.message : String(err), + })); + }, + }); + + // Save integration + const saveMutation = useMutation({ + mutationFn: async () => { + await trpcClient.projects.integrations.upsert.mutate({ + projectId, + category: 'sms', + provider: 'twilio', + config: {}, + }); + + const credPairs = [ + { role: 'account_sid', credentialId: state.accountSidCredentialId }, + { role: 'auth_token', credentialId: state.authTokenCredentialId }, + { role: 'phone_number', credentialId: state.phoneNumberCredentialId }, + ].filter((p): p is { role: string; credentialId: number } => p.credentialId !== null); + + for (const { role, credentialId } of credPairs) { + await trpcClient.projects.integrationCredentials.set.mutate({ + projectId, + category: 'sms', + role, + credentialId, + }); + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrations.list.queryOptions({ projectId }).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: trpc.projects.integrationCredentials.list.queryOptions({ + projectId, + category: 'sms', + }).queryKey, + }); + }, + }); + + return ( +
+

+ Configure Twilio to send and receive SMS messages. You'll need a Twilio account with a + purchased phone number. +

+ + {/* Step 1: Credentials */} + toggleStep(1)} + > +
+ setState((prev) => ({ ...prev, accountSidCredentialId: id }))} + credentials={orgCredentials} + suggestedKey="TWILIO_ACCOUNT_SID" + /> + setState((prev) => ({ ...prev, authTokenCredentialId: id }))} + credentials={orgCredentials} + suggestedKey="TWILIO_AUTH_TOKEN" + /> + setState((prev) => ({ ...prev, phoneNumberCredentialId: id }))} + credentials={orgCredentials} + suggestedKey="TWILIO_PHONE_NUMBER" + /> + {step1Complete && ( + + )} +
+
+ + {/* Step 2: Verify */} + toggleStep(2)} + > +
+ {step2Complete ? ( +
+ + Verified: {state.verifiedName} +
+ ) : ( + <> +

+ Test the Twilio credentials by fetching your account details. +

+ + + )} + {state.verifyError && ( +
+ + {state.verifyError} +
+ )} +
+
+ + {/* Step 3: Save */} + toggleStep(3)} + > +
+
+
+ Provider + Twilio +
+ {state.verifiedName && ( +
+ Account + {state.verifiedName} +
+ )} +
+ {/* Webhook URL — shown after save (and on re-edit) */} + {(saveMutation.isSuccess || step1Complete) && } +
+ + {saveMutation.isSuccess && ( + Integration saved successfully. + )} + {saveMutation.isError && ( + {saveMutation.error.message} + )} +
+
+
+
+ ); +}