mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
1f0a3ecc68
commit
f0c43dcf72
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Generated
+10
-1
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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"]),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")}>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user