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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
],
"dependencies": {
"@doist/cli-core": "0.24.0",
"@doist/comms-sdk": "0.3.0",
"@doist/comms-sdk": "0.4.1",
"@pnpm/tabtab": "0.5.4",
"chalk": "5.6.2",
"commander": "14.0.3",
Expand Down
3 changes: 3 additions & 0 deletions skills/comms-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,15 @@ tdc thread create <channel-ref> "Title" "content" --notify 123,456 # Notify spe
tdc thread create <channel-ref> "Title" "content" --unarchive # Land thread in author's Inbox (overrides default Comms auto-archive)
tdc thread create <channel-ref> "Title" "content" --no-unarchive # Force archive even when userSettings.unarchiveNewThreads=true
tdc thread create <channel-ref> "Title" "content" --dry-run # Preview without posting
tdc thread create <channel-ref> "Title" --file ./a.png # Attach a file (repeatable; content optional)
tdc thread reply <ref> "content" # Post a comment (notifies EVERYONE_IN_THREAD by default)
tdc thread reply <ref> "content" --notify EVERYONE # Notify all workspace members
tdc thread reply <ref> "content" --notify 123,id:456 # Notify specific user IDs
tdc thread reply <ref> "content" --json # Post and return comment as JSON
tdc thread reply <ref> "content" --json --full # Include all comment fields
tdc thread reply <ref> "content" --close # Reply and close the thread
tdc thread reply <ref> "content" --reopen # Reply and reopen a closed thread
tdc thread reply <ref> "content" --file ./a.png # Attach a file (repeatable; content optional)
tdc thread done <ref> # Preview thread archive (requires --yes to execute)
tdc thread done <ref> --yes # Archive thread (mark done)
tdc thread done <ref> --yes --json # Archive and return status as JSON
Expand Down Expand Up @@ -153,6 +155,7 @@ tdc conversation with <user-ref> --include-groups # List any conversations with
tdc conversation reply <ref> "content" # Send a message
tdc conversation reply <ref> "content" --json # Send and return message as JSON
tdc conversation reply <ref> "content" --json --full # Include all message fields
tdc conversation reply <ref> "content" --file ./a.png # Attach a file (repeatable; content optional)
tdc conversation done <ref> # Preview conversation archive (requires --yes to execute)
tdc conversation done <ref> --yes # Archive conversation
tdc conversation done <ref> --yes --json # Archive and return status as JSON
Expand Down
199 changes: 197 additions & 2 deletions src/commands/conversation/conversation.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
captureConsole,
createTestProgram,
describeEmptyMachineOutput,
} from '@doist/cli-core/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { clearWorkspaceUserCache } from '../../lib/api.js'
import { CliError } from '../../lib/errors.js'

