diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3c58bff..514b996 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,7 +3,7 @@ name: Publish to npm on: push: tags: - - 'v*' + - "v*" jobs: publish: @@ -23,8 +23,8 @@ jobs: - name: Setup Node for npm uses: actions/setup-node@v4 with: - node-version: '24' - registry-url: 'https://registry.npmjs.org' + node-version: "24" + registry-url: "https://registry.npmjs.org" - name: Install dependencies run: bun install @@ -43,5 +43,3 @@ jobs: - name: Publish to npm run: npm publish --provenance --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index 8d48341..3214aa9 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ intercom contact delete intercom conversation list intercom conversation search --state open intercom conversation get -intercom conversation reply --admin --body "Thank you!" +intercom conversation reply --admin --body "Internal triage note" --type note intercom conversation close --admin # Manage companies @@ -86,7 +86,8 @@ intercom event track --name "purchase" --user-id "user123" 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!" +intercom ticket reply --admin --body "We're on it!" --type comment +intercom ticket reply --admin --body "Internal note" --json '{"message_type":"note"}' intercom ticket close --admin # List ticket types @@ -135,6 +136,9 @@ intercom ticket-type list | `intercom conversation snooze ` | Snooze conversation | | `intercom conversation convert ` | Convert conversation to ticket | +`intercom conversation reply` supports `--type ` and `--json `. +Message type precedence: `--type` > `--json.message_type` > `comment`. + ### Companies | Command | Description | @@ -191,6 +195,9 @@ intercom ticket-type list | `intercom ticket close ` | Close a ticket | | `intercom ticket assign ` | Assign ticket to admin/team | +`intercom ticket reply` supports `--type ` and `--json `. +Message type precedence: `--type` > `--json.message_type` > `comment`. + ### Ticket Types | Command | Description | diff --git a/package.json b/package.json index bc350c9..5b8f9d3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kyoji2/intercom-cli", - "version": "0.1.4", + "version": "0.1.5", "description": "AI-native CLI for Intercom - manage customer conversations, contacts, messages, and support", "author": "kyoji2", "repository": { diff --git a/skills/intercom/README.md b/skills/intercom/README.md index 1be2bcf..0b2affc 100644 --- a/skills/intercom/README.md +++ b/skills/intercom/README.md @@ -35,6 +35,7 @@ intercom conversation list intercom conversation search --state open intercom conversation get intercom conversation reply --admin --body "Message" +intercom conversation reply --admin --body "Internal note" --type note intercom conversation close --admin intercom conversation assign --admin --assignee diff --git a/skills/intercom/SKILL.md b/skills/intercom/SKILL.md index 4ff7b0c..2d55994 100644 --- a/skills/intercom/SKILL.md +++ b/skills/intercom/SKILL.md @@ -6,7 +6,7 @@ compatibility: Requires Bun runtime (v1.0+) and Intercom account with API token metadata: author: kyoji2 homepage: https://github.com/kyoji2/intercom-cli - version: "1.1" + version: "1.2" --- # Intercom CLI @@ -113,6 +113,8 @@ intercom conversation search --assignee intercom conversation search --json '{"query":{"field":"state","operator":"=","value":"open"}}' intercom conversation reply --admin --body "Message" +intercom conversation reply --admin --body "Internal note" --type note +intercom conversation reply --admin --body "Internal note" --json '{"message_type":"note"}' intercom conversation assign --admin --assignee intercom conversation close --admin intercom conversation open --admin @@ -123,6 +125,8 @@ intercom conversation convert --type-id intercom conversation convert --type-id --title "Bug Report" --description "Details" ``` +For `conversation reply`, message type precedence is `--type` > `--json.message_type` > `comment`. + ## Tickets ```bash @@ -150,6 +154,7 @@ intercom ticket update --json '{"ticket_attributes":{"_default_title_":"Upd # Reply (comment visible to customer, note internal only) intercom ticket reply --admin --body "We're investigating" intercom ticket reply --admin --body "Internal note" --type note +intercom ticket reply --admin --body "Internal note" --json '{"message_type":"note"}' # Workflow actions intercom ticket close --admin @@ -159,6 +164,8 @@ intercom ticket assign --admin --assignee intercom ticket delete ``` +For `ticket reply`, message type precedence is `--type` > `--json.message_type` > `comment`. + ## Companies ```bash diff --git a/skills/intercom/references/commands.md b/skills/intercom/references/commands.md index f653dfb..57d9148 100644 --- a/skills/intercom/references/commands.md +++ b/skills/intercom/references/commands.md @@ -222,12 +222,17 @@ Reply to a conversation. |--------|-------------|----------| | `--admin ` | Admin ID sending reply | Yes | | `--body ` | Reply message | Yes | +| `--type ` | Message type (`comment`, `note`) | No | | `--json ` | Additional data as JSON | No | ```bash intercom conversation reply 12345 --admin 67890 --body "Thank you for reaching out!" +intercom conversation reply 12345 --admin 67890 --body "Internal note" --type note +intercom conversation reply 12345 --admin 67890 --body "Internal note" --json '{"message_type":"note"}' ``` +Message type precedence: `--type` > `--json.message_type` > `comment`. + ### `intercom conversation assign ` Assign conversation to admin/team. @@ -280,6 +285,29 @@ intercom conversation snooze 12345 --admin 67890 --until 1735689600 --- +## Ticket Commands + +### `intercom ticket reply ` + +Reply to a ticket. + +| Option | Description | Required | +|--------|-------------|----------| +| `--admin ` | Admin ID sending reply | Yes | +| `--body ` | Reply message | Yes | +| `--type ` | Message type (`comment`, `note`) | No | +| `--json ` | Additional data as JSON | No | + +```bash +intercom ticket reply 12345 --admin 67890 --body "We're investigating" +intercom ticket reply 12345 --admin 67890 --body "Internal note" --type note +intercom ticket reply 12345 --admin 67890 --body "Internal note" --json '{"message_type":"note"}' +``` + +Message type precedence: `--type` > `--json.message_type` > `comment`. + +--- + ## Company Commands ### `intercom company create` diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 4dfe762..68f321e 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -430,13 +430,17 @@ export function registerCommands(program: Command, ctx: RegisterContext): void { { flags: "--admin ", description: "Admin ID sending the reply" }, { flags: "--body ", description: "Reply message body" }, ], - options: [{ flags: "--json ", description: "Additional reply data as JSON" }], + options: [ + { flags: "--type ", description: "Message type (comment, note)" }, + { flags: "--json ", description: "Additional reply data as JSON" }, + ], action: async ({ globals, args, options }) => { await cmdConversationReply({ ...globals, id: String(args[0]), adminId: options.admin as string, body: options.body as string, + messageType: options.type as string | undefined, json: options.json as string | undefined, }); }, @@ -845,7 +849,10 @@ export function registerCommands(program: Command, ctx: RegisterContext): void { { flags: "--admin ", description: "Admin ID sending the reply" }, { flags: "--body ", description: "Reply message body" }, ], - options: [{ flags: "--type ", description: "Message type (comment, note)", defaultValue: "comment" }], + options: [ + { flags: "--type ", description: "Message type (comment, note)" }, + { flags: "--json ", description: "Additional reply data as JSON" }, + ], action: async ({ globals, args, options }) => { await cmdTicketReply({ ...globals, @@ -853,6 +860,7 @@ export function registerCommands(program: Command, ctx: RegisterContext): void { adminId: options.admin as string, body: options.body as string, messageType: options.type as string | undefined, + json: options.json as string | undefined, }); }, }, diff --git a/src/client.ts b/src/client.ts index 6cb9279..4d6a98f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -109,6 +109,14 @@ function getDryRunResponse(method: string): unknown { } export function handleIntercomError(error: unknown): never { + if (error instanceof SyntaxError) { + throw new CLIError( + "Invalid JSON input provided to command.", + 400, + "Ensure your JSON data is valid and properly escaped for the shell.", + ); + } + if (error instanceof IntercomError) { let hint: string | undefined; if (error.statusCode === 401) { diff --git a/src/commands/conversations.ts b/src/commands/conversations.ts index 4ba7627..cc02ed7 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 { buildAdminReplyPayload } from "./replyPayload.ts"; export interface ConversationListOptions extends GlobalOptions { limit?: string; @@ -21,6 +22,7 @@ export interface ConversationReplyOptions extends GlobalOptions { id: string; adminId: string; body: string; + messageType?: string; json?: string; } @@ -208,12 +210,12 @@ export async function cmdConversationReply(options: ConversationReplyOptions): P const result = await client.conversations.reply({ conversation_id: options.id, - body: { - message_type: "comment", - type: "admin", - admin_id: options.adminId, + body: buildAdminReplyPayload({ + adminId: options.adminId, body: options.body, - }, + messageType: options.messageType, + json: options.json, + }), }); spinner.succeed("Reply sent"); diff --git a/src/commands/overview.ts b/src/commands/overview.ts index 46464a4..884602e 100644 --- a/src/commands/overview.ts +++ b/src/commands/overview.ts @@ -118,7 +118,9 @@ export function cmdSchema(): void { create_contact: 'intercom contact create --email "user@example.com" --name "John Doe"', search_contacts: 'intercom contact search --email "user@example.com"', list_conversations: "intercom conversation list --limit 10", - reply_conversation: 'intercom conversation reply --admin --body "Thank you!"', + reply_conversation: 'intercom conversation reply --admin --body "Internal note" --type note', + reply_ticket: + 'intercom ticket reply --admin --body "Internal note" --json \'{"message_type":"note"}\'', create_tag: 'intercom tag create "VIP Customer"', search_articles: 'intercom article search "getting started"', }, diff --git a/src/commands/replyPayload.ts b/src/commands/replyPayload.ts new file mode 100644 index 0000000..ebb4411 --- /dev/null +++ b/src/commands/replyPayload.ts @@ -0,0 +1,46 @@ +import { CLIError } from "../utils/index.ts"; + +type ReplyPayloadInput = { + adminId: string; + body: string; + messageType?: string; + json?: string; +}; + +export type ReplyBodyPayload = { + message_type: "comment" | "note"; + type: "admin"; + admin_id: string; + body: string; +} & Record; + +function validateReplyType(value: unknown, source: "--type" | "--json"): "comment" | "note" { + if (value !== "comment" && value !== "note") { + throw new CLIError(`Invalid ${source} value for message type: ${String(value)}`, 400, "Use comment or note."); + } + return value; +} + +export function buildAdminReplyPayload(input: ReplyPayloadInput): ReplyBodyPayload { + const parsed = input.json ? JSON.parse(input.json) : {}; + + if (parsed === null || Array.isArray(parsed) || typeof parsed !== "object") { + throw new CLIError("Invalid --json value. Expected a JSON object.", 400); + } + + const messageTypeFromJson = + Object.hasOwn(parsed, "message_type") && (parsed as Record).message_type !== undefined + ? validateReplyType((parsed as Record).message_type, "--json") + : undefined; + const messageType = input.messageType + ? validateReplyType(input.messageType, "--type") + : (messageTypeFromJson ?? "comment"); + + return { + ...(parsed as Record), + message_type: messageType, + type: "admin", + admin_id: input.adminId, + body: input.body, + }; +} diff --git a/src/commands/tickets.ts b/src/commands/tickets.ts index cfaa2a5..8245c03 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 { buildAdminReplyPayload } from "./replyPayload.ts"; export interface TicketGetOptions extends GlobalOptions { id: string; @@ -42,6 +43,7 @@ export interface TicketReplyOptions extends GlobalOptions { adminId: string; body: string; messageType?: string; + json?: string; } export interface TicketCloseOptions extends GlobalOptions { @@ -306,16 +308,14 @@ export async function cmdTicketReply(options: TicketReplyOptions): Promise try { const client = createClient({ token, dryRun: options.dryRun }); - const messageType = options.messageType === "note" ? "note" : "comment"; - const result = await client.tickets.reply({ ticket_id: options.id, - body: { - message_type: messageType, - type: "admin", - admin_id: options.adminId, + body: buildAdminReplyPayload({ + adminId: options.adminId, body: options.body, - }, + messageType: options.messageType, + json: options.json, + }), }); spinner.succeed("Reply sent"); diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 98abd5f..bcec1b8 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -71,6 +71,13 @@ describe("CLI Integration", () => { expect(stdout).toContain("close"); }); + test("conversation reply --help shows --type and --json", async () => { + const { stdout } = await cli("conversation reply --help"); + + expect(stdout).toContain("--type"); + expect(stdout).toContain("--json"); + }); + test("company --help shows subcommands", async () => { const { stdout } = await cli("company --help"); @@ -112,6 +119,13 @@ describe("CLI Integration", () => { expect(stdout).toContain("list"); expect(stdout).toContain("get"); }); + + test("ticket reply --help shows --type and --json", async () => { + const { stdout } = await cli("ticket reply --help"); + + expect(stdout).toContain("--type"); + expect(stdout).toContain("--json"); + }); }); describe("schema command", () => { diff --git a/tests/client.test.ts b/tests/client.test.ts index 3e59d71..27926e9 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -84,6 +84,20 @@ describe("handleIntercomError", () => { expect((e as CLIError).statusCode).toBe(500); } }); + + test("converts SyntaxError to CLIError with 400 status", () => { + const error = new SyntaxError("Unexpected end of JSON input"); + + expect(() => handleIntercomError(error)).toThrow(CLIError); + + try { + handleIntercomError(error); + } catch (e) { + expect(e).toBeInstanceOf(CLIError); + expect((e as CLIError).statusCode).toBe(400); + expect((e as CLIError).message).toBe("Invalid JSON input provided to command."); + } + }); }); describe("CLIError", () => { diff --git a/tests/reply-payload.test.ts b/tests/reply-payload.test.ts new file mode 100644 index 0000000..2802489 --- /dev/null +++ b/tests/reply-payload.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; +import { buildAdminReplyPayload } from "../src/commands/replyPayload.ts"; +import { CLIError } from "../src/utils/output.ts"; + +describe("buildAdminReplyPayload", () => { + test("defaults message_type to comment", () => { + const payload = buildAdminReplyPayload({ + adminId: "1", + body: "hello", + }); + + expect(payload.message_type).toBe("comment"); + expect(payload.type).toBe("admin"); + expect(payload.admin_id).toBe("1"); + expect(payload.body).toBe("hello"); + }); + + test("uses --type note", () => { + const payload = buildAdminReplyPayload({ + adminId: "1", + body: "internal note", + messageType: "note", + }); + + expect(payload.message_type).toBe("note"); + }); + + test("uses message_type from --json when --type is absent", () => { + const payload = buildAdminReplyPayload({ + adminId: "1", + body: "internal note", + json: '{"message_type":"note"}', + }); + + expect(payload.message_type).toBe("note"); + }); + + test("prioritizes --type over json message_type", () => { + const payload = buildAdminReplyPayload({ + adminId: "1", + body: "public reply", + messageType: "comment", + json: '{"message_type":"note"}', + }); + + expect(payload.message_type).toBe("comment"); + }); + + test("keeps additional fields from json", () => { + const payload = buildAdminReplyPayload({ + adminId: "1", + body: "reply", + json: '{"attachment_urls":["https://example.com/a.png"],"created_at":1700000000}', + }); + + expect(payload.attachment_urls).toEqual(["https://example.com/a.png"]); + expect(payload.created_at).toBe(1700000000); + }); + + test("does not allow json to override fixed fields", () => { + const payload = buildAdminReplyPayload({ + adminId: "1", + body: "final body", + json: '{"type":"user","admin_id":"999","body":"wrong"}', + }); + + expect(payload.type).toBe("admin"); + expect(payload.admin_id).toBe("1"); + expect(payload.body).toBe("final body"); + }); + + test("rejects invalid --type", () => { + expect(() => + buildAdminReplyPayload({ + adminId: "1", + body: "hello", + messageType: "quick_reply", + }), + ).toThrow(CLIError); + }); + + test("rejects invalid json message_type", () => { + expect(() => + buildAdminReplyPayload({ + adminId: "1", + body: "hello", + json: '{"message_type":"quick_reply"}', + }), + ).toThrow(CLIError); + }); + + test("rejects non-object json", () => { + expect(() => + buildAdminReplyPayload({ + adminId: "1", + body: "hello", + json: '["not-an-object"]', + }), + ).toThrow(CLIError); + }); +});