diff --git a/README.md b/README.md index ac9ee1c..bef6faf 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ 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 reply --admin --body-file ./reply.md --body-format markdown intercom conversation close --admin # Manage companies @@ -89,6 +90,7 @@ 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 "# Update" --body-format markdown intercom ticket reply --admin --body "Internal note" --json '{"message_type":"note"}' intercom ticket close --admin @@ -138,7 +140,7 @@ intercom ticket-type list | `intercom conversation snooze ` | Snooze conversation | | `intercom conversation convert ` | Convert conversation to ticket | -`intercom conversation reply` requires exactly one of `--body ` or `--body-file ` (read as UTF-8), and supports `--type ` and `--json `. +`intercom conversation reply` requires exactly one of `--body ` or `--body-file ` (read as UTF-8), and supports `--body-format markdown`, `--type `, and `--json `. Omitting `--body-format` preserves the supplied body unchanged; Markdown is rendered with source HTML escaped and soft line breaks preserved as newlines (not `
`). Message type precedence: `--type` > `--json.message_type` > `comment`. ### Companies @@ -197,7 +199,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` requires exactly one of `--body ` or `--body-file ` (read as UTF-8), and supports `--type ` and `--json `. +`intercom ticket reply` requires exactly one of `--body ` or `--body-file ` (read as UTF-8), and supports `--body-format markdown`, `--type `, and `--json `. Omitting `--body-format` preserves the supplied body unchanged; Markdown is rendered with source HTML escaped and soft line breaks preserved as newlines (not `
`). Message type precedence: `--type` > `--json.message_type` > `comment`. ### Ticket Types diff --git a/bun.lock b/bun.lock index 5312be7..654f80d 100644 --- a/bun.lock +++ b/bun.lock @@ -8,12 +8,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/markdown-it": "^14.2.0", }, "peerDependencies": { "typescript": "^5", @@ -43,10 +45,18 @@ "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + "@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="], + + "@types/markdown-it": ["@types/markdown-it@14.2.0", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ=="], + + "@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="], + "@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "argparse": ["argparse@3.0.1", "", {}, "sha512-nM4mHF/KM1v59ZNKX7zfusQz5wUAxR511YG8Vo6TyiV4aqhu++rbJW4v04xsWhpSsHFj66flT8P7znVpyO20xQ=="], + "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -57,6 +67,8 @@ "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], + "entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="], + "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], "intercom-client": ["intercom-client@7.0.1", "", {}, "sha512-Jw82h6GQOvxHrjDMh0DSjNdDAJZrJNCbXYnC1Ku+laSahORAlAyxPV74IKaN2A2SWJeblaeXH16U6lNCeBJ89Q=="], @@ -65,14 +77,22 @@ "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + "linkify-it": ["linkify-it@6.1.0", "", { "dependencies": { "uc.micro": "^3.0.0" } }, "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw=="], + "log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="], + "markdown-it": ["markdown-it@15.0.1", "", { "dependencies": { "argparse": "^3.0.0", "entities": "^8.0.0", "linkify-it": "^6.0.0", "mdurl": "^2.1.0", "punycode.js": "^2.3.1", "uc.micro": "^3.0.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-9/7gE95FNPkfUWrjJIoHZza2iLmuJlPD0UNMxPi7bxUrbCR525YZY0r+zyfes0dZI5ZZ/uNIXUJca0pJvtw41g=="], + + "mdurl": ["mdurl@2.1.0", "", {}, "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg=="], + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "ora": ["ora@9.1.0", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.2.2", "string-width": "^8.1.0" } }, "sha512-53uuLsXHOAJl5zLrUrzY9/kE+uIFEx7iaH4g2BIJQK4LZjY4LpCCYZVKDWIkL+F01wAaCg93duQ1whnK/AmY1A=="], + "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -85,6 +105,8 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "uc.micro": ["uc.micro@3.0.0", "", {}, "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw=="], + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], diff --git a/package.json b/package.json index 206b2fd..5ce7138 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 2022b18..1faed13 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -430,6 +430,7 @@ export function registerCommands(program: Command, ctx: RegisterContext): void { options: [ { flags: "--body ", description: "Reply message body" }, { flags: "--body-file ", description: "Read reply message body from a UTF-8 file" }, + { flags: "--body-format ", description: "Body format (markdown renders Markdown to HTML)" }, { flags: "--type ", description: "Message type (comment, note)" }, { flags: "--json ", description: "Additional reply data as JSON" }, ], @@ -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, }); @@ -849,6 +851,7 @@ export function registerCommands(program: Command, ctx: RegisterContext): void { options: [ { flags: "--body ", description: "Reply message body" }, { flags: "--body-file ", description: "Read reply message body from a UTF-8 file" }, + { flags: "--body-format ", description: "Body format (markdown renders Markdown to HTML)" }, { flags: "--type ", description: "Message type (comment, note)" }, { flags: "--json ", description: "Additional reply data as JSON" }, ], @@ -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, }); diff --git a/src/commands/conversations.ts b/src/commands/conversations.ts index 2a63ca5..89513a5 100644 --- a/src/commands/conversations.ts +++ b/src/commands/conversations.ts @@ -24,6 +24,7 @@ export interface ConversationReplyOptions extends GlobalOptions { adminId: string; body?: string; bodyFile?: string; + bodyFormat?: string; messageType?: string; json?: string; } @@ -204,7 +205,11 @@ export async function cmdConversationSearch(options: ConversationSearchOptions): } export async function cmdConversationReply(options: ConversationReplyOptions): Promise { - 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(); diff --git a/src/commands/replyBody.ts b/src/commands/replyBody.ts index 8d468b2..b0a03eb 100644 --- a/src/commands/replyBody.ts +++ b/src/commands/replyBody.ts @@ -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 { const hasBody = input.body !== undefined; const hasBodyFile = input.bodyFile !== undefined; @@ -15,7 +34,7 @@ export async function resolveReplyBody(input: ReplyBodyInput): Promise { } if (hasBody) { - return input.body as string; + return renderReplyBody(input.body as string, input.bodyFormat); } try { @@ -23,7 +42,7 @@ export async function resolveReplyBody(input: ReplyBodyInput): Promise { 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; diff --git a/src/commands/tickets.ts b/src/commands/tickets.ts index 5b622f5..27fb722 100644 --- a/src/commands/tickets.ts +++ b/src/commands/tickets.ts @@ -44,6 +44,7 @@ export interface TicketReplyOptions extends GlobalOptions { adminId: string; body?: string; bodyFile?: string; + bodyFormat?: string; messageType?: string; json?: string; } @@ -304,7 +305,11 @@ export async function cmdTicketSearch(options: TicketSearchOptions): Promise { - 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(); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index a0b3ea3..555a1ff 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -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"); }); @@ -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"); }); @@ -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", () => { @@ -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("

