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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/cli/docs/messages.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@ 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|<id>] [--asc] [--limit N] [--ids] [--pick N]
beeper messages list --chat SEL [--before-cursor CURSOR | --after-cursor CURSOR] [--sender me|others|<id>] [--asc] [--limit N] [--ids] [--pick N]
beeper messages search [query] [--account SEL]... [--chat SEL]... [--chat-type group|single] [--sender me|others|<id>] [--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]
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.
Expand Down
9 changes: 5 additions & 4 deletions packages/cli/src/commands/messages/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)' }),
Expand All @@ -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')
}
}
8 changes: 4 additions & 4 deletions packages/cli/src/commands/messages/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ 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'
static override description = 'Lightweight per-chat JSON export. For a full export with transcripts, attachments, and multiple chats, use `beeper export`.'
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' }),
Expand All @@ -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
Expand Down
9 changes: 5 additions & 4 deletions packages/cli/src/commands/messages/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
Expand All @@ -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.' })
Expand Down
27 changes: 27 additions & 0 deletions packages/cli/src/lib/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined> {
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
}
1 change: 1 addition & 0 deletions packages/cli/test/fixtures/fake-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type FakeChat = {
export type FakeMessage = {
id: string
chatID: string
sortKey?: string
text?: string
isSender?: boolean
senderID?: string
Expand Down
131 changes: 131 additions & 0 deletions packages/cli/test/messages-cursor.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})