From 66d5353fef2783a7d31df0c8f12a71d2c2ba756b Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 19 Sep 2026 18:28:35 +0200 Subject: [PATCH 1/2] feat(cli): add typed media, template, interactive and group mutation commands Add message send image|video|audio|document|template|location|contact|poll|buttons|list, message edit|delete, and group create, participants add|remove|promote|demote, subject, description and settings. Every command calls an existing MessagingClient method; local media is sent inline as base64 through messages.send. --- README.md | 85 ++- package.json | 3 + src/commands/group/create.ts | 45 ++ src/commands/group/description.ts | 49 ++ src/commands/group/participants/add.ts | 49 ++ src/commands/group/participants/demote.ts | 49 ++ src/commands/group/participants/promote.ts | 49 ++ src/commands/group/participants/remove.ts | 57 ++ src/commands/group/settings.ts | 63 +++ src/commands/group/subject.ts | 45 ++ src/commands/message/delete.ts | 52 ++ src/commands/message/edit.ts | 45 ++ src/commands/message/send/audio.ts | 48 ++ src/commands/message/send/buttons.ts | 60 ++ src/commands/message/send/contact.ts | 102 ++++ src/commands/message/send/document.ts | 48 ++ src/commands/message/send/image.ts | 44 ++ src/commands/message/send/list.ts | 66 +++ src/commands/message/send/location.ts | 55 ++ src/commands/message/send/poll.ts | 84 +++ src/commands/message/send/template.ts | 73 +++ src/commands/message/send/video.ts | 44 ++ src/input/message-send.ts | 51 ++ src/messaging/media-send.ts | 107 ++++ src/services/groups.ts | 191 ++++++- src/services/message-content.ts | 282 +++++++++ src/services/messages.ts | 52 ++ test/commands/group-mutations.test.ts | 208 +++++++ test/commands/message-sends.test.ts | 629 +++++++++++++++++++++ 29 files changed, 2727 insertions(+), 8 deletions(-) create mode 100644 src/commands/group/create.ts create mode 100644 src/commands/group/description.ts create mode 100644 src/commands/group/participants/add.ts create mode 100644 src/commands/group/participants/demote.ts create mode 100644 src/commands/group/participants/promote.ts create mode 100644 src/commands/group/participants/remove.ts create mode 100644 src/commands/group/settings.ts create mode 100644 src/commands/group/subject.ts create mode 100644 src/commands/message/delete.ts create mode 100644 src/commands/message/edit.ts create mode 100644 src/commands/message/send/audio.ts create mode 100644 src/commands/message/send/buttons.ts create mode 100644 src/commands/message/send/contact.ts create mode 100644 src/commands/message/send/document.ts create mode 100644 src/commands/message/send/image.ts create mode 100644 src/commands/message/send/list.ts create mode 100644 src/commands/message/send/location.ts create mode 100644 src/commands/message/send/poll.ts create mode 100644 src/commands/message/send/template.ts create mode 100644 src/commands/message/send/video.ts create mode 100644 src/input/message-send.ts create mode 100644 src/messaging/media-send.ts create mode 100644 src/services/message-content.ts create mode 100644 test/commands/group-mutations.test.ts create mode 100644 test/commands/message-sends.test.ts diff --git a/README.md b/README.md index 3d206bde..641c23e1 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,32 @@ polymorfa group invite-link 750000000001 --session session_123 Add `--json` for complete resource data and response metadata. Group and participant lists are unpaged because the source API does not expose cursors. -Mutating group commands remain under implementation. + +Create and change groups with a server API key that has `groups:manage`: + +```shell +polymorfa group create --session session_123 \ + --name 'Launch team' \ + --participant 750000000001 --participant 750000000002 + +polymorfa group participants add 750000000009 --session session_123 --participant 750000000003 +polymorfa group participants promote 750000000009 --session session_123 --participant 750000000003 +polymorfa group participants demote 750000000009 --session session_123 --participant 750000000003 +polymorfa group participants remove 750000000009 --session session_123 --participant 750000000003 --yes + +polymorfa group subject 750000000009 --session session_123 --value 'Launch crew' +polymorfa group description 750000000009 --session session_123 --value '' + +polymorfa group settings 750000000009 --session session_123 \ + --messaging admins --info-editing admins --member-add all --join-approval on +``` + +Participants are Polymorfa user IDs. Resolve phone numbers with +`polymorfa identity resolve` first. An empty `--value` clears the description. +`group settings` applies each requested setting with its own API call, in the +order info editing, messaging, member add, join approval. If one fails, the +error names the settings that were already applied. Removing participants +requires `--yes`. ## Messages @@ -191,9 +216,63 @@ Every mutation accepts `--idempotency-key` for safe retry identity. Server API keys require `messages:write`; client tokens remain subject to their live recipient rules, allowed actions, and rate limits. +### Typed sends + +Every `message send ` command accepts the same `--session`, `--to`, +`--reply-to`, `--mention`, `--forwarded`, and `--idempotency-key` flags as +`message send text`: + +```shell +# Media from a local file (sent inline as base64) or a public URL +polymorfa message send image --session session_123 --to +15551234567 --file ./photo.jpg --caption 'Today' +polymorfa message send video --session session_123 --to +15551234567 --url https://example.com/clip.mp4 +polymorfa message send audio --session session_123 --to +15551234567 --file ./note.ogg --voice +polymorfa message send document --session session_123 --to +15551234567 --file ./invoice.pdf + +polymorfa message send template --session session_123 --to +15551234567 \ + --name order_update --language en_US --components components.json +polymorfa message send location --session session_123 --to +15551234567 \ + --lat 51.5007 --long=-0.1246 --address 'Westminster' +polymorfa message send contact --session session_123 --to +15551234567 \ + --name 'Ada Lovelace' --phone +15557654321 +polymorfa message send poll --session session_123 --to 730000000001 \ + --title 'Lunch?' --option Pizza --option Sushi --multi-select +polymorfa message send buttons --session session_123 --to +15551234567 \ + --body 'Confirm your order?' --button reply:yes=Yes --button reply:no=No +polymorfa message send list --session session_123 --to +15551234567 \ + --title 'Pick a slot' --button-text Choose \ + --row 'Morning/am9=9:00' --row 'Afternoon/pm2=14:00|Near the office' +``` + +- Media commands take exactly one of `--file` or `--url`. Local files are + limited by `--max-bytes` (100 MiB by default) and the MIME type is inferred + from the extension unless `--mime-type` is set. `document` sends the local + file name unless `--filename` overrides it. `audio --voice` sends a + push-to-talk voice note. +- `template --components` reads a JSON array from a file or `-` for stdin. +- Pass negative coordinates as `--lat=-33.86` so they are not read as flags. +- `contact` builds a vCard 3.0 from `--name`, repeatable `--phone`, and + `--organization`, or sends the vCard file given by `--vcard`. +- `poll` takes 2 to 12 unique `--option` values. +- `buttons` takes repeatable `--button` values: `reply:=`, + `url:=`, `call:=<+E.164>`, or `copy:=`. +- `list` takes repeatable `--row` values `[
/]=[|<description>]`. + Rows with the same section are grouped in order. + +The API has no sticker send kind, so the CLI has no `message send sticker`. + +### Edit and delete + +```shell +polymorfa message edit 740000000001 --session session_123 --chat 730000000001 --body 'Corrected text' +polymorfa message delete 740000000001 --session session_123 --chat 730000000001 --yes +``` + +Edit and delete require `chats:manage`. Delete asks for confirmation; pass +`--yes` in scripts. + The API does not expose message history, list, or search operations. Those -commands are not synthesized by the CLI. Additional typed send kinds will be -added as focused subcommands instead of a generic JSON payload interface. +commands are not synthesized by the CLI. ## Media diff --git a/package.json b/package.json index 55ae2b8b..ce99677a 100644 --- a/package.json +++ b/package.json @@ -137,6 +137,9 @@ "group": { "description": "Inspect and manage Messaging groups." }, + "group:participants": { + "description": "List and change group participants and admins." + }, "key": { "description": "Inspect and revoke organization API keys." }, diff --git a/src/commands/group/create.ts b/src/commands/group/create.ts new file mode 100644 index 00000000..0494a7fd --- /dev/null +++ b/src/commands/group/create.ts @@ -0,0 +1,45 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {idempotencyOptions} from '../../input/message-send.js' +import {createGroup} from '../../services/groups.js' + +export default class GroupCreate extends BaseCommand { + static override description = 'Create a group with initial participants.' + + static override examples = [ + '<%= config.bin %> group create --session session_123 --name "Launch team" --participant 750000000001 --participant 750000000002', + ] + + static override flags = { + 'idempotency-key': Flags.string({ + description: 'Safely identify retries of this creation.', + }), + name: Flags.string({description: 'Group subject.', required: true}), + participant: Flags.string({ + description: 'Polymorfa user ID to add. Repeat for several participants.', + multiple: true, + required: true, + }), + session: Flags.string({ + description: 'Messaging session ID.', + required: true, + }), + } + + async run(): Promise<void> { + const {flags} = await this.parse(GroupCreate) + const result = await createGroup( + await this.messagingClient(flags, {serverApiKeyOnly: 'Groups'}), + flags.session, + flags.name, + flags.participant, + idempotencyOptions(flags['idempotency-key']), + ) + this.writeValue( + result, + `Created group ${result.data.id} (${result.data.name}).`, + flags, + ) + } +} diff --git a/src/commands/group/description.ts b/src/commands/group/description.ts new file mode 100644 index 00000000..8bc69403 --- /dev/null +++ b/src/commands/group/description.ts @@ -0,0 +1,49 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {idempotencyOptions} from '../../input/message-send.js' +import {setGroupText} from '../../services/groups.js' + +export default class GroupDescription extends BaseCommand { + static override args = { + group: Args.string({description: 'Group identifier.', required: true}), + } + + static override description = + 'Replace a group description. Pass an empty value to clear it.' + + static override examples = [ + '<%= config.bin %> group description 750000000009 --session session_123 --value "Weekly launch coordination"', + ] + + static override flags = { + 'idempotency-key': Flags.string({ + description: 'Safely identify retries of this change.', + }), + session: Flags.string({ + description: 'Messaging session ID.', + required: true, + }), + value: Flags.string({ + description: 'New description. An empty string clears it.', + required: true, + }), + } + + async run(): Promise<void> { + const {args, flags} = await this.parse(GroupDescription) + const result = await setGroupText( + await this.messagingClient(flags, {serverApiKeyOnly: 'Groups'}), + flags.session, + args.group, + 'description', + flags.value, + idempotencyOptions(flags['idempotency-key']), + ) + this.writeValue( + result, + `Updated the description of group ${args.group}.`, + flags, + ) + } +} diff --git a/src/commands/group/participants/add.ts b/src/commands/group/participants/add.ts new file mode 100644 index 00000000..f3edca3d --- /dev/null +++ b/src/commands/group/participants/add.ts @@ -0,0 +1,49 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {idempotencyOptions} from '../../../input/message-send.js' +import {changeGroupParticipants} from '../../../services/groups.js' + +export default class GroupParticipantsAdd extends BaseCommand { + static override args = { + group: Args.string({description: 'Group identifier.', required: true}), + } + + static override description = 'Add participants to a group.' + + static override examples = [ + '<%= config.bin %> group participants add 750000000009 --session session_123 --participant 750000000001', + ] + + static override flags = { + 'idempotency-key': Flags.string({ + description: 'Safely identify retries of this change.', + }), + participant: Flags.string({ + description: 'Polymorfa user ID. Repeat for several participants.', + multiple: true, + required: true, + }), + session: Flags.string({ + description: 'Messaging session ID.', + required: true, + }), + } + + async run(): Promise<void> { + const {args, flags} = await this.parse(GroupParticipantsAdd) + const result = await changeGroupParticipants( + await this.messagingClient(flags, {serverApiKeyOnly: 'Groups'}), + flags.session, + args.group, + 'add', + flags.participant, + idempotencyOptions(flags['idempotency-key']), + ) + this.writeValue( + result, + `Added ${flags.participant.length} participant(s) in group ${args.group}.`, + flags, + ) + } +} diff --git a/src/commands/group/participants/demote.ts b/src/commands/group/participants/demote.ts new file mode 100644 index 00000000..cc8e4c62 --- /dev/null +++ b/src/commands/group/participants/demote.ts @@ -0,0 +1,49 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {idempotencyOptions} from '../../../input/message-send.js' +import {changeGroupParticipants} from '../../../services/groups.js' + +export default class GroupParticipantsDemote extends BaseCommand { + static override args = { + group: Args.string({description: 'Group identifier.', required: true}), + } + + static override description = 'Demote group admins to regular participants.' + + static override examples = [ + '<%= config.bin %> group participants demote 750000000009 --session session_123 --participant 750000000001', + ] + + static override flags = { + 'idempotency-key': Flags.string({ + description: 'Safely identify retries of this change.', + }), + participant: Flags.string({ + description: 'Polymorfa user ID. Repeat for several participants.', + multiple: true, + required: true, + }), + session: Flags.string({ + description: 'Messaging session ID.', + required: true, + }), + } + + async run(): Promise<void> { + const {args, flags} = await this.parse(GroupParticipantsDemote) + const result = await changeGroupParticipants( + await this.messagingClient(flags, {serverApiKeyOnly: 'Groups'}), + flags.session, + args.group, + 'demote', + flags.participant, + idempotencyOptions(flags['idempotency-key']), + ) + this.writeValue( + result, + `Demoted ${flags.participant.length} participant(s) in group ${args.group}.`, + flags, + ) + } +} diff --git a/src/commands/group/participants/promote.ts b/src/commands/group/participants/promote.ts new file mode 100644 index 00000000..4f13b93a --- /dev/null +++ b/src/commands/group/participants/promote.ts @@ -0,0 +1,49 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {idempotencyOptions} from '../../../input/message-send.js' +import {changeGroupParticipants} from '../../../services/groups.js' + +export default class GroupParticipantsPromote extends BaseCommand { + static override args = { + group: Args.string({description: 'Group identifier.', required: true}), + } + + static override description = 'Promote participants to group admins.' + + static override examples = [ + '<%= config.bin %> group participants promote 750000000009 --session session_123 --participant 750000000001', + ] + + static override flags = { + 'idempotency-key': Flags.string({ + description: 'Safely identify retries of this change.', + }), + participant: Flags.string({ + description: 'Polymorfa user ID. Repeat for several participants.', + multiple: true, + required: true, + }), + session: Flags.string({ + description: 'Messaging session ID.', + required: true, + }), + } + + async run(): Promise<void> { + const {args, flags} = await this.parse(GroupParticipantsPromote) + const result = await changeGroupParticipants( + await this.messagingClient(flags, {serverApiKeyOnly: 'Groups'}), + flags.session, + args.group, + 'promote', + flags.participant, + idempotencyOptions(flags['idempotency-key']), + ) + this.writeValue( + result, + `Promoted ${flags.participant.length} participant(s) in group ${args.group}.`, + flags, + ) + } +} diff --git a/src/commands/group/participants/remove.ts b/src/commands/group/participants/remove.ts new file mode 100644 index 00000000..eaed4cc4 --- /dev/null +++ b/src/commands/group/participants/remove.ts @@ -0,0 +1,57 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {confirmDangerous} from '../../../input/confirmation.js' +import {idempotencyOptions} from '../../../input/message-send.js' +import {changeGroupParticipants} from '../../../services/groups.js' + +export default class GroupParticipantsRemove extends BaseCommand { + static override args = { + group: Args.string({description: 'Group identifier.', required: true}), + } + + static override description = 'Remove participants from a group.' + + static override examples = [ + '<%= config.bin %> group participants remove 750000000009 --session session_123 --participant 750000000001 --yes', + ] + + static override flags = { + 'idempotency-key': Flags.string({ + description: 'Safely identify retries of this change.', + }), + participant: Flags.string({ + description: 'Polymorfa user ID. Repeat for several participants.', + multiple: true, + required: true, + }), + session: Flags.string({ + description: 'Messaging session ID.', + required: true, + }), + yes: Flags.boolean({ + description: 'Confirm removal without an interactive prompt.', + }), + } + + async run(): Promise<void> { + const {args, flags} = await this.parse(GroupParticipantsRemove) + await confirmDangerous({ + prompt: `Remove ${flags.participant.length} participant(s) from group ${args.group}?`, + yes: flags.yes, + }) + const result = await changeGroupParticipants( + await this.messagingClient(flags, {serverApiKeyOnly: 'Groups'}), + flags.session, + args.group, + 'remove', + flags.participant, + idempotencyOptions(flags['idempotency-key']), + ) + this.writeValue( + result, + `Removed ${flags.participant.length} participant(s) in group ${args.group}.`, + flags, + ) + } +} diff --git a/src/commands/group/settings.ts b/src/commands/group/settings.ts new file mode 100644 index 00000000..535641c5 --- /dev/null +++ b/src/commands/group/settings.ts @@ -0,0 +1,63 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {updateGroupSettings} from '../../services/groups.js' + +export default class GroupSettings extends BaseCommand { + static override args = { + group: Args.string({description: 'Group identifier.', required: true}), + } + + static override description = + 'Change who can edit group info, send messages, and add members, and whether joining needs admin approval.' + + static override examples = [ + '<%= config.bin %> group settings 750000000009 --session session_123 --messaging admins --join-approval on', + ] + + static override flags = { + 'info-editing': Flags.string({ + description: 'Who can edit the subject, description, and picture.', + options: ['admins', 'all'], + }), + 'join-approval': Flags.string({ + description: 'Require admin approval for join requests.', + options: ['on', 'off'], + }), + 'member-add': Flags.string({ + description: 'Who can add members.', + options: ['admins', 'all'], + }), + messaging: Flags.string({ + description: 'Who can send messages.', + options: ['admins', 'all'], + }), + session: Flags.string({ + description: 'Messaging session ID.', + required: true, + }), + } + + async run(): Promise<void> { + const {args, flags} = await this.parse(GroupSettings) + const result = await updateGroupSettings( + await this.messagingClient(flags, {serverApiKeyOnly: 'Groups'}), + flags.session, + args.group, + { + infoEditing: flags['info-editing'] as 'admins' | 'all' | undefined, + joinApproval: + flags['join-approval'] === undefined + ? undefined + : flags['join-approval'] === 'on', + memberAdd: flags['member-add'] as 'admins' | 'all' | undefined, + messaging: flags.messaging as 'admins' | 'all' | undefined, + }, + ) + this.writeValue( + result, + `Updated ${result.data.applied.join(', ')} for group ${args.group}.`, + flags, + ) + } +} diff --git a/src/commands/group/subject.ts b/src/commands/group/subject.ts new file mode 100644 index 00000000..3c1381f2 --- /dev/null +++ b/src/commands/group/subject.ts @@ -0,0 +1,45 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {idempotencyOptions} from '../../input/message-send.js' +import {setGroupText} from '../../services/groups.js' + +export default class GroupSubject extends BaseCommand { + static override args = { + group: Args.string({description: 'Group identifier.', required: true}), + } + + static override description = 'Rename a group.' + + static override examples = [ + '<%= config.bin %> group subject 750000000009 --session session_123 --value "Launch team"', + ] + + static override flags = { + 'idempotency-key': Flags.string({ + description: 'Safely identify retries of this change.', + }), + session: Flags.string({ + description: 'Messaging session ID.', + required: true, + }), + value: Flags.string({description: 'New group subject.', required: true}), + } + + async run(): Promise<void> { + const {args, flags} = await this.parse(GroupSubject) + const result = await setGroupText( + await this.messagingClient(flags, {serverApiKeyOnly: 'Groups'}), + flags.session, + args.group, + 'subject', + flags.value, + idempotencyOptions(flags['idempotency-key']), + ) + this.writeValue( + result, + `Updated the subject of group ${args.group}.`, + flags, + ) + } +} diff --git a/src/commands/message/delete.ts b/src/commands/message/delete.ts new file mode 100644 index 00000000..5f0f6890 --- /dev/null +++ b/src/commands/message/delete.ts @@ -0,0 +1,52 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {confirmDangerous} from '../../input/confirmation.js' +import {idempotencyOptions} from '../../input/message-send.js' +import {deleteMessage} from '../../services/messages.js' + +export default class MessageDelete extends BaseCommand { + static override args = { + message: Args.string({description: 'Message ID.', required: true}), + } + + static override description = + 'Delete a message you sent for everyone in the conversation.' + + static override examples = [ + '<%= config.bin %> message delete 740000000001 --session session_123 --chat 730000000001 --yes', + ] + + static override flags = { + chat: Flags.string({ + description: 'Conversation that contains the message.', + required: true, + }), + 'idempotency-key': Flags.string({ + description: 'Safely identify retries of this deletion.', + }), + session: Flags.string({ + description: 'Messaging session ID.', + required: true, + }), + yes: Flags.boolean({ + description: 'Confirm deletion without an interactive prompt.', + }), + } + + async run(): Promise<void> { + const {args, flags} = await this.parse(MessageDelete) + await confirmDangerous({ + prompt: `Delete message ${args.message} for everyone?`, + yes: flags.yes, + }) + const result = await deleteMessage( + await this.messagingClient(flags), + flags.session, + flags.chat, + args.message, + idempotencyOptions(flags['idempotency-key']), + ) + this.writeValue(result, `Deleted message ${args.message}.`, flags) + } +} diff --git a/src/commands/message/edit.ts b/src/commands/message/edit.ts new file mode 100644 index 00000000..9108840e --- /dev/null +++ b/src/commands/message/edit.ts @@ -0,0 +1,45 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {idempotencyOptions} from '../../input/message-send.js' +import {editMessage} from '../../services/messages.js' + +export default class MessageEdit extends BaseCommand { + static override args = { + message: Args.string({description: 'Message ID.', required: true}), + } + + static override description = 'Replace the text of a message you sent.' + + static override examples = [ + '<%= config.bin %> message edit 740000000001 --session session_123 --chat 730000000001 --body "Corrected text"', + ] + + static override flags = { + body: Flags.string({description: 'Replacement text.', required: true}), + chat: Flags.string({ + description: 'Conversation that contains the message.', + required: true, + }), + 'idempotency-key': Flags.string({ + description: 'Safely identify retries of this edit.', + }), + session: Flags.string({ + description: 'Messaging session ID.', + required: true, + }), + } + + async run(): Promise<void> { + const {args, flags} = await this.parse(MessageEdit) + const result = await editMessage( + await this.messagingClient(flags), + flags.session, + flags.chat, + args.message, + flags.body, + idempotencyOptions(flags['idempotency-key']), + ) + this.writeValue(result, `Edited message ${args.message}.`, flags) + } +} diff --git a/src/commands/message/send/audio.ts b/src/commands/message/send/audio.ts new file mode 100644 index 00000000..71f7696b --- /dev/null +++ b/src/commands/message/send/audio.ts @@ -0,0 +1,48 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + idempotencyOptions, + sendContextFlags, +} from '../../../input/message-send.js' +import { + mediaSendRequest, + mediaSourceFlags, + sentMediaMessage, +} from '../../../messaging/media-send.js' +import {sendMessage} from '../../../services/messages.js' + +export default class MessageSendAudio extends BaseCommand { + static override description = + 'Send audio or a voice note from a local file or URL.' + + static override examples = [ + '<%= config.bin %> message send audio --session session_123 --to +15551234567 --file ./note.ogg', + '<%= config.bin %> message send audio --session session_123 --to +15551234567 --url https://example.com/note.ogg', + ] + + static override flags = { + ...sendContextFlags, + ...mediaSourceFlags, + voice: Flags.boolean({ + description: + 'Send as a push-to-talk voice note instead of an audio file.', + }), + } + + async run(): Promise<void> { + const {flags} = await this.parse(MessageSendAudio) + const request = await mediaSendRequest('audio', flags) + const result = await sendMessage( + await this.messagingClient(flags), + flags.session, + request, + idempotencyOptions(flags['idempotency-key']), + ) + this.writeValue( + result, + sentMediaMessage('audio', result.data, flags.to), + flags, + ) + } +} diff --git a/src/commands/message/send/buttons.ts b/src/commands/message/send/buttons.ts new file mode 100644 index 00000000..b40e6273 --- /dev/null +++ b/src/commands/message/send/buttons.ts @@ -0,0 +1,60 @@ +import {Flags} from '@oclif/core' +import type {SendButtonsMessageRequest} from '@polymorfa/sdk' + +import {BaseCommand} from '../../../base-command.js' +import { + idempotencyOptions, + sendContext, + sendContextFlags, +} from '../../../input/message-send.js' +import {parseButton} from '../../../services/message-content.js' +import {sendMessage} from '../../../services/messages.js' + +export default class MessageSendButtons extends BaseCommand { + static override description = + 'Send an interactive message with reply, URL, call, or copy-code buttons.' + + static override examples = [ + '<%= config.bin %> message send buttons --session session_123 --to +15551234567 --body "Confirm your order?" --button reply:yes=Yes --button reply:no=No', + '<%= config.bin %> message send buttons --session session_123 --to +15551234567 --body "Track it" --button "url:Open tracking=https://example.com/t/123"', + ] + + static override flags = { + ...sendContextFlags, + body: Flags.string({description: 'Message body.', required: true}), + button: Flags.string({ + description: + 'Button as reply:<id>=<text>, url:<text>=<url>, call:<text>=<+E.164>, or copy:<text>=<code>. Repeat for several buttons.', + multiple: true, + required: true, + }), + footer: Flags.string({description: 'Footer text.'}), + title: Flags.string({description: 'Header title.'}), + } + + async run(): Promise<void> { + const {flags} = await this.parse(MessageSendButtons) + const request: SendButtonsMessageRequest = { + ...sendContext(flags), + content: { + buttons: { + body: flags.body, + buttons: flags.button.map(parseButton), + ...(flags.footer === undefined ? {} : {footer: flags.footer}), + ...(flags.title === undefined ? {} : {title: flags.title}), + }, + }, + } + const result = await sendMessage( + await this.messagingClient(flags), + flags.session, + request, + idempotencyOptions(flags['idempotency-key']), + ) + this.writeValue( + result, + `Sent buttons message ${result.data.id} to ${flags.to}.`, + flags, + ) + } +} diff --git a/src/commands/message/send/contact.ts b/src/commands/message/send/contact.ts new file mode 100644 index 00000000..a97c5dfc --- /dev/null +++ b/src/commands/message/send/contact.ts @@ -0,0 +1,102 @@ +import {Flags} from '@oclif/core' +import {readFile} from 'node:fs/promises' + +import { + PolymorfaConfigurationError, + type SendContactMessageRequest, +} from '@polymorfa/sdk' + +import {BaseCommand} from '../../../base-command.js' +import { + idempotencyOptions, + sendContext, + sendContextFlags, +} from '../../../input/message-send.js' +import {buildVcard} from '../../../services/message-content.js' +import {sendMessage} from '../../../services/messages.js' + +export default class MessageSendContact extends BaseCommand { + static override description = + 'Send a contact card built from flags or read from a vCard file.' + + static override examples = [ + '<%= config.bin %> message send contact --session session_123 --to +15551234567 --name "Ada Lovelace" --phone +15557654321', + '<%= config.bin %> message send contact --session session_123 --to +15551234567 --vcard ./ada.vcf', + ] + + static override flags = { + ...sendContextFlags, + name: Flags.string({ + description: 'Contact display name.', + exclusive: ['vcard'], + }), + organization: Flags.string({ + description: 'Contact organization.', + exclusive: ['vcard'], + }), + phone: Flags.string({ + description: 'Contact E.164 phone number. Repeat for several numbers.', + exclusive: ['vcard'], + multiple: true, + }), + vcard: Flags.string({ + description: 'Read one vCard from a file.', + exclusive: ['name', 'phone', 'organization'], + }), + } + + async run(): Promise<void> { + const {flags} = await this.parse(MessageSendContact) + let vcard: string + if (flags.vcard === undefined) { + if (flags.name === undefined) { + throw new PolymorfaConfigurationError( + 'Pass --name and --phone, or --vcard.', + 'name', + ) + } + vcard = buildVcard({ + name: flags.name, + organization: flags.organization, + phones: flags.phone ?? [], + }) + } else { + vcard = await readVcard(flags.vcard) + } + const request: SendContactMessageRequest = { + ...sendContext(flags), + content: {contact: {vcard}}, + } + const result = await sendMessage( + await this.messagingClient(flags), + flags.session, + request, + idempotencyOptions(flags['idempotency-key']), + ) + this.writeValue( + result, + `Sent contact message ${result.data.id} to ${flags.to}.`, + flags, + ) + } +} + +async function readVcard(path: string): Promise<string> { + let contents: string + try { + contents = await readFile(path, 'utf8') + } catch { + throw new PolymorfaConfigurationError( + `Could not read vCard file: ${path}`, + 'vcard', + ) + } + const trimmed = contents.trim() + if (!/^BEGIN:VCARD/i.test(trimmed) || !/END:VCARD$/i.test(trimmed)) { + throw new PolymorfaConfigurationError( + `${path} does not contain a vCard.`, + 'vcard', + ) + } + return trimmed +} diff --git a/src/commands/message/send/document.ts b/src/commands/message/send/document.ts new file mode 100644 index 00000000..89090d55 --- /dev/null +++ b/src/commands/message/send/document.ts @@ -0,0 +1,48 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + idempotencyOptions, + sendContextFlags, +} from '../../../input/message-send.js' +import { + mediaSendRequest, + mediaSourceFlags, + sentMediaMessage, +} from '../../../messaging/media-send.js' +import {sendMessage} from '../../../services/messages.js' + +export default class MessageSendDocument extends BaseCommand { + static override description = 'Send a document from a local file or URL.' + + static override examples = [ + '<%= config.bin %> message send document --session session_123 --to +15551234567 --file ./invoice.pdf', + '<%= config.bin %> message send document --session session_123 --to +15551234567 --url https://example.com/invoice.pdf', + ] + + static override flags = { + ...sendContextFlags, + ...mediaSourceFlags, + caption: Flags.string({description: 'Caption sent with the document.'}), + filename: Flags.string({ + description: + 'File name shown to the recipient. Defaults to the local file name.', + }), + } + + async run(): Promise<void> { + const {flags} = await this.parse(MessageSendDocument) + const request = await mediaSendRequest('document', flags) + const result = await sendMessage( + await this.messagingClient(flags), + flags.session, + request, + idempotencyOptions(flags['idempotency-key']), + ) + this.writeValue( + result, + sentMediaMessage('document', result.data, flags.to), + flags, + ) + } +} diff --git a/src/commands/message/send/image.ts b/src/commands/message/send/image.ts new file mode 100644 index 00000000..82ff1c71 --- /dev/null +++ b/src/commands/message/send/image.ts @@ -0,0 +1,44 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + idempotencyOptions, + sendContextFlags, +} from '../../../input/message-send.js' +import { + mediaSendRequest, + mediaSourceFlags, + sentMediaMessage, +} from '../../../messaging/media-send.js' +import {sendMessage} from '../../../services/messages.js' + +export default class MessageSendImage extends BaseCommand { + static override description = 'Send an image from a local file or URL.' + + static override examples = [ + '<%= config.bin %> message send image --session session_123 --to +15551234567 --file ./photo.jpg', + '<%= config.bin %> message send image --session session_123 --to +15551234567 --url https://example.com/photo.jpg', + ] + + static override flags = { + ...sendContextFlags, + ...mediaSourceFlags, + caption: Flags.string({description: 'Caption sent with the image.'}), + } + + async run(): Promise<void> { + const {flags} = await this.parse(MessageSendImage) + const request = await mediaSendRequest('image', flags) + const result = await sendMessage( + await this.messagingClient(flags), + flags.session, + request, + idempotencyOptions(flags['idempotency-key']), + ) + this.writeValue( + result, + sentMediaMessage('image', result.data, flags.to), + flags, + ) + } +} diff --git a/src/commands/message/send/list.ts b/src/commands/message/send/list.ts new file mode 100644 index 00000000..d690434f --- /dev/null +++ b/src/commands/message/send/list.ts @@ -0,0 +1,66 @@ +import {Flags} from '@oclif/core' +import type {SendListMessageRequest} from '@polymorfa/sdk' + +import {BaseCommand} from '../../../base-command.js' +import { + idempotencyOptions, + sendContext, + sendContextFlags, +} from '../../../input/message-send.js' +import {parseListRows} from '../../../services/message-content.js' +import {sendMessage} from '../../../services/messages.js' + +export default class MessageSendList extends BaseCommand { + static override description = + 'Send an interactive list message with selectable rows.' + + static override examples = [ + '<%= config.bin %> message send list --session session_123 --to +15551234567 --title "Pick a slot" --button-text Choose --row "Morning/am9=9:00" --row "Afternoon/pm2=14:00|Near the office"', + ] + + static override flags = { + ...sendContextFlags, + 'button-text': Flags.string({ + description: 'Label of the button that opens the list.', + required: true, + }), + description: Flags.string({description: 'Body text above the button.'}), + footer: Flags.string({description: 'Footer text.'}), + row: Flags.string({ + description: + 'Row as [<section>/]<id>=<title>[|<description>]. Repeat for several rows; rows with the same section are grouped.', + multiple: true, + required: true, + }), + title: Flags.string({description: 'List title.', required: true}), + } + + async run(): Promise<void> { + const {flags} = await this.parse(MessageSendList) + const request: SendListMessageRequest = { + ...sendContext(flags), + content: { + list: { + buttonText: flags['button-text'], + sections: parseListRows(flags.row), + title: flags.title, + ...(flags.description === undefined + ? {} + : {description: flags.description}), + ...(flags.footer === undefined ? {} : {footer: flags.footer}), + }, + }, + } + const result = await sendMessage( + await this.messagingClient(flags), + flags.session, + request, + idempotencyOptions(flags['idempotency-key']), + ) + this.writeValue( + result, + `Sent list message ${result.data.id} to ${flags.to}.`, + flags, + ) + } +} diff --git a/src/commands/message/send/location.ts b/src/commands/message/send/location.ts new file mode 100644 index 00000000..704938c4 --- /dev/null +++ b/src/commands/message/send/location.ts @@ -0,0 +1,55 @@ +import {Flags} from '@oclif/core' +import type {SendLocationMessageRequest} from '@polymorfa/sdk' + +import {BaseCommand} from '../../../base-command.js' +import { + idempotencyOptions, + sendContext, + sendContextFlags, +} from '../../../input/message-send.js' +import {parseCoordinate} from '../../../services/message-content.js' +import {sendMessage} from '../../../services/messages.js' + +export default class MessageSendLocation extends BaseCommand { + static override description = 'Send a location pin.' + + static override examples = [ + '<%= config.bin %> message send location --session session_123 --to +15551234567 --lat 51.5007 --long=-0.1246 --address "Westminster, London"', + ] + + static override flags = { + ...sendContextFlags, + address: Flags.string({description: 'Address shown with the pin.'}), + lat: Flags.string({description: 'Latitude in degrees.', required: true}), + long: Flags.string({ + description: + 'Longitude in degrees. Use --long=-0.12 for negative values.', + required: true, + }), + } + + async run(): Promise<void> { + const {flags} = await this.parse(MessageSendLocation) + const request: SendLocationMessageRequest = { + ...sendContext(flags), + content: { + location: { + lat: parseCoordinate(flags.lat, 'lat'), + long: parseCoordinate(flags.long, 'long'), + ...(flags.address === undefined ? {} : {address: flags.address}), + }, + }, + } + const result = await sendMessage( + await this.messagingClient(flags), + flags.session, + request, + idempotencyOptions(flags['idempotency-key']), + ) + this.writeValue( + result, + `Sent location message ${result.data.id} to ${flags.to}.`, + flags, + ) + } +} diff --git a/src/commands/message/send/poll.ts b/src/commands/message/send/poll.ts new file mode 100644 index 00000000..4cf10866 --- /dev/null +++ b/src/commands/message/send/poll.ts @@ -0,0 +1,84 @@ +import {Flags} from '@oclif/core' +import { + PolymorfaConfigurationError, + type SendPollMessageRequest, +} from '@polymorfa/sdk' + +import {BaseCommand} from '../../../base-command.js' +import { + idempotencyOptions, + sendContext, + sendContextFlags, +} from '../../../input/message-send.js' +import {sendMessage} from '../../../services/messages.js' + +export default class MessageSendPoll extends BaseCommand { + static override description = 'Send a poll.' + + static override examples = [ + '<%= config.bin %> message send poll --session session_123 --to 730000000001 --title "Lunch?" --option Pizza --option Sushi', + ] + + static override flags = { + ...sendContextFlags, + 'multi-select': Flags.boolean({ + description: 'Allow voters to choose more than one option.', + }), + option: Flags.string({ + description: 'Poll option. Repeat for 2 to 12 options.', + multiple: true, + required: true, + }), + title: Flags.string({description: 'Poll question.', required: true}), + } + + async run(): Promise<void> { + const {flags} = await this.parse(MessageSendPoll) + const options = flags.option.map((option) => option.trim()) + if (options.length < 2 || options.length > 12) { + throw new PolymorfaConfigurationError( + 'A poll needs 2 to 12 --option values.', + 'option', + ) + } + if (options.some((option) => option.length === 0)) { + throw new PolymorfaConfigurationError( + 'Poll options cannot be empty.', + 'option', + ) + } + if (new Set(options).size !== options.length) { + throw new PolymorfaConfigurationError( + 'Poll options must be unique.', + 'option', + ) + } + if (flags.title.trim().length === 0) { + throw new PolymorfaConfigurationError( + 'Poll --title cannot be empty.', + 'title', + ) + } + const request: SendPollMessageRequest = { + ...sendContext(flags), + content: { + poll: { + options, + title: flags.title, + ...(flags['multi-select'] ? {multiSelect: true} : {}), + }, + }, + } + const result = await sendMessage( + await this.messagingClient(flags), + flags.session, + request, + idempotencyOptions(flags['idempotency-key']), + ) + this.writeValue( + result, + `Sent poll message ${result.data.id} to ${flags.to}.`, + flags, + ) + } +} diff --git a/src/commands/message/send/template.ts b/src/commands/message/send/template.ts new file mode 100644 index 00000000..f31c1fa9 --- /dev/null +++ b/src/commands/message/send/template.ts @@ -0,0 +1,73 @@ +import {Flags} from '@oclif/core' +import { + PolymorfaConfigurationError, + type SendTemplateMessageRequest, +} from '@polymorfa/sdk' + +import {BaseCommand} from '../../../base-command.js' +import { + idempotencyOptions, + sendContext, + sendContextFlags, +} from '../../../input/message-send.js' +import {readJsonInput} from '../../../api/request.js' +import {sendMessage} from '../../../services/messages.js' + +export default class MessageSendTemplate extends BaseCommand { + static override description = + 'Send an approved message template by name and language.' + + static override examples = [ + '<%= config.bin %> message send template --session session_123 --to +15551234567 --name order_update --language en_US', + '<%= config.bin %> message send template --session session_123 --to +15551234567 --name order_update --language en_US --components components.json', + ] + + static override flags = { + ...sendContextFlags, + components: Flags.string({ + description: + 'Read the template components JSON array from a file or stdin (-).', + }), + language: Flags.string({ + description: 'Template language code, such as en_US.', + required: true, + }), + name: Flags.string({description: 'Template name.', required: true}), + } + + async run(): Promise<void> { + const {flags} = await this.parse(MessageSendTemplate) + let components: readonly unknown[] | undefined + if (flags.components !== undefined) { + const parsed = await readJsonInput(flags.components) + if (!Array.isArray(parsed)) { + throw new PolymorfaConfigurationError( + 'Template --components must contain a JSON array.', + 'components', + ) + } + components = parsed + } + const request: SendTemplateMessageRequest = { + ...sendContext(flags), + content: { + template: { + language: flags.language, + name: flags.name, + ...(components === undefined ? {} : {components}), + }, + }, + } + const result = await sendMessage( + await this.messagingClient(flags), + flags.session, + request, + idempotencyOptions(flags['idempotency-key']), + ) + this.writeValue( + result, + `Sent template message ${result.data.id} to ${flags.to}.`, + flags, + ) + } +} diff --git a/src/commands/message/send/video.ts b/src/commands/message/send/video.ts new file mode 100644 index 00000000..74647b07 --- /dev/null +++ b/src/commands/message/send/video.ts @@ -0,0 +1,44 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + idempotencyOptions, + sendContextFlags, +} from '../../../input/message-send.js' +import { + mediaSendRequest, + mediaSourceFlags, + sentMediaMessage, +} from '../../../messaging/media-send.js' +import {sendMessage} from '../../../services/messages.js' + +export default class MessageSendVideo extends BaseCommand { + static override description = 'Send a video from a local file or URL.' + + static override examples = [ + '<%= config.bin %> message send video --session session_123 --to +15551234567 --file ./clip.mp4', + '<%= config.bin %> message send video --session session_123 --to +15551234567 --url https://example.com/clip.mp4', + ] + + static override flags = { + ...sendContextFlags, + ...mediaSourceFlags, + caption: Flags.string({description: 'Caption sent with the video.'}), + } + + async run(): Promise<void> { + const {flags} = await this.parse(MessageSendVideo) + const request = await mediaSendRequest('video', flags) + const result = await sendMessage( + await this.messagingClient(flags), + flags.session, + request, + idempotencyOptions(flags['idempotency-key']), + ) + this.writeValue( + result, + sentMediaMessage('video', result.data, flags.to), + flags, + ) + } +} diff --git a/src/input/message-send.ts b/src/input/message-send.ts new file mode 100644 index 00000000..a6611697 --- /dev/null +++ b/src/input/message-send.ts @@ -0,0 +1,51 @@ +import {Flags} from '@oclif/core' +import type {MessageSendContext, RequestOptions} from '@polymorfa/sdk' + +import {conversationReference} from '../services/conversation.js' + +/** Flags shared by every `message send <kind>` command. */ +export const sendContextFlags = { + forwarded: Flags.boolean({ + description: 'Mark the message as forwarded.', + }), + 'idempotency-key': Flags.string({ + description: 'Safely identify retries of this send operation.', + }), + mention: Flags.string({ + description: 'Mention a Polymorfa user ID. Repeat for multiple recipients.', + multiple: true, + }), + 'reply-to': Flags.string({ + description: 'Polymorfa message ID to quote in the reply.', + }), + session: Flags.string({ + description: 'Messaging session ID.', + required: true, + }), + to: Flags.string({ + description: 'Conversation ID, E.164 phone number, or BSUID.', + required: true, + }), +} + +export interface SendContextFlagValues { + readonly forwarded?: boolean | undefined + readonly mention?: readonly string[] | undefined + readonly 'reply-to'?: string | undefined + readonly to: string +} + +export function sendContext(flags: SendContextFlagValues): MessageSendContext { + return { + conversation: conversationReference(flags.to), + ...(flags.forwarded === true ? {isForwarded: true} : {}), + ...(flags.mention === undefined ? {} : {mentions: flags.mention}), + ...(flags['reply-to'] === undefined + ? {} + : {quotedMessage: {id: flags['reply-to']}}), + } +} + +export function idempotencyOptions(key: string | undefined): RequestOptions { + return key === undefined ? {} : {idempotencyKey: key} +} diff --git a/src/messaging/media-send.ts b/src/messaging/media-send.ts new file mode 100644 index 00000000..32989f12 --- /dev/null +++ b/src/messaging/media-send.ts @@ -0,0 +1,107 @@ +import {Flags} from '@oclif/core' +import type {MessageResponse, SendMediaMessageRequest} from '@polymorfa/sdk' + +import {sendContext, type SendContextFlagValues} from '../input/message-send.js' +import { + DEFAULT_MAX_MEDIA_BYTES, + mediaSource, + type MediaSource, +} from '../services/message-content.js' + +export type MediaSendKind = 'image' | 'video' | 'audio' | 'document' + +export const mediaSourceFlags = { + file: Flags.string({ + description: 'Local file to upload with the message.', + exclusive: ['url'], + }), + 'max-bytes': Flags.integer({ + default: DEFAULT_MAX_MEDIA_BYTES, + description: 'Reject local files larger than this many bytes.', + min: 1, + }), + 'mime-type': Flags.string({ + description: 'MIME type. Inferred from the file extension when omitted.', + }), + url: Flags.string({ + description: 'Public http or https URL the API fetches the media from.', + exclusive: ['file'], + }), +} + +export interface MediaSendFlagValues extends SendContextFlagValues { + readonly caption?: string | undefined + readonly file?: string | undefined + readonly filename?: string | undefined + readonly 'max-bytes'?: number | undefined + readonly 'mime-type'?: string | undefined + readonly url?: string | undefined + readonly voice?: boolean | undefined +} + +export async function mediaSendRequest( + kind: MediaSendKind, + flags: MediaSendFlagValues, +): Promise<SendMediaMessageRequest> { + const source = await mediaSource({ + caption: flags.caption, + file: flags.file, + maxBytes: flags['max-bytes'], + mimeType: flags['mime-type'], + url: flags.url, + }) + const context = sendContext(flags) + const base = mediaBody(source) + + switch (kind) { + case 'image': + return {...context, content: {image: base}} + case 'video': + return {...context, content: {video: base}} + case 'audio': + return { + ...context, + content: { + voice: {...base, ...(flags.voice === true ? {ptt: true} : {})}, + }, + } + case 'document': { + const filename = flags.filename ?? source.filename + return { + ...context, + content: { + file: {...base, ...(filename === undefined ? {} : {filename})}, + }, + } + } + } +} + +function mediaBody(source: MediaSource): + | { + readonly url: string + readonly mimeType?: string + readonly caption?: string + } + | { + readonly base64: string + readonly mimeType?: string + readonly caption?: string + } { + const extra = { + ...(source.mimeType === undefined ? {} : {mimeType: source.mimeType}), + ...(source.caption === undefined ? {} : {caption: source.caption}), + } + return source.url === undefined + ? {base64: source.base64 as string, ...extra} + : {url: source.url, ...extra} +} + +export function sentMediaMessage( + kind: MediaSendKind, + data: MessageResponse, + to: string, +): string { + const media = data.mediaId === undefined ? '' : ` (media ${data.mediaId})` + return `Sent ${kind} message ${data.id} to ${to}${media}.` +} diff --git a/src/services/groups.ts b/src/services/groups.ts index b9746120..37281259 100644 --- a/src/services/groups.ts +++ b/src/services/groups.ts @@ -1,8 +1,12 @@ -import type { - Group, - GroupParticipant, - MessagingClient, - ResponseMetadata, +import { + PolymorfaConfigurationError, + type Group, + type GroupMemberAddMode, + type GroupParticipant, + type MessagingClient, + type RequestOptions, + type ResponseMetadata, + type SuccessResponse, } from '@polymorfa/sdk' export interface ResourceResult<T> { @@ -59,6 +63,183 @@ export async function getGroupInviteLink( ) } +export type ParticipantAction = 'add' | 'remove' | 'promote' | 'demote' + +const USER_ID = /^[1-9][0-9]{0,18}$/ + +/** Group participants are Polymorfa user IDs; resolve phones with `identity`. */ +export function participantIds(values: readonly string[]): string[] { + const ids = values.map((value) => value.trim()) + if (ids.length === 0) { + throw new PolymorfaConfigurationError( + 'Pass at least one --participant.', + 'participant', + ) + } + for (const id of ids) { + if (!USER_ID.test(id)) { + throw new PolymorfaConfigurationError( + `Participant ${id} is not a Polymorfa user ID. Resolve phone numbers with \`polymorfa identity resolve\` first.`, + 'participant', + ) + } + } + return [...new Set(ids)] +} + +export async function createGroup( + client: MessagingClient, + session: string, + name: string, + participants: readonly string[], + options: RequestOptions = {}, +): Promise<ResourceResult<Group>> { + if (name.trim().length === 0) { + throw new PolymorfaConfigurationError( + 'Group --name cannot be empty.', + 'name', + ) + } + const response = await client.groups.create( + session, + {name, participants: participantIds(participants)}, + options, + ) + return result(response.data.data, response.metadata) +} + +export async function changeGroupParticipants( + client: MessagingClient, + session: string, + group: string, + action: ParticipantAction, + participants: readonly string[], + options: RequestOptions = {}, +): Promise<ResourceResult<SuccessResponse>> { + const body = {participants: participantIds(participants)} + const response = + action === 'add' + ? await client.groups.addParticipants(session, group, body, options) + : action === 'remove' + ? await client.groups.removeParticipants(session, group, body, options) + : action === 'promote' + ? await client.groups.promoteParticipants( + session, + group, + body, + options, + ) + : await client.groups.demoteParticipants( + session, + group, + body, + options, + ) + return result(response.data, response.metadata) +} + +export async function setGroupText( + client: MessagingClient, + session: string, + group: string, + field: 'subject' | 'description', + value: string, + options: RequestOptions = {}, +): Promise<ResourceResult<SuccessResponse>> { + if (field === 'subject' && value.trim().length === 0) { + throw new PolymorfaConfigurationError( + 'Group subject cannot be empty.', + 'subject', + ) + } + const response = + field === 'subject' + ? await client.groups.setSubject(session, group, {value}, options) + : await client.groups.setDescription(session, group, {value}, options) + return result(response.data, response.metadata) +} + +export interface GroupSettingsChange { + readonly infoEditing?: 'admins' | 'all' | undefined + readonly joinApproval?: boolean | undefined + readonly memberAdd?: 'admins' | 'all' | undefined + readonly messaging?: 'admins' | 'all' | undefined +} + +export type GroupSettingName = + 'info-editing' | 'join-approval' | 'member-add' | 'messaging' + +export interface GroupSettingsResult { + readonly applied: readonly GroupSettingName[] +} + +/** + * Apply each requested setting through its own SDK call, in a fixed order. + * The API has no combined settings write, so a failure leaves earlier + * settings applied; the thrown error lists them. + */ +export async function updateGroupSettings( + client: MessagingClient, + session: string, + group: string, + change: GroupSettingsChange, +): Promise<ResourceResult<GroupSettingsResult>> { + const steps: [ + GroupSettingName, + () => Promise<{metadata: ResponseMetadata}>, + ][] = [] + if (change.infoEditing !== undefined) { + const adminsOnly = change.infoEditing === 'admins' + steps.push([ + 'info-editing', + () => client.groups.setInfoEditing(session, group, {adminsOnly}), + ]) + } + if (change.messaging !== undefined) { + const adminsOnly = change.messaging === 'admins' + steps.push([ + 'messaging', + () => client.groups.setMessaging(session, group, {adminsOnly}), + ]) + } + if (change.memberAdd !== undefined) { + const mode: GroupMemberAddMode = + change.memberAdd === 'admins' ? 'admin_add' : 'all_member_add' + steps.push([ + 'member-add', + () => client.groups.setMemberAddMode(session, group, {mode}), + ]) + } + if (change.joinApproval !== undefined) { + const required = change.joinApproval + steps.push([ + 'join-approval', + () => client.groups.setJoinApproval(session, group, {required}), + ]) + } + if (steps.length === 0) { + throw new PolymorfaConfigurationError( + 'Pass at least one of --info-editing, --messaging, --member-add, or --join-approval.', + 'settings', + ) + } + + const applied: GroupSettingName[] = [] + let metadata: ResponseMetadata | undefined + for (const [name, run] of steps) { + try { + metadata = (await run()).metadata + } catch (error) { + if (applied.length > 0 && error instanceof Error) { + error.message = `${error.message} Settings already applied: ${applied.join(', ')}.` + } + throw error + } + applied.push(name) + } + return result({applied}, metadata as ResponseMetadata) +} + function result<T>(data: T, metadata: ResponseMetadata): ResourceResult<T> { return { data, diff --git a/src/services/message-content.ts b/src/services/message-content.ts new file mode 100644 index 00000000..4e26ffa1 --- /dev/null +++ b/src/services/message-content.ts @@ -0,0 +1,282 @@ +import {readFile, stat} from 'node:fs/promises' +import {basename, extname} from 'node:path' + +import { + PolymorfaConfigurationError, + type ListMessageSection, + type MessageButton, +} from '@polymorfa/sdk' + +import {DEFAULT_MAX_MEDIA_BYTES} from './media.js' + +export {DEFAULT_MAX_MEDIA_BYTES} + +export interface MediaSourceInput { + readonly caption?: string | undefined + readonly file?: string | undefined + readonly maxBytes?: number | undefined + readonly mimeType?: string | undefined + readonly url?: string | undefined +} + +export interface MediaSource { + readonly base64?: string + readonly caption?: string + readonly filename?: string + readonly mimeType?: string + readonly url?: string +} + +const MIME_TYPES: Readonly<Record<string, string>> = { + '.3gp': 'video/3gpp', + '.aac': 'audio/aac', + '.amr': 'audio/amr', + '.csv': 'text/csv', + '.doc': 'application/msword', + '.docx': + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.gif': 'image/gif', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.json': 'application/json', + '.m4a': 'audio/mp4', + '.mov': 'video/quicktime', + '.mp3': 'audio/mpeg', + '.mp4': 'video/mp4', + '.oga': 'audio/ogg', + '.ogg': 'audio/ogg', + '.opus': 'audio/ogg', + '.pdf': 'application/pdf', + '.png': 'image/png', + '.ppt': 'application/vnd.ms-powerpoint', + '.pptx': + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + '.txt': 'text/plain', + '.wav': 'audio/wav', + '.webm': 'video/webm', + '.webp': 'image/webp', + '.xls': 'application/vnd.ms-excel', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.zip': 'application/zip', +} + +/** Guess a MIME type from a file name; the API accepts omission. */ +export function mimeTypeFor(path: string): string | undefined { + return MIME_TYPES[extname(path).toLowerCase()] +} + +/** + * Resolve `--file` or `--url` into inline media content. Local files are sent + * as base64 in the send request, which is how the SDK uploads Messaging media. + */ +export async function mediaSource( + input: MediaSourceInput, +): Promise<MediaSource> { + if ((input.file === undefined) === (input.url === undefined)) { + throw new PolymorfaConfigurationError( + 'Specify exactly one of --file or --url.', + 'media', + ) + } + + const shared = { + ...(input.caption === undefined ? {} : {caption: input.caption}), + } + + if (input.url !== undefined) { + let parsed: URL + try { + parsed = new URL(input.url) + } catch { + throw new PolymorfaConfigurationError( + 'Media --url must be an absolute http or https URL.', + 'url', + ) + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new PolymorfaConfigurationError( + 'Media --url must be an absolute http or https URL.', + 'url', + ) + } + return { + url: input.url, + ...shared, + ...(input.mimeType === undefined ? {} : {mimeType: input.mimeType}), + } + } + + const path = input.file as string + const maxBytes = input.maxBytes ?? DEFAULT_MAX_MEDIA_BYTES + let size: number + try { + const details = await stat(path) + if (!details.isFile()) throw new Error('not a file') + size = details.size + } catch { + throw new PolymorfaConfigurationError( + `Could not read media file: ${path}`, + 'file', + ) + } + if (size === 0) { + throw new PolymorfaConfigurationError( + `Media file ${path} is empty.`, + 'file', + ) + } + if (size > maxBytes) { + throw new PolymorfaConfigurationError( + `Media file ${path} is ${size} bytes and exceeds the ${maxBytes} byte limit. Use --url for large media or raise --max-bytes.`, + 'max-bytes', + ) + } + + const bytes = await readFile(path) + const mimeType = input.mimeType ?? mimeTypeFor(path) + return { + base64: bytes.toString('base64'), + filename: basename(path), + ...shared, + ...(mimeType === undefined ? {} : {mimeType}), + } +} + +/** + * Parse `--button` values: + * reply:<id>=<text>, url:<text>=<url>, call:<text>=<phone>, copy:<text>=<code> + */ +export function parseButton(value: string): MessageButton { + const colon = value.indexOf(':') + const type = colon < 0 ? '' : value.slice(0, colon) + const rest = colon < 0 ? '' : value.slice(colon + 1) + const equals = rest.indexOf('=') + const left = equals < 0 ? '' : rest.slice(0, equals).trim() + const right = equals < 0 ? '' : rest.slice(equals + 1).trim() + + if (left.length === 0 || right.length === 0) throw invalidButton(value) + + switch (type) { + case 'reply': + return {type: 'reply', id: left, text: right} + case 'url': + return {type: 'url', text: left, url: right} + case 'call': + if (!/^\+[1-9]\d{1,14}$/.test(right)) throw invalidButton(value) + return {type: 'call', text: left, phoneNumber: right} + case 'copy': + return {type: 'copy', text: left, copyCode: right} + default: + throw invalidButton(value) + } +} + +function invalidButton(value: string): PolymorfaConfigurationError { + return new PolymorfaConfigurationError( + `Invalid --button "${value}". Use reply:<id>=<text>, url:<text>=<url>, call:<text>=<+E.164>, or copy:<text>=<code>.`, + 'button', + ) +} + +/** + * Parse `--row` values into list sections. A row is `<id>=<title>` with an + * optional `|<description>`, and may be prefixed by `<section>/` to group it. + */ +export function parseListRows(values: readonly string[]): ListMessageSection[] { + const sections = new Map< + string, + {id: string; title: string; description?: string}[] + >() + for (const value of values) { + const slash = value.indexOf('/') + const equals = value.indexOf('=') + const hasSection = slash > 0 && (equals < 0 || slash < equals) + const section = hasSection ? value.slice(0, slash).trim() : '' + const row = hasSection ? value.slice(slash + 1) : value + const rowEquals = row.indexOf('=') + const id = rowEquals < 0 ? '' : row.slice(0, rowEquals).trim() + const text = rowEquals < 0 ? '' : row.slice(rowEquals + 1) + const pipe = text.indexOf('|') + const title = (pipe < 0 ? text : text.slice(0, pipe)).trim() + const description = pipe < 0 ? undefined : text.slice(pipe + 1).trim() + if (id.length === 0 || title.length === 0) { + throw new PolymorfaConfigurationError( + `Invalid --row "${value}". Use [<section>/]<id>=<title>[|<description>].`, + 'row', + ) + } + const rows = sections.get(section) ?? [] + rows.push({ + id, + title, + ...(description === undefined || description.length === 0 + ? {} + : {description}), + }) + sections.set(section, rows) + } + return [...sections.entries()].map(([title, rows]) => ({ + ...(title.length === 0 ? {} : {title}), + rows, + })) +} + +export function parseCoordinate(value: string, name: 'lat' | 'long'): number { + const limit = name === 'lat' ? 90 : 180 + const parsed = Number(value) + if ( + value.trim().length === 0 || + !Number.isFinite(parsed) || + Math.abs(parsed) > limit + ) { + throw new PolymorfaConfigurationError( + `--${name} must be a number between -${limit} and ${limit}.`, + name, + ) + } + return parsed +} + +/** Build a minimal vCard 3.0 for one contact. */ +export function buildVcard(input: { + readonly name: string + readonly phones: readonly string[] + readonly organization?: string | undefined +}): string { + const name = input.name.trim() + if (name.length === 0) { + throw new PolymorfaConfigurationError( + 'Contact --name cannot be empty.', + 'name', + ) + } + if (input.phones.length === 0) { + throw new PolymorfaConfigurationError( + 'Pass at least one --phone for the contact.', + 'phone', + ) + } + const lines = ['BEGIN:VCARD', 'VERSION:3.0', `FN:${escapeVcard(name)}`] + if (input.organization !== undefined) { + lines.push(`ORG:${escapeVcard(input.organization)}`) + } + for (const phone of input.phones) { + if (!/^\+[1-9]\d{1,14}$/.test(phone)) { + throw new PolymorfaConfigurationError( + `Contact phone ${phone} must be an E.164 number such as +15551234567.`, + 'phone', + ) + } + lines.push(`TEL;type=CELL;waid=${phone.slice(1)}:${phone}`) + } + lines.push('END:VCARD') + return lines.join('\n') +} + +function escapeVcard(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/\n/g, '\\n') + .replace(/,/g, '\\,') + .replace(/;/g, '\\;') +} diff --git a/src/services/messages.ts b/src/services/messages.ts index 54c79260..23eb1c48 100644 --- a/src/services/messages.ts +++ b/src/services/messages.ts @@ -7,6 +7,7 @@ import { type RequestOptions, type ResponseMetadata, type SeenRequest, + type SendMessageRequest, type SendTextMessageRequest, type StarRequest, type SuccessResponse, @@ -39,6 +40,57 @@ export async function sendTextMessage( return result(response.data.data, response.metadata) } +/** Send any typed message kind through the SDK's single send method. */ +export async function sendMessage( + client: MessagingClient, + session: string, + request: SendMessageRequest, + options: RequestOptions = {}, +): Promise<MessageResult<MessageResponse>> { + const response = await client.messages.send(session, request, options) + return result(response.data.data, response.metadata) +} + +export async function editMessage( + client: MessagingClient, + session: string, + chat: string, + message: string, + text: string, + options: RequestOptions = {}, +): Promise<MessageResult<SuccessResponse>> { + if (text.trim().length === 0) { + throw new PolymorfaConfigurationError( + 'Edited message text cannot be empty.', + 'body', + ) + } + const response = await client.chats.editMessage( + session, + chat, + message, + {text}, + options, + ) + return result(response.data, response.metadata) +} + +export async function deleteMessage( + client: MessagingClient, + session: string, + chat: string, + message: string, + options: RequestOptions = {}, +): Promise<MessageResult<SuccessResponse>> { + const response = await client.chats.deleteMessage( + session, + chat, + message, + options, + ) + return result(response.data, response.metadata) +} + export async function reactToMessage( client: MessagingClient, session: string, diff --git a/test/commands/group-mutations.test.ts b/test/commands/group-mutations.test.ts new file mode 100644 index 00000000..840e247b --- /dev/null +++ b/test/commands/group-mutations.test.ts @@ -0,0 +1,208 @@ +import {runCommand} from '@oclif/test' +import {afterEach, describe, expect, test} from 'vitest' + +import { + startHttpSequenceServer, + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +const API_KEY = + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +describe('group mutation commands', () => { + let server: TestHttpServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + }) + + test('creates a group with de-duplicated participants', async () => { + server = await startHttpServer({ + body: { + data: { + createdAt: 1_766_000_000, + id: '750000000009', + name: 'Launch team', + participants: [], + }, + success: true, + }, + }) + + const result = await run( + 'group create --session session_1 --name "Launch team" --participant 750000000001 --participant 750000000001 --participant 750000000002 --idempotency-key grp_1', + ) + + expect(result.error).toBeUndefined() + expect(result.stdout).toContain('Created group 750000000009') + expect(server.requests[0]).toMatchObject({ + body: JSON.stringify({ + name: 'Launch team', + participants: ['750000000001', '750000000002'], + }), + method: 'POST', + url: '/messaging/session_1/groups', + }) + expect(server.requests[0]?.headers['idempotency-key']).toBe('grp_1') + }) + + test('rejects phone numbers as participants before transport', async () => { + server = await startHttpServer({body: {success: true}}) + + const result = await run( + 'group create --session session_1 --name Team --participant +15551234567 --json', + ) + + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('identity resolve') + expect(server.requests).toHaveLength(0) + }) + + test.each([ + ['add', '/participants/add'], + ['promote', '/admin/promote'], + ['demote', '/admin/demote'], + ])('%s participants through the matching route', async (action, route) => { + server = await startHttpServer({body: {success: true}}) + + const result = await run( + `group participants ${action} 750000000009 --session session_1 --participant 750000000001 --json`, + ) + + expect(result.error).toBeUndefined() + expect(server.requests[0]).toMatchObject({ + body: JSON.stringify({participants: ['750000000001']}), + method: 'POST', + url: `/messaging/session_1/groups/750000000009${route}`, + }) + }) + + test('requires confirmation to remove participants', async () => { + server = await startHttpServer({body: {success: true}}) + + const refused = await run( + 'group participants remove 750000000009 --session session_1 --participant 750000000001 --json', + ) + expect(exitCode(refused.error)).toBe(2) + expect(server.requests).toHaveLength(0) + + const result = await run( + 'group participants remove 750000000009 --session session_1 --participant 750000000001 --yes --json', + ) + expect(result.error).toBeUndefined() + expect(server.requests[0]?.url).toBe( + '/messaging/session_1/groups/750000000009/participants/remove', + ) + }) + + test('sets the subject and clears the description', async () => { + server = await startHttpServer({body: {success: true}}) + + const subject = await run( + 'group subject 750000000009 --session session_1 --value "New name" --json', + ) + const description = await run( + 'group description 750000000009 --session session_1 --value "" --json', + ) + + expect(subject.error).toBeUndefined() + expect(description.error).toBeUndefined() + expect( + server.requests.map((request) => [ + request.method, + request.url, + request.body, + ]), + ).toEqual([ + [ + 'PUT', + '/messaging/session_1/groups/750000000009/subject', + '{"value":"New name"}', + ], + [ + 'PUT', + '/messaging/session_1/groups/750000000009/description', + '{"value":""}', + ], + ]) + }) + + test('applies each requested setting through its own route', async () => { + server = await startHttpServer({body: {success: true}}) + + const result = await run( + 'group settings 750000000009 --session session_1 --info-editing all --messaging admins --member-add admins --join-approval on --json', + ) + + expect(result.error).toBeUndefined() + expect(JSON.parse(result.stdout)).toMatchObject({ + data: { + applied: ['info-editing', 'messaging', 'member-add', 'join-approval'], + }, + }) + expect( + server.requests.map((request) => [request.url, request.body]), + ).toEqual([ + [ + '/messaging/session_1/groups/750000000009/settings/info-edit', + '{"adminsOnly":false}', + ], + [ + '/messaging/session_1/groups/750000000009/settings/messages', + '{"adminsOnly":true}', + ], + [ + '/messaging/session_1/groups/750000000009/settings/member-add', + '{"mode":"admin_add"}', + ], + [ + '/messaging/session_1/groups/750000000009/settings/join-approval', + '{"required":true}', + ], + ]) + }) + + test('reports settings applied before a failure', async () => { + server = await startHttpSequenceServer([ + {body: {success: true}}, + {body: {error: 'forbidden', message: 'Not an admin'}, status: 403}, + ]) + + const result = await run( + 'group settings 750000000009 --session session_1 --info-editing admins --messaging admins', + ) + + expect(result.error).toBeDefined() + expect(result.stderr).toContain('Settings already applied: info-editing') + }) + + test('requires at least one setting', async () => { + server = await startHttpServer({body: {success: true}}) + + const result = await run( + 'group settings 750000000009 --session session_1 --json', + ) + + expect(exitCode(result.error)).toBe(2) + expect(server.requests).toHaveLength(0) + }) + + async function run(command: string) { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + () => runCommand(command), + ) + } +}) + +function exitCode(error: unknown): number | undefined { + return (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit +} diff --git a/test/commands/message-sends.test.ts b/test/commands/message-sends.test.ts new file mode 100644 index 00000000..00fac6f6 --- /dev/null +++ b/test/commands/message-sends.test.ts @@ -0,0 +1,629 @@ +import {mkdtemp, rm, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {runCommand} from '@oclif/test' +import {afterEach, beforeEach, describe, expect, test} from 'vitest' + +import { + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +const API_KEY = + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +describe('typed message send commands', () => { + let server: TestHttpServer | undefined + let directory: string + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-cli-send-')) + }) + + afterEach(async () => { + await server?.close() + server = undefined + await rm(directory, {force: true, recursive: true}) + }) + + test('uploads a local image as base64 with an inferred MIME type', async () => { + const path = join(directory, 'photo.png') + await writeFile(path, Buffer.from([0x89, 0x50, 0x4e, 0x47])) + server = await messageServer() + + const result = await send([ + 'message', + 'send', + 'image', + '--session', + 'session_1', + '--to', + '+15551234567', + '--file', + path, + '--caption', + 'Look', + '--json', + ]) + + expect(result.error).toBeUndefined() + expect(sentBody()).toEqual({ + conversation: {phoneNumber: '+15551234567'}, + content: { + image: {base64: 'iVBORw==', mimeType: 'image/png', caption: 'Look'}, + }, + }) + expect(server.requests[0]?.url).toBe('/messaging/session_1/messages/send') + }) + + test('sends a video by URL without reading local files', async () => { + server = await messageServer() + + const result = await send([ + 'message', + 'send', + 'video', + '--session', + 'session_1', + '--to', + '730000000001', + '--url', + 'https://example.com/clip.mp4', + '--json', + ]) + + expect(result.error).toBeUndefined() + expect(sentBody()).toEqual({ + conversation: {id: '730000000001'}, + content: {video: {url: 'https://example.com/clip.mp4'}}, + }) + }) + + test('sends audio as a voice note', async () => { + const path = join(directory, 'note.ogg') + await writeFile(path, 'OggS') + server = await messageServer() + + const result = await send([ + 'message', + 'send', + 'audio', + '--session', + 'session_1', + '--to', + '730000000001', + '--file', + path, + '--voice', + '--json', + ]) + + expect(result.error).toBeUndefined() + expect(sentBody()).toMatchObject({ + content: {voice: {mimeType: 'audio/ogg', ptt: true}}, + }) + }) + + test('sends a document with the local file name and idempotency key', async () => { + const path = join(directory, 'invoice.pdf') + await writeFile(path, '%PDF-1.7') + server = await messageServer() + + const result = await send([ + 'message', + 'send', + 'document', + '--session', + 'session_1', + '--to', + '730000000001', + '--file', + path, + '--idempotency-key', + 'doc_1', + '--reply-to', + '740000000009', + '--json', + ]) + + expect(result.error).toBeUndefined() + expect(sentBody()).toMatchObject({ + content: { + file: {filename: 'invoice.pdf', mimeType: 'application/pdf'}, + }, + quotedMessage: {id: '740000000009'}, + }) + expect(server.requests[0]?.headers['idempotency-key']).toBe('doc_1') + }) + + test('rejects media without exactly one source before transport', async () => { + server = await messageServer() + + const result = await send([ + 'message', + 'send', + 'image', + '--session', + 'session_1', + '--to', + '730000000001', + '--json', + ]) + + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('exactly one of --file or --url') + expect(server.requests).toHaveLength(0) + }) + + test('rejects files above --max-bytes before transport', async () => { + const path = join(directory, 'big.jpg') + await writeFile(path, Buffer.alloc(32)) + server = await messageServer() + + const result = await send([ + 'message', + 'send', + 'image', + '--session', + 'session_1', + '--to', + '730000000001', + '--file', + path, + '--max-bytes', + '16', + '--json', + ]) + + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('exceeds the 16 byte limit') + expect(server.requests).toHaveLength(0) + }) + + test('sends a template with components from a file', async () => { + const path = join(directory, 'components.json') + await writeFile( + path, + JSON.stringify([ + {type: 'body', parameters: [{type: 'text', text: 'A1'}]}, + ]), + ) + server = await messageServer() + + const result = await send([ + 'message', + 'send', + 'template', + '--session', + 'session_1', + '--to', + '730000000001', + '--name', + 'order_update', + '--language', + 'en_US', + '--components', + path, + '--json', + ]) + + expect(result.error).toBeUndefined() + expect(sentBody()).toMatchObject({ + content: { + template: { + components: [ + {type: 'body', parameters: [{type: 'text', text: 'A1'}]}, + ], + language: 'en_US', + name: 'order_update', + }, + }, + }) + }) + + test('rejects non-array template components', async () => { + const path = join(directory, 'components.json') + await writeFile(path, '{}') + server = await messageServer() + + const result = await send([ + 'message', + 'send', + 'template', + '--session', + 'session_1', + '--to', + '730000000001', + '--name', + 'n', + '--language', + 'en', + '--components', + path, + '--json', + ]) + + expect(exitCode(result.error)).toBe(2) + expect(server.requests).toHaveLength(0) + }) + + test('sends a location with signed coordinates', async () => { + server = await messageServer() + + const result = await send([ + 'message', + 'send', + 'location', + '--session', + 'session_1', + '--to', + '730000000001', + '--lat', + '51.5007', + '--long=-0.1246', + '--address', + 'Westminster', + '--json', + ]) + + expect(result.error).toBeUndefined() + expect(sentBody()).toMatchObject({ + content: { + location: {address: 'Westminster', lat: 51.5007, long: -0.1246}, + }, + }) + }) + + test('rejects out-of-range coordinates', async () => { + server = await messageServer() + + const result = await send([ + 'message', + 'send', + 'location', + '--session', + 'session_1', + '--to', + '730000000001', + '--lat', + '91', + '--long', + '0', + '--json', + ]) + + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('--lat must be a number') + expect(server.requests).toHaveLength(0) + }) + + test('builds a contact vCard from flags', async () => { + server = await messageServer() + + const result = await send([ + 'message', + 'send', + 'contact', + '--session', + 'session_1', + '--to', + '730000000001', + '--name', + '"Ada, Countess"', + '--phone', + '+15557654321', + '--json', + ]) + + expect(result.error).toBeUndefined() + expect(sentBody()).toMatchObject({ + content: { + contact: { + vcard: + 'BEGIN:VCARD\nVERSION:3.0\nFN:Ada\\, Countess\nTEL;type=CELL;waid=15557654321:+15557654321\nEND:VCARD', + }, + }, + }) + }) + + test('reads a contact vCard file', async () => { + const path = join(directory, 'ada.vcf') + await writeFile(path, 'BEGIN:VCARD\nVERSION:3.0\nFN:Ada\nEND:VCARD\n') + server = await messageServer() + + const result = await send([ + 'message', + 'send', + 'contact', + '--session', + 'session_1', + '--to', + '730000000001', + '--vcard', + path, + '--json', + ]) + + expect(result.error).toBeUndefined() + expect(sentBody()).toMatchObject({ + content: { + contact: {vcard: 'BEGIN:VCARD\nVERSION:3.0\nFN:Ada\nEND:VCARD'}, + }, + }) + }) + + test('sends a multi-select poll', async () => { + server = await messageServer() + + const result = await send([ + 'message', + 'send', + 'poll', + '--session', + 'session_1', + '--to', + '730000000001', + '--title', + 'Lunch?', + '--option', + 'Pizza', + '--option', + 'Sushi', + '--multi-select', + '--json', + ]) + + expect(result.error).toBeUndefined() + expect(sentBody()).toMatchObject({ + content: { + poll: {multiSelect: true, options: ['Pizza', 'Sushi'], title: 'Lunch?'}, + }, + }) + }) + + test('rejects polls with duplicate or too few options', async () => { + server = await messageServer() + + const one = await send([ + 'message', + 'send', + 'poll', + '--session', + 'session_1', + '--to', + '730000000001', + '--title', + 'Q', + '--option', + 'A', + '--json', + ]) + const duplicate = await send([ + 'message', + 'send', + 'poll', + '--session', + 'session_1', + '--to', + '730000000001', + '--title', + 'Q', + '--option', + 'A', + '--option', + 'A', + '--json', + ]) + + expect(exitCode(one.error)).toBe(2) + expect(exitCode(duplicate.error)).toBe(2) + expect(server.requests).toHaveLength(0) + }) + + test('sends buttons of every supported type', async () => { + server = await messageServer() + + const result = await send([ + 'message', + 'send', + 'buttons', + '--session', + 'session_1', + '--to', + '730000000001', + '--body', + 'Pick', + '--footer', + 'Thanks', + '--button', + 'reply:yes=Yes', + '--button', + 'url:Open=https://example.com/a?b=c', + '--button', + '"call:Call us=+15550001111"', + '--button', + 'copy:Code=SAVE10', + '--json', + ]) + + expect(result.error).toBeUndefined() + expect(sentBody()).toMatchObject({ + content: { + buttons: { + body: 'Pick', + buttons: [ + {id: 'yes', text: 'Yes', type: 'reply'}, + {text: 'Open', type: 'url', url: 'https://example.com/a?b=c'}, + {phoneNumber: '+15550001111', text: 'Call us', type: 'call'}, + {copyCode: 'SAVE10', text: 'Code', type: 'copy'}, + ], + footer: 'Thanks', + }, + }, + }) + }) + + test('rejects malformed buttons before transport', async () => { + server = await messageServer() + + const result = await send([ + 'message', + 'send', + 'buttons', + '--session', + 'session_1', + '--to', + '730000000001', + '--body', + 'Pick', + '--button', + 'reply:yes', + '--json', + ]) + + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('Invalid --button') + expect(server.requests).toHaveLength(0) + }) + + test('groups list rows into sections', async () => { + server = await messageServer() + + const result = await send([ + 'message', + 'send', + 'list', + '--session', + 'session_1', + '--to', + '730000000001', + '--title', + 'Slots', + '--button-text', + 'Choose', + '--row', + 'Morning/am9=9:00', + '--row', + 'Morning/am10=10:00|Quiet', + '--row', + 'Afternoon/pm2=14:00', + '--json', + ]) + + expect(result.error).toBeUndefined() + expect(sentBody()).toMatchObject({ + content: { + list: { + buttonText: 'Choose', + sections: [ + { + rows: [ + {id: 'am9', title: '9:00'}, + {description: 'Quiet', id: 'am10', title: '10:00'}, + ], + title: 'Morning', + }, + {rows: [{id: 'pm2', title: '14:00'}], title: 'Afternoon'}, + ], + title: 'Slots', + }, + }, + }) + }) + + test('edits a message through the chats resource', async () => { + server = await startHttpServer({body: {success: true}}) + + const result = await send([ + 'message', + 'edit', + '740000000001', + '--session', + 'session_1', + '--chat', + '730000000001', + '--body', + 'Fixed', + '--json', + ]) + + expect(result.error).toBeUndefined() + expect(server.requests[0]).toMatchObject({ + body: JSON.stringify({text: 'Fixed'}), + method: 'PUT', + url: '/messaging/session_1/chats/730000000001/messages/740000000001', + }) + }) + + test('requires confirmation before deleting a message', async () => { + server = await startHttpServer({body: {success: true}}) + + const refused = await send([ + 'message', + 'delete', + '740000000001', + '--session', + 'session_1', + '--chat', + '730000000001', + '--json', + ]) + expect(exitCode(refused.error)).toBe(2) + expect(server.requests).toHaveLength(0) + + const result = await send([ + 'message', + 'delete', + '740000000001', + '--session', + 'session_1', + '--chat', + '730000000001', + '--yes', + '--json', + ]) + expect(result.error).toBeUndefined() + expect(server.requests[0]).toMatchObject({ + method: 'DELETE', + url: '/messaging/session_1/chats/730000000001/messages/740000000001', + }) + }) + + function sentBody(): unknown { + return JSON.parse(server?.requests[0]?.body ?? 'null') + } + + async function send(argv: string[]) { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + () => runCommand(argv), + ) + } +}) + +async function messageServer(): Promise<TestHttpServer> { + return startHttpServer({ + body: { + data: { + conversation: {id: '730000000001'}, + id: '740000000001', + status: 'sent', + timestamp: '2026-09-19T00:00:00.000Z', + type: 'image', + whatsapp_id: '3A0000', + }, + success: true, + }, + }) +} + +function exitCode(error: unknown): number | undefined { + return (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit +} From a166f1a22d2e4d9077844650248dcf7e97962981 Mon Sep 17 00:00:00 2001 From: Rajeh Taher <rajeh@reforward.dev> Date: Sat, 19 Sep 2026 20:52:17 +0200 Subject: [PATCH 2/2] fix(cli): report normalized participant counts --- src/commands/group/participants/add.ts | 10 +++++++--- src/commands/group/participants/demote.ts | 10 +++++++--- src/commands/group/participants/promote.ts | 10 +++++++--- src/commands/group/participants/remove.ts | 12 ++++++++---- 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/commands/group/participants/add.ts b/src/commands/group/participants/add.ts index f3edca3d..b35d352f 100644 --- a/src/commands/group/participants/add.ts +++ b/src/commands/group/participants/add.ts @@ -2,7 +2,10 @@ import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../../base-command.js' import {idempotencyOptions} from '../../../input/message-send.js' -import {changeGroupParticipants} from '../../../services/groups.js' +import { + changeGroupParticipants, + participantIds, +} from '../../../services/groups.js' export default class GroupParticipantsAdd extends BaseCommand { static override args = { @@ -32,17 +35,18 @@ export default class GroupParticipantsAdd extends BaseCommand { async run(): Promise<void> { const {args, flags} = await this.parse(GroupParticipantsAdd) + const participants = participantIds(flags.participant) const result = await changeGroupParticipants( await this.messagingClient(flags, {serverApiKeyOnly: 'Groups'}), flags.session, args.group, 'add', - flags.participant, + participants, idempotencyOptions(flags['idempotency-key']), ) this.writeValue( result, - `Added ${flags.participant.length} participant(s) in group ${args.group}.`, + `Added ${participants.length} participant(s) in group ${args.group}.`, flags, ) } diff --git a/src/commands/group/participants/demote.ts b/src/commands/group/participants/demote.ts index cc8e4c62..4956a700 100644 --- a/src/commands/group/participants/demote.ts +++ b/src/commands/group/participants/demote.ts @@ -2,7 +2,10 @@ import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../../base-command.js' import {idempotencyOptions} from '../../../input/message-send.js' -import {changeGroupParticipants} from '../../../services/groups.js' +import { + changeGroupParticipants, + participantIds, +} from '../../../services/groups.js' export default class GroupParticipantsDemote extends BaseCommand { static override args = { @@ -32,17 +35,18 @@ export default class GroupParticipantsDemote extends BaseCommand { async run(): Promise<void> { const {args, flags} = await this.parse(GroupParticipantsDemote) + const participants = participantIds(flags.participant) const result = await changeGroupParticipants( await this.messagingClient(flags, {serverApiKeyOnly: 'Groups'}), flags.session, args.group, 'demote', - flags.participant, + participants, idempotencyOptions(flags['idempotency-key']), ) this.writeValue( result, - `Demoted ${flags.participant.length} participant(s) in group ${args.group}.`, + `Demoted ${participants.length} participant(s) in group ${args.group}.`, flags, ) } diff --git a/src/commands/group/participants/promote.ts b/src/commands/group/participants/promote.ts index 4f13b93a..c52feafb 100644 --- a/src/commands/group/participants/promote.ts +++ b/src/commands/group/participants/promote.ts @@ -2,7 +2,10 @@ import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../../base-command.js' import {idempotencyOptions} from '../../../input/message-send.js' -import {changeGroupParticipants} from '../../../services/groups.js' +import { + changeGroupParticipants, + participantIds, +} from '../../../services/groups.js' export default class GroupParticipantsPromote extends BaseCommand { static override args = { @@ -32,17 +35,18 @@ export default class GroupParticipantsPromote extends BaseCommand { async run(): Promise<void> { const {args, flags} = await this.parse(GroupParticipantsPromote) + const participants = participantIds(flags.participant) const result = await changeGroupParticipants( await this.messagingClient(flags, {serverApiKeyOnly: 'Groups'}), flags.session, args.group, 'promote', - flags.participant, + participants, idempotencyOptions(flags['idempotency-key']), ) this.writeValue( result, - `Promoted ${flags.participant.length} participant(s) in group ${args.group}.`, + `Promoted ${participants.length} participant(s) in group ${args.group}.`, flags, ) } diff --git a/src/commands/group/participants/remove.ts b/src/commands/group/participants/remove.ts index eaed4cc4..0399fbff 100644 --- a/src/commands/group/participants/remove.ts +++ b/src/commands/group/participants/remove.ts @@ -3,7 +3,10 @@ import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../../base-command.js' import {confirmDangerous} from '../../../input/confirmation.js' import {idempotencyOptions} from '../../../input/message-send.js' -import {changeGroupParticipants} from '../../../services/groups.js' +import { + changeGroupParticipants, + participantIds, +} from '../../../services/groups.js' export default class GroupParticipantsRemove extends BaseCommand { static override args = { @@ -36,8 +39,9 @@ export default class GroupParticipantsRemove extends BaseCommand { async run(): Promise<void> { const {args, flags} = await this.parse(GroupParticipantsRemove) + const participants = participantIds(flags.participant) await confirmDangerous({ - prompt: `Remove ${flags.participant.length} participant(s) from group ${args.group}?`, + prompt: `Remove ${participants.length} participant(s) from group ${args.group}?`, yes: flags.yes, }) const result = await changeGroupParticipants( @@ -45,12 +49,12 @@ export default class GroupParticipantsRemove extends BaseCommand { flags.session, args.group, 'remove', - flags.participant, + participants, idempotencyOptions(flags['idempotency-key']), ) this.writeValue( result, - `Removed ${flags.participant.length} participant(s) in group ${args.group}.`, + `Removed ${participants.length} participant(s) in group ${args.group}.`, flags, ) }