openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
1,290 lines (1,289 loc) • 48.6 kB
JavaScript
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 };