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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ intercom conversation list
intercom conversation search --state open
intercom conversation get <id>
intercom conversation reply <id> --admin <admin-id> --body "Internal triage note" --type note
intercom conversation reply <id> --admin <admin-id> --body-file ./reply.html --type note
intercom conversation close <id> --admin <admin-id>

# Manage companies
Expand All @@ -87,6 +88,7 @@ intercom ticket create --type-id 1234 --contact-id abc123 --title "Issue"
intercom ticket search --state open
intercom ticket get <id>
intercom ticket reply <id> --admin <admin-id> --body "We're on it!" --type comment
intercom ticket reply <id> --admin <admin-id> --body-file ./reply.txt --type comment
intercom ticket reply <id> --admin <admin-id> --body "Internal note" --json '{"message_type":"note"}'
intercom ticket close <id> --admin <admin-id>

Expand Down Expand Up @@ -136,7 +138,7 @@ intercom ticket-type list
| `intercom conversation snooze <id>` | Snooze conversation |
| `intercom conversation convert <id>` | Convert conversation to ticket |

`intercom conversation reply` supports `--type <comment|note>` and `--json <json>`.
`intercom conversation reply` requires exactly one of `--body <body>` or `--body-file <path>` (read as UTF-8), and supports `--type <comment|note>` and `--json <json>`.
Message type precedence: `--type` > `--json.message_type` > `comment`.

### Companies
Expand Down Expand Up @@ -195,7 +197,7 @@ Message type precedence: `--type` > `--json.message_type` > `comment`.
| `intercom ticket close <id>` | Close a ticket |
| `intercom ticket assign <id>` | Assign ticket to admin/team |

`intercom ticket reply` supports `--type <comment|note>` and `--json <json>`.
`intercom ticket reply` requires exactly one of `--body <body>` or `--body-file <path>` (read as UTF-8), and supports `--type <comment|note>` and `--json <json>`.
Message type precedence: `--type` > `--json.message_type` > `comment`.

### Ticket Types
Expand Down
20 changes: 10 additions & 10 deletions src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,11 +426,10 @@ export function registerCommands(program: Command, ctx: RegisterContext): void {
name: "reply",
description: "Reply to a conversation",
args: [{ name: "<id>", description: "Conversation ID" }],
requiredOptions: [
{ flags: "--admin <id>", description: "Admin ID sending the reply" },
{ flags: "--body <body>", description: "Reply message body" },
],
requiredOptions: [{ flags: "--admin <id>", description: "Admin ID sending the reply" }],
options: [
{ flags: "--body <body>", description: "Reply message body" },
{ flags: "--body-file <path>", description: "Read reply message body from a UTF-8 file" },
{ flags: "--type <type>", description: "Message type (comment, note)" },
{ flags: "--json <json>", description: "Additional reply data as JSON" },
],
Expand All @@ -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,
});
Expand Down Expand Up @@ -845,11 +845,10 @@ export function registerCommands(program: Command, ctx: RegisterContext): void {
name: "reply",
description: "Reply to a ticket",
args: [{ name: "<id>", description: "Ticket ID" }],
requiredOptions: [
{ flags: "--admin <id>", description: "Admin ID sending the reply" },
{ flags: "--body <body>", description: "Reply message body" },
],
requiredOptions: [{ flags: "--admin <id>", description: "Admin ID sending the reply" }],
options: [
{ flags: "--body <body>", description: "Reply message body" },
{ flags: "--body-file <path>", description: "Read reply message body from a UTF-8 file" },
{ flags: "--type <type>", description: "Message type (comment, note)" },
{ flags: "--json <json>", description: "Additional reply data as JSON" },
],
Expand All @@ -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,
});
Expand Down
7 changes: 5 additions & 2 deletions src/commands/conversations.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
}
Expand Down Expand Up @@ -202,6 +204,7 @@ export async function cmdConversationSearch(options: ConversationSearchOptions):
}

export async function cmdConversationReply(options: ConversationReplyOptions): Promise<void> {
const body = await resolveReplyBody({ body: options.body, bodyFile: options.bodyFile });
const token = await requireToken(options.configDir);
const spinner = ora("Sending reply...").start();

Expand All @@ -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,
}),
Expand Down
33 changes: 33 additions & 0 deletions src/commands/replyBody.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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);
}
}
7 changes: 5 additions & 2 deletions src/commands/tickets.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -302,6 +304,7 @@ export async function cmdTicketSearch(options: TicketSearchOptions): Promise<voi
}

export async function cmdTicketReply(options: TicketReplyOptions): Promise<void> {
const body = await resolveReplyBody({ body: options.body, bodyFile: options.bodyFile });
const token = await requireToken(options.configDir);
const spinner = ora("Sending reply...").start();

Expand All @@ -312,7 +315,7 @@ export async function cmdTicketReply(options: TicketReplyOptions): Promise<void>
ticket_id: options.id,
body: buildAdminReplyPayload({
adminId: options.adminId,
body: options.body,
body,
messageType: options.messageType,
json: options.json,
}),
Expand Down
87 changes: 85 additions & 2 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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");
});
Expand Down Expand Up @@ -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");
});
Expand Down Expand Up @@ -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", () => {
Expand Down
59 changes: 59 additions & 0 deletions tests/reply-body.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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);
});
});
Loading