From 959bd6a72675461e413c80c23bc449ba7d786a36 Mon Sep 17 00:00:00 2001 From: Mikolaj Adamowicz Date: Mon, 19 Oct 2026 09:40:56 +0000 Subject: [PATCH] refactor(contracts): move telephony input parsing out of device-rotation The telephony constants, TelephonyEventInput, and its parsers lived in device-rotation.ts only to keep the CLI and session-journal eager closures flat when telephony was added. Now that the code is on main, moving it to its own module is a pure split that the eager-closure no-growth rule tolerates, so telephony-input.ts owns it. Callers of @agent-device/contracts/device are unchanged. --- packages/contracts/src/client-system.ts | 3 +- packages/contracts/src/device-rotation.ts | 110 ------------------ packages/contracts/src/facades/device.ts | 6 +- ...ephony.test.ts => telephony-input.test.ts} | 2 +- packages/contracts/src/telephony-input.ts | 106 +++++++++++++++++ packages/contracts/src/telephony-runtime.ts | 2 +- src/commands/system/index.ts | 2 +- 7 files changed, 115 insertions(+), 116 deletions(-) rename packages/contracts/src/{device-rotation-telephony.test.ts => telephony-input.test.ts} (99%) create mode 100644 packages/contracts/src/telephony-input.ts diff --git a/packages/contracts/src/client-system.ts b/packages/contracts/src/client-system.ts index 401cb9b0be..62932a49de 100644 --- a/packages/contracts/src/client-system.ts +++ b/packages/contracts/src/client-system.ts @@ -5,7 +5,8 @@ import type { BackMode } from './back-mode.ts'; import type { SelectorSnapshotCommandOptions } from './client-capture.ts'; import type { DeviceCommandBaseOptions } from './client-connection.ts'; import type { SettleCommandOptions } from './client-gesture.ts'; -import type { DeviceRotation, SetFoldPoseInput, TelephonyEventInput } from './device-rotation.ts'; +import type { DeviceRotation, SetFoldPoseInput } from './device-rotation.ts'; +import type { TelephonyEventInput } from './telephony-input.ts'; import type { TvRemoteButton } from './tv-remote.ts'; export type WaitCommandTarget = diff --git a/packages/contracts/src/device-rotation.ts b/packages/contracts/src/device-rotation.ts index b97040a8bd..64b1411725 100644 --- a/packages/contracts/src/device-rotation.ts +++ b/packages/contracts/src/device-rotation.ts @@ -206,113 +206,3 @@ export function parseFoldKeyframesJson(value: string): readonly FoldKeyframe[] { } return parseFoldKeyframes(decoded); } - -// ---- Telephony ------------------------------------------------------------------------------- -// The CLI and session journal load this module eagerly; moving these values to a module of their -// own would grow their eager closures past scripts/__tests__/eager-closure-budgets.ts. The types -// live in telephony-runtime.ts. - -/** - * The telephony events an emulator can be asked to simulate: an incoming voice call (`call`), - * answering it (`accept`), ending it (`cancel`), and an inbound text message (`sms`). - */ -export const TELEPHONY_CALL_ACTIONS = ['call', 'accept', 'cancel'] as const; -export const TELEPHONY_ACTIONS = [...TELEPHONY_CALL_ACTIONS, 'sms'] as const; -type TelephonyAction = (typeof TELEPHONY_ACTIONS)[number]; -type TelephonyCallAction = (typeof TELEPHONY_CALL_ACTIONS)[number]; - -export const TELEPHONY_USAGE = - 'telephony call|accept|cancel | telephony sms '; - -/** - * A caller number: digits with an optional leading `+`, at most the 15 digits of an E.164 number, - * which also fits the emulator modem's 16-character call number field. - */ -const TELEPHONY_NUMBER_PATTERN = /^\+?\d{1,15}$/; -/** - * The emulator console reads one command per line, so a line break inside the text would end the - * `sms send` command and start another. Line breaks are escaped for the console instead (a CRLF - * pair counts as one); every other control character, including a lone CR, is refused. - */ -// eslint-disable-next-line no-control-regex -const TELEPHONY_TEXT_FORBIDDEN_CHARACTERS = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/; - -/** Neutral intent for one telephony event, already validated by {@link parseTelephonyEventInput}. */ -export type TelephonyEventInput = - | Readonly<{ action: TelephonyCallAction; number: string }> - | Readonly<{ action: 'sms'; number: string; text: string }>; - -function parseTelephonyAction(input: unknown): TelephonyAction { - if (input === undefined) { - throw new AppError('INVALID_ARGS', `telephony requires an action. Use ${TELEPHONY_USAGE}.`); - } - const normalized = typeof input === 'string' ? input.trim().toLowerCase() : undefined; - const action = TELEPHONY_ACTIONS.find((candidate) => candidate === normalized); - if (!action) { - throw new AppError( - 'INVALID_ARGS', - `Invalid telephony action: ${String(input)}. Use ${TELEPHONY_ACTIONS.join(', ')}.`, - ); - } - return action; -} - -export function parseTelephonyEventInput( - input: Readonly<{ action?: unknown; number?: unknown; text?: unknown }>, -): TelephonyEventInput { - const action = parseTelephonyAction(input.action); - const number = parseTelephonyNumber(input.number, action); - if (action !== 'sms') { - if (input.text !== undefined) { - throw new AppError('INVALID_ARGS', `telephony ${action} takes only a phone number.`); - } - return { action, number }; - } - return { action, number, text: parseTelephonySmsText(input.text) }; -} - -/** - * `telephony [text…]` as the CLI and the daemon receive it. The words after the - * number are the SMS text, joined with single spaces the way an unquoted shell argument list splits. - */ -export function parseTelephonyPositionals(positionals: readonly string[]): TelephonyEventInput { - const [action, number, ...words] = positionals; - return parseTelephonyEventInput({ - action, - number, - text: words.length > 0 ? words.join(' ') : undefined, - }); -} - -/** The daemon positionals for one validated event; the inverse of {@link parseTelephonyPositionals}. */ -export function telephonyPositionals(input: TelephonyEventInput): string[] { - return input.action === 'sms' - ? [input.action, input.number, input.text] - : [input.action, input.number]; -} - -function parseTelephonyNumber(input: unknown, action: TelephonyAction): string { - if (typeof input !== 'string' || input.trim() === '') { - throw new AppError('INVALID_ARGS', `telephony ${action} requires a phone number.`); - } - const number = input.trim(); - if (!TELEPHONY_NUMBER_PATTERN.test(number)) { - throw new AppError('INVALID_ARGS', `Invalid phone number: ${number}.`, { - hint: 'Use digits with an optional leading +, for example 5551234 or +15551234567.', - }); - } - return number; -} - -function parseTelephonySmsText(input: unknown): string { - if (typeof input !== 'string' || input === '') { - throw new AppError('INVALID_ARGS', 'telephony sms requires message text.'); - } - const text = input.replaceAll('\r\n', '\n'); - if (TELEPHONY_TEXT_FORBIDDEN_CHARACTERS.test(text)) { - throw new AppError('INVALID_ARGS', 'telephony sms text must not contain control characters.', { - hint: 'Line breaks (LF or CRLF) are allowed; tabs, lone carriage returns, and other control characters are not.', - }); - } - return text; -} diff --git a/packages/contracts/src/facades/device.ts b/packages/contracts/src/facades/device.ts index 0318a025a4..dde853f8a7 100644 --- a/packages/contracts/src/facades/device.ts +++ b/packages/contracts/src/facades/device.ts @@ -45,21 +45,23 @@ export { isDeviceRotation, parseDeviceRotation, parseFoldPose, +} from '../device-rotation.ts'; +export { TELEPHONY_ACTIONS, TELEPHONY_CALL_ACTIONS, TELEPHONY_USAGE, parseTelephonyEventInput, parseTelephonyPositionals, telephonyPositionals, -} from '../device-rotation.ts'; +} from '../telephony-input.ts'; export type { DeviceRotation, FoldPose, FoldKeyframe, FoldScreenCoordinateSpace, SetFoldPoseInput, - TelephonyEventInput, } from '../device-rotation.ts'; +export type { TelephonyEventInput } from '../telephony-input.ts'; export type { TelephonyCommandResult, TelephonyRuntimeOperations } from '../telephony-runtime.ts'; export type { BootCommandResult, ShutdownCommandResult } from '../device.ts'; export type { diff --git a/packages/contracts/src/device-rotation-telephony.test.ts b/packages/contracts/src/telephony-input.test.ts similarity index 99% rename from packages/contracts/src/device-rotation-telephony.test.ts rename to packages/contracts/src/telephony-input.test.ts index 8ac4b870b3..b2e904a9e2 100644 --- a/packages/contracts/src/device-rotation-telephony.test.ts +++ b/packages/contracts/src/telephony-input.test.ts @@ -3,7 +3,7 @@ import { parseTelephonyEventInput, parseTelephonyPositionals, telephonyPositionals, -} from './device-rotation.ts'; +} from './telephony-input.ts'; test('parses each action with its number, normalizing the action and trimming the number', () => { expect(parseTelephonyEventInput({ action: ' CALL ', number: ' 5551234 ' })).toEqual({ diff --git a/packages/contracts/src/telephony-input.ts b/packages/contracts/src/telephony-input.ts new file mode 100644 index 0000000000..036d052b1c --- /dev/null +++ b/packages/contracts/src/telephony-input.ts @@ -0,0 +1,106 @@ +import { AppError } from '@agent-device/kernel/errors'; + +/** + * The telephony events an emulator can be asked to simulate: an incoming voice call (`call`), + * answering it (`accept`), ending it (`cancel`), and an inbound text message (`sms`). + */ +export const TELEPHONY_CALL_ACTIONS = ['call', 'accept', 'cancel'] as const; +export const TELEPHONY_ACTIONS = [...TELEPHONY_CALL_ACTIONS, 'sms'] as const; +type TelephonyAction = (typeof TELEPHONY_ACTIONS)[number]; +type TelephonyCallAction = (typeof TELEPHONY_CALL_ACTIONS)[number]; + +export const TELEPHONY_USAGE = + 'telephony call|accept|cancel | telephony sms '; + +/** + * A caller number: digits with an optional leading `+`, at most the 15 digits of an E.164 number, + * which also fits the emulator modem's 16-character call number field. + */ +const TELEPHONY_NUMBER_PATTERN = /^\+?\d{1,15}$/; +/** + * The emulator console reads one command per line, so a line break inside the text would end the + * `sms send` command and start another. Line breaks are escaped for the console instead (a CRLF + * pair counts as one); every other control character, including a lone CR, is refused. + */ +// eslint-disable-next-line no-control-regex +const TELEPHONY_TEXT_FORBIDDEN_CHARACTERS = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/; + +/** Neutral intent for one telephony event, already validated by {@link parseTelephonyEventInput}. */ +export type TelephonyEventInput = + | Readonly<{ action: TelephonyCallAction; number: string }> + | Readonly<{ action: 'sms'; number: string; text: string }>; + +function parseTelephonyAction(input: unknown): TelephonyAction { + if (input === undefined) { + throw new AppError('INVALID_ARGS', `telephony requires an action. Use ${TELEPHONY_USAGE}.`); + } + const normalized = typeof input === 'string' ? input.trim().toLowerCase() : undefined; + const action = TELEPHONY_ACTIONS.find((candidate) => candidate === normalized); + if (!action) { + throw new AppError( + 'INVALID_ARGS', + `Invalid telephony action: ${String(input)}. Use ${TELEPHONY_ACTIONS.join(', ')}.`, + ); + } + return action; +} + +export function parseTelephonyEventInput( + input: Readonly<{ action?: unknown; number?: unknown; text?: unknown }>, +): TelephonyEventInput { + const action = parseTelephonyAction(input.action); + const number = parseTelephonyNumber(input.number, action); + if (action !== 'sms') { + if (input.text !== undefined) { + throw new AppError('INVALID_ARGS', `telephony ${action} takes only a phone number.`); + } + return { action, number }; + } + return { action, number, text: parseTelephonySmsText(input.text) }; +} + +/** + * `telephony [text…]` as the CLI and the daemon receive it. The words after the + * number are the SMS text, joined with single spaces the way an unquoted shell argument list splits. + */ +export function parseTelephonyPositionals(positionals: readonly string[]): TelephonyEventInput { + const [action, number, ...words] = positionals; + return parseTelephonyEventInput({ + action, + number, + text: words.length > 0 ? words.join(' ') : undefined, + }); +} + +/** The daemon positionals for one validated event; the inverse of {@link parseTelephonyPositionals}. */ +export function telephonyPositionals(input: TelephonyEventInput): string[] { + return input.action === 'sms' + ? [input.action, input.number, input.text] + : [input.action, input.number]; +} + +function parseTelephonyNumber(input: unknown, action: TelephonyAction): string { + if (typeof input !== 'string' || input.trim() === '') { + throw new AppError('INVALID_ARGS', `telephony ${action} requires a phone number.`); + } + const number = input.trim(); + if (!TELEPHONY_NUMBER_PATTERN.test(number)) { + throw new AppError('INVALID_ARGS', `Invalid phone number: ${number}.`, { + hint: 'Use digits with an optional leading +, for example 5551234 or +15551234567.', + }); + } + return number; +} + +function parseTelephonySmsText(input: unknown): string { + if (typeof input !== 'string' || input === '') { + throw new AppError('INVALID_ARGS', 'telephony sms requires message text.'); + } + const text = input.replaceAll('\r\n', '\n'); + if (TELEPHONY_TEXT_FORBIDDEN_CHARACTERS.test(text)) { + throw new AppError('INVALID_ARGS', 'telephony sms text must not contain control characters.', { + hint: 'Line breaks (LF or CRLF) are allowed; tabs, lone carriage returns, and other control characters are not.', + }); + } + return text; +} diff --git a/packages/contracts/src/telephony-runtime.ts b/packages/contracts/src/telephony-runtime.ts index 74ff59c5ec..f3f97b73b2 100644 --- a/packages/contracts/src/telephony-runtime.ts +++ b/packages/contracts/src/telephony-runtime.ts @@ -1,4 +1,4 @@ -import type { TelephonyEventInput } from './device-rotation.ts'; +import type { TelephonyEventInput } from './telephony-input.ts'; /** * One simulated telephony event. The owner returns nothing: the emulator console acknowledges the diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index 16aad73a84..ad24cee650 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -152,7 +152,7 @@ export const SYSTEM_COMMAND_OUTPUT_SCHEMAS = { }, ['action', 'button', 'message'], ), - // packages/contracts/src/device-rotation.ts — discriminated union on `action`. + // packages/contracts/src/telephony-input.ts — discriminated union on `action`. telephony: { type: 'object', oneOf: [