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 @@ -64,6 +64,7 @@ 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 reply <id> --admin <admin-id> --body-file ./reply.md --body-format markdown
intercom conversation close <id> --admin <admin-id>

# Manage companies
Expand All @@ -89,6 +90,7 @@ 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 "# Update" --body-format markdown
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 @@ -138,7 +140,7 @@ intercom ticket-type list
| `intercom conversation snooze <id>` | Snooze conversation |
| `intercom conversation convert <id>` | Convert conversation to ticket |

`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>`.
`intercom conversation reply` requires exactly one of `--body <body>` or `--body-file <path>` (read as UTF-8), and supports `--body-format markdown`, `--type <comment|note>`, and `--json <json>`. Omitting `--body-format` preserves the supplied body unchanged; Markdown is rendered with source HTML escaped and soft line breaks preserved as newlines (not `<br>`).
Message type precedence: `--type` > `--json.message_type` > `comment`.

### Companies
Expand Down Expand Up @@ -197,7 +199,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` requires exactly one of `--body <body>` or `--body-file <path>` (read as UTF-8), and 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 `--body-format markdown`, `--type <comment|note>`, and `--json <json>`. Omitting `--body-format` preserves the supplied body unchanged; Markdown is rendered with source HTML escaped and soft line breaks preserved as newlines (not `<br>`).
Message type precedence: `--type` > `--json.message_type` > `comment`.

### Ticket Types
Expand Down
22 changes: 22 additions & 0 deletions bun.lock

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

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,14 @@
"@toon-format/toon": "^2.1.0",
"commander": "^14.0.2",
"intercom-client": "^7.0.1",
"markdown-it": "^15.0.1",
"ora": "^9.0.0",
"zod": "^4.3.5"
},
"devDependencies": {
"@biomejs/biome": "^2.3.11",
"@types/bun": "latest"
"@types/bun": "latest",
"@types/markdown-it": "^14.2.0"
},
"peerDependencies": {
"typescript": "^5"
Expand Down
4 changes: 4 additions & 0 deletions src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@ export function registerCommands(program: Command, ctx: RegisterContext): void {
options: [
{ flags: "--body <body>", description: "Reply message body" },
{ flags: "--body-file <path>", description: "Read reply message body from a UTF-8 file" },
{ flags: "--body-format <format>", description: "Body format (markdown renders Markdown to HTML)" },
{ flags: "--type <type>", description: "Message type (comment, note)" },
{ flags: "--json <json>", description: "Additional reply data as JSON" },
],
Expand All @@ -440,6 +441,7 @@ export function registerCommands(program: Command, ctx: RegisterContext): void {
adminId: options.admin as string,
body: options.body as string | undefined,
bodyFile: options.bodyFile as string | undefined,
bodyFormat: options.bodyFormat as string | undefined,
messageType: options.type as string | undefined,
json: options.json as string | undefined,
});
Expand Down Expand Up @@ -849,6 +851,7 @@ export function registerCommands(program: Command, ctx: RegisterContext): void {
options: [
{ flags: "--body <body>", description: "Reply message body" },
{ flags: "--body-file <path>", description: "Read reply message body from a UTF-8 file" },
{ flags: "--body-format <format>", description: "Body format (markdown renders Markdown to HTML)" },
{ flags: "--type <type>", description: "Message type (comment, note)" },
{ flags: "--json <json>", description: "Additional reply data as JSON" },
],
Expand All @@ -859,6 +862,7 @@ export function registerCommands(program: Command, ctx: RegisterContext): void {
adminId: options.admin as string,
body: options.body as string | undefined,
bodyFile: options.bodyFile as string | undefined,
bodyFormat: options.bodyFormat as string | undefined,
messageType: options.type as string | undefined,
json: options.json as string | undefined,
});
Expand Down
7 changes: 6 additions & 1 deletion src/commands/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface ConversationReplyOptions extends GlobalOptions {
adminId: string;
body?: string;
bodyFile?: string;
bodyFormat?: string;
messageType?: string;
json?: string;
}
Expand Down Expand Up @@ -204,7 +205,11 @@ 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 body = await resolveReplyBody({
body: options.body,
bodyFile: options.bodyFile,
bodyFormat: options.bodyFormat,
});
const token = await requireToken(options.configDir);
const spinner = ora("Sending reply...").start();

Expand Down
23 changes: 21 additions & 2 deletions src/commands/replyBody.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,30 @@
import { readFile } from "node:fs/promises";
import MarkdownIt from "markdown-it";
import { CLIError } from "../utils/index.ts";

export type ReplyBodyInput = {
body?: string;
bodyFile?: string;
bodyFormat?: string;
};

const markdown = new MarkdownIt({
html: false,
breaks: false,
});

function renderReplyBody(body: string, bodyFormat?: string): string {
if (bodyFormat === undefined) {
return body;
}

if (bodyFormat !== "markdown") {
throw new CLIError(`Unsupported --body-format value: ${bodyFormat}`, 400, "Use markdown.");
}

return markdown.render(body);
}

export async function resolveReplyBody(input: ReplyBodyInput): Promise<string> {
const hasBody = input.body !== undefined;
const hasBodyFile = input.bodyFile !== undefined;
Expand All @@ -15,15 +34,15 @@ export async function resolveReplyBody(input: ReplyBodyInput): Promise<string> {
}

if (hasBody) {
return input.body as string;
return renderReplyBody(input.body as string, input.bodyFormat);
}

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;
return renderReplyBody(body, input.bodyFormat);
} catch (error) {
if (error instanceof CLIError) {
throw error;
Expand Down
7 changes: 6 additions & 1 deletion src/commands/tickets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export interface TicketReplyOptions extends GlobalOptions {
adminId: string;
body?: string;
bodyFile?: string;
bodyFormat?: string;
messageType?: string;
json?: string;
}
Expand Down Expand Up @@ -304,7 +305,11 @@ 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 body = await resolveReplyBody({
body: options.body,
bodyFile: options.bodyFile,
bodyFormat: options.bodyFormat,
});
const token = await requireToken(options.configDir);
const spinner = ora("Sending reply...").start();

Expand Down
78 changes: 76 additions & 2 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,12 @@ describe("CLI Integration", () => {
expect(stdout).toContain("close");
});

test("conversation reply --help shows body input, --type, and --json options", async () => {
test("conversation reply --help shows body input, format, --type, and --json options", async () => {
const { stdout } = await cli("conversation reply --help");

expect(stdout).toContain("--body");
expect(stdout).toContain("--body-file");
expect(stdout).toContain("--body-format");
expect(stdout).toContain("--type");
expect(stdout).toContain("--json");
});
Expand Down Expand Up @@ -125,11 +126,12 @@ describe("CLI Integration", () => {
expect(stdout).toContain("get");
});

test("ticket reply --help shows body input, --type, and --json options", async () => {
test("ticket reply --help shows body input, format, --type, and --json options", async () => {
const { stdout } = await cli("ticket reply --help");

expect(stdout).toContain("--body");
expect(stdout).toContain("--body-file");
expect(stdout).toContain("--body-format");
expect(stdout).toContain("--type");
expect(stdout).toContain("--json");
});
Expand Down Expand Up @@ -160,6 +162,15 @@ describe("CLI Integration", () => {

expect(exitCode).not.toBe(0);
});

test("unsupported reply body format exits with a clear error", async () => {
const { exitCode, stderr } = await cli(
"conversation reply conversation-id --admin admin-id --body reply --body-format html",
);

expect(exitCode).not.toBe(0);
expect(stderr).toContain("Unsupported --body-format value: html");
});
});

describe("dry-run mode", () => {
Expand Down Expand Up @@ -206,6 +217,40 @@ describe("CLI Integration", () => {
}
});

test("conversation reply renders Markdown in its final dry-run payload", async () => {
const body = "# Status\nSoft line";
const proc = spawn({
cmd: [
"bun",
"run",
"src/index.ts",
"--dry-run",
"conversation",
"reply",
"conversation-id",
"--admin",
"admin-id",
"--body",
body,
"--body-format",
"markdown",
"--type",
"note",
"--json",
'{"message_type":"comment"}',
],
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(JSON.stringify("<h1>Status</h1>\n<p>Soft line</p>\n"));
expect(stdout).toContain('"message_type": "note"');
});

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");
Expand Down Expand Up @@ -244,6 +289,35 @@ describe("CLI Integration", () => {
await rm(directory, { recursive: true, force: true });
}
});

test("ticket reply renders Markdown in its final dry-run payload", async () => {
const proc = spawn({
cmd: [
"bun",
"run",
"src/index.ts",
"--dry-run",
"ticket",
"reply",
"ticket-id",
"--admin",
"admin-id",
"--body",
"**Resolved**",
"--body-format",
"markdown",
],
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("<p><strong>Resolved</strong></p>\n"));
});
});

describe("format option", () => {
Expand Down
Loading
Loading