Status

\n

Soft line

\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"); @@ -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("

Resolved

\n")); + }); }); describe("format option", () => { diff --git a/tests/reply-body.test.ts b/tests/reply-body.test.ts index 2237069..d6359b0 100644 --- a/tests/reply-body.test.ts +++ b/tests/reply-body.test.ts @@ -18,6 +18,36 @@ afterEach(async () => { }); describe("resolveReplyBody", () => { + test("renders inline Markdown as deterministic HTML when requested", async () => { + await expect( + resolveReplyBody({ body: "# Status\n\nAll *systems* are `go`.", bodyFormat: "markdown" }), + ).resolves.toBe("

Status

\n

All systems are go.

\n"); + }); + + test("preserves Markdown soft line breaks inside paragraphs", async () => { + await expect(resolveReplyBody({ body: "First line\nsecond line", bodyFormat: "markdown" })).resolves.toBe( + "

First line\nsecond line

\n", + ); + }); + + test("renders a Markdown file and escapes source HTML", async () => { + const directory = await createTemporaryDirectory(); + const path = join(directory, "reply.md"); + await writeFile( + path, + "- [Guide](https://example.com)\n- ``\n\n
untrusted
\n\n```ts\nconst 名称 = '🎉';\n```", + "utf8", + ); + + await expect(resolveReplyBody({ bodyFile: path, bodyFormat: "markdown" })).resolves.toBe( + '\n

<div>untrusted</div>

\n
const 名称 = \'🎉\';\n
\n', + ); + }); + + test("rejects unsupported body formats", async () => { + await expect(resolveReplyBody({ body: "reply", bodyFormat: "html" })).rejects.toThrow(CLIError); + }); + test("returns --body unchanged", async () => { await expect(resolveReplyBody({ body: "inline reply" })).resolves.toBe("inline reply"); });