UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

1,290 lines (1,289 loc) 48.6 kB
import { v as parseStrictInteger } from "../../number-coercion-CJQ8TR--.js"; import { l as normalizeStringEntries } from "../../string-normalization-WNUDCpXX.js"; import { s as hasConfiguredSecretInput, u as normalizeResolvedSecretInputString } from "../../types.secrets-_0JOMGE5.js"; import { r as normalizeOptionalAccountId, t as DEFAULT_ACCOUNT_ID } from "../../account-id-Df9e41E6.js"; import { At as boolean, Nn as record, Rn as string, Tn as object, wn as number } from "../../schemas-6cH6bZ7o.js"; import { o as DmPolicySchema, z as requireOpenAllowFrom } from "../../zod-schema.core-1wQTyhOa.js"; import { r as buildChannelConfigSchema, t as AllowFromListSchema } from "../../config-schema-CO2ikh7C.js"; import { r as fetchWithSsrFGuard } from "../../fetch-guard-BttkNCLm.js"; import { t as resolveAccountEntry } from "../../account-lookup-DL1YTqjF.js"; import { t as createMessageReceiptFromOutboundResults } from "../../receipt-B3SXxhHV.js"; import { i as createHybridChannelConfigAdapter, l as createScopedDmSecurityResolver } from "../../channel-config-helpers-lj5quus3.js"; import "../../number-runtime-DBLVDypr.js"; import "../../string-coerce-runtime-CEGJWkQ_.js"; import { n as stripMarkdown } from "../../chunk-items-HPRB2OIa.js"; import { t as chunkTextForOutbound } from "../../text-chunking-D45TeeEs.js"; import { a as listCombinedAccountIds, c as resolveMergedAccountConfig, s as resolveListedDefaultAccountId } from "../../account-helpers-pcH3lGFY.js"; import { i as createChatChannelPlugin } from "../../core-DSxVv-v1.js"; import "../../channel-core-IukkNCd-.js"; import { r as buildSecretInputSchema } from "../../secret-input-DVCzFGxN.js"; import { a as waitUntilAbort } from "../../channel-lifecycle.core-Bfh0_sXw.js"; import { b as createConditionalWarningCollector } from "../../channel-policy-CIW58SA5.js"; import { n as createEmptyChannelDirectoryAdapter } from "../../directory-runtime-om8jJAZt.js"; import { T as defineChannelMessageAdapter } from "../../channel-outbound-B3_Zy-kG.js"; import { s as readRequestBodyWithLimit } from "../../http-body-DASeqdKJ.js"; import "../../ssrf-runtime-BOGN5pUi.js"; import { t as registerPluginHttpRoute } from "../../http-registry-C6KnnU9A.js"; import "../../account-resolution-DBB0e3KP.js"; import { f as requireChannelOpenAllowFrom } from "../../extension-shared-B8fkO3TV.js"; import "../../channel-config-primitives-Bk3iiMir.js"; import { i as resolveStableChannelMessageIngress } from "../../message-access-mwxdTUGC.js"; import "../../channel-ingress-runtime-rGltVkKC.js"; import { t as createChannelPairingChallengeIssuer } from "../../channel-pairing-Bk6_JhTm.js"; import { a as createFixedWindowRateLimiter } from "../../webhook-ingress-Dgd3Jtks.js"; import { n as collectRuntimeConfigAssignments, r as secretTargetRegistryEntries } from "../../secret-contract-5k57SS3F.js"; import { createHmac, timingSafeEqual } from "node:crypto"; import * as querystring from "node:querystring"; //#region extensions/sms/src/phone.ts function normalizeSmsPhoneNumber(raw) { const trimmed = raw.trim().replace(/^(?:sms|twilio-sms):/i, ""); if (!trimmed) return ""; return (trimmed.startsWith("+") ? trimmed : `+${trimmed}`).replace(/[^\d+]/g, ""); } function looksLikeSmsPhoneNumber(raw) { const normalized = normalizeSmsPhoneNumber(raw); return /^\+[1-9]\d{6,14}$/.test(normalized); } function normalizeSmsAllowFrom(raw) { if (raw.trim() === "*") return "*"; return normalizeSmsPhoneNumber(raw).toLowerCase(); } //#endregion //#region extensions/sms/src/accounts.ts const CHANNEL_ID$3 = "sms"; const DEFAULT_WEBHOOK_PATH = "/webhooks/sms"; const DEFAULT_TEXT_CHUNK_LIMIT = 1500; function getChannelConfig(cfg) { return cfg?.channels?.[CHANNEL_ID$3]; } function parseList(raw) { if (!raw) return []; return (Array.isArray(raw) ? raw : typeof raw === "string" ? normalizeStringEntries(raw.split(",")) : [raw]).map((entry) => normalizeSmsAllowFrom(String(entry))).filter(Boolean); } function parseTextChunkLimit(raw) { if (typeof raw === "number" && Number.isSafeInteger(raw) && raw > 0) return raw; if (typeof raw === "string" && /^\d+$/.test(raw.trim())) return parseStrictInteger(raw.trim()) ?? DEFAULT_TEXT_CHUNK_LIMIT; return DEFAULT_TEXT_CHUNK_LIMIT; } function firstNonBlankEnv(...values) { return values.find((value) => value?.trim()); } function hasBaseAccount(channelCfg) { return Boolean(channelCfg?.accountSid || hasConfiguredSecretInput(channelCfg?.authToken) || channelCfg?.fromNumber || channelCfg?.messagingServiceSid || process.env.TWILIO_ACCOUNT_SID || process.env.TWILIO_AUTH_TOKEN || process.env.TWILIO_PHONE_NUMBER || process.env.TWILIO_SMS_FROM || process.env.TWILIO_MESSAGING_SERVICE_SID); } function listSmsAccountIds(cfg) { const channelCfg = getChannelConfig(cfg); return listCombinedAccountIds({ configuredAccountIds: Object.keys(channelCfg?.accounts ?? {}), implicitAccountId: hasBaseAccount(channelCfg) ? DEFAULT_ACCOUNT_ID : void 0 }); } function resolveDefaultSmsAccountId(cfg) { const channelCfg = getChannelConfig(cfg); return resolveListedDefaultAccountId({ accountIds: listSmsAccountIds(cfg), configuredDefaultAccountId: normalizeOptionalAccountId(channelCfg?.defaultAccount) }); } function resolveSmsAccount(cfg, accountId) { const channelCfg = getChannelConfig(cfg) ?? {}; const id = normalizeOptionalAccountId(accountId) ?? resolveDefaultSmsAccountId(cfg); const accountConfig = resolveAccountEntry(channelCfg.accounts, id); const merged = resolveMergedAccountConfig({ channelConfig: { ...channelCfg }, accounts: channelCfg.accounts ? Object.fromEntries(Object.entries(channelCfg.accounts).map(([accountKey, account]) => [accountKey, { ...account }])) : void 0, accountId: id, omitKeys: ["defaultAccount"] }); const useEnvFallbacks = id === DEFAULT_ACCOUNT_ID; const envAccountSid = useEnvFallbacks ? process.env.TWILIO_ACCOUNT_SID : void 0; const envAuthToken = useEnvFallbacks ? process.env.TWILIO_AUTH_TOKEN : void 0; const envFromNumber = useEnvFallbacks ? firstNonBlankEnv(process.env.TWILIO_PHONE_NUMBER, process.env.TWILIO_SMS_FROM) : void 0; const envMessagingServiceSid = useEnvFallbacks ? process.env.TWILIO_MESSAGING_SERVICE_SID : void 0; const envWebhookPath = useEnvFallbacks ? process.env.SMS_WEBHOOK_PATH : void 0; const envPublicWebhookUrl = useEnvFallbacks ? process.env.SMS_PUBLIC_WEBHOOK_URL : void 0; const envAllowFrom = useEnvFallbacks ? process.env.SMS_ALLOWED_USERS : void 0; const envTextChunkLimit = useEnvFallbacks ? process.env.SMS_TEXT_CHUNK_LIMIT : void 0; const envDisableSignatureValidation = useEnvFallbacks ? process.env.SMS_DANGEROUSLY_DISABLE_SIGNATURE_VALIDATION : void 0; const webhookPath = (merged.webhookPath ?? envWebhookPath ?? DEFAULT_WEBHOOK_PATH).trim(); const publicWebhookUrl = (merged.publicWebhookUrl ?? envPublicWebhookUrl ?? "").trim(); const authToken = normalizeResolvedSecretInputString({ value: merged.authToken ?? envAuthToken, path: id === "default" ? "channels.sms.authToken" : `channels.sms.accounts.${id}.authToken` }) ?? ""; return { accountId: id, enabled: channelCfg.enabled !== false && accountConfig?.enabled !== false, accountSid: (merged.accountSid ?? envAccountSid ?? "").trim(), authToken, fromNumber: normalizeSmsPhoneNumber(merged.fromNumber ?? envFromNumber ?? ""), messagingServiceSid: (merged.messagingServiceSid ?? envMessagingServiceSid ?? "").trim(), defaultTo: normalizeSmsPhoneNumber(merged.defaultTo ?? ""), webhookPath: webhookPath || DEFAULT_WEBHOOK_PATH, publicWebhookUrl, dangerouslyDisableSignatureValidation: merged.dangerouslyDisableSignatureValidation === true || envDisableSignatureValidation === "true", dmPolicy: merged.dmPolicy ?? "pairing", allowFrom: parseList(merged.allowFrom ?? envAllowFrom), textChunkLimit: parseTextChunkLimit(merged.textChunkLimit ?? envTextChunkLimit) }; } function inspectSmsAccount(cfg, accountId) { const account = resolveSmsAccount(cfg, accountId); const configured = isSmsAccountConfigured(account); return { enabled: account.enabled, configured, tokenStatus: account.authToken ? "available" : "missing", webhookPath: account.webhookPath, signatureValidation: account.dangerouslyDisableSignatureValidation || account.publicWebhookUrl ? "configured" : "missing-public-url" }; } function isSmsAccountConfigured(account) { return Boolean(account.accountSid && account.authToken && (account.fromNumber || account.messagingServiceSid)); } //#endregion //#region extensions/sms/src/config-schema.ts const SecretInputSchema = buildSecretInputSchema(); const SmsAccountConfigSchema = object({ name: string().optional(), enabled: boolean().optional(), accountSid: string().optional(), authToken: SecretInputSchema.optional(), fromNumber: string().optional(), messagingServiceSid: string().optional(), defaultTo: string().optional(), webhookPath: string().optional(), publicWebhookUrl: string().optional(), dangerouslyDisableSignatureValidation: boolean().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), allowFrom: AllowFromListSchema, textChunkLimit: number().int().positive().optional() }).strict().superRefine((value, ctx) => { requireChannelOpenAllowFrom({ channel: "sms", policy: value.dmPolicy, allowFrom: value.allowFrom, ctx, requireOpenAllowFrom }); }); const SmsChannelConfigSchema = buildChannelConfigSchema(SmsAccountConfigSchema.extend({ accounts: record(string(), SmsAccountConfigSchema.optional()).optional(), defaultAccount: string().optional() }), { uiHints: { "": { label: "SMS", help: "Twilio SMS channel configuration for inbound webhooks and outbound text replies." }, accountSid: { label: "Twilio Account SID", help: "Twilio Account SID used for SMS outbound API calls." }, authToken: { label: "Twilio Auth Token", help: "Twilio Auth Token used to sign webhook validation and SMS outbound API calls." }, fromNumber: { label: "SMS From Number", help: "Twilio SMS-capable phone number in E.164 format, for example +15551234567." }, messagingServiceSid: { label: "Twilio Messaging Service SID", help: "Twilio Messaging Service SID to use instead of a dedicated fromNumber." }, defaultTo: { label: "SMS Default To Number", help: "Optional default outbound phone number used when a send flow omits an explicit SMS target." }, publicWebhookUrl: { label: "SMS Public Webhook URL", help: "Public URL configured in Twilio for incoming messages. Must match Twilio's signed URL exactly." }, webhookPath: { label: "SMS Webhook Path", help: "Gateway HTTP path that receives Twilio incoming-message webhooks. Use a distinct path per account." }, dmPolicy: { label: "SMS DM Policy", help: "Direct SMS access control (\"pairing\" recommended). \"open\" requires channels.sms.allowFrom=[\"*\"]." }, allowFrom: { label: "SMS Allow From", help: "Allowed sender phone numbers in E.164 format, or * when dmPolicy is open." }, textChunkLimit: { label: "SMS Text Chunk Limit", help: "Maximum characters per outbound SMS chunk before OpenClaw splits long replies." } } }); //#endregion //#region extensions/sms/src/twilio.ts const TWILIO_ACCOUNTS_URL = "https://api.twilio.com/2010-04-01/Accounts"; const TWILIO_MESSAGING_URL = "https://messaging.twilio.com/v1"; const TWILIO_API_HOSTNAME = "api.twilio.com"; const TWILIO_MESSAGING_HOSTNAME = "messaging.twilio.com"; const TWILIO_API_TIMEOUT_MS = 3e4; const WEBHOOK_BODY_LIMIT_BYTES = 32 * 1024; const WEBHOOK_BODY_TIMEOUT_MS = 5e3; function firstString(value) { if (Array.isArray(value)) return firstString(value[0]); return typeof value === "string" ? value : ""; } function firstTrimmedString(value) { return firstString(value).trim(); } function firstStringish(value) { const first = Array.isArray(value) ? value[0] : value; if (typeof first === "string") return first; return typeof first === "number" ? String(first) : ""; } function parseTwilioApiError(text) { try { const parsed = JSON.parse(text); if (!parsed || typeof parsed !== "object") return {}; const record = parsed; return { code: typeof record.code === "number" ? record.code : void 0, message: typeof record.message === "string" ? record.message : void 0 }; } catch { return {}; } } function parseTwilioSuccessPayload(text) { if (!text.trim()) return {}; try { const parsed = JSON.parse(text); if (!parsed || typeof parsed !== "object") throw new Error("Twilio SMS send returned malformed JSON."); const record = parsed; return { sid: typeof record.sid === "string" ? record.sid : void 0, to: typeof record.to === "string" ? record.to : void 0, from: typeof record.from === "string" ? record.from : void 0, status: typeof record.status === "string" ? record.status : void 0 }; } catch (cause) { if (cause instanceof Error && cause.message === "Twilio SMS send returned malformed JSON.") throw cause; throw new Error("Twilio SMS send returned malformed JSON.", { cause }); } } function requestSearch(req) { try { return new URL(req.url ?? "/", "http://localhost").search; } catch { return ""; } } function configuredUrlHasQuery(url) { const hashIndex = url.indexOf("#"); return (hashIndex === -1 ? url : url.slice(0, hashIndex)).includes("?"); } function resolveTwilioWebhookSignatureUrl(params) { if (configuredUrlHasQuery(params.publicWebhookUrl)) return params.publicWebhookUrl; const search = requestSearch(params.req); if (!search) return params.publicWebhookUrl; const hashIndex = params.publicWebhookUrl.indexOf("#"); if (hashIndex === -1) return `${params.publicWebhookUrl}${search}`; return `${params.publicWebhookUrl.slice(0, hashIndex)}${search}${params.publicWebhookUrl.slice(hashIndex)}`; } var TwilioSmsApiError = class extends Error { constructor(httpStatus, responseText, operation = "send") { const parsed = parseTwilioApiError(responseText); const detail = parsed.message ?? (responseText || "unknown"); super(`Twilio SMS ${operation} failed (${httpStatus}): ${detail}`); this.name = "TwilioSmsApiError"; this.httpStatus = httpStatus; this.responseText = responseText; this.twilioCode = parsed.code; } }; function parseTwilioFormBody(body) { const parsed = querystring.parse(body); const out = {}; for (const [key, value] of Object.entries(parsed)) out[key] = firstString(value); return out; } function computeTwilioSignature(params) { const data = params.url + Object.keys(params.form).toSorted().map((key) => `${key}${params.form[key] ?? ""}`).join(""); return createHmac("sha1", params.authToken).update(data).digest("base64"); } function safeEqual(a, b) { const left = Buffer.from(a); const right = Buffer.from(b); return left.length === right.length && timingSafeEqual(left, right); } function verifyTwilioSignature(params) { if (!params.signature || !params.url || !params.authToken) return false; return safeEqual(params.signature, computeTwilioSignature({ url: params.url, authToken: params.authToken, form: params.form })); } function buildTwilioInboundMessage(form) { const from = firstTrimmedString(form.From); const to = firstTrimmedString(form.To); const body = firstString(form.Body); const accountSid = firstTrimmedString(form.AccountSid); const messageSid = firstTrimmedString(form.MessageSid) || firstTrimmedString(form.SmsSid) || firstTrimmedString(form.SmsMessageSid); if (!from || !to || !body || !messageSid) return null; return { accountSid, from, to, body, messageSid }; } async function readTwilioWebhookForm(req) { return parseTwilioFormBody(await readRequestBodyWithLimit(req, { maxBytes: WEBHOOK_BODY_LIMIT_BYTES, timeoutMs: WEBHOOK_BODY_TIMEOUT_MS })); } function respondTwiml(res, statusCode, body = "") { res.statusCode = statusCode; res.setHeader("content-type", "text/xml; charset=utf-8"); res.end(body || "<Response></Response>"); } function twilioApiUrl(accountSid, path, query) { const normalizedPath = path.startsWith("/") ? path : `/${path}`; const url = new URL(`${TWILIO_ACCOUNTS_URL}/${encodeURIComponent(accountSid)}${normalizedPath}`); if (query) url.search = query.toString(); return url.toString(); } function twilioMessagingUrl(path, query) { const normalizedPath = path.startsWith("/") ? path : `/${path}`; const url = new URL(`${TWILIO_MESSAGING_URL}${normalizedPath}`); if (query) url.search = query.toString(); return url.toString(); } function basicAuthHeader(account) { return `Basic ${Buffer.from(`${account.accountSid}:${account.authToken}`).toString("base64")}`; } function normalizeRequestHeaders(headers) { if (!headers) return {}; if (headers instanceof Headers) return Object.fromEntries(headers.entries()); if (Array.isArray(headers)) return Object.fromEntries(headers.map(([key, value]) => [key, value])); return Object.fromEntries(Object.entries(headers)); } async function requestTwilioApi(params) { const init = { ...params.init, headers: { ...normalizeRequestHeaders(params.init?.headers), authorization: basicAuthHeader(params.account) } }; if (params.fetchImpl) { const response = await params.fetchImpl(params.url, init); return { ok: response.ok, status: response.status, text: await response.text() }; } const guarded = await fetchWithSsrFGuard({ url: params.url, init, auditContext: "sms-twilio-api", policy: { allowedHostnames: [params.allowedHostname] }, requireHttps: true, timeoutMs: params.timeoutMs ?? TWILIO_API_TIMEOUT_MS }); try { return { ok: guarded.response.ok, status: guarded.response.status, text: await guarded.response.text() }; } finally { await guarded.release(); } } function parseTwilioIncomingPhoneNumber(record) { return { sid: firstTrimmedString(record.sid), phoneNumber: firstTrimmedString(record.phone_number ?? record.phoneNumber), smsUrl: firstTrimmedString(record.sms_url ?? record.smsUrl), smsMethod: firstTrimmedString(record.sms_method ?? record.smsMethod), voiceUrl: firstTrimmedString(record.voice_url ?? record.voiceUrl) }; } function parseTwilioMessageLogEntry(record) { return { sid: firstTrimmedString(record.sid), direction: firstTrimmedString(record.direction), status: firstTrimmedString(record.status), to: firstTrimmedString(record.to), from: firstTrimmedString(record.from), errorCode: firstStringish(record.error_code ?? record.errorCode).trim(), body: firstString(record.body), dateCreated: firstTrimmedString(record.date_created ?? record.dateCreated), dateSent: firstTrimmedString(record.date_sent ?? record.dateSent) }; } function parseTwilioMessagingService(record) { return { sid: firstTrimmedString(record.sid), inboundRequestUrl: firstTrimmedString(record.inbound_request_url ?? record.inboundRequestUrl), inboundMethod: firstTrimmedString(record.inbound_method ?? record.inboundMethod), useInboundWebhookOnNumber: Boolean(record.use_inbound_webhook_on_number ?? record.useInboundWebhookOnNumber) }; } function parseTwilioListPayload(text, key, parseEntry) { if (!text.trim()) return []; const parsed = JSON.parse(text); if (!parsed || typeof parsed !== "object") return []; const items = parsed[key]; if (!Array.isArray(items)) return []; return items.filter((item) => Boolean(item && typeof item === "object" && !Array.isArray(item))).map(parseEntry); } async function listTwilioIncomingPhoneNumbers(params) { const query = new URLSearchParams(); if (params.phoneNumber) query.set("PhoneNumber", params.phoneNumber); const response = await requestTwilioApi({ account: params.account, url: twilioApiUrl(params.account.accountSid, "/IncomingPhoneNumbers.json", query), allowedHostname: TWILIO_API_HOSTNAME, fetchImpl: params.fetchImpl, timeoutMs: params.timeoutMs }); if (!response.ok) throw new TwilioSmsApiError(response.status, response.text, "phone-number lookup"); return parseTwilioListPayload(response.text, "incoming_phone_numbers", parseTwilioIncomingPhoneNumber); } async function retrieveTwilioMessagingService(params) { const response = await requestTwilioApi({ account: params.account, url: twilioMessagingUrl(`/Services/${encodeURIComponent(params.serviceSid)}`), allowedHostname: TWILIO_MESSAGING_HOSTNAME, fetchImpl: params.fetchImpl, timeoutMs: params.timeoutMs }); if (!response.ok) throw new TwilioSmsApiError(response.status, response.text, "messaging-service lookup"); const parsed = JSON.parse(response.text); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Twilio Messaging Service lookup returned malformed JSON."); return parseTwilioMessagingService(parsed); } async function listTwilioMessages(params) { const query = new URLSearchParams(); if (params.to) query.set("To", params.to); if (params.from) query.set("From", params.from); query.set("PageSize", String(params.pageSize ?? 5)); const response = await requestTwilioApi({ account: params.account, url: twilioApiUrl(params.account.accountSid, "/Messages.json", query), allowedHostname: TWILIO_API_HOSTNAME, fetchImpl: params.fetchImpl, timeoutMs: params.timeoutMs }); if (!response.ok) throw new TwilioSmsApiError(response.status, response.text, "message lookup"); return parseTwilioListPayload(response.text, "messages", parseTwilioMessageLogEntry); } async function sendSmsViaTwilio(params) { if (!params.account.fromNumber && !params.account.messagingServiceSid) throw new Error("Twilio SMS send requires fromNumber or messagingServiceSid."); const body = new URLSearchParams({ To: params.to, Body: params.text }); if (params.account.fromNumber) body.set("From", params.account.fromNumber); else body.set("MessagingServiceSid", params.account.messagingServiceSid); const init = { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body }; const response = await requestTwilioApi({ account: params.account, url: twilioApiUrl(params.account.accountSid, "/Messages.json"), allowedHostname: TWILIO_API_HOSTNAME, init, fetchImpl: params.fetchImpl }); if (!response.ok) throw new TwilioSmsApiError(response.status, response.text); const payload = parseTwilioSuccessPayload(response.text); const sid = payload.sid?.trim(); if (!sid) throw new Error("Twilio SMS send response did not include a Message SID."); return { sid, to: payload.to?.trim() || params.to, ...payload.from?.trim() ? { from: payload.from.trim() } : {}, ...payload.status?.trim() ? { status: payload.status.trim() } : {} }; } //#endregion //#region extensions/sms/src/send.ts function toSmsPlainText(text) { return stripMarkdown(text.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_match, body) => body.trim()).replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, (_match, label, url) => { const cleanLabel = label.trim(); const cleanUrl = url.trim(); return cleanLabel && cleanLabel !== cleanUrl ? `${cleanLabel} (${cleanUrl})` : cleanUrl; })).replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim(); } async function sendSmsTextChunks(params) { const text = toSmsPlainText(params.text); if (!text) throw new Error("SMS send requires non-empty text."); const chunks = chunkTextForOutbound(text, params.account.textChunkLimit).filter(Boolean); const sendChunks = chunks.length ? chunks : [text]; const results = []; for (const textLocal of sendChunks) results.push(await sendSmsViaTwilio({ account: params.account, to: params.to, text: textLocal })); return results; } //#endregion //#region extensions/sms/src/inbound.ts const CHANNEL_ID$2 = "sms"; async function authorizeSmsSender(params) { return await resolveStableChannelMessageIngress({ channelId: CHANNEL_ID$2, accountId: params.account.accountId, cfg: params.cfg, identity: { key: "phone", entryIdPrefix: "sms-entry" }, readStoreAllowFrom: async () => await params.channelRuntime.pairing.readAllowFromStore({ channel: CHANNEL_ID$2, accountId: params.account.accountId }), subject: { stableId: params.from }, conversation: { kind: "direct", id: "direct" }, event: { mayPair: true }, dmPolicy: params.account.dmPolicy, allowFrom: params.account.allowFrom }); } async function issueSmsPairingChallenge(params) { await createChannelPairingChallengeIssuer({ channel: CHANNEL_ID$2, upsertPairingRequest: async (input) => await params.channelRuntime.pairing.upsertPairingRequest({ channel: CHANNEL_ID$2, accountId: params.account.accountId, ...input }) })({ senderId: params.from, senderIdLine: `Your SMS phone number: ${params.from}`, sendPairingReply: async (text) => { await sendSmsTextChunks({ account: params.account, to: params.from, text }); }, onCreated: () => { params.log?.info?.(`SMS pairing request created for ${params.from}`); }, onReplyError: (err) => { params.log?.warn?.(`SMS pairing reply failed for ${params.from}: ${String(err)}`); } }); } async function dispatchSmsInboundEvent(params) { const from = normalizeSmsPhoneNumber(params.msg.from); const auth = await authorizeSmsSender({ cfg: params.cfg, account: params.account, channelRuntime: params.channelRuntime, from }); if (!auth.senderAccess.allowed) { if (auth.senderAccess.decision === "pairing") { await issueSmsPairingChallenge({ account: params.account, channelRuntime: params.channelRuntime, from, log: params.log }); return; } params.log?.warn?.(`SMS sender ${from} is not authorized`); return; } const route = params.channelRuntime.routing.resolveAgentRoute({ cfg: params.cfg, channel: CHANNEL_ID$2, accountId: params.account.accountId, peer: { kind: "direct", id: from } }); const sessionKey = route.sessionKey; await params.channelRuntime.inbound.run({ channel: CHANNEL_ID$2, accountId: params.account.accountId, raw: params.msg, adapter: { ingest: (msg) => ({ id: msg.messageSid, timestamp: Date.now(), rawText: msg.body, textForAgent: msg.body, textForCommands: msg.body, raw: msg }), resolveTurn: async (input) => { const ctxPayload = params.channelRuntime.inbound.buildContext({ channel: CHANNEL_ID$2, accountId: params.account.accountId, timestamp: input.timestamp, from: `sms:${from}`, sender: { id: from, name: from }, conversation: { kind: "direct", id: from, label: from }, route: { agentId: route.agentId, accountId: params.account.accountId, routeSessionKey: sessionKey, dispatchSessionKey: sessionKey }, reply: { to: `sms:${from}` }, message: { rawBody: input.rawText, commandBody: input.textForCommands, bodyForAgent: input.textForAgent }, extra: { MessageSid: params.msg.messageSid, To: params.msg.to } }); const storePath = params.channelRuntime.session.resolveStorePath(params.cfg.session?.store, { agentId: route.agentId }); return { cfg: params.cfg, channel: CHANNEL_ID$2, accountId: params.account.accountId, agentId: route.agentId, routeSessionKey: sessionKey, storePath, ctxPayload, recordInboundSession: params.channelRuntime.session.recordInboundSession, dispatchReplyWithBufferedBlockDispatcher: params.channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher, delivery: { durable: () => ({ to: from }), deliver: async (payload) => { const text = payload.text; if (!text) return { visibleReplySent: false }; await sendSmsTextChunks({ account: params.account, to: from, text }); return { visibleReplySent: true }; } }, dispatcherOptions: { onReplyStart: () => { params.log?.info?.(`SMS reply started for ${from}`); } } }; } } }); } //#endregion //#region extensions/sms/src/webhook.ts const rateLimiter = createFixedWindowRateLimiter({ maxRequests: 30, windowMs: 6e4, maxTrackedKeys: 5e3 }); const REPLAY_CACHE_TTL_MS = 10 * 6e4; const REPLAY_CACHE_MAX_KEYS = 1e4; const replayCache = /* @__PURE__ */ new Map(); function headerValue(value) { if (Array.isArray(value)) return value[0]; return value; } function rateLimitKey(req) { return req.socket?.remoteAddress ?? "unknown"; } function rememberWebhookMessage(params) { const now = params.now ?? Date.now(); for (const [key, expiresAt] of replayCache) { if (expiresAt > now && replayCache.size <= REPLAY_CACHE_MAX_KEYS) break; replayCache.delete(key); } const key = `${params.accountId}:${params.messageSid}`; if ((replayCache.get(key) ?? 0) > now) return false; replayCache.set(key, now + REPLAY_CACHE_TTL_MS); return true; } function createSmsWebhookHandler(params) { return async (req, res) => { if (req.method !== "POST") { respondTwiml(res, 405, "Method not allowed"); return true; } const key = rateLimitKey(req); if (rateLimiter.isRateLimited(key)) { params.log?.warn?.(`SMS webhook rate limit exceeded for ${key}`); respondTwiml(res, 429, "Rate limit exceeded"); return true; } let form; try { form = await readTwilioWebhookForm(req); } catch { respondTwiml(res, 400, "Invalid request body"); return true; } if (!params.account.dangerouslyDisableSignatureValidation) { if (!verifyTwilioSignature({ signature: headerValue(req.headers["x-twilio-signature"]), url: resolveTwilioWebhookSignatureUrl({ req, publicWebhookUrl: params.account.publicWebhookUrl }), authToken: params.account.authToken, form })) { params.log?.warn?.("SMS webhook rejected invalid Twilio signature"); respondTwiml(res, 403, "Invalid signature"); return true; } } const msg = buildTwilioInboundMessage(form); if (!msg) { respondTwiml(res, 400, "Missing SMS payload"); return true; } if (msg.accountSid && msg.accountSid !== params.account.accountSid) { params.log?.warn?.("SMS webhook rejected mismatched Twilio AccountSid"); respondTwiml(res, 403, "Invalid account"); return true; } if (!rememberWebhookMessage({ accountId: params.account.accountId, messageSid: msg.messageSid })) { params.log?.warn?.(`SMS webhook ignored replayed message ${msg.messageSid}`); respondTwiml(res, 200); return true; } dispatchSmsInboundEvent({ cfg: params.cfg, account: params.account, msg, channelRuntime: params.channelRuntime, log: params.log }).catch((err) => { params.log?.error?.(`SMS webhook dispatch failed: ${err instanceof Error ? err.message : String(err)}`); }); respondTwiml(res, 200); return true; }; } //#endregion //#region extensions/sms/src/gateway.ts const CHANNEL_ID$1 = "sms"; const activeRoutes = /* @__PURE__ */ new Map(); const activeRoutePaths = /* @__PURE__ */ new Map(); function routeKey(account) { return `${account.accountId}:${normalizeWebhookPath(account.webhookPath)}`; } function normalizeWebhookPath(path) { const trimmed = path.trim(); return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; } function collectSmsStartupWarnings(account) { const warnings = []; if (!account.accountSid || !account.authToken || !account.fromNumber && !account.messagingServiceSid) warnings.push("- SMS: accountSid, authToken, and fromNumber or messagingServiceSid are required."); if (!account.publicWebhookUrl && !account.dangerouslyDisableSignatureValidation) warnings.push("- SMS: publicWebhookUrl is required for Twilio signature validation. Set dangerouslyDisableSignatureValidation=true only for local testing."); if (account.dmPolicy === "allowlist" && account.allowFrom.length === 0) warnings.push("- SMS: dmPolicy=allowlist with empty allowFrom rejects every sender."); if (account.dmPolicy === "open" && !account.allowFrom.includes("*")) warnings.push("- SMS: dmPolicy=open should set allowFrom=[\"*\"] or explicit sender numbers."); return warnings; } function registerSmsWebhookRoute(params) { const key = routeKey(params.account); const webhookPath = normalizeWebhookPath(params.account.webhookPath); const currentPathOwner = activeRoutePaths.get(webhookPath); if (currentPathOwner && currentPathOwner !== params.account.accountId) throw new Error(`SMS webhook path ${webhookPath} is already registered by account ${currentPathOwner}; configure a distinct webhookPath for account ${params.account.accountId}.`); activeRoutes.get(key)?.(); activeRoutePaths.delete(webhookPath); const unregister = registerPluginHttpRoute({ path: webhookPath, auth: "plugin", pluginId: CHANNEL_ID$1, accountId: params.account.accountId, log: (msg) => params.log?.info?.(msg), handler: createSmsWebhookHandler(params) }); activeRoutes.set(key, unregister); activeRoutePaths.set(webhookPath, params.account.accountId); return () => { unregister(); activeRoutes.delete(key); if (activeRoutePaths.get(webhookPath) === params.account.accountId) activeRoutePaths.delete(webhookPath); }; } async function startSmsGatewayAccount(params) { if (!params.account.enabled) { params.log?.info?.(`SMS account ${params.account.accountId} is disabled`); return waitUntilAbort(params.abortSignal); } const warnings = collectSmsStartupWarnings(params.account); if (warnings.some((warning) => warning.includes("required"))) { for (const warning of warnings) params.log?.warn?.(warning); return waitUntilAbort(params.abortSignal); } for (const warning of warnings) params.log?.warn?.(warning); const unregister = registerSmsWebhookRoute(params); params.log?.info?.(`Registered SMS webhook route ${params.account.webhookPath} for account ${params.account.accountId}`); return waitUntilAbort(params.abortSignal, unregister); } //#endregion //#region extensions/sms/src/status.ts const TWILIO_ERROR_WEBHOOK_REACHABILITY = "11200"; function addTailscaleHint(account, hints) { let host; try { host = new URL(account.publicWebhookUrl).hostname; } catch { return; } if (!host.endsWith(".ts.net")) return; hints.push(`Tailscale Funnel must expose the exact SMS path: tailscale funnel --bg --set-path ${account.webhookPath} http://127.0.0.1:<gateway-port>${account.webhookPath}`); } function compareTwilioWebhook(account, phoneNumber) { if (!account.fromNumber) return { status: "skipped", reason: "Messaging Service senders do not have one phone-number SMS webhook to inspect." }; if (!phoneNumber) return { status: "number-not-found", expectedNumber: account.fromNumber }; const configuredMethod = phoneNumber.smsMethod.toUpperCase(); if (!phoneNumber.smsUrl) return { status: "missing", phoneNumber: phoneNumber.phoneNumber || account.fromNumber, expectedUrl: account.publicWebhookUrl, configuredMethod }; if (configuredMethod && configuredMethod !== "POST") return { status: "method-mismatch", phoneNumber: phoneNumber.phoneNumber || account.fromNumber, expectedUrl: account.publicWebhookUrl, configuredUrl: phoneNumber.smsUrl, configuredMethod }; if (phoneNumber.smsUrl !== account.publicWebhookUrl) return { status: "url-mismatch", phoneNumber: phoneNumber.phoneNumber || account.fromNumber, expectedUrl: account.publicWebhookUrl, configuredUrl: phoneNumber.smsUrl, configuredMethod }; return { status: "matches", phoneNumber: phoneNumber.phoneNumber || account.fromNumber, expectedUrl: account.publicWebhookUrl, configuredUrl: phoneNumber.smsUrl, configuredMethod, voiceUrl: phoneNumber.voiceUrl }; } function compareTwilioMessagingService(account, service) { if (service.useInboundWebhookOnNumber) return { status: "unavailable", reason: "Twilio Messaging Service defers inbound webhooks to sender phone numbers; configure fromNumber or disable defer-to-sender before probing." }; const configuredMethod = service.inboundMethod.toUpperCase(); if (!service.inboundRequestUrl) return { status: "messaging-service-missing", serviceSid: service.sid || account.messagingServiceSid, expectedUrl: account.publicWebhookUrl, configuredMethod }; if (configuredMethod && configuredMethod !== "POST") return { status: "messaging-service-method-mismatch", serviceSid: service.sid || account.messagingServiceSid, expectedUrl: account.publicWebhookUrl, configuredUrl: service.inboundRequestUrl, configuredMethod }; if (service.inboundRequestUrl !== account.publicWebhookUrl) return { status: "messaging-service-url-mismatch", serviceSid: service.sid || account.messagingServiceSid, expectedUrl: account.publicWebhookUrl, configuredUrl: service.inboundRequestUrl, configuredMethod }; return { status: "messaging-service-matches", serviceSid: service.sid || account.messagingServiceSid, expectedUrl: account.publicWebhookUrl, configuredUrl: service.inboundRequestUrl, configuredMethod }; } function recentInboundSummary(messages) { const message = messages[0]; if (!message) return; return { sid: message.sid, direction: message.direction, status: message.status, errorCode: message.errorCode, dateCreated: message.dateCreated, dateSent: message.dateSent }; } function webhookError(probe) { switch (probe.status) { case "matches": case "skipped": return; case "unavailable": return probe.reason; case "number-not-found": return `Twilio account does not list ${probe.expectedNumber} as an incoming phone number.`; case "missing": return `Twilio number ${probe.phoneNumber} has no SMS webhook URL configured.`; case "method-mismatch": return `Twilio number ${probe.phoneNumber} uses ${probe.configuredMethod || "an unknown method"} for SMS webhooks; use POST.`; case "url-mismatch": return `Twilio number ${probe.phoneNumber} points SMS webhooks at ${probe.configuredUrl}; expected ${probe.expectedUrl}.`; case "messaging-service-missing": return `Twilio Messaging Service ${probe.serviceSid} has no inbound request URL configured.`; case "messaging-service-method-mismatch": return `Twilio Messaging Service ${probe.serviceSid} uses ${probe.configuredMethod || "an unknown method"} for inbound webhooks; use POST.`; case "messaging-service-url-mismatch": return `Twilio Messaging Service ${probe.serviceSid} points inbound webhooks at ${probe.configuredUrl}; expected ${probe.expectedUrl}.`; case "messaging-service-matches": return; } } async function probeSmsAccount(params) { const hints = []; addTailscaleHint(params.account, hints); const webhook = params.account.fromNumber ? compareTwilioWebhook(params.account, (await listTwilioIncomingPhoneNumbers({ account: params.account, phoneNumber: params.account.fromNumber, fetchImpl: params.options?.fetchImpl, timeoutMs: params.timeoutMs }))[0]) : params.account.messagingServiceSid ? compareTwilioMessagingService(params.account, await retrieveTwilioMessagingService({ account: params.account, serviceSid: params.account.messagingServiceSid, fetchImpl: params.options?.fetchImpl, timeoutMs: params.timeoutMs })) : { status: "unavailable", reason: "Twilio SMS probe requires fromNumber or messagingServiceSid." }; const recentInbound = recentInboundSummary(params.account.fromNumber ? await listTwilioMessages({ account: params.account, to: params.account.fromNumber, pageSize: 3, fetchImpl: params.options?.fetchImpl, timeoutMs: params.timeoutMs }) : []); if (recentInbound?.errorCode === TWILIO_ERROR_WEBHOOK_REACHABILITY) hints.push("Twilio error 11200 means Twilio could not reach the SMS webhook. Check the public URL, tunnel/Funnel route, and Twilio Messaging webhook method."); const error = webhookError(webhook) ?? (recentInbound?.errorCode === TWILIO_ERROR_WEBHOOK_REACHABILITY ? `Recent inbound SMS ${recentInbound.sid} has Twilio error 11200.` : void 0); return { ok: !error, ...error ? { error } : {}, webhook, ...recentInbound ? { recentInbound } : {}, hints }; } function formatSmsProbeLines(probe) { if (!probe || typeof probe !== "object") return []; const smsProbe = probe; const lines = []; if (smsProbe.ok === true) lines.push({ text: "Probe: ok", tone: "success" }); else if (smsProbe.ok === false) lines.push({ text: `Probe: failed${smsProbe.error ? ` (${smsProbe.error})` : ""}`, tone: "error" }); if (smsProbe.webhook?.status === "matches" || smsProbe.webhook?.status === "messaging-service-matches") lines.push({ text: `Twilio SMS webhook: ${smsProbe.webhook.configuredUrl}` }); else if (smsProbe.webhook?.status && smsProbe.webhook.status !== "skipped") lines.push({ text: `Twilio SMS webhook: ${smsProbe.webhook.status}`, tone: "warn" }); if (smsProbe.recentInbound?.sid) { const error = smsProbe.recentInbound.errorCode ? ` error=${smsProbe.recentInbound.errorCode}` : ""; lines.push({ text: `Recent inbound: ${smsProbe.recentInbound.status || "unknown"}${error}`, tone: smsProbe.recentInbound.errorCode ? "warn" : "muted" }); } for (const hint of smsProbe.hints ?? []) lines.push({ text: hint, tone: "warn" }); return lines; } //#endregion //#region extensions/sms/src/channel.ts const CHANNEL_ID = "sms"; const smsConfigAdapter = createHybridChannelConfigAdapter({ sectionKey: CHANNEL_ID, listAccountIds: listSmsAccountIds, resolveAccount: resolveSmsAccount, defaultAccountId: resolveDefaultSmsAccountId, clearBaseFields: [ "accountSid", "authToken", "fromNumber", "messagingServiceSid", "defaultTo", "webhookPath", "publicWebhookUrl", "dangerouslyDisableSignatureValidation", "dmPolicy", "allowFrom", "textChunkLimit" ], resolveAllowFrom: (account) => account.allowFrom, formatAllowFrom: (allowFrom) => normalizeStringEntries(allowFrom.map((entry) => normalizeSmsAllowFrom(String(entry)))), resolveDefaultTo: (account) => account.defaultTo }); const resolveSmsDmPolicy = createScopedDmSecurityResolver({ channelKey: CHANNEL_ID, resolvePolicy: (account) => account.dmPolicy, resolveAllowFrom: (account) => account.allowFrom, policyPathSuffix: "dmPolicy", defaultPolicy: "pairing", approveHint: "openclaw pairing approve sms <code>", normalizeEntry: normalizeSmsAllowFrom }); const collectSmsSecurityWarnings = createConditionalWarningCollector((account) => account.dangerouslyDisableSignatureValidation && "- SMS: Twilio signature validation is disabled. Only use this for local testing.", (account) => account.dmPolicy === "open" && account.allowFrom.includes("*") && "- SMS: dmPolicy=\"open\" allows any phone number to message the bot."); function smsSetupPatch(input) { const patch = {}; for (const key of [ "accountSid", "authToken", "fromNumber", "messagingServiceSid", "defaultTo", "webhookPath", "publicWebhookUrl", "dmPolicy", "allowFrom" ]) if (input[key] !== void 0) patch[key] = input[key]; return patch; } function applySmsAccountConfig(params) { const patch = smsSetupPatch(params.input); const channels = { ...params.cfg.channels }; const current = { ...channels[CHANNEL_ID] }; if (params.accountId === "default") { channels[CHANNEL_ID] = { ...current, ...patch }; return { ...params.cfg, channels }; } const accounts = { ...current.accounts }; accounts[params.accountId] = { ...accounts[params.accountId], ...patch }; channels[CHANNEL_ID] = { ...current, accounts }; return { ...params.cfg, channels }; } function createSmsReceipt(params) { const first = params.results[0]; if (!first) throw new Error("SMS send did not return a Twilio Message SID."); return { channel: CHANNEL_ID, messageId: first.sid, chatId: first.to, receipt: createMessageReceiptFromOutboundResults({ results: params.results.map((result) => ({ channel: CHANNEL_ID, messageId: result.sid, chatId: result.to, toJid: result.to, conversationId: result.to, meta: { ...result.from ? { from: result.from } : {}, ...result.status ? { status: result.status } : {} } })), threadId: first.to, kind: params.kind }) }; } function resolveSmsTextChunkLimit(params) { return resolveSmsAccount(params.cfg, params.accountId).textChunkLimit || params.fallbackLimit || 1500; } async function sendSmsText(ctx) { const account = resolveSmsAccount(ctx.cfg, ctx.accountId); const to = normalizeSmsPhoneNumber(ctx.to) || account.defaultTo; if (!looksLikeSmsPhoneNumber(to)) throw new Error(`Invalid SMS target: ${ctx.to}`); return createSmsReceipt({ results: await sendSmsTextChunks({ account, to, text: ctx.text }), kind: "text" }); } const smsMessageAdapter = defineChannelMessageAdapter({ id: CHANNEL_ID, durableFinal: { capabilities: { text: true, media: false, messageSendingHooks: true } }, send: { text: async (ctx) => await sendSmsText(ctx) } }); const smsPlugin = createChatChannelPlugin({ base: { id: CHANNEL_ID, meta: { id: CHANNEL_ID, label: "SMS", selectionLabel: "SMS (Twilio)", detailLabel: "Twilio SMS", docsPath: "/channels/sms", docsLabel: "sms", blurb: "Twilio-backed SMS with inbound webhooks and outbound replies.", order: 88 }, capabilities: { chatTypes: ["direct"], media: false, threads: false, reactions: false, edit: false, unsend: false, reply: false, effects: false, blockStreaming: false }, reload: { configPrefixes: [`channels.${CHANNEL_ID}`] }, configSchema: SmsChannelConfigSchema, setup: { applyAccountConfig: applySmsAccountConfig }, config: { ...smsConfigAdapter, inspectAccount: inspectSmsAccount, isConfigured: isSmsAccountConfigured, unconfiguredReason: () => "SMS requires accountSid, authToken, and fromNumber or messagingServiceSid.", describeAccount: (account) => ({ accountId: account.accountId, name: account.fromNumber || account.messagingServiceSid || "SMS", configured: isSmsAccountConfigured(account), enabled: account.enabled }) }, messaging: { targetPrefixes: ["twilio-sms"], normalizeTarget: (target) => normalizeSmsPhoneNumber(target), targetResolver: { looksLikeId: looksLikeSmsPhoneNumber, hint: "<+15551234567>" } }, directory: createEmptyChannelDirectoryAdapter(), gateway: { startAccount: async (ctx) => { if (!ctx.channelRuntime) { ctx.log?.warn?.("SMS channel runtime is not available; webhook route not started"); return; } return await startSmsGatewayAccount({ cfg: ctx.cfg, account: ctx.account, channelRuntime: ctx.channelRuntime, abortSignal: ctx.abortSignal, log: ctx.log }); } }, status: { buildAccountSnapshot: ({ account }) => { const configured = isSmsAccountConfigured(account); return { accountId: account.accountId, name: account.fromNumber || account.messagingServiceSid || "SMS", enabled: account.enabled, configured, statusState: !account.enabled ? "disabled" : configured ? "configured" : "unconfigured" }; }, probeAccount: async ({ account, timeoutMs }) => await probeSmsAccount({ account, timeoutMs }), formatCapabilitiesProbe: ({ probe }) => formatSmsProbeLines(probe), buildCapabilitiesDiagnostics: async ({ account }) => ({ lines: collectSmsStartupWarnings(account).map((text) => ({ text, tone: "warn" })) }) }, secrets: { secretTargetRegistryEntries, collectRuntimeConfigAssignments }, agentPrompt: { messageToolHints: () => [ "", "### SMS Formatting", "SMS is plain text only. Keep replies brief, avoid markdown tables, and split long details into short messages." ] }, message: smsMessageAdapter }, pairing: { text: { idLabel: "phoneNumber", message: "OpenClaw: your SMS access has been approved.", normalizeAllowEntry: normalizeSmsAllowFrom, notify: async ({ cfg, id, message, accountId }) => { await sendSmsTextChunks({ account: resolveSmsAccount(cfg, accountId), to: normalizeSmsPhoneNumber(id), text: message }); } } }, security: { resolveDmPolicy: resolveSmsDmPolicy, collectWarnings: ({ account }) => collectSmsSecurityWarnings(account) }, outbound: { deliveryMode: "gateway", chunker: chunkTextForOutbound, chunkerMode: "text", textChunkLimit: 1500, resolveEffectiveTextChunkLimit: resolveSmsTextChunkLimit, resolveTarget: ({ cfg, to, accountId }) => { const explicit = normalizeSmsPhoneNumber(to ?? ""); if (explicit) return { ok: true, to: explicit }; if (cfg) { const account = resolveSmsAccount(cfg, accountId); if (account.defaultTo) return { ok: true, to: account.defaultTo }; } return { ok: false, error: /* @__PURE__ */ new Error("SMS target must be an E.164 phone number.") }; }, sanitizeText: ({ text }) => toSmsPlainText(text), sendText: sendSmsText } }); //#endregion export { smsPlugin };