Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/contracts/src/client-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
110 changes: 0 additions & 110 deletions packages/contracts/src/device-rotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <number> | telephony sms <number> <text>';

/**
* 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 <action> <number> [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;
}
6 changes: 4 additions & 2 deletions packages/contracts/src/facades/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
106 changes: 106 additions & 0 deletions packages/contracts/src/telephony-input.ts
Original file line number Diff line number Diff line change
@@ -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 <number> | telephony sms <number> <text>';

/**
* 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 <action> <number> [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;
}
2 changes: 1 addition & 1 deletion packages/contracts/src/telephony-runtime.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/commands/system/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
Loading