diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b67a38..7b88226 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.6] - 2026-03-23 + +### Added + +- Added `beepctl react ` to add emoji, shortcode, or custom emoji reactions to existing messages. +- Added `beepctl react --remove` to remove the authenticated user's reaction for a reaction key. +- Added focused command tests covering add, remove, alias resolution, quiet mode, and shared write-permission errors. +- Updated README and packaged skill docs with reaction command usage and semantics. + +### Changed + +- Upgraded `@beeper/desktop-api` to `4.7.0` to use the official SDK reaction methods exposed at `client.chats.messages.reactions.*`. + + ## [0.1.5] - 2026-02-20 ### Added @@ -58,6 +72,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Migrated to official `@beeper/desktop-api` SDK +[0.1.6]: https://github.com/blqke/beepctl/releases/tag/v0.1.6 [0.1.5]: https://github.com/blqke/beepctl/releases/tag/v0.1.5 [0.1.3]: https://github.com/blqke/beepctl/releases/tag/v0.1.3 [0.1.2]: https://github.com/blqke/beepctl/releases/tag/v0.1.2 diff --git a/README.md b/README.md index dd899ba..a64167f 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,20 @@ beepctl reminders set tomorrow # Remind tomorrow beepctl reminders clear # Clear reminder ``` +### React to Messages + +Add or remove emoji reactions on messages: + +```bash +beepctl react 👍 # React with emoji +beepctl react thumbsup # React with shortcode +beepctl react custom_emoji_key # React with custom emoji key +beepctl react 👍 --remove # Remove your reaction +beepctl react work ❤️ # Use alias +``` + +`` can be an emoji character, a shortcode (e.g. `thumbsup`), or a custom emoji key. `--remove` removes the authenticated user's reaction for that key — it cannot remove other users' reactions. + ## Development ```bash diff --git a/package.json b/package.json index 22a25ec..e6dfaa1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "beepctl", - "version": "0.1.5", + "version": "0.1.6", "description": "CLI for Beeper Desktop API - unified messaging from terminal", "license": "MIT", "type": "module", @@ -25,7 +25,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@beeper/desktop-api": "^4.2.3", + "@beeper/desktop-api": "^4.7.0", "commander": "^14.0.0", "kleur": "^4.1.5" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7397008..f6091ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@beeper/desktop-api': - specifier: ^4.2.3 - version: 4.2.3 + specifier: ^4.7.0 + version: 4.7.0 commander: specifier: ^14.0.0 version: 14.0.2 @@ -39,8 +39,8 @@ importers: packages: - '@beeper/desktop-api@4.2.3': - resolution: {integrity: sha512-b5dKEKK6FzbAWRmh6udLxAt1QB1vEtLcBScWdDnMXdVA/rIkpd67LTJz+7KJDa/RQnzOL2zxcDNAC4b7OZpSZw==} + '@beeper/desktop-api@4.7.0': + resolution: {integrity: sha512-XIPwQKhqJwl3/95i/xkZN5i0MRc1asjGMvQ4rlOc8Lp19HIxLP4PI551rWFqF4g0RxUGL/bsU7DOOTSFR4Qx4Q==} '@biomejs/biome@2.3.11': resolution: {integrity: sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==} @@ -676,7 +676,7 @@ packages: snapshots: - '@beeper/desktop-api@4.2.3': {} + '@beeper/desktop-api@4.7.0': {} '@biomejs/biome@2.3.11': optionalDependencies: diff --git a/skills/beepctl/SKILL.md b/skills/beepctl/SKILL.md index 18759c2..b9a326e 100644 --- a/skills/beepctl/SKILL.md +++ b/skills/beepctl/SKILL.md @@ -164,6 +164,17 @@ beepctl reminders set tomorrow # Remind tomorrow beepctl reminders clear # Clear reminder ``` +### React to Messages +```bash +beepctl react 👍 # React with emoji +beepctl react thumbsup # React with shortcode +beepctl react custom_emoji_key # React with custom emoji key +beepctl react 👍 --remove # Remove your reaction +beepctl react work ❤️ # Use alias +``` + +`` accepts an emoji, shortcode, or custom emoji key. `--remove` removes the authenticated user's reaction only — it cannot remove other users' reactions. + ## Tips - Chat IDs look like: `!gZ42vWzDxl8V0sZXWBgO:beeper.local` diff --git a/src/cli.ts b/src/cli.ts index c1755ca..5cca7ff 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ import { contactsCommand } from "./commands/contacts.js"; import { downloadCommand } from "./commands/download.js"; import { focusCommand } from "./commands/focus.js"; import { messagesCommand } from "./commands/messages.js"; +import { reactCommand } from "./commands/react.js"; import { remindersCommand } from "./commands/reminders.js"; import { searchCommand } from "./commands/search.js"; import { sendCommand } from "./commands/send.js"; @@ -29,6 +30,7 @@ program .addCommand(downloadCommand) .addCommand(focusCommand) .addCommand(messagesCommand) + .addCommand(reactCommand) .addCommand(remindersCommand) .addCommand(sendCommand) .addCommand(searchCommand) diff --git a/src/commands/accounts.ts b/src/commands/accounts.ts index 33b584c..b9547bf 100644 --- a/src/commands/accounts.ts +++ b/src/commands/accounts.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; import kleur from "kleur"; -import { getClient } from "../lib/client.js"; +import { getAccountNetwork, getClient } from "../lib/client.js"; import { handleError } from "../lib/errors.js"; export const accountsCommand = new Command("accounts") @@ -20,7 +20,7 @@ export const accountsCommand = new Command("accounts") for (const account of accounts) { const name = account.user?.fullName || account.user?.username || account.accountID; - console.log(` ${kleur.cyan(account.network)} ${kleur.bold(name)}`); + console.log(` ${kleur.cyan(getAccountNetwork(account))} ${kleur.bold(name)}`); console.log(kleur.dim(` ID: ${account.accountID}\n`)); } } catch (error) { diff --git a/src/commands/chats.ts b/src/commands/chats.ts index aa04b3b..721ca2d 100644 --- a/src/commands/chats.ts +++ b/src/commands/chats.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import kleur from "kleur"; import { isValidChatId, resolveAlias } from "../lib/aliases.js"; import type { ChatListResponse } from "../lib/client.js"; -import { getClient } from "../lib/client.js"; +import { getChatDescription, getChatNetwork, getClient } from "../lib/client.js"; import { getConfig } from "../lib/config.js"; import { parseRelativeDate } from "../lib/dates.js"; import { handleError } from "../lib/errors.js"; @@ -65,10 +65,11 @@ chatsCommand console.log(`${kleur.dim("ID:")} ${chat.id}`); console.log(`${kleur.dim("Title:")} ${chat.title || "(none)"}`); console.log(`${kleur.dim("Type:")} ${chat.type}`); - console.log(`${kleur.dim("Network:")} ${chat.network}`); + console.log(`${kleur.dim("Network:")} ${getChatNetwork(chat)}`); console.log(`${kleur.dim("Account:")} ${chat.accountID}`); - if (chat.description) { - console.log(`${kleur.dim("Description:")} ${chat.description}`); + const description = getChatDescription(chat); + if (description) { + console.log(`${kleur.dim("Description:")} ${description}`); } console.log(SEPARATOR); console.log(`${kleur.dim("Unread:")} ${chat.unreadCount}`); @@ -254,8 +255,8 @@ function printChatList( for (let i = 0; i < chats.length; i++) { const chat = chats[i]; const num = kleur.dim(`${i + 1}.`); - const name = kleur.bold(chat.title || chat.description || "Unknown"); - const network = kleur.dim(`[${chat.network || chat.accountID}]`); + const name = kleur.bold(chat.title || getChatDescription(chat) || "Unknown"); + const network = kleur.dim(`[${getChatNetwork(chat)}]`); const unread = chat.unreadCount ? kleur.red(` (${chat.unreadCount} unread)`) : ""; console.log(`${num} ${name} ${network}${unread}`); diff --git a/src/commands/messages.ts b/src/commands/messages.ts index da8d3cd..19d6802 100644 --- a/src/commands/messages.ts +++ b/src/commands/messages.ts @@ -1,7 +1,7 @@ import { Command } from "commander"; import kleur from "kleur"; import type { Message } from "../lib/client.js"; -import { getClient } from "../lib/client.js"; +import { getAccountNetwork, getClient } from "../lib/client.js"; import { formatSize, handleCommandError, @@ -75,7 +75,7 @@ export const messagesCommand = new Command("messages") const accounts = await client.accounts.list(); const networkMap = new Map(); for (const account of accounts) { - networkMap.set(account.accountID, account.network || account.accountID); + networkMap.set(account.accountID, getAccountNetwork(account)); } console.log(kleur.bold(`\nMessages (${messages.length})`)); diff --git a/src/commands/react.ts b/src/commands/react.ts new file mode 100644 index 0000000..41f9b77 --- /dev/null +++ b/src/commands/react.ts @@ -0,0 +1,56 @@ +import { Command } from "commander"; +import kleur from "kleur"; +import { getClient } from "../lib/client.js"; +import { + handleCommandError, + resolveChatIdOrExit, + WRITE_PERMISSION_ERROR_HANDLER, +} from "../lib/command-utils.js"; +import { getConfig } from "../lib/config.js"; + +export const reactCommand = new Command("react") + .description("Add or remove your reaction on an existing message") + .argument("", "Chat ID or alias") + .argument("", "Message ID to react to") + .argument("", "Reaction key (emoji, shortcode, or custom emoji key)") + .option("--remove", "Remove your reaction instead of adding it") + .option("-q, --quiet", "Don't show confirmation") + .action(async (chatIdArg: string, messageId: string, reactionKey: string, options) => { + try { + const client = getClient(); + const config = getConfig(); + const chatID = resolveChatIdOrExit(chatIdArg, config); + + if (options.remove) { + const removed = await client.chats.messages.reactions.delete(messageId, { + chatID, + reactionKey, + }); + + if (!options.quiet) { + console.log(kleur.green("Reaction removed")); + console.log(kleur.dim(` Chat: ${removed.chatID}`)); + console.log(kleur.dim(` Message: ${removed.messageID}`)); + console.log(kleur.dim(` Reaction: ${removed.reactionKey}`)); + } + return; + } + + const added = await client.chats.messages.reactions.add(messageId, { + chatID, + reactionKey, + }); + + if (!options.quiet) { + console.log(kleur.green("Reaction added")); + console.log(kleur.dim(` Chat: ${added.chatID}`)); + console.log(kleur.dim(` Message: ${added.messageID}`)); + console.log(kleur.dim(` Reaction: ${added.reactionKey}`)); + if (added.transactionID) { + console.log(kleur.dim(` Transaction: ${added.transactionID}`)); + } + } + } catch (error) { + handleCommandError(error, [WRITE_PERMISSION_ERROR_HANDLER]); + } + }); diff --git a/src/commands/search.ts b/src/commands/search.ts index b1d48b2..ad77b2f 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import kleur from "kleur"; import { isValidChatId, resolveAlias } from "../lib/aliases.js"; import type { Chat, Message } from "../lib/client.js"; -import { getClient } from "../lib/client.js"; +import { getAccountNetwork, getChatDescription, getClient } from "../lib/client.js"; import { handleCommandError, highlightQuery, @@ -68,7 +68,7 @@ export const searchCommand = new Command("search") const accounts = await client.accounts.list(); const networkMap = new Map(); for (const account of accounts) { - networkMap.set(account.accountID, account.network || account.accountID); + networkMap.set(account.accountID, getAccountNetwork(account)); } // Build search params @@ -237,7 +237,7 @@ function printChats(chats: Chat[]): void { for (let i = 0; i < chats.length; i++) { const chat = chats[i]; const num = kleur.dim(`${i + 1}.`); - console.log(`${num} ${kleur.bold(chat.title || chat.description || "Unknown")}`); + console.log(`${num} ${kleur.bold(chat.title || getChatDescription(chat) || "Unknown")}`); console.log(kleur.dim(` ID: ${chat.id}`)); if (i < chats.length - 1) console.log(THIN_SEP); } diff --git a/src/commands/send.ts b/src/commands/send.ts index e677d04..5a55a45 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -2,7 +2,7 @@ import { Command } from "commander"; import kleur from "kleur"; import { resolveAlias } from "../lib/aliases.js"; import { getClient } from "../lib/client.js"; -import { handleCommandError } from "../lib/command-utils.js"; +import { handleCommandError, WRITE_PERMISSION_ERROR_HANDLER } from "../lib/command-utils.js"; import { getConfig } from "../lib/config.js"; export const sendCommand = new Command("send") @@ -32,13 +32,7 @@ export const sendCommand = new Command("send") } } } catch (error) { - handleCommandError(error, [ - { - match: "403", - message: "Permission denied", - hint: "Enable write permissions: Settings -> Developers -> Edit token -> Enable 'write' scope", - }, - ]); + handleCommandError(error, [WRITE_PERMISSION_ERROR_HANDLER]); } }); diff --git a/src/lib/client.ts b/src/lib/client.ts index 11cca18..2dbc61a 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -10,6 +10,24 @@ export type { export type { Chat, ChatListResponse } from "@beeper/desktop-api/resources/chats/chats.js"; export type { Message } from "@beeper/desktop-api/resources/shared.js"; +function readOptionalStringField(value: unknown, field: string): string | undefined { + if (!value || typeof value !== "object") return undefined; + const record = value as Record; + return typeof record[field] === "string" ? record[field] : undefined; +} + +export function getAccountNetwork(account: { accountID: string }): string { + return readOptionalStringField(account, "network") ?? account.accountID; +} + +export function getChatDescription(chat: unknown): string | undefined { + return readOptionalStringField(chat, "description"); +} + +export function getChatNetwork(chat: { accountID: string }): string { + return readOptionalStringField(chat, "network") ?? chat.accountID; +} + let _client: BeeperDesktop | null = null; export function getClient(): BeeperDesktop { diff --git a/src/lib/command-utils.ts b/src/lib/command-utils.ts index ce8890f..d93322d 100644 --- a/src/lib/command-utils.ts +++ b/src/lib/command-utils.ts @@ -39,6 +39,12 @@ export function validateDateRangeOrExit(dateAfter: string, dateBefore: string): } } +export const WRITE_PERMISSION_ERROR_HANDLER: ErrorHandler = { + match: "403", + message: "Permission denied", + hint: "Enable write permissions: Settings -> Developers -> Edit token -> Enable 'write' scope", +}; + /** * Standard error handler for command actions. */ @@ -69,7 +75,7 @@ export function handleCommandError(error: unknown, extraHandlers?: ErrorHandler[ process.exit(1); } -interface ErrorHandler { +export interface ErrorHandler { match: string; message: string; hint?: string; diff --git a/src/version.ts b/src/version.ts index b41a6df..3c2dfff 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const version = "0.1.0"; +export const version = "0.1.6"; diff --git a/tests/react-command.test.ts b/tests/react-command.test.ts new file mode 100644 index 0000000..0b6a7da --- /dev/null +++ b/tests/react-command.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const addReaction = vi.fn(); +const deleteReaction = vi.fn(); +const mockConfig = { + token: "test-token", + baseUrl: "http://localhost:23373", + aliases: {} as Record, +}; + +const mockClient = { + chats: { + messages: { + reactions: { + add: addReaction, + delete: deleteReaction, + }, + }, + }, +}; + +vi.mock("../src/lib/client.js", () => ({ + getClient: () => mockClient, +})); + +vi.mock("../src/lib/config.js", () => ({ + getConfig: () => mockConfig, +})); + +async function runReactCommand(args: string[]): Promise { + vi.resetModules(); + const { reactCommand } = await import("../src/commands/react.js"); + await reactCommand.parseAsync(args, { from: "user" }); +} + +function joinedOutput(spy: ReturnType): string { + return spy.mock.calls.flat().join("\n"); +} + +describe("reactCommand", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + mockConfig.aliases = {}; + addReaction.mockResolvedValue({ + chatID: "!chat:beeper.local", + messageID: "708111", + reactionKey: "✅", + success: true, + transactionID: "txn-123", + }); + deleteReaction.mockResolvedValue({ + chatID: "!chat:beeper.local", + messageID: "708111", + reactionKey: "👍", + success: true, + }); + }); + + it("adds a reaction with the official reactions API", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + + await runReactCommand(["!chat:beeper.local", "708111", "✅"]); + + expect(addReaction).toHaveBeenCalledWith("708111", { + chatID: "!chat:beeper.local", + reactionKey: "✅", + }); + expect(deleteReaction).not.toHaveBeenCalled(); + + const output = joinedOutput(logSpy); + expect(output).toContain("Reaction added"); + expect(output).toContain("!chat:beeper.local"); + expect(output).toContain("708111"); + expect(output).toContain("✅"); + expect(output).toContain("txn-123"); + }); + + it("removes a reaction using an alias and --remove", async () => { + mockConfig.aliases = { work: "!resolved:beeper.local" }; + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + + await runReactCommand(["work", "708111", "--remove", "👍"]); + + expect(deleteReaction).toHaveBeenCalledWith("708111", { + chatID: "!resolved:beeper.local", + reactionKey: "👍", + }); + expect(addReaction).not.toHaveBeenCalled(); + + const output = joinedOutput(logSpy); + expect(output).toContain("Reaction removed"); + expect(output).toContain("!chat:beeper.local"); + expect(output).toContain("708111"); + expect(output).toContain("👍"); + }); + + it("suppresses confirmation output in quiet mode", async () => { + const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + + await runReactCommand(["!chat:beeper.local", "708111", "❌", "--quiet"]); + + expect(addReaction).toHaveBeenCalledWith("708111", { + chatID: "!chat:beeper.local", + reactionKey: "❌", + }); + expect(logSpy).not.toHaveBeenCalled(); + }); + + it("prints the shared write-permission hint on 403 errors", async () => { + addReaction.mockRejectedValue(new Error("403 Forbidden")); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit:${code}`); + }) as never); + + await expect(runReactCommand(["!chat:beeper.local", "708111", "✅"])).rejects.toThrow( + "process.exit:1", + ); + + expect(addReaction).toHaveBeenCalledWith("708111", { + chatID: "!chat:beeper.local", + reactionKey: "✅", + }); + const output = joinedOutput(errorSpy); + expect(output).toContain("Permission denied"); + expect(output).toContain("Enable write permissions"); + expect(output).toContain("write' scope"); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +});