diff --git a/README.md b/README.md index 3214aa9..ac9ee1c 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ intercom conversation list intercom conversation search --state open intercom conversation get intercom conversation reply --admin --body "Internal triage note" --type note +intercom conversation reply --admin --body-file ./reply.html --type note intercom conversation close --admin # Manage companies @@ -87,6 +88,7 @@ intercom ticket create --type-id 1234 --contact-id abc123 --title "Issue" intercom ticket search --state open intercom ticket get intercom ticket reply --admin --body "We're on it!" --type comment +intercom ticket reply --admin --body-file ./reply.txt --type comment intercom ticket reply --admin --body "Internal note" --json '{"message_type":"note"}' intercom ticket close --admin @@ -136,7 +138,7 @@ intercom ticket-type list | `intercom conversation snooze ` | Snooze conversation | | `intercom conversation convert ` | Convert conversation to ticket | -`intercom conversation reply` supports `--type ` and `--json `. +`intercom conversation reply` requires exactly one of `--body ` or `--body-file ` (read as UTF-8), and supports `--type ` and `--json `. Message type precedence: `--type` > `--json.message_type` > `comment`. ### Companies @@ -195,7 +197,7 @@ Message type precedence: `--type` > `--json.message_type` > `comment`. | `intercom ticket close ` | Close a ticket | | `intercom ticket assign ` | Assign ticket to admin/team | -`intercom ticket reply` supports `--type ` and `--json `. +`intercom ticket reply` requires exactly one of `--body ` or `--body-file ` (read as UTF-8), and supports `--type ` and `--json `. Message type precedence: `--type` > `--json.message_type` > `comment`. ### Ticket Types diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 68f321e..2022b18 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -426,11 +426,10 @@ export function registerCommands(program: Command, ctx: RegisterContext): void { name: "reply", description: "Reply to a conversation", args: [{ name: "", description: "Conversation ID" }], - requiredOptions: [ - { flags: "--admin ", description: "Admin ID sending the reply" }, - { flags: "--body ", description: "Reply message body" }, - ], + requiredOptions: [{ flags: "--admin ", description: "Admin ID sending the reply" }], options: [ + { flags: "--body ", description: "Reply message body" }, + { flags: "--body-file ", description: "Read reply message body from a UTF-8 file" }, { flags: "--type ", description: "Message type (comment, note)" }, { flags: "--json ", description: "Additional reply data as JSON" }, ], @@ -439,7 +438,8 @@ export function registerCommands(program: Command, ctx: RegisterContext): void { ...globals, id: String(args[0]), adminId: options.admin as string, - body: options.body as string, + body: options.body as string | undefined, + bodyFile: options.bodyFile as string | undefined, messageType: options.type as string | undefined, json: options.json as string | undefined, }); @@ -845,11 +845,10 @@ export function registerCommands(program: Command, ctx: RegisterContext): void { name: "reply", description: "Reply to a ticket", args: [{ name: "", description: "Ticket ID" }], - requiredOptions: [ - { flags: "--admin ", description: "Admin ID sending the reply" }, - { flags: "--body ", description: "Reply message body" }, - ], + requiredOptions: [{ flags: "--admin ", description: "Admin ID sending the reply" }], options: [ + { flags: "--body ", description: "Reply message body" }, + { flags: "--body-file ", description: "Read reply message body from a UTF-8 file" }, { flags: "--type ", description: "Message type (comment, note)" }, { flags: "--json ", description: "Additional reply data as JSON" }, ], @@ -858,7 +857,8 @@ export function registerCommands(program: Command, ctx: RegisterContext): void { ...globals, id: String(args[0]), adminId: options.admin as string, - body: options.body as string, + body: options.body as string | undefined, + bodyFile: options.bodyFile as string | undefined, messageType: options.type as string | undefined, json: options.json as string | undefined, }); diff --git a/src/commands/conversations.ts b/src/commands/conversations.ts index cc02ed7..2a63ca5 100644 --- a/src/commands/conversations.ts +++ b/src/commands/conversations.ts @@ -1,6 +1,7 @@ import ora from "ora"; import { createClient, handleIntercomError } from "../client.ts"; import { CLIError, type GlobalOptions, getTokenAsync, output } from "../utils/index.ts"; +import { resolveReplyBody } from "./replyBody.ts"; import { buildAdminReplyPayload } from "./replyPayload.ts"; export interface ConversationListOptions extends GlobalOptions { @@ -21,7 +22,8 @@ export interface ConversationSearchOptions extends GlobalOptions { export interface ConversationReplyOptions extends GlobalOptions { id: string; adminId: string; - body: string; + body?: string; + bodyFile?: string; messageType?: string; json?: string; } @@ -202,6 +204,7 @@ export async function cmdConversationSearch(options: ConversationSearchOptions): } export async function cmdConversationReply(options: ConversationReplyOptions): Promise { + const body = await resolveReplyBody({ body: options.body, bodyFile: options.bodyFile }); const token = await requireToken(options.configDir); const spinner = ora("Sending reply...").start(); @@ -212,7 +215,7 @@ export async function cmdConversationReply(options: ConversationReplyOptions): P conversation_id: options.id, body: buildAdminReplyPayload({ adminId: options.adminId, - body: options.body, + body, messageType: options.messageType, json: options.json, }), diff --git a/src/commands/replyBody.ts b/src/commands/replyBody.ts new file mode 100644 index 0000000..8d468b2 --- /dev/null +++ b/src/commands/replyBody.ts @@ -0,0 +1,33 @@ +import { readFile } from "node:fs/promises"; +import { CLIError } from "../utils/index.ts"; + +export type ReplyBodyInput = { + body?: string; + bodyFile?: string; +}; + +export async function resolveReplyBody(input: ReplyBodyInput): Promise { + const hasBody = input.body !== undefined; + const hasBodyFile = input.bodyFile !== undefined; + + if (hasBody === hasBodyFile) { + throw new CLIError("Provide exactly one of --body or --body-file.", 400); + } + + if (hasBody) { + return input.body as string; + } + + try { + const body = await readFile(input.bodyFile as string, "utf8"); + if (body.length === 0) { + throw new CLIError(`Body file is empty: ${input.bodyFile}`, 400); + } + return body; + } catch (error) { + if (error instanceof CLIError) { + throw error; + } + throw new CLIError(`Unable to read body file: ${input.bodyFile}`, 400); + } +} diff --git a/src/commands/tickets.ts b/src/commands/tickets.ts index 8245c03..5b622f5 100644 --- a/src/commands/tickets.ts +++ b/src/commands/tickets.ts @@ -1,6 +1,7 @@ import ora from "ora"; import { createClient, handleIntercomError } from "../client.ts"; import { CLIError, type GlobalOptions, getTokenAsync, output } from "../utils/index.ts"; +import { resolveReplyBody } from "./replyBody.ts"; import { buildAdminReplyPayload } from "./replyPayload.ts"; export interface TicketGetOptions extends GlobalOptions { @@ -41,7 +42,8 @@ export interface TicketSearchOptions extends GlobalOptions { export interface TicketReplyOptions extends GlobalOptions { id: string; adminId: string; - body: string; + body?: string; + bodyFile?: string; messageType?: string; json?: string; } @@ -302,6 +304,7 @@ export async function cmdTicketSearch(options: TicketSearchOptions): Promise { + const body = await resolveReplyBody({ body: options.body, bodyFile: options.bodyFile }); const token = await requireToken(options.configDir); const spinner = ora("Sending reply...").start(); @@ -312,7 +315,7 @@ export async function cmdTicketReply(options: TicketReplyOptions): Promise ticket_id: options.id, body: buildAdminReplyPayload({ adminId: options.adminId, - body: options.body, + body, messageType: options.messageType, json: options.json, }), diff --git a/tests/cli.test.ts b/tests/cli.test.ts index bcec1b8..a0b3ea3 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { spawn } from "bun"; const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")) as { @@ -71,9 +74,11 @@ describe("CLI Integration", () => { expect(stdout).toContain("close"); }); - test("conversation reply --help shows --type and --json", async () => { + test("conversation reply --help shows body input, --type, and --json options", async () => { const { stdout } = await cli("conversation reply --help"); + expect(stdout).toContain("--body"); + expect(stdout).toContain("--body-file"); expect(stdout).toContain("--type"); expect(stdout).toContain("--json"); }); @@ -120,9 +125,11 @@ describe("CLI Integration", () => { expect(stdout).toContain("get"); }); - test("ticket reply --help shows --type and --json", async () => { + test("ticket reply --help shows body input, --type, and --json options", async () => { const { stdout } = await cli("ticket reply --help"); + expect(stdout).toContain("--body"); + expect(stdout).toContain("--body-file"); expect(stdout).toContain("--type"); expect(stdout).toContain("--json"); }); @@ -161,6 +168,82 @@ describe("CLI Integration", () => { expect(exitCode).toBe(0); }); + + test("conversation reply reads a body file and displays its final payload", async () => { + const directory = await mkdtemp(join(tmpdir(), "intercom-cli-dry-run-")); + const bodyFile = join(directory, "reply.txt"); + const body = "Hello, δΈ–η•Œ!\n\nSecond line πŸŽ‰"; + await writeFile(bodyFile, body, "utf8"); + + try { + const proc = spawn({ + cmd: [ + "bun", + "run", + "src/index.ts", + "--dry-run", + "conversation", + "reply", + "conversation-id", + "--admin", + "admin-id", + "--body-file", + bodyFile, + ], + env: { ...process.env, INTERCOM_ACCESS_TOKEN: "test-token" }, + stdout: "pipe", + stderr: "pipe", + }); + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + + expect(exitCode).toBe(0); + expect(stdout).toContain("[DRY RUN] client.conversations.reply"); + expect(stdout).toContain(JSON.stringify(body)); + expect(stdout).toContain('"message_type": "comment"'); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test("ticket reply reads a body file and displays its final payload", async () => { + const directory = await mkdtemp(join(tmpdir(), "intercom-cli-dry-run-")); + const bodyFile = join(directory, "reply.txt"); + const body = "Ticket reply\nwith multiple lines"; + await writeFile(bodyFile, body, "utf8"); + + try { + const proc = spawn({ + cmd: [ + "bun", + "run", + "src/index.ts", + "--dry-run", + "ticket", + "reply", + "ticket-id", + "--admin", + "admin-id", + "--body-file", + bodyFile, + "--type", + "note", + ], + env: { ...process.env, INTERCOM_ACCESS_TOKEN: "test-token" }, + stdout: "pipe", + stderr: "pipe", + }); + const stdout = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + + expect(exitCode).toBe(0); + expect(stdout).toContain("[DRY RUN] client.tickets.reply"); + expect(stdout).toContain(JSON.stringify(body)); + expect(stdout).toContain('"message_type": "note"'); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); }); describe("format option", () => { diff --git a/tests/reply-body.test.ts b/tests/reply-body.test.ts new file mode 100644 index 0000000..2237069 --- /dev/null +++ b/tests/reply-body.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveReplyBody } from "../src/commands/replyBody.ts"; +import { CLIError } from "../src/utils/output.ts"; + +const temporaryDirectories: string[] = []; + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "intercom-cli-reply-body-")); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe("resolveReplyBody", () => { + test("returns --body unchanged", async () => { + await expect(resolveReplyBody({ body: "inline reply" })).resolves.toBe("inline reply"); + }); + + test("reads an exact Unicode multiline body from --body-file", async () => { + const directory = await createTemporaryDirectory(); + const path = join(directory, "reply.txt"); + const contents = "Hello, δΈ–η•Œ!\n\nSecond line with emoji πŸŽ‰"; + await writeFile(path, contents, "utf8"); + + await expect(resolveReplyBody({ bodyFile: path })).resolves.toBe(contents); + }); + + test("rejects both --body and --body-file", async () => { + await expect(resolveReplyBody({ body: "inline", bodyFile: "reply.txt" })).rejects.toThrow(CLIError); + }); + + test("rejects when neither --body nor --body-file is supplied", async () => { + await expect(resolveReplyBody({})).rejects.toThrow(CLIError); + }); + + test("rejects a missing body file", async () => { + await expect(resolveReplyBody({ bodyFile: "/missing/reply.txt" })).rejects.toThrow(CLIError); + }); + + test("rejects an unreadable body file", async () => { + const directory = await createTemporaryDirectory(); + + await expect(resolveReplyBody({ bodyFile: directory })).rejects.toThrow(CLIError); + }); + + test("rejects an empty body file", async () => { + const directory = await createTemporaryDirectory(); + const path = join(directory, "empty.txt"); + await writeFile(path, "", "utf8"); + + await expect(resolveReplyBody({ bodyFile: path })).rejects.toThrow(CLIError); + }); +});