diff --git a/README.md b/README.md index bfa05d1..4dc4d9c 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ npm install -g @textbee/mcp ### send_sms -Send an SMS to one or more phone numbers or short codes. International format with the country code (for example `+15550100123`) works everywhere. Local numbers and short codes only reach recipients in the sending phone's country. The sending phone is chosen automatically: your default device, otherwise the enabled device with the most recent heartbeat. Optional `device_id`, `sim_subscription_id`, and `scheduled_at`. Returns an `sms_batch_id` for delivery checks when the account uses the SMS queue. +Send an SMS to one or more phone numbers or short codes. International format with the country code (for example `+15550100123`) works everywhere. Local numbers and short codes only reach recipients in the sending phone's country. The sending phone is chosen automatically: your default device, otherwise the enabled device with the most recent heartbeat. Optional `device_id`, `sim_number`, `sim_subscription_id`, and `scheduled_at`. `sim_number` picks the SIM: `1` for SIM 1, `2` for SIM 2. Omit it and the phone uses its default SIM. If the SIM slot is empty, the message fails and is never sent from another SIM. `sim_subscription_id` is an older option. Android can change it after a SIM swap, so prefer `sim_number`. If both are given, only `sim_number` is used. Returns an `sms_batch_id` for delivery checks when the account uses the SMS queue. ### get_messages diff --git a/package.json b/package.json index d9b098e..6cad4cd 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "1.30.0", - "@textbee/sdk": "^0.2.0", + "@textbee/sdk": "^0.6.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4f21bd..6f4b761 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: 1.30.0 version: 1.30.0(zod@4.4.3) '@textbee/sdk': - specifier: ^0.2.0 - version: 0.2.0 + specifier: ^0.6.0 + version: 0.6.0 zod: specifier: ^4.4.3 version: 4.4.3 @@ -318,8 +318,8 @@ packages: cpu: [x64] os: [win32] - '@textbee/sdk@0.2.0': - resolution: {integrity: sha512-TVVdIQKW16Iz63vX3ZfVa3RVwOL3vPdIRQznwVBYjtkZwkHoajqM+5KAzpRZP0sEnF1mTlW2RjGsHaBdNeTJOQ==} + '@textbee/sdk@0.6.0': + resolution: {integrity: sha512-3AX7gJzsmzjZ6+gioNGqnJRDXiVRWZDW7oDAQOoiP//RxkEWRQN83cpEMnxp142L94BQRHsbYJDADeUiGgZjhQ==} engines: {node: '>=18'} '@types/estree@1.0.9': @@ -1063,7 +1063,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.4': optional: true - '@textbee/sdk@0.2.0': {} + '@textbee/sdk@0.6.0': {} '@types/estree@1.0.9': {} diff --git a/src/format.ts b/src/format.ts index 161ddd2..537bd1a 100644 --- a/src/format.ts +++ b/src/format.ts @@ -5,7 +5,8 @@ const BODY_PREVIEW_CHARS = 300 /** Statuses that rarely change. failed and unknown can still change. */ export const FINAL_STATUSES = ['delivered'] as const -// Local shape until the published SDK types carry issueHistory. +// Local shapes until the published SDK types carry simSlotIndex and issueHistory. +type WithSimSlot = { simSlotIndex?: number } type WithIssueHistory = { issueHistory?: Array<{ deliveryReport?: boolean }> } /** @@ -45,6 +46,10 @@ function formatMessageLine(m: Message, index: number): string { const status = m.status ?? 'unknown' const header = `[${index}] ${when} ${direction} ${party} status=${status} id=${m._id}` const lines = [header, ` ${messageBody(m)}`] + const slot = (m as WithSimSlot).simSlotIndex + if (typeof slot === 'number') { + lines.push(` Requested SIM ${slot + 1}`) + } const state = String(m.status ?? '').toLowerCase() if (state === 'failed' && (m.errorCode || m.errorMessage)) { const code = m.errorCode ?? '' diff --git a/src/tools.ts b/src/tools.ts index 5971372..cb51234 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -95,15 +95,23 @@ const sendSmsShape = { 'textbee choose. Pass it only when the user asks for a specific phone. Ids come ' + 'from list_devices.', ), + sim_number: z + .number() + .int() + .min(1) + .max(8) + .optional() + .describe( + "Which SIM sends the message: 1 for SIM 1, 2 for SIM 2. Omit to use the phone's default SIM.", + ), sim_subscription_id: z .number() .int() .min(0) .optional() .describe( - 'Which SIM sends on a dual-SIM phone. The server does not validate this: a wrong ' + - 'value is silently ignored and the phone default SIM is used. The id is shown in ' + - 'the textbee Android app.', + 'Older option. The Android id of a SIM. It can change after a SIM swap, and a wrong ' + + 'value is silently ignored. Prefer sim_number. If both are given, only sim_number is used.', ), scheduled_at: z .string() @@ -214,6 +222,7 @@ export function buildToolDefinitions(opts: BuildOptions): TextbeeToolDefinition[ message: string recipients: string[] device_id?: string + sim_number?: number sim_subscription_id?: number scheduled_at?: string } @@ -261,9 +270,11 @@ export function buildToolDefinitions(opts: BuildOptions): TextbeeToolDefinition[ message: input.message, recipients, ...(deviceId !== undefined ? { deviceId } : {}), - ...(input.sim_subscription_id !== undefined - ? { simSubscriptionId: input.sim_subscription_id } - : {}), + ...(input.sim_number !== undefined + ? { simSlotIndex: input.sim_number - 1 } + : input.sim_subscription_id !== undefined + ? { simSubscriptionId: input.sim_subscription_id } + : {}), ...(input.scheduled_at !== undefined ? { scheduledAt: input.scheduled_at } : {}), }) diff --git a/test/format.test.ts b/test/format.test.ts index 33b51b3..bb2e577 100644 --- a/test/format.test.ts +++ b/test/format.test.ts @@ -48,6 +48,29 @@ describe('formatMessages', () => { expect(text).toContain('66b0000000000000000001aa') }) + it('shows the requested SIM as a number, one-based', () => { + const list: MessageList = { + data: [message({ direction: 'sent', status: 'sent', simSlotIndex: 1 } as Partial)], + meta: baseMeta, + } + const text = formatMessages(list, { direction: 'all', usedCursor: false, order: 'desc' }) + expect(text).toContain('Requested SIM 2') + }) + + it('shows SIM 1 for slot 0 and no SIM line when unset', () => { + const first: MessageList = { + data: [message({ direction: 'sent', status: 'sent', simSlotIndex: 0 } as Partial)], + meta: baseMeta, + } + const none: MessageList = { + data: [message({ direction: 'sent', status: 'sent' })], + meta: baseMeta, + } + const ctx = { direction: 'all', usedCursor: false, order: 'desc' } as const + expect(formatMessages(first, ctx)).toContain('Requested SIM 1') + expect(formatMessages(none, ctx)).not.toContain('SIM ') + }) + it('shows the current error on a failed message', () => { const list: MessageList = { data: [message({ direction: 'sent', status: 'failed', errorCode: 'FCM_SEND_FAILED', errorMessage: 'push failed' })], diff --git a/test/tools.test.ts b/test/tools.test.ts index ba3ba8e..c4d6815 100644 --- a/test/tools.test.ts +++ b/test/tools.test.ts @@ -170,6 +170,45 @@ describe('send_sms', () => { expect(JSON.parse(init.body as string)).not.toHaveProperty('deviceId') }) + it('maps sim_number to a zero-based simSlotIndex', async () => { + fetchMock.mockResolvedValue( + jsonResponse(200, { data: { successCount: 1, failureCount: 0 } }), + ) + await tool('send_sms').handler( + { message: 'hi', recipients: ['+15550100123'], sim_number: 2 }, + {}, + ) + const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit] + const body = JSON.parse(init.body as string) + expect(body.simSlotIndex).toBe(1) + expect(body).not.toHaveProperty('simSubscriptionId') + }) + + it('uses only sim_number when sim_subscription_id is also given', async () => { + fetchMock.mockResolvedValue( + jsonResponse(200, { data: { successCount: 1, failureCount: 0 } }), + ) + await tool('send_sms').handler( + { message: 'hi', recipients: ['+15550100123'], sim_number: 1, sim_subscription_id: 7 }, + {}, + ) + const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit] + const body = JSON.parse(init.body as string) + expect(body.simSlotIndex).toBe(0) + expect(body).not.toHaveProperty('simSubscriptionId') + }) + + it('sends no SIM fields when none is given', async () => { + fetchMock.mockResolvedValue( + jsonResponse(200, { data: { successCount: 1, failureCount: 0 } }), + ) + await tool('send_sms').handler({ message: 'hi', recipients: ['+15550100123'] }, {}) + const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit] + const body = JSON.parse(init.body as string) + expect(body).not.toHaveProperty('simSlotIndex') + expect(body).not.toHaveProperty('simSubscriptionId') + }) + it('suppresses an identical send inside the dedupe window', async () => { fetchMock.mockResolvedValue( jsonResponse(200, {