feat(ui): format international phone numbers for display (#112400)

* feat(ui): format phone numbers for display

* ci: track normalization package exports in Knip
This commit is contained in:
Peter Steinberger
2026-07-21 17:10:25 -07:00
committed by GitHub
parent 1f0a3ecc68
commit f0c43dcf72
38 changed files with 657 additions and 51 deletions
+18
View File
@@ -482,6 +482,24 @@ const config = {
],
project: ["src/**/*.ts!"],
},
"packages/normalization-core": {
// Mirror package.json exports; root and UI builds consume these source subpaths directly.
entry: [
"src/index.ts!",
"src/agent-id.ts!",
"src/boolean-coercion.ts!",
"src/error-coercion.ts!",
"src/expect.ts!",
"src/number-coercion.ts!",
"src/phone-presentation.ts!",
"src/record-coerce.ts!",
"src/result.ts!",
"src/string-coerce.ts!",
"src/string-normalization.ts!",
"src/utf16-slice.ts!",
],
project: ["src/**/*.ts!"],
},
"packages/net-policy": {
entry: ["src/index.ts!", "src/ip.ts!"],
project: ["src/**/*.ts!"],
+18 -17
View File
@@ -589,14 +589,15 @@ Supported evidence entries:
Each field hint can include:
| Field | Type | What it means |
| ------------- | ---------- | --------------------------------------- |
| `label` | `string` | User-facing field label. |
| `help` | `string` | Short helper text. |
| `tags` | `string[]` | Optional UI tags. |
| `advanced` | `boolean` | Marks the field as advanced. |
| `sensitive` | `boolean` | Marks the field as secret or sensitive. |
| `placeholder` | `string` | Placeholder text for form inputs. |
| Field | Type | What it means |
| -------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------- |
| `label` | `string` | User-facing field label. |
| `help` | `string` | Short helper text. |
| `tags` | `string[]` | Optional UI tags. |
| `advanced` | `boolean` | Marks the field as advanced. |
| `sensitive` | `boolean` | Marks the field as secret or sensitive. |
| `placeholder` | `string` | Placeholder text for form inputs. |
| `presentation` | `"phone-number"` | Display-only localized phone formatting for parseable international (`+...`) values; raw values remain unchanged. |
## contracts reference
@@ -776,7 +777,7 @@ For a channel plugin, `configSchema` and `channelConfigs` describe different pat
- `configSchema` validates `plugins.entries.<plugin-id>.config`
- `channelConfigs.<channel-id>.schema` validates `channels.<channel-id>`
Non-bundled plugins that declare `channels[]` should also declare matching `channelConfigs` entries. Without them, OpenClaw can still load the plugin, but cold-path config schema, setup, and Control UI surfaces cannot know the channel-owned option shape until plugin runtime executes.
Non-bundled plugins that declare `channels[]` should also declare matching `channelConfigs` entries. Without them, OpenClaw can still load the plugin, but cold-path config schema, setup, and Control UI surfaces cannot know the channel-owned option shape or display-only UI hints until plugin runtime executes.
`channelConfigs.<channel-id>.commands.nativeCommandsAutoEnabled` and `nativeSkillsAutoEnabled` can declare static `auto` defaults for command config checks that run before channel runtime loads. Bundled channels can also publish the same defaults through `package.json#openclaw.channel.commands` alongside their other package-owned channel catalog metadata.
@@ -811,14 +812,14 @@ Non-bundled plugins that declare `channels[]` should also declare matching `chan
Each channel entry can include:
| Field | Type | What it means |
| ------------- | ------------------------ | ----------------------------------------------------------------------------------------- |
| `schema` | `object` | JSON Schema for `channels.<id>`. Required for each declared channel config entry. |
| `uiHints` | `Record<string, object>` | Optional UI labels/placeholders/sensitive hints for that channel config section. |
| `label` | `string` | Channel label merged into picker and inspect surfaces when runtime metadata is not ready. |
| `description` | `string` | Short channel description for inspect and catalog surfaces. |
| `commands` | `object` | Static native command and native skill auto-defaults for pre-runtime config checks. |
| `preferOver` | `string[]` | Legacy or lower-priority plugin ids this channel should outrank in selection surfaces. |
| Field | Type | What it means |
| ------------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `schema` | `object` | JSON Schema for `channels.<id>`. Required for each declared channel config entry. |
| `uiHints` | `Record<string, object>` | Optional labels, placeholders, sensitivity, and display-only presentation hints for that channel config section. |
| `label` | `string` | Channel label merged into picker and inspect surfaces when runtime metadata is not ready. |
| `description` | `string` | Short channel description for inspect and catalog surfaces. |
| `commands` | `object` | Static native command and native skill auto-defaults for pre-runtime config checks. |
| `preferOver` | `string[]` | Legacy or lower-priority plugin ids this channel should outrank in selection surfaces. |
### Replacing another channel plugin
@@ -12,6 +12,12 @@ export const iMessageChannelConfigUiHints = {
dmPolicy: { channelKey: "imessage" },
configWrites: true,
}),
allowFrom: { presentation: "phone-number" },
defaultTo: { presentation: "phone-number" },
groupAllowFrom: { presentation: "phone-number" },
"accounts.*.allowFrom.*": { presentation: "phone-number" },
"accounts.*.defaultTo": { presentation: "phone-number" },
"accounts.*.groupAllowFrom.*": { presentation: "phone-number" },
cliPath: {
label: "iMessage CLI Path",
help: "Filesystem path to the iMessage bridge CLI binary used for send/receive operations. Set explicitly when the binary is not on PATH in service runtime environments.",
+10
View File
@@ -15,7 +15,17 @@ export const signalChannelConfigUiHints = {
account: {
label: "Signal Account",
help: "Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state.",
presentation: "phone-number",
},
allowFrom: { presentation: "phone-number" },
defaultTo: { presentation: "phone-number" },
groupAllowFrom: { presentation: "phone-number" },
reactionAllowlist: { presentation: "phone-number" },
"accounts.*.account": { presentation: "phone-number" },
"accounts.*.allowFrom.*": { presentation: "phone-number" },
"accounts.*.defaultTo": { presentation: "phone-number" },
"accounts.*.groupAllowFrom.*": { presentation: "phone-number" },
"accounts.*.reactionAllowlist.*": { presentation: "phone-number" },
configPath: {
label: "Signal CLI Config Path",
help: "Optional directory passed to signal-cli via --config when the service needs a non-default signal-cli data path.",
+6
View File
@@ -60,6 +60,7 @@ export const SmsChannelConfigSchema = buildChannelConfigSchema(SmsConfigSchema,
fromNumber: {
label: "SMS From Number",
help: "Twilio SMS-capable phone number in E.164 format, for example +15551234567.",
presentation: "phone-number",
},
messagingServiceSid: {
label: "Twilio Messaging Service SID",
@@ -68,6 +69,7 @@ export const SmsChannelConfigSchema = buildChannelConfigSchema(SmsConfigSchema,
defaultTo: {
label: "SMS Default To Number",
help: "Optional default outbound phone number used when a send flow omits an explicit SMS target.",
presentation: "phone-number",
},
publicWebhookUrl: {
label: "SMS Public Webhook URL",
@@ -84,7 +86,11 @@ export const SmsChannelConfigSchema = buildChannelConfigSchema(SmsConfigSchema,
allowFrom: {
label: "SMS Allow From",
help: "Allowed sender phone numbers in E.164 format, or * when dmPolicy is open.",
presentation: "phone-number",
},
"accounts.*.fromNumber": { presentation: "phone-number" },
"accounts.*.defaultTo": { presentation: "phone-number" },
"accounts.*.allowFrom.*": { presentation: "phone-number" },
textChunkLimit: {
label: "SMS Text Chunk Limit",
help: "Maximum characters per outbound SMS chunk before OpenClaw splits long replies.",
@@ -11,6 +11,12 @@ export const whatsAppChannelConfigUiHints = {
channelLabel: "WhatsApp",
dmPolicy: { channelKey: "whatsapp" },
}),
allowFrom: { presentation: "phone-number" },
defaultTo: { presentation: "phone-number" },
groupAllowFrom: { presentation: "phone-number" },
"accounts.*.allowFrom.*": { presentation: "phone-number" },
"accounts.*.defaultTo": { presentation: "phone-number" },
"accounts.*.groupAllowFrom.*": { presentation: "phone-number" },
selfChatMode: {
label: "WhatsApp Self-Phone Mode",
help: "Same-phone setup (bot uses your personal WhatsApp number).",
@@ -0,0 +1,33 @@
import { Value } from "typebox/value";
import { describe, expect, it } from "vitest";
import { ConfigSchemaResponseSchema } from "./config.js";
const response = {
schema: {},
uiHints: {
"channels.sms.fromNumber": {
presentation: "phone-number",
},
},
version: "1",
generatedAt: "2026-07-20T00:00:00.000Z",
};
describe("ConfigSchemaResponseSchema", () => {
it("accepts the phone-number presentation hint", () => {
expect(Value.Check(ConfigSchemaResponseSchema, response)).toBe(true);
});
it("rejects unknown presentation hint values", () => {
expect(
Value.Check(ConfigSchemaResponseSchema, {
...response,
uiHints: {
"channels.sms.fromNumber": {
presentation: "telephone",
},
},
}),
).toBe(false);
});
});
@@ -83,6 +83,7 @@ const ConfigUiHintSchema = closedObject({
advanced: Type.Optional(Type.Boolean()),
sensitive: Type.Optional(Type.Boolean()),
placeholder: Type.Optional(Type.String()),
presentation: Type.Optional(Type.Literal("phone-number")),
itemTemplate: Type.Optional(Type.Unknown()),
});
+9 -1
View File
@@ -39,6 +39,11 @@
"import": "./dist/number-coercion.mjs",
"default": "./dist/number-coercion.mjs"
},
"./phone-presentation": {
"types": "./dist/phone-presentation.d.mts",
"import": "./dist/phone-presentation.mjs",
"default": "./dist/phone-presentation.mjs"
},
"./record-coerce": {
"types": "./dist/record-coerce.d.mts",
"import": "./dist/record-coerce.mjs",
@@ -66,6 +71,9 @@
}
},
"scripts": {
"build": "tsdown src/index.ts src/agent-id.ts src/boolean-coercion.ts src/error-coercion.ts src/expect.ts src/number-coercion.ts src/record-coerce.ts src/result.ts src/string-coerce.ts src/string-normalization.ts src/utf16-slice.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
"build": "tsdown src/index.ts src/agent-id.ts src/boolean-coercion.ts src/error-coercion.ts src/expect.ts src/number-coercion.ts src/phone-presentation.ts src/record-coerce.ts src/result.ts src/string-coerce.ts src/string-normalization.ts src/utf16-slice.ts --no-config --platform node --format esm --dts --out-dir dist --clean"
},
"dependencies": {
"libphonenumber-js": "1.13.9"
}
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { formatInternationalPhoneNumberForDisplay } from "./phone-presentation.js";
describe("formatInternationalPhoneNumberForDisplay", () => {
it.each([
["+4930123456", "Germany · +49 30 123456"],
[" +4930123456 ", "Germany · +49 30 123456"],
["+15551234567", "+1 555 123 4567"],
])("formats %s for display without requiring assignment validity", (raw, expected) => {
expect(formatInternationalPhoneNumberForDisplay(raw, "en")).toBe(expected);
});
it.each([
["NANPA US", "+12133734253", "+1 213 373 4253"],
["NANPA Canada", "+16045551234", "+1 604 555 1234"],
["NANPA toll-free", "+18005551234", "+1 800 555 1234"],
["United Kingdom", "+442079460018", "+44 20 7946 0018"],
["Finland and Åland", "+358412345678", "+358 41 2345678"],
["Australia and external territories", "+61412345678", "+61 412 345 678"],
])("does not claim a country for shared calling codes: %s", (_name, raw, expected) => {
expect(formatInternationalPhoneNumberForDisplay(raw, "en")).toBe(expected);
});
it("formats non-geographic numbers without a country label", () => {
expect(formatInternationalPhoneNumberForDisplay("+80012345678", "en")).toBe("+800 1234 5678");
});
it.each([
["malformed", "+not-a-number"],
["short", "+123"],
["national", "020 7946 0018"],
["token", "bot-token"],
["JID", "15551234567@s.whatsapp.net"],
["email", "person@example.com"],
["whitespace", " "],
])("returns undefined for %s input", (_kind, raw) => {
expect(formatInternationalPhoneNumberForDisplay(raw, "en")).toBeUndefined();
});
it("returns undefined for a malformed locale", () => {
expect(formatInternationalPhoneNumberForDisplay("+4930123456", "not_a_locale")).toBeUndefined();
});
});
@@ -0,0 +1,45 @@
import {
getCountries,
getCountryCallingCode,
parsePhoneNumberFromString,
} from "libphonenumber-js/min";
const sharedCountryCallingCodes = (() => {
const counts = new Map<string, number>();
for (const country of getCountries()) {
const callingCode = getCountryCallingCode(country);
counts.set(callingCode, (counts.get(callingCode) ?? 0) + 1);
}
return new Set(
[...counts.entries()].filter(([, count]) => count > 1).map(([callingCode]) => callingCode),
);
})();
export function formatInternationalPhoneNumberForDisplay(
raw: string,
locale?: string,
): string | undefined {
const candidate = raw.trim();
if (!candidate.startsWith("+")) {
return undefined;
}
try {
const phoneNumber = parsePhoneNumberFromString(candidate, { extract: false });
if (!phoneNumber?.isPossible()) {
return undefined;
}
const international = phoneNumber.formatInternational();
if (!phoneNumber.country || sharedCountryCallingCodes.has(phoneNumber.countryCallingCode)) {
return international;
}
const countryName = new Intl.DisplayNames(locale ? [locale] : undefined, {
type: "region",
}).of(phoneNumber.country);
return `${countryName || phoneNumber.country} · ${international}`;
} catch {
return undefined;
}
}
+10 -1
View File
@@ -2186,7 +2186,11 @@ importers:
specifier: 2.4.0
version: 2.4.0
packages/normalization-core: {}
packages/normalization-core:
dependencies:
libphonenumber-js:
specifier: 1.13.9
version: 1.13.9
packages/plugin-package-contract: {}
@@ -6374,6 +6378,9 @@ packages:
resolution: {integrity: sha512-x/2Gu1/C6L3IICY09zyfp984AWiOYjn53u4WfdY3yh+3KTzMN8Xkm77q3lenWMVIk5SnSzjGEkQT+VQMFHLBHQ==}
engines: {node: '>=20'}
libphonenumber-js@1.13.9:
resolution: {integrity: sha512-VNS5vWMM7r0P66BYv+TQJATxExEgLxN+34hfHDVhDkUsGAE4cRg0shCNSLTXNKm7nIUscC7AfB51TjxEeF7msQ==}
libsignal@6.0.0:
resolution: {integrity: sha512-d/5V3YFtDljbFMufz4ncyUYGYhJl+vzAe+c2EFFBQ6bz1h8Q3IOMEGXYMzlibU60I+e8GagMMpji18iez3P1hA==}
@@ -12862,6 +12869,8 @@ snapshots:
libopus-wasm@0.2.0: {}
libphonenumber-js@1.13.9: {}
libsignal@6.0.0:
dependencies:
curve25519-js: 0.0.4
+2
View File
@@ -3,6 +3,7 @@
*
* Defines JSON Schema metadata, UI hints, and runtime parser result shapes.
*/
import type { ConfigUiPresentation } from "../../shared/config-ui-hints-types.js";
import type { JsonSchemaObject } from "../../shared/json-schema.types.js";
/** Optional UI metadata for a JSON Schema property. */
@@ -13,6 +14,7 @@ export type ChannelConfigUiHint = {
advanced?: boolean;
sensitive?: boolean;
placeholder?: string;
presentation?: ConfigUiPresentation;
itemTemplate?: unknown;
};
@@ -672,6 +672,26 @@ describe("channels command", () => {
expect(telegramIndex).toBeLessThan(whatsappIndex);
});
it("formats phone allowlists without interpreting arbitrary account names", () => {
const lines = formatGatewayChannelsStatusLines({
channelLabels: { signal: "Signal" },
channelAccounts: {
signal: [
{
accountId: "work",
name: "+12133734253",
configured: true,
allowFrom: ["+442079460018", "uuid:123e4567-e89b-12d3-a456-426614174000"],
},
],
},
});
expect(lines).toContain(
"- Signal work (+12133734253): configured, allow:+44 20 7946 0018 (id: +442079460018),uuid:123e4567-e89b-12d3-a456-426614174000",
);
});
it.each([
{
name: "surfaces Discord privileged intent issues in channels status output",
+5 -1
View File
@@ -15,6 +15,7 @@ import { isGatewaySecretRefUnavailableError } from "../../gateway/credentials.js
import { collectChannelStatusIssues } from "../../infra/channels-status-issues.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
import { formatPhoneNumberForCli } from "../../infra/phone-number-presentation.js";
import { listConfiguredAnnounceChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js";
import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
import {
@@ -143,7 +144,10 @@ export function formatGatewayChannelsStatusLines(payload: Record<string, unknown
bits.push(`dm:${account.dmPolicy}`);
}
if (Array.isArray(account.allowFrom) && account.allowFrom.length > 0) {
bits.push(`allow:${account.allowFrom.slice(0, 2).join(",")}`);
const allowFrom = account.allowFrom
.slice(0, 2)
.map((entry) => formatPhoneNumberForCli(String(entry)));
bits.push(`allow:${allowFrom.join(",")}`);
}
appendTokenSourceBits(bits, account);
const application = account.application as
+46
View File
@@ -122,6 +122,52 @@ describe("buildChannelsTable", () => {
expect(detailRow?.Notes).toContain("credential not checked");
});
it("formats human phone identity while preserving raw account ids", async () => {
const phonePlugin = {
id: "signal",
meta: { label: "Signal" },
config: {
listAccountIds: () => ["work"],
defaultAccountId: () => "work",
formatAllowFrom: ({ allowFrom }: { allowFrom: Array<string | number> }) =>
allowFrom.map((entry) => String(entry).replace(/^\+/u, "")),
},
configSchema: {
schema: { type: "object" },
uiHints: { allowFrom: { presentation: "phone-number" } },
},
status: {
buildChannelSummary: async () => ({
statusState: "linked",
self: { e164: "+15551234567" },
}),
},
};
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([phonePlugin]);
mocks.resolveInspectedChannelAccount.mockResolvedValue({
account: {
name: "+12133734253",
allowFrom: ["+442079460018", "bot-token"],
},
enabled: true,
configured: true,
});
const table = await buildChannelsTable({ channels: { signal: { enabled: true } } });
expect(table.rows).toContainEqual(
expect.objectContaining({
id: "signal",
detail: "linked · +1 555 123 4567 (id: +15551234567)",
}),
);
expect(table.details[0]?.rows[0]).toEqual({
Account: "work (+12133734253)",
Status: "OK",
Notes: "allow:+44 20 7946 0018 (id: 442079460018),bot-token",
});
});
it("shows configured official external channels when the plugin is missing", async () => {
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]);
mocks.missingOfficialExternalChannels.add("feishu");
+8 -3
View File
@@ -25,6 +25,7 @@ import {
markConfiguredUnavailableCredentialStatusesAvailable,
} from "../../channels/status/read-model.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatPhoneNumberForCli } from "../../infra/phone-number-presentation.js";
import { listExplicitConfiguredChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js";
import { resolveMissingOfficialExternalChannelPluginRepairHint } from "../../plugins/official-external-plugin-repair-hints.js";
import {
@@ -153,12 +154,16 @@ const buildAccountNotes = (params: {
plugin.config.resolveAllowFrom?.({ cfg, accountId: snapshot.accountId }) ?? snapshot.allowFrom;
if (allowFrom?.length) {
// Cap allow-list output so large channel policies do not dominate the status table.
const allowInternationalDigits =
plugin.configSchema?.uiHints?.allowFrom?.presentation === "phone-number";
const formatted = formatChannelAllowFrom({
plugin,
cfg,
accountId: snapshot.accountId,
allowFrom,
}).slice(0, 3);
})
.slice(0, 3)
.map((allowEntry) => formatPhoneNumberForCli(allowEntry, { allowInternationalDigits }));
if (formatted.length > 0) {
notes.push(`allow:${formatted.join(",")}`);
}
@@ -374,7 +379,7 @@ export async function buildChannelsTable(
if (link.statusState === "linked") {
const extra: string[] = [];
if (link.selfE164) {
extra.push(link.selfE164);
extra.push(formatPhoneNumberForCli(link.selfE164));
}
if (link.authAgeMs != null && link.authAgeMs >= 0) {
extra.push(`auth ${formatTimeAgo(link.authAgeMs)}`);
@@ -393,7 +398,7 @@ export async function buildChannelsTable(
const base = link.linked ? "linked" : "not linked";
const extra: string[] = [];
if (link.linked && link.selfE164) {
extra.push(link.selfE164);
extra.push(formatPhoneNumberForCli(link.selfE164));
}
if (link.linked && link.authAgeMs != null && link.authAgeMs >= 0) {
extra.push(`auth ${formatTimeAgo(link.authAgeMs)}`);
File diff suppressed because one or more lines are too long
+35
View File
@@ -127,6 +127,41 @@ describe("config schema", () => {
expect(res.uiHints["nodeHost.mcp.servers.*.env.*"]?.sensitive).toBe(true);
expect(res.uiHints["nodeHost.mcp.servers.*.url"]?.tags).toContain(SENSITIVE_URL_HINT_TAG);
expect(res.uiHints["models.providers.*.baseUrl"]?.tags).toContain(SENSITIVE_URL_HINT_TAG);
const phonePresentationPaths = [
"channels.sms.fromNumber",
"channels.sms.defaultTo",
"channels.sms.allowFrom",
"channels.sms.accounts.*.fromNumber",
"channels.sms.accounts.*.defaultTo",
"channels.sms.accounts.*.allowFrom.*",
"channels.signal.account",
"channels.signal.allowFrom",
"channels.signal.defaultTo",
"channels.signal.groupAllowFrom",
"channels.signal.reactionAllowlist",
"channels.signal.accounts.*.account",
"channels.signal.accounts.*.allowFrom.*",
"channels.signal.accounts.*.defaultTo",
"channels.signal.accounts.*.groupAllowFrom.*",
"channels.signal.accounts.*.reactionAllowlist.*",
"channels.whatsapp.allowFrom",
"channels.whatsapp.defaultTo",
"channels.whatsapp.groupAllowFrom",
"channels.whatsapp.accounts.*.allowFrom.*",
"channels.whatsapp.accounts.*.defaultTo",
"channels.whatsapp.accounts.*.groupAllowFrom.*",
"channels.imessage.allowFrom",
"channels.imessage.defaultTo",
"channels.imessage.groupAllowFrom",
"channels.imessage.accounts.*.allowFrom.*",
"channels.imessage.accounts.*.defaultTo",
"channels.imessage.accounts.*.groupAllowFrom.*",
];
for (const path of phonePresentationPaths) {
expect(res.uiHints[path]?.presentation, path).toBe("phone-number");
}
expect(res.uiHints["channels.sms.authToken"]?.presentation).toBeUndefined();
expect(res.uiHints["channels.signal.configPath"]?.presentation).toBeUndefined();
expect(res.uiHints["proxy.tls.caFile"]?.tags).toEqual(
expect.arrayContaining(["security", "network", "storage"]),
);
+4 -1
View File
@@ -142,7 +142,10 @@ export type PluginUiMetadata = {
description?: string;
configUiHints?: Record<
string,
Pick<ConfigUiHint, "label" | "help" | "tags" | "advanced" | "sensitive" | "placeholder">
Pick<
ConfigUiHint,
"label" | "help" | "tags" | "advanced" | "sensitive" | "placeholder" | "presentation"
>
>;
configSchema?: JsonSchemaNode;
};
+5 -3
View File
@@ -103,7 +103,7 @@ function makeTelegramSummaryPlugin(params: {
resolveAccount: getAccount,
isConfigured: isFixtureAccountConfigured,
isEnabled: isFixtureAccountEnabled,
formatAllowFrom: () => ["alice", "bob", "carol"],
formatAllowFrom: ({ allowFrom }) => allowFrom.map(String),
},
status: {
buildChannelSummary: async () => ({
@@ -222,13 +222,15 @@ describe("buildChannelSummary", () => {
configured: true,
linked: true,
authAgeMs: 300_000,
allowFrom: ["alice", "bob", "carol"],
allowFrom: ["+12133734253", "bot-token", "ignored"],
}),
],
});
expect(lines).toContain("Telegram: linked +15551234567 auth 5m ago");
expect(lines).toContain(" - primary (Main Bot) (dm:mutuals, token:env, allow:alice,bob)");
expect(lines).toContain(
" - primary (Main Bot) (dm:mutuals, token:env, allow:+12133734253,bot-token)",
);
});
it("shows not-linked status when linked metadata is explicitly false", async () => {
+12
View File
@@ -0,0 +1,12 @@
import { formatInternationalPhoneNumberForDisplay } from "@openclaw/normalization-core/phone-presentation";
export function formatPhoneNumberForCli(
raw: string,
options?: { allowInternationalDigits?: boolean },
): string {
const trimmed = raw.trim();
const candidate =
options?.allowInternationalDigits === true && /^\d{7,15}$/u.test(trimmed) ? `+${trimmed}` : raw;
const presentation = formatInternationalPhoneNumberForDisplay(candidate);
return presentation && presentation !== raw ? `${presentation} (id: ${raw})` : raw;
}
+40
View File
@@ -2517,6 +2517,46 @@ describe("loadPluginManifestRegistry", () => {
});
});
it("normalizes config hint presentation values at the manifest boundary", () => {
const dir = makeTempDir();
writeManifest(dir, {
id: "phone-hints",
channels: ["phone-hints"],
configSchema: { type: "object" },
uiHints: {
phone: { label: "Phone", presentation: "phone-number" },
legacy: { help: "Keep this hint", presentation: "telephone" },
ignored: "not-an-object",
},
channelConfigs: {
"phone-hints": {
schema: { type: "object" },
uiHints: {
phone: { presentation: "phone-number" },
legacy: { help: "Keep this channel hint", presentation: "telephone" },
ignored: false,
},
},
},
});
const registry = loadSingleCandidateRegistry({
idHint: "phone-hints",
rootDir: dir,
origin: "workspace",
});
const plugin = registry.plugins[0];
expect(plugin?.configUiHints).toEqual({
phone: { label: "Phone", presentation: "phone-number" },
legacy: { help: "Keep this hint" },
});
expect(plugin?.channelConfigs?.["phone-hints"]?.uiHints).toEqual({
phone: { presentation: "phone-number" },
legacy: { help: "Keep this channel hint" },
});
});
it("hydrates bundled channel config metadata onto manifest records", () => {
const dir = makeTempDir();
const registry = loadRegistry([
+3
View File
@@ -1,3 +1,5 @@
import type { ConfigUiPresentation } from "../shared/config-ui-hints-types.js";
/** UI hint metadata for plugin config schema fields. */
export type PluginConfigUiHint = {
label?: string;
@@ -6,6 +8,7 @@ export type PluginConfigUiHint = {
advanced?: boolean;
sensitive?: boolean;
placeholder?: string;
presentation?: ConfigUiPresentation;
};
/** Top-level plugin manifest format. */
+20 -7
View File
@@ -1729,6 +1729,24 @@ function normalizeProviderAuthChoices(
return normalized.length > 0 ? normalized : undefined;
}
function normalizeConfigUiHints(value: unknown): Record<string, PluginConfigUiHint> | undefined {
if (!isRecord(value)) {
return undefined;
}
const normalized: Record<string, PluginConfigUiHint> = Object.create(null);
for (const [hintPath, rawHint] of Object.entries(value)) {
if (!isRecord(rawHint)) {
continue;
}
const hint = { ...rawHint } as Record<string, unknown>;
if ("presentation" in hint && hint.presentation !== "phone-number") {
delete hint.presentation;
}
normalized[hintPath] = hint as PluginConfigUiHint;
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
function normalizeChannelConfigs(
value: unknown,
): Record<string, PluginManifestChannelConfig> | undefined {
@@ -1745,9 +1763,7 @@ function normalizeChannelConfigs(
if (!schema) {
continue;
}
const uiHints = isRecord(rawEntry.uiHints)
? (rawEntry.uiHints as Record<string, PluginConfigUiHint>)
: undefined;
const uiHints = normalizeConfigUiHints(rawEntry.uiHints);
const runtime =
isRecord(rawEntry.runtime) && typeof rawEntry.runtime.safeParse === "function"
? (rawEntry.runtime as ChannelConfigRuntimeSchema)
@@ -2005,10 +2021,7 @@ export function loadPluginManifest(
const configContracts = normalizeManifestConfigContracts(raw.configContracts);
const channelConfigs = normalizeChannelConfigs(raw.channelConfigs);
let uiHints: Record<string, PluginConfigUiHint> | undefined;
if (isRecord(raw.uiHints)) {
uiHints = raw.uiHints as Record<string, PluginConfigUiHint>;
}
const uiHints = normalizeConfigUiHints(raw.uiHints);
return cacheResult({
ok: true,
+3
View File
@@ -1,3 +1,5 @@
export type ConfigUiPresentation = "phone-number";
/** UI metadata attached to config schema paths for forms, docs, and redaction policy. */
export type ConfigUiHint = {
label?: string;
@@ -8,6 +10,7 @@ export type ConfigUiHint = {
advanced?: boolean;
sensitive?: boolean;
placeholder?: string;
presentation?: ConfigUiPresentation;
itemTemplate?: unknown;
};
+10
View File
@@ -439,6 +439,16 @@ export const sharedVitestConfig = {
"number-coercion.ts",
),
},
{
find: "@openclaw/normalization-core/phone-presentation",
replacement: path.join(
repoRoot,
"packages",
"normalization-core",
"src",
"phone-presentation.ts",
),
},
{
find: "@openclaw/normalization-core/record-coerce",
replacement: path.join(
+3
View File
@@ -152,6 +152,9 @@
"@openclaw/normalization-core/number-coercion": [
"./packages/normalization-core/src/number-coercion.ts"
],
"@openclaw/normalization-core/phone-presentation": [
"./packages/normalization-core/src/phone-presentation.ts"
],
"@openclaw/normalization-core/record-coerce": [
"./packages/normalization-core/src/record-coerce.ts"
],
+5 -1
View File
@@ -51,7 +51,11 @@ export function controlUiStableChunkName(id: string): string | undefined {
return "markdown-runtime";
}
if (moduleIdIncludesPackage(id, "zod") || moduleIdIncludesPackage(id, "json5")) {
if (
moduleIdIncludesPackage(id, "zod") ||
moduleIdIncludesPackage(id, "json5") ||
moduleIdIncludesPackage(id, "libphonenumber-js")
) {
return "config-runtime";
}
+5
View File
@@ -23,6 +23,11 @@ describe("Control UI build chunking", () => {
expect(controlUiStableChunkName("/tmp/openclaw-pnpm-node-modules/json5/dist/index.js")).toBe(
"config-runtime",
);
expect(
controlUiStableChunkName(
"/tmp/openclaw-pnpm-node-modules/libphonenumber-js/max/exports/parsePhoneNumber.js",
),
).toBe("config-runtime");
expect(controlUiStableChunkName("/repo/ui/src/components/config-form.shared.ts")).toBe(
"control-ui-shared",
);
+6
View File
@@ -269,6 +269,12 @@ describe("Control UI Vite config", () => {
find: "@openclaw/normalization-core/string-coerce",
replacement: path.join(repoRoot, "packages/normalization-core/src/string-coerce.ts"),
});
expect(
aliases.find((alias) => alias.find === "@openclaw/normalization-core/phone-presentation"),
)?.toEqual({
find: "@openclaw/normalization-core/phone-presentation",
replacement: path.join(repoRoot, "packages/normalization-core/src/phone-presentation.ts"),
});
});
it("uses Node package resolution for external packages inherited by worktrees", () => {
@@ -124,6 +124,123 @@ describe("config form renderer", () => {
expect(onPatch).toHaveBeenCalledWith(["bind"], "tailnet");
});
it("shows phone presentations without changing raw config values", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
const analysis = analyzeConfigSchema({
type: "object",
properties: {
fromNumber: { type: "string" },
target: { type: "string" },
accounts: {
type: "object",
additionalProperties: {
type: "object",
properties: {
allowFrom: {
type: "array",
items: { type: "string" },
},
},
},
},
},
});
render(
renderConfigForm({
schema: analysis.schema,
uiHints: {
fromNumber: { presentation: "phone-number" },
target: { presentation: "phone-number" },
"accounts.*.allowFrom.*": { presentation: "phone-number" },
},
unsupportedPaths: analysis.unsupportedPaths,
value: {
fromNumber: "+4930123456",
target: "token-value",
accounts: { work: { allowFrom: ["+81312345678"] } },
},
onPatch,
}),
container,
);
const phoneInputs = Array.from(
container.querySelectorAll<HTMLInputElement>(".settings-phone-presentation input"),
);
expect(phoneInputs.map((input) => input.value)).toEqual(
expect.arrayContaining(["+4930123456", "+81312345678", "token-value"]),
);
expect(phoneInputs).toHaveLength(3);
expect(
Array.from(container.querySelectorAll(".settings-phone-presentation__value")).map((node) =>
node.textContent?.trim(),
),
).toEqual(expect.arrayContaining(["Germany · +49 30 123456", "Japan · +81 3 1234 5678"]));
const tokenInput = expectElement(
Array.from(container.querySelectorAll<HTMLInputElement>("input.settings-input")).find(
(input) => input.value === "token-value",
),
"token input",
);
const tokenPresentation = expectElement(
tokenInput.closest(".settings-phone-presentation"),
"token phone presentation wrapper",
);
expect(tokenPresentation.querySelector(".settings-phone-presentation__value")).toBeNull();
const fromNumberInput = expectElement(
phoneInputs.find((input) => input.value === "+4930123456"),
"from number input",
);
fromNumberInput.value = " +4930123456 ";
fromNumberInput.dispatchEvent(new Event("input", { bubbles: true }));
expect(onPatch).toHaveBeenLastCalledWith(["fromNumber"], " +4930123456 ");
fromNumberInput.dispatchEvent(new Event("change", { bubbles: true }));
expect(onPatch).toHaveBeenLastCalledWith(["fromNumber"], "+4930123456");
});
it("keeps phone inputs focused while presentation appears and disappears", () => {
const container = document.createElement("div");
document.body.append(container);
const analysis = analyzeConfigSchema({
type: "object",
properties: { phone: { type: "string" } },
});
const renderValue = (phone: string) => {
render(
renderConfigForm({
schema: analysis.schema,
uiHints: { phone: { presentation: "phone-number" } },
unsupportedPaths: analysis.unsupportedPaths,
value: { phone },
onPatch: vi.fn(),
}),
container,
);
};
renderValue("+123");
const input = expectElement(
container.querySelector<HTMLInputElement>("input.settings-input"),
"phone input",
);
input.focus();
renderValue("+4930123456");
expect(container.querySelector(".settings-phone-presentation__value")?.textContent).toContain(
"+49 30 123456",
);
expect(container.querySelector("input.settings-input")).toBe(input);
expect(document.activeElement).toBe(input);
renderValue("+123");
expect(container.querySelector(".settings-phone-presentation__value")).toBeNull();
expect(container.querySelector("input.settings-input")).toBe(input);
expect(document.activeElement).toBe(input);
container.remove();
});
it("renders subsection labels exactly once", () => {
const container = document.createElement("div");
render(
+19 -2
View File
@@ -1,9 +1,10 @@
// Control UI view renders config form screen content.
import { formatInternationalPhoneNumberForDisplay } from "@openclaw/normalization-core/phone-presentation";
import { html, nothing, type TemplateResult } from "lit";
import type { ConfigUiHints } from "../api/types.ts";
import { icons } from "../components/icons.ts";
import "../components/tooltip.ts";
import { t } from "../i18n/index.ts";
import { i18n, t } from "../i18n/index.ts";
import { formatUnknownText } from "../lib/format.ts";
import {
hasConfigSearchCriteria as hasSearchCriteria,
@@ -494,6 +495,11 @@ function renderTextInput(params: {
? jsonValue(value)
: (value ?? "");
const effectiveInputType = sensitiveState.isSensitive && !effectiveRedacted ? "text" : inputType;
const isPhonePresentation = hint?.presentation === "phone-number";
const phonePresentation =
isPhonePresentation && !effectiveRedacted && typeof value === "string"
? formatInternationalPhoneNumberForDisplay(value, i18n.getLocale())
: undefined;
const inputControl = html`
<input
@@ -541,8 +547,19 @@ function renderTextInput(params: {
disabled,
onToggleSensitivePath: params.onToggleSensitivePath,
});
const wrappedInput = wrapSensitiveControl(inputControl, revealToggle);
const presentedInput = isPhonePresentation
? html`
<span class="settings-phone-presentation">
${wrappedInput}
${phonePresentation
? html`<span class="settings-phone-presentation__value">${phonePresentation}</span>`
: nothing}
</span>
`
: wrappedInput;
const control = html`
${wrapSensitiveControl(inputControl, revealToggle)}
${presentedInput}
${schema.default !== undefined
? html`
<openclaw-tooltip .content=${t("configForm.resetToDefault")}>
+1
View File
@@ -224,6 +224,7 @@ export const en: TranslationMap = {
whatsapp: {
title: "WhatsApp",
subtitle: "Link WhatsApp Web and monitor connection health.",
phoneNumber: "Phone number",
loggedOut: "Logged out.",
logoutNotCleared:
"No stored WhatsApp session was cleared. It may already be absent, or its auth directory may require manual cleanup.",
+30
View File
@@ -172,6 +172,36 @@ describe("channel display selectors", () => {
});
});
describe("WhatsApp status", () => {
function renderPhoneFact(self: WhatsAppStatus["self"]): string | undefined {
const whatsapp = createWhatsAppStatus({ linked: true, self });
const props = createProps({
ts: Date.now(),
channelOrder: ["whatsapp"],
channelLabels: { whatsapp: "WhatsApp" },
channels: { whatsapp },
channelAccounts: {},
channelDefaultAccountId: {},
});
const container = document.createElement("div");
render(renderWhatsAppCard({ props, whatsapp }), container);
const label = Array.from(container.querySelectorAll("dt")).find(
(node) => node.textContent?.trim() === "Phone number",
);
return label?.nextElementSibling?.textContent?.trim();
}
it("renders readable phone identity with raw fallback and no JID fallback", () => {
expect(renderPhoneFact({ e164: "+4930123456", jid: "4930123456@s.whatsapp.net" })).toBe(
"Germany · +49 30 123456",
);
expect(renderPhoneFact({ e164: "not-a-phone", jid: "account@s.whatsapp.net" })).toBe(
"not-a-phone",
);
expect(renderPhoneFact({ jid: "account@s.whatsapp.net" })).toBeUndefined();
});
});
describe("WhatsApp card actions", () => {
it("shows QR as the primary action before WhatsApp is linked", () => {
const onWhatsAppStart = vi.fn();
+14 -1
View File
@@ -1,7 +1,8 @@
// Channels page renders WhatsApp status.
import { formatInternationalPhoneNumberForDisplay } from "@openclaw/normalization-core/phone-presentation";
import { html, nothing } from "lit";
import type { WhatsAppStatus } from "../../api/types.ts";
import { t } from "../../i18n/index.ts";
import { i18n, t } from "../../i18n/index.ts";
import { formatRelativeTimestamp, formatDurationHuman } from "../../lib/format.ts";
import { renderChannelConfigSection } from "./view.config.ts";
import {
@@ -21,6 +22,10 @@ export function renderWhatsAppCard(params: {
const configured = resolveChannelConfigured("whatsapp", props);
const linked = whatsapp?.linked === true;
const hasQr = props.whatsappQrDataUrl != null;
const rawPhoneNumber = whatsapp?.self?.e164;
const phoneNumber = rawPhoneNumber
? (formatInternationalPhoneNumberForDisplay(rawPhoneNumber, i18n.getLocale()) ?? rawPhoneNumber)
: undefined;
return renderSingleAccountChannelCard({
title: t("channels.whatsapp.title"),
@@ -37,6 +42,14 @@ export function renderWhatsAppCard(params: {
value: whatsapp?.linked ? t("common.yes") : t("common.no"),
kind: boolStatusKind(whatsapp?.linked),
},
...(phoneNumber
? [
{
label: t("channels.whatsapp.phoneNumber"),
value: phoneNumber,
},
]
: []),
{
label: t("common.running"),
value: whatsapp?.running ? t("common.yes") : t("common.no"),
+26 -1
View File
@@ -459,10 +459,35 @@ select.settings-select {
here (the shrink-to-fit control resolves 100% circularly); the fixed width
shrinks via flex because .settings-input/.settings-secret set min-width: 0. */
.settings-row:not(.settings-row--stacked) .settings-row__control > .settings-input,
.settings-row:not(.settings-row--stacked) .settings-row__control > .settings-secret {
.settings-row:not(.settings-row--stacked) .settings-row__control > .settings-secret,
.settings-row:not(.settings-row--stacked) .settings-row__control > .settings-phone-presentation {
width: 340px;
}
.settings-phone-presentation {
display: inline-flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.settings-phone-presentation > .settings-input,
.settings-phone-presentation > .settings-secret {
width: 100%;
}
.settings-phone-presentation__value {
color: var(--muted);
font-size: var(--control-ui-text-xs);
line-height: 1.35;
overflow-wrap: anywhere;
}
.settings-row--stacked .settings-row__control > .settings-phone-presentation {
flex: 1 1 auto;
width: 100%;
}
/* Number inputs hold short values (ports, counts, limits); the shared 340px
text width turns /+ stepper rows into a stretched bar. */
.settings-row:not(.settings-row--stacked)
+1
View File
@@ -301,6 +301,7 @@ function sourcePackageAlias(packageId: string, subpath?: string): ControlUiViteA
export function resolveSourcePackageAliasesForVite(): ControlUiViteAlias[] {
return [
sourcePackageAlias("normalization-core", "number-coercion"),
sourcePackageAlias("normalization-core", "phone-presentation"),
sourcePackageAlias("normalization-core", "record-coerce"),
sourcePackageAlias("normalization-core", "string-coerce"),
sourcePackageAlias("normalization-core", "string-normalization"),