diff --git a/packages/cli/docs/messages.md b/packages/cli/docs/messages.md index 70ccb009..b92c2f35 100644 --- a/packages/cli/docs/messages.md +++ b/packages/cli/docs/messages.md @@ -6,7 +6,7 @@ reacting to, or exporting messages from chats. ## Commands ```sh -beeper messages list --chat SEL [--before-cursor MSG_ID | --after-cursor MSG_ID] [--sender me|others|] [--asc] [--limit N] [--ids] [--pick N] +beeper messages list --chat SEL [--before-cursor CURSOR | --after-cursor CURSOR] [--sender me|others|] [--asc] [--limit N] [--ids] [--pick N] beeper messages search [query] [--account SEL]... [--chat SEL]... [--chat-type group|single] [--sender me|others|] [--media TYPE]... [--after ISO] [--before ISO] [--include-muted | --no-include-muted] [--exclude-low-priority | --no-exclude-low-priority] [--limit N] [--ids] beeper messages show --chat SEL --id MSG_ID [--pick N] beeper messages context --chat SEL --id MSG_ID [--before N] [--after N] [--pick N] @@ -14,12 +14,12 @@ beeper messages edit --chat SEL --id MSG_ID --message TEXT [--pick N] beeper messages delete --chat SEL --id MSG_ID [--for-everyone] [--pick N] beeper messages react --chat SEL --id MSG_ID --reaction KEY [--pick N] # hidden; prefer `send react` beeper messages unreact --chat SEL --id MSG_ID --reaction KEY [--pick N] # hidden; prefer `send unreact` -beeper messages export --chat SEL [--before-cursor MSG_ID | --after-cursor MSG_ID] [--after ISO] [--before ISO] [--limit N] [--output PATH | -o -] [--asc] [--pick N] +beeper messages export --chat SEL [--before-cursor CURSOR | --after-cursor CURSOR] [--after ISO] [--before ISO] [--limit N] [--output PATH | -o -] [--asc] [--pick N] ``` ## Notes -- `--before-cursor` / `--after-cursor` paginate by message ID (the SDK's cursor model). +- `--before-cursor` / `--after-cursor` paginate by Desktop API cursor (`sortKey`). Message IDs are accepted and resolved to `sortKey` via message lookup. - `--before` / `--after` in `messages search` and `messages export` filter by ISO timestamp. - `messages search` rejects an empty query *and* no filter flags with exit code 2 (`usageError`). - `messages list --sender` filters client-side: `me` (your own messages), `others`, or an exact user ID. diff --git a/packages/cli/src/commands/messages/context.ts b/packages/cli/src/commands/messages/context.ts index a70a23fa..9f16a9b4 100644 --- a/packages/cli/src/commands/messages/context.ts +++ b/packages/cli/src/commands/messages/context.ts @@ -2,13 +2,13 @@ import { Flags } from '@oclif/core' import { BeeperCommand } from '../../lib/command.js' import { createClient } from '../../lib/client.js' import { collectPage, printData } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' +import { resolveChatID, resolveMessageCursor } from '../../lib/resolve.js' export default class MessagesContext extends BeeperCommand { static override summary = 'Show message context' static override flags = { chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - id: Flags.string({ required: true, description: 'Target message ID to center the window on' }), + id: Flags.string({ required: true, description: 'Target message ID to center the window on (resolved to sortKey for paging)' }), before: Flags.integer({ default: 10, description: 'Number of messages to include before the target' }), after: Flags.integer({ default: 10, description: 'Number of messages to include after the target' }), pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), @@ -17,8 +17,9 @@ export default class MessagesContext extends BeeperCommand { const { flags } = await this.parse(MessagesContext) const client = await createClient(flags) const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - const before = await collectPage(client.messages.list(chatID, { cursor: flags.id, direction: 'before' }), flags.before) - const after = await collectPage(client.messages.list(chatID, { cursor: flags.id, direction: 'after' }), flags.after) + const cursor = await resolveMessageCursor(client, chatID, flags.id) + const before = await collectPage(client.messages.list(chatID, { cursor, direction: 'before' }), flags.before) + const after = await collectPage(client.messages.list(chatID, { cursor, direction: 'after' }), flags.after) await printData({ chatID, messageID: flags.id, before, after }, flags.json ? 'json' : 'human') } } diff --git a/packages/cli/src/commands/messages/export.ts b/packages/cli/src/commands/messages/export.ts index 74080e8f..123ee669 100644 --- a/packages/cli/src/commands/messages/export.ts +++ b/packages/cli/src/commands/messages/export.ts @@ -2,7 +2,7 @@ import { writeFile } from 'node:fs/promises' import { Flags } from '@oclif/core' import { BeeperCommand } from '../../lib/command.js' import { createClient } from '../../lib/client.js' -import { resolveChatID } from '../../lib/resolve.js' +import { resolveChatID, resolveMessageCursor } from '../../lib/resolve.js' export default class MessagesExport extends BeeperCommand { static override summary = 'Export one chat to JSON' @@ -10,8 +10,8 @@ export default class MessagesExport extends BeeperCommand { static override flags = { chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), pick: Flags.integer({ description: 'Pick the Nth result when the selector is ambiguous (1-indexed)' }), - 'before-cursor': Flags.string({ description: 'Paginate messages older than this message ID' }), - 'after-cursor': Flags.string({ description: 'Paginate messages newer than this message ID' }), + 'before-cursor': Flags.string({ description: 'Paginate messages older than this cursor (message ID or sortKey)' }), + 'after-cursor': Flags.string({ description: 'Paginate messages newer than this cursor (message ID or sortKey)' }), after: Flags.string({ description: 'Only messages at or after this ISO timestamp (client-side filter)' }), before: Flags.string({ description: 'Only messages at or before this ISO timestamp (client-side filter)' }), limit: Flags.integer({ description: 'Maximum messages to export' }), @@ -24,7 +24,7 @@ export default class MessagesExport extends BeeperCommand { if (flags['before-cursor'] && flags['after-cursor']) throw new Error('Use only one of --before-cursor or --after-cursor') const client = await createClient(flags) const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick }) - const cursor = flags['before-cursor'] ?? flags['after-cursor'] + const cursor = await resolveMessageCursor(client, chatID, flags['before-cursor'] ?? flags['after-cursor']) const direction = flags['before-cursor'] ? 'before' : flags['after-cursor'] ? 'after' : undefined const afterTs = flags.after ? Date.parse(flags.after) : undefined const beforeTs = flags.before ? Date.parse(flags.before) : undefined diff --git a/packages/cli/src/commands/messages/list.ts b/packages/cli/src/commands/messages/list.ts index d3798d62..0fd1808a 100644 --- a/packages/cli/src/commands/messages/list.ts +++ b/packages/cli/src/commands/messages/list.ts @@ -2,14 +2,14 @@ import { Flags } from '@oclif/core' import { BeeperCommand } from '../../lib/command.js' import { createClient } from '../../lib/client.js' import { collectPage, printIDs, printList } from '../../lib/output.js' -import { resolveChatID } from '../../lib/resolve.js' +import { resolveChatID, resolveMessageCursor } from '../../lib/resolve.js' export default class MessagesList extends BeeperCommand { static override summary = 'List chat messages' static override flags = { chat: Flags.string({ required: true, description: 'Chat selector (ID, local ID, title, or search text)' }), - 'before-cursor': Flags.string({ description: 'Paginate messages older than this message ID' }), - 'after-cursor': Flags.string({ description: 'Paginate messages newer than this message ID' }), + 'before-cursor': Flags.string({ description: 'Paginate messages older than this cursor (message ID or sortKey)' }), + 'after-cursor': Flags.string({ description: 'Paginate messages newer than this cursor (message ID or sortKey)' }), sender: Flags.string({ description: 'Filter by sender: me, others, or a specific user ID (client-side)' }), asc: Flags.boolean({ default: false, description: 'Order oldest first (default: newest first)' }), ids: Flags.boolean({ default: false, description: 'Print only message IDs' }), @@ -23,7 +23,8 @@ export default class MessagesList extends BeeperCommand { const before = flags['before-cursor'] const after = flags['after-cursor'] if (before && after) throw new Error('Use only one of --before-cursor or --after-cursor') - let items = await collectFiltered(client.messages.list(chatID, { cursor: before ?? after, direction: before ? 'before' : after ? 'after' : undefined }), flags.limit, flags.sender) + const cursor = await resolveMessageCursor(client, chatID, before ?? after) + let items = await collectFiltered(client.messages.list(chatID, { cursor, direction: before ? 'before' : after ? 'after' : undefined }), flags.limit, flags.sender) if (flags.asc) items = [...items].reverse() if (flags.ids) printIDs(items) else await printList(items, flags.json ? 'json' : 'human', { title: 'No messages yet', subtitle: 'This chat is empty.' }) diff --git a/packages/cli/src/lib/resolve.ts b/packages/cli/src/lib/resolve.ts index d83eb3b7..fb21b9f6 100644 --- a/packages/cli/src/lib/resolve.ts +++ b/packages/cli/src/lib/resolve.ts @@ -192,3 +192,30 @@ export function userQueryFromInput(input: string): AnyRecord { if (/^\+?[\d\s().-]{5,}$/.test(trimmed)) return { phoneNumber: trimmed } return { fullName: trimmed, username: trimmed, id: trimmed } } + +/** + * Desktop API message list cursors are sortKeys, not message IDs. + * When chatID is available, resolve a user-supplied cursor (typically a message + * ID from CLI flags) by fetching the message and returning its sortKey. + * Values that are already sortKeys (retrieve 404s / no sortKey) pass through. + */ +export async function resolveMessageCursor( + client: any, + chatID: string, + cursor: string | undefined, +): Promise { + if (!cursor) return undefined + if (!client.messages?.retrieve) return cursor + + try { + const message = await client.messages.retrieve(cursor, { chatID }) + if (typeof message?.sortKey === 'string' && message.sortKey.length > 0) { + return message.sortKey + } + } catch (error) { + const text = error instanceof Error ? error.message : String(error) + if (/not\s*found|404/i.test(text)) return cursor + throw error + } + return cursor +} diff --git a/packages/cli/test/fixtures/fake-client.ts b/packages/cli/test/fixtures/fake-client.ts index 5dd83619..344fe281 100644 --- a/packages/cli/test/fixtures/fake-client.ts +++ b/packages/cli/test/fixtures/fake-client.ts @@ -24,6 +24,7 @@ export type FakeChat = { export type FakeMessage = { id: string chatID: string + sortKey?: string text?: string isSender?: boolean senderID?: string diff --git a/packages/cli/test/messages-cursor.test.ts b/packages/cli/test/messages-cursor.test.ts new file mode 100644 index 00000000..cca7d95e --- /dev/null +++ b/packages/cli/test/messages-cursor.test.ts @@ -0,0 +1,131 @@ +import { fileURLToPath } from 'node:url' +import { describe, expect, it, mock } from 'bun:test' +import { resolveMessageCursor } from '../src/lib/resolve.js' + +const cliRoot = fileURLToPath(new URL('..', import.meta.url)) +const cliEnv = { ...process.env, BEEPER_ACCESS_TOKEN: 'test-token', BEEPER_CLI_CONFIG_DIR: '/tmp/beeper-cli-bun-test', BEEPER_NO_LOGO: '1' } + +describe('resolveMessageCursor', () => { + const chatID = '!chat:beeper.com' + + it('resolves a message ID to its sortKey', async () => { + const retrieve = mock(async (id: string, params: { chatID: string }) => { + expect(id).toBe('1343993') + expect(params.chatID).toBe(chatID) + return { id: '1343993', chatID, sortKey: '821744079' } + }) + const client = { messages: { retrieve } } + await expect(resolveMessageCursor(client, chatID, '1343993')).resolves.toBe('821744079') + expect(retrieve).toHaveBeenCalledTimes(1) + }) + + it('passes through an already-resolved sortKey when retrieve 404s', async () => { + const retrieve = mock(async () => { + throw new Error('Message not found (404)') + }) + const client = { messages: { retrieve } } + await expect(resolveMessageCursor(client, chatID, '821744079')).resolves.toBe('821744079') + }) + + it('returns undefined when no cursor is provided', async () => { + const retrieve = mock(async () => ({ id: 'x', sortKey: 'y' })) + const client = { messages: { retrieve } } + await expect(resolveMessageCursor(client, chatID, undefined)).resolves.toBeUndefined() + expect(retrieve).toHaveBeenCalledTimes(0) + }) + + it('passes through when retrieve is unavailable', async () => { + const client = { messages: {} } + await expect(resolveMessageCursor(client, chatID, '1343993')).resolves.toBe('1343993') + }) + + it('passes through when retrieve succeeds but sortKey is missing', async () => { + const retrieve = mock(async () => ({ id: '1343993', chatID })) + const client = { messages: { retrieve } } + await expect(resolveMessageCursor(client, chatID, '1343993')).resolves.toBe('1343993') + }) + + it('rethrows unexpected retrieve errors', async () => { + const retrieve = mock(async () => { + throw new Error('network down') + }) + const client = { messages: { retrieve } } + await expect(resolveMessageCursor(client, chatID, '1343993')).rejects.toThrow('network down') + }) +}) + +describe('messages list + context cursor wiring', () => { + it('list command sends the resolved sortKey as the API cursor', async () => { + const chatID = '!c:beeper.com' + const requests: Array<{ path: string; cursor: string | null; direction: string | null }> = [] + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(request) { + const url = new URL(request.url) + const path = decodeURIComponent(url.pathname) + if (path === `/v1/chats/${chatID}/messages/msg-1`) { + return Response.json({ id: 'msg-1', chatID, accountID: 'a', senderID: 's', timestamp: '2026-01-01T00:00:00Z', sortKey: 'sort-from-id' }) + } + if (path === `/v1/chats/${chatID}/messages`) { + requests.push({ path, cursor: url.searchParams.get('cursor'), direction: url.searchParams.get('direction') }) + return Response.json({ items: [], hasMore: false, oldestCursor: null, newestCursor: null }) + } + return Response.json({ message: 'not found', code: 'not_found' }, { status: 404 }) + }, + }) + + try { + const child = Bun.spawn([process.execPath, './bin/dev.js', 'messages', 'list', '--chat', chatID, '--before-cursor', 'msg-1', '--base-url', server.url.origin, '--json'], { + cwd: cliRoot, + env: cliEnv, + stdout: 'pipe', + stderr: 'pipe', + }) + expect(await child.exited).toBe(0) + expect(requests).toEqual([{ path: `/v1/chats/${chatID}/messages`, cursor: 'sort-from-id', direction: 'before' }]) + } finally { + server.stop(true) + } + }, 20_000) + + it('context command resolves once and uses the same sortKey for before and after', async () => { + const chatID = '!c:beeper.com' + let retrieveCount = 0 + const requests: Array<{ cursor: string | null; direction: string | null }> = [] + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(request) { + const url = new URL(request.url) + const path = decodeURIComponent(url.pathname) + if (path === `/v1/chats/${chatID}/messages/target-id`) { + retrieveCount += 1 + return Response.json({ id: 'target-id', chatID, accountID: 'a', senderID: 's', timestamp: '2026-01-01T00:00:00Z', sortKey: 'sk-99' }) + } + if (path === `/v1/chats/${chatID}/messages`) { + requests.push({ cursor: url.searchParams.get('cursor'), direction: url.searchParams.get('direction') }) + return Response.json({ items: [], hasMore: false, oldestCursor: null, newestCursor: null }) + } + return Response.json({ message: 'not found', code: 'not_found' }, { status: 404 }) + }, + }) + + try { + const child = Bun.spawn([process.execPath, './bin/dev.js', 'messages', 'context', '--chat', chatID, '--id', 'target-id', '--before', '1', '--after', '1', '--base-url', server.url.origin, '--json'], { + cwd: cliRoot, + env: cliEnv, + stdout: 'pipe', + stderr: 'pipe', + }) + expect(await child.exited).toBe(0) + expect(retrieveCount).toBe(1) + expect(requests).toEqual([ + { cursor: 'sk-99', direction: 'before' }, + { cursor: 'sk-99', direction: 'after' }, + ]) + } finally { + server.stop(true) + } + }, 20_000) +})