Expand Down Expand Up @@ -32,6 +35,12 @@ vi.mock('../../lib/markdown.js', () => ({

vi.mock('chalk')

vi.mock('../../lib/input.js', () => ({
readStdin: vi.fn().mockResolvedValue(''),
openEditor: vi.fn().mockResolvedValue(''),
}))

import { openEditor } from '../../lib/input.js'
import { registerConversationCommand } from './index.js'

type TestConversation = {
Expand Down Expand Up @@ -115,7 +124,25 @@ function createClient({
async ({ conversationId }: { conversationId: string; limit?: number }) =>
messagesByConversation[conversationId] ?? [],
),
createMessage: vi.fn(),
createMessage: vi.fn(
async (args: {
conversationId: string
content: string
attachments?: Array<{ fileName?: string | null }>
}) => ({
id: '999',
conversationId: args.conversationId,
content: args.content,
url: 'https://comms.todoist.com/a/1/msg/999',
}),
),
},
attachments: {
upload: vi.fn(async (args: { file: Blob; fileName: string }) => ({
attachmentId: `att-${args.fileName}`,
urlType: 'file',
fileName: args.fileName,
})),
},
workspaceUsers: {
getUserById: vi.fn(
Expand All @@ -128,6 +155,30 @@ function createClient({

const createProgram = () => createTestProgram(registerConversationCommand)

// Shared setup for the --file suite: a fresh mock client wired into getCommsClient
// plus a program. Tests asserting on output call captureConsole('log') themselves.
function setupFileTest() {
const client = createClient({})
apiMocks.getCommsClient.mockResolvedValue(client)
return { client, program: createProgram() }
}

// Registers a temp dir with two files for a --file suite, cleaned up afterwards.
function useFileFixtures(prefix: string, png: string, pdf: string) {
const paths = { dir: '', png: '', pdf: '' }
beforeAll(async () => {
paths.dir = await mkdtemp(join(tmpdir(), prefix))
paths.png = join(paths.dir, png)
paths.pdf = join(paths.dir, pdf)
await writeFile(paths.png, 'png-bytes')
await writeFile(paths.pdf, 'pdf-bytes')
})
afterAll(async () => {
await rm(paths.dir, { recursive: true, force: true })
})
return paths
}

// Cache lives in api.ts module scope, so reset between tests.
beforeEach(() => {
clearWorkspaceUserCache()
Expand Down Expand Up @@ -689,3 +740,147 @@ describe('conversation done', () => {
expect(client.conversations.archiveConversation).not.toHaveBeenCalled()
})
})

describe('conversation reply --file', () => {
const files = useFileFixtures('tdc-convo-reply-', 'photo.png', 'doc.pdf')

beforeEach(() => {
vi.clearAllMocks()
})

it('uploads the file and attaches it to the message', async () => {
const { client, program } = setupFileTest()
const consoleSpy = captureConsole('log')

await program.parseAsync([
'node',
'tdc',
'conversation',
'reply',
'42',
'See attached',
'--file',
files.png,
])

expect(client.attachments.upload).toHaveBeenCalledTimes(1)
expect(client.attachments.upload).toHaveBeenCalledWith(
expect.objectContaining({ fileName: 'photo.png' }),
)
expect(client.conversationMessages.createMessage).toHaveBeenCalledWith(
expect.objectContaining({
conversationId: '42',
content: 'See attached',
attachments: [expect.objectContaining({ fileName: 'photo.png' })],
}),
)
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Attached: photo.png'))
})

it('attaches multiple repeated --file values', async () => {
const { client, program } = setupFileTest()

await program.parseAsync([
'node',
'tdc',
'conversation',
'reply',
'42',
'two files',
'--file',
files.png,
'--file',
files.pdf,
])

expect(client.attachments.upload).toHaveBeenCalledTimes(2)
const args = client.conversationMessages.createMessage.mock.calls[0][0] as {
attachments: Array<{ fileName?: string }>
}
expect(args.attachments.map((a) => a.fileName)).toEqual(['photo.png', 'doc.pdf'])
})

it('allows a file-only message with no text content', async () => {
const { client, program } = setupFileTest()

await program.parseAsync([
'node',
'tdc',
'conversation',
'reply',
'42',
'--file',
files.png,
])

expect(client.conversationMessages.createMessage).toHaveBeenCalledWith(
expect.objectContaining({ content: '', attachments: expect.any(Array) }),
)
// A file-only message must not block on the editor.
expect(openEditor).not.toHaveBeenCalled()
})

it('errors with FILE_NOT_FOUND for a missing path and does not send', async () => {
const { client, program } = setupFileTest()

await expect(
program.parseAsync([
'node',
'tdc',
'conversation',
'reply',
'42',
'x',
'--file',
join(files.dir, 'missing.png'),
]),
).rejects.toMatchObject({ code: 'FILE_NOT_FOUND' })

expect(client.attachments.upload).not.toHaveBeenCalled()
expect(client.conversationMessages.createMessage).not.toHaveBeenCalled()
})

it('skips the upload when the conversation preflight fails (no orphaned upload)', async () => {
const { client, program } = setupFileTest()
client.conversations.getConversation.mockRejectedValueOnce(
new CliError('NOT_FOUND', 'Conversation not found'),
)

await expect(
program.parseAsync([
'node',
'tdc',
'conversation',
'reply',
'42',
'See attached',
'--file',
files.png,
]),
).rejects.toMatchObject({ code: 'NOT_FOUND' })

expect(client.attachments.upload).not.toHaveBeenCalled()
expect(client.conversationMessages.createMessage).not.toHaveBeenCalled()
})

it('does not upload during --dry-run but lists the attachment', async () => {
const { client, program } = setupFileTest()
const consoleSpy = captureConsole('log')

await program.parseAsync([
'node',
'tdc',
'conversation',
'reply',
'42',
'preview',
'--file',
files.png,
'--dry-run',
])

expect(client.attachments.upload).not.toHaveBeenCalled()
expect(client.conversationMessages.createMessage).not.toHaveBeenCalled()
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining(files.png))
})
})
2 changes: 1 addition & 1 deletion src/commands/conversation/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type ConversationWithOptions = PaginatedViewOptions & {
snippet?: boolean
}

export type ReplyOptions = MutationOptions
export type ReplyOptions = MutationOptions & { file?: string[] }

export type MuteOptions = MutationOptions & { minutes?: string }

Expand Down
5 changes: 4 additions & 1 deletion src/commands/conversation/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Command } from 'commander'
import { collect } from '../../lib/options.js'
import { markConversationDone } from './done.js'
import { muteConversation } from './mute.js'
import { replyToConversation } from './reply.js'
Expand Down Expand Up @@ -77,6 +78,7 @@ Examples:
conversation
.command('reply <conversation-ref> [content]')
.description('Send a message in a conversation')
.option('--file <path>', 'Attach a file (repeatable; content optional)', collect, [])
.option('--dry-run', 'Show what would be sent without sending')
.option('--json', 'Output sent message as JSON')
.option('--full', 'Include all fields in JSON output')
Expand All @@ -86,7 +88,8 @@ Examples:
Examples:
tdc conversation reply 12345 "Hello!"
echo "Message body" | tdc conversation reply 12345
tdc conversation reply 12345 "Update" --json`,
tdc conversation reply 12345 "Update" --json
tdc conversation reply 12345 "See attached" --file ./photo.jpg`,
)
.action(replyToConversation)

Expand Down
38 changes: 32 additions & 6 deletions src/commands/conversation/reply.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import chalk from 'chalk'
import { getCommsClient } from '../../lib/api.js'
import { uploadAttachments, validateAttachmentFiles } from '../../lib/attachments.js'
import { CliError } from '../../lib/errors.js'
import { openEditor, readStdin } from '../../lib/input.js'
import { formatJson, printDryRun } from '../../lib/output.js'
Expand All @@ -12,34 +14,54 @@ export async function replyToConversation(
): Promise<void> {
const conversationId = resolveConversationId(ref)

const files = options.file ?? []
const hasFiles = files.length > 0

let replyContent = await readStdin()
if (!replyContent && content) {
replyContent = content
}
if (!replyContent) {
// A file-only message is allowed: skip the editor prompt and the empty-content guard.
if (!replyContent && !hasFiles) {
replyContent = await openEditor()
}
if (!replyContent || replyContent.trim() === '') {
if ((!replyContent || replyContent.trim() === '') && !hasFiles) {
throw new CliError(
'MISSING_CONTENT',
'No content provided. Pass content as an argument or pipe via stdin.',
'No content provided. Pass content as an argument, pipe via stdin, or attach a file.',
)
}
const messageContent = replyContent ?? ''

if (options.dryRun) {
// Validate attachment paths so the preview fails on a bad path exactly
// as a real run would (no upload happens in dry-run).
if (hasFiles) {
await validateAttachmentFiles(files)
}
const preview =
replyContent.length > 200 ? `${replyContent.slice(0, 200)}...` : replyContent
messageContent.length > 200 ? `${messageContent.slice(0, 200)}...` : messageContent
printDryRun('send message to conversation', {
Conversation: String(conversationId),
Content: preview,
Attach: hasFiles ? files.join(', ') : undefined,
Content: preview || undefined,
})
return
}

const client = await getCommsClient()

// Preflight the target before uploading so an invalid or forbidden
// conversation fails fast instead of leaving an orphaned upload behind.
if (hasFiles) {
await client.conversations.getConversation(conversationId)
}

const attachments = hasFiles ? await uploadAttachments(files) : undefined
const message = await client.conversationMessages.createMessage({
conversationId,
content: replyContent,
content: messageContent,
attachments,
})

if (options.json) {
Expand All @@ -48,4 +70,8 @@ export async function replyToConversation(
}

console.log(`Message sent: ${message.url}`)
if (attachments && attachments.length > 0) {
const names = attachments.map((a) => a.fileName ?? 'file').join(', ')
console.log(chalk.dim(`Attached: ${names}`))
}
}
Loading
Loading