npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@cherrydotfun/bots-sdk

v0.2.0

Published

Node SDK for the Cherry Bot API — typed bot client with DM, group mentions, inline keyboards, polling, webhooks, signature/transaction requests, and inline miniapp messages.

Readme

@cherrydotfun/bots-sdk

Typed Node.js SDK for the Cherry Bot API — build bots for Cherry with DMs, group mentions, inline keyboards, long-polling or webhooks, signature/transaction requests, inline miniapp "blinks", and a Telegram-style / command menu.

  • Runtime: Node.js ≥ 20 (uses the global fetch; a polyfill can be injected).
  • Language: TypeScript (ships .d.ts), works from plain JS too.
  • Transport: long-poll (bot.start()) or HMAC-signed webhooks — mutually exclusive.
  • Auth: a single Bearer token cherry_bot_<botId>_<secret> (the legacy cha_<appId>_<secret> still works).

Table of contents

  1. Install
  2. Quick start
  3. Authentication & token
  4. Configuration — BotConfig
  5. The Bot class
  6. Events
  7. Modules
  8. Inline keyboards
  9. Blinks (inline widgets)
  10. Transports
  11. Error handling
  12. Type reference
  13. Endpoint reference
  14. Scopes reference
  15. Example
  16. License

Install

npm install @cherrydotfun/bots-sdk
# or
pnpm add @cherrydotfun/bots-sdk
# or
bun add @cherrydotfun/bots-sdk

Quick start

import { Bot, InlineKeyboard } from '@cherrydotfun/bots-sdk';

const bot = new Bot({
  baseUrl: 'https://chat.cherry.fun',
  token: process.env.CHERRY_BOT_TOKEN!, // cherry_bot_<botId>_<secret>
  polling: { timeout: 25 },
});

// Who am I?
const me = await bot.me();
console.log(`Connected as bot ${me.botId ?? me.appId} wallet=${me.botWallet} scopes=${me.scopes.join(',')}`);

// Reply to DMs
bot.on('message', async (m) => {
  await bot.dm.send({ toWallet: m.from.walletAddress, content: `You said: ${m.text}` });
});

// Reply when @-mentioned in a group
bot.on('mention', async (m) => {
  await bot.messages.send(m.roomId, { content: `Hi <@${m.from.walletAddress}>!` });
});

// Inline keyboard + callback
bot.on('callback_query', async (q) => {
  await bot.callbacks.answer(q.id, { text: `You picked ${q.callbackData}` });
});

await bot.start(); // begins the long-poll loop; resolves when bot.stop() is called

Authentication & token

Every request is authenticated with a Bearer token of the form:

cherry_bot_<botId>_<secret>

The token is minted in the Cherry admin panel (Apps → API Keys). The app must have API access enabled and the relevant scopes granted (see Scopes reference). The constructor validates the prefix and throws synchronously unless the token starts with cherry_bot_ (issued by the developer portal) or the legacy cha_.

Scopes are read once per process via bot.me(). If an admin grants a new scope while the bot is running, restart the bot so it re-fetches /me.


Configuration — BotConfig

const bot = new Bot(config: BotConfig);

| Field | Type | Default | Description | | ------------ | ---------------------- | ------------------ | ----------- | | baseUrl | string | — (required) | Cherry server base URL, e.g. https://chat.cherry.fun. | | token | string | — (required) | Bot token cherry_bot_<botId>_<secret> (legacy cha_<appId>_<secret> accepted). | | fetch | typeof fetch | globalThis.fetch | Optional fetch polyfill (Node < 18 / custom agents). | | timeout | number | 30000 | Per-request timeout, ms. The poll loop overrides this for getUpdates. | | polling | PollingConfig | — | Enables long-poll transport. Mutually exclusive with webhooks. | | keyCustody | 'server' \| 'self' | 'server' | DM encryption custody. server (default): Cherry holds the bot key and encrypts/decrypts DMs server-side. 'self' is reserved / not yet implemented — the SDK performs no client-side crypto; use 'server'. |

The constructor throws Error if baseUrl/token are missing, the token has the wrong prefix, or keyCustody is an invalid value.


The Bot class

class Bot {
  // Modules
  readonly dm: DmModule;
  readonly messages: MessagesModule;
  readonly callbacks: CallbacksModule;
  readonly auth: AuthModule;
  readonly handle: HandleModule;
  readonly miniapp: MiniappModule;
  readonly commands: CommandsModule;

  constructor(config: BotConfig);

  /** GET /api/v1/bots/me — identity, handle, scopes, keyCustody, webhookUrl, blinkOrigins. */
  me(): Promise<BotMeResponse>;

  /** Subscribe / unsubscribe to events. Returns `this` for chaining. */
  on<K extends keyof BotEventMap>(event: K, handler: BotEventMap[K]): this;
  off<K extends keyof BotEventMap>(event: K, handler: BotEventMap[K]): this;

  /** Manually dispatch an update (used by webhook adapters after verification). */
  dispatch(update: BotUpdate): Promise<void>;

  /** Long-poll transport control. */
  start(): Promise<void>; // resolves only when stop() is called
  stop(): void;           // idempotent
}

/** Type-narrow an update by kind. */
function isUpdateKind<K extends UpdateKind>(u: BotUpdate, kind: K): u is BotUpdate & { kind: K };

Handlers are stored in a Set per event — registering the same function twice is a no-op. A handler that throws is routed to the error event (never crashes the loop).


Events

Subscribe with bot.on(kind, handler). Each incoming BotUpdate is first delivered to the catch-all update listener, then to the kind-specific one.

| Event | Payload | Fires when | | ---------------------- | --------------------------------------------- | ---------- | | message | BotMessageRef | A user sends the bot a DM. | | group_message | BotMessageRef | A message is posted in a group where the bot opted into read-all (botReadAll). | | mention | BotMessageRef | The bot is @-mentioned in a group it's a member of. | | callback_query | CallbackQuery | A user taps an inline-keyboard button or a blink widget action. | | signature_response | SignatureResponse | A user responds to a request_sign. | | transaction_response | TransactionResponse | A user responds to a request_tx. | | member_event | MemberEvent | A membership change in a room the bot is in (join/leave/kick/ban/role…). | | assignment_event | AssignmentEvent | The bot is attached/detached from a room, or an assignment invite is answered. | | update | BotUpdate | Every update (before the kind-specific handler). | | error | unknown | A transport error or an exception thrown inside a handler. |

bot.on('update', (u) => console.debug('update', u.kind, u.update_id));
bot.on('error', (err) => console.error('bot error', err));

Modules

bot.dm — direct messages

// Send a 1-to-1 message (server-custody: pass `content`).
bot.dm.send(payload: SendDirectMessageRequest): Promise<BotMessageRef>;

// Fetch DM history with one user (paginated, newest first).
bot.dm.getChat(opts: {
  withWallet: string;
  limit?: number;   // page size
  before?: string;  // cursor (messageId)
}): Promise<{ messages: BotMessageRef[]; nextCursor?: string }>;
await bot.dm.send({ toWallet: '5U6mn…DnWs', content: 'gm 🌅' });

const { messages, nextCursor } = await bot.dm.getChat({ withWallet: '5U6mn…DnWs', limit: 20 });

Self-custody (keyCustody: 'self') is not yet implemented — the SDK ships no client-side crypto and won't build the encrypted payload for you. Use content (server-custody). The encrypted field exists on the wire type for forward-compat only.


bot.messages — group messages & edits

// Plain text into a group room.
bot.messages.send(roomId: string, payload: SendMessageRequest): Promise<BotMessageRef>;

// Text + inline keyboard and/or inline blink.
bot.messages.sendInteractive(roomId: string, payload: SendInteractiveMessageRequest): Promise<BotMessageRef>;

// Edit a previously-sent message's text (optionally swap reply_markup).
bot.messages.editText(roomId, messageId, content, opts?: { replyMarkup?: ReplyMarkup | null }): Promise<BotMessageRef>;

// Replace (or clear with `null`) the inline keyboard.
bot.messages.editReplyMarkup(roomId, messageId, replyMarkup: ReplyMarkup | null): Promise<BotMessageRef>;

// Delete a bot-authored message.
bot.messages.delete(roomId, messageId): Promise<void>;
import { InlineKeyboard } from '@cherrydotfun/bots-sdk';

const kb = new InlineKeyboard()
  .row({ text: '👍', callback_data: 'yes' }, { text: '👎', callback_data: 'no' })
  .build();

const msg = await bot.messages.sendInteractive(roomId, {
  content: 'Do you like Cherry?',
  reply_markup: kb,
});

await bot.messages.editText(roomId, msg.messageId, 'Thanks for voting!');

bot.callbacks — answer callback queries

bot.callbacks.answer(callbackId: string, payload?: AnswerCallbackQueryRequest): Promise<void>;

Acknowledge an inline-keyboard / blink callback. You can piggyback an atomic message edit (text, reply_markup, or blink params) to avoid a second round-trip:

bot.on('callback_query', async (q) => {
  await bot.callbacks.answer(q.id, {
    text: 'Saved ✓',                 // toast
    editReplyMarkup: null,           // clear the buttons
    // alert: true,                  // blocking dialog instead of toast
    // editMessage: { content: '…' },
    // updateBlink: { params: { … } },
  });
});

bot.auth — signature & transaction requests

Convenience wrappers that send an interactive message containing exactly one request_sign / request_tx button. Pair with the matching event.

bot.auth.requestSignature(opts: RequestSignatureOptions): Promise<BotMessageRef>;
bot.auth.requestTransaction(opts: RequestTransactionOptions): Promise<BotMessageRef>;
await bot.auth.requestSignature({
  toWallet: user,            // or roomId for an in-group request
  requestId: 'login-42',
  message: 'I agree to the Cherry ToS',
  prompt: 'Please confirm:',
  buttonText: 'Sign in',
});

bot.on('signature_response', (r) => {
  if (r.status === 'ok' && r.verified) {
    console.log(`${r.from.walletAddress} signed request ${r.requestId}`);
  }
});

await bot.auth.requestTransaction({
  toWallet: user,
  requestId: 'tip-1',
  transaction: base64SerializedTx, // ≤ 1232 bytes
  submit: true,                    // server submits via Helius and reports the signature
});

bot.on('transaction_response', (r) => {
  if (r.status === 'ok') console.log('on-chain signature:', r.signature);
});
interface RequestSignatureOptions {
  roomId?: string; toWallet?: string; // one of the two — in-group vs DM
  requestId: string;
  message: string;        // ≤ 1024 bytes utf-8
  prompt?: string;
  buttonText?: string;    // default "Sign"
}
interface RequestTransactionOptions {
  roomId?: string; toWallet?: string;
  requestId: string;
  transaction: string;    // base64 serialized Solana tx, ≤ 1232 bytes
  submit?: boolean;       // server submits via Helius and reports the on-chain signature
  expiresAt?: number;
  prompt?: string;
  buttonText?: string;
}

bot.handle — read the @handle

bot.handle.get(): Promise<HandleInfo | null>; // null when no handle is reserved
// interface HandleInfo { handle: string; botWallet: string; reservedAt: string }

Handles are globally unique ([a-z0-9_]{3,32}, must end in bot) and admin-reserved — there is no self-serve setter. When a user types @handle in a group the bot is a member of, the server emits a mention event. The current value is also on (await bot.me()).handle.


bot.miniapp — miniapps & blinks

// Attach the default miniapp used for inline blinks / launch.
bot.miniapp.setBlinkMiniApp(req: SetBlinkMiniAppRequest): Promise<BlinkMiniAppConfig>;

// Mint a one-time full-screen launch URL + token.
bot.miniapp.launch(req?: LaunchMiniAppRequest): Promise<LaunchMiniAppResponse>;
await bot.miniapp.setBlinkMiniApp({ miniAppId: 'suk3…4S', defaultRoute: '/home' });

const { url, token, expiresAt } = await bot.miniapp.launch({ route: '/play', roomId });
interface SetBlinkMiniAppRequest { miniAppId: string; defaultRoute?: string }
interface BlinkMiniAppConfig     { miniAppId: string; version?: string; defaultRoute?: string }
interface LaunchMiniAppRequest   { miniAppId?: string; route?: string; params?: Record<string, unknown>; roomId?: string }
interface LaunchMiniAppResponse  { url: string; token: string; expiresAt: string }

bot.commands — the / command menu

Publish a Telegram-style command list. Cherry clients suggest these when a user types / in a DM with the bot (and /command@bothandle in groups).

bot.commands.set(commands: BotCommand[]): Promise<{ commands: BotCommand[] }>; // replace (not merge), max 100
bot.commands.get(): Promise<{ commands: BotCommand[] }>;
await bot.commands.set([
  { command: '/start', name: 'Start', description: 'Begin' },
  { command: '/tip', name: 'Tip', description: 'Send a tip', params: '<amount> [token]' },
]);

Command tokens must match /^\/[a-z0-9_]{1,32}$/ (no hyphens). Requires the bots:commands:manage scope; gate the call on me.scopes.includes('bots:commands:manage') to avoid a 403.


Inline keyboards

reply_markup.inline_keyboard is a 2-D array of InlineButton. Use the fluent InlineKeyboard builder:

import { InlineKeyboard } from '@cherrydotfun/bots-sdk';

const kb = new InlineKeyboard()
  .row({ text: '👍', callback_data: 'up' }, { text: '👎', callback_data: 'down' })
  .row({ text: 'Open site', url: 'https://cherry.fun' })
  .row({ text: 'Sign in', request_sign: { requestId: 'r1', message: 'I agree' } })
  .button({ text: 'Mini app', web_app: { route: '/home' } })
  .build(); // → ReplyMarkup

InlineKeyboard methods: .row(...buttons), .button(btn), .build(), .rowCount.

Button variants (exactly one action field per button):

| Variant | Action field | Effect on tap | | ------------- | --------------------------------------------- | ------------- | | Callback | callback_data: string (≤ 64 bytes) | callback_query event | | URL | url: string (https) | opens URL | | Web app | web_app: { route?, params? } | opens miniapp | | Request sign | request_sign: { requestId, message } | signature_response event | | Request tx | request_tx: { requestId, transaction, expiresAt?, submit? } | transaction_response event |

Shared options on every button: once?, once_global?, expires_at? (see InlineButton).


Blinks (inline widgets)

A blink is a widget mounted inside a message bubble. Two flavours via the discriminated union BlinkMessage:

// 1) Miniapp blink (default) — embeds the bot's attached miniapp by route.
await bot.messages.sendInteractive(roomId, {
  content: 'Weekly leaderboard:',
  blink: { type: 'miniapp', route: '/leaderboard', height: 'medium', interactive: true },
});

// 2) URL blink — embeds an arbitrary bot-hosted page.
await bot.messages.sendInteractive(roomId, {
  content: 'Play:',
  blink: { type: 'url', url: 'https://yourbot.example/widget', height: 'tall' },
});

// 3) Pin an exact initial height so the card doesn't "jump" on load.
await bot.messages.sendInteractive(roomId, {
  content: 'Result:',
  blink: { type: 'miniapp', route: '/result', height: 'tall', initialHeight: 360 },
});
  • Miniapp blinks require an attached miniapp (bot.miniapp.setBlinkMiniApp).
  • URL blinks require the origin to be in the workspace blinkOrigins allowlist (admin-managed; read via (await bot.me()).blinkOrigins). Outside the list → BLINK_URL_NOT_ALLOWED. There is no setBlinkOrigins — origins are workspace policy.
  • The hosted page must implement the Cherry Protocol v2 bridge (cherry:request / cherry:init / cherry:callback).
  • Widget actions arrive as callback_query with source: 'blink_widget'.

Render height

height is the bucket (render ceiling), one of 'compact' | 'medium' | 'tall' → 96 | 220 | 420 px.

initialHeight (optional, CSS px) pins the height the card opens at, so it renders at its real size on the first frame instead of jumping once the miniapp reports its content height via host.resize. It must be a positive integer ≤ the bucket max for the chosen height (else BLINK_INITIAL_HEIGHT_INVALID). When omitted, the card opens at the full bucket height. The miniapp can still call host.resize afterwards — initialHeight only controls the first paint.


Transports

Exactly one transport at a time — polling or webhooks.

Long-polling

Pass polling in the config and call bot.start():

const bot = new Bot({
  baseUrl, token,
  polling: { timeout: 25, limit: 100, offsetStorage: myStorage },
});
await bot.start();        // loops on GET /api/v1/bots/getUpdates
process.on('SIGINT', () => bot.stop());

PollingConfig / OffsetStorage

interface PollingConfig {
  timeout?: number;          // long-poll wait, seconds. Default 25 (server caps at 50)
  limit?: number;            // max updates per request. Default 100
  offsetStorage?: OffsetStorage;
}
interface OffsetStorage {
  load(): Promise<number | undefined> | number | undefined;
  save(updateId: number): Promise<void> | void;
}

A trivial file-backed example:

import { readFileSync, writeFileSync } from 'node:fs';

const offsetStorage: OffsetStorage = {
  load: () => { try { return Number(readFileSync('.offset', 'utf8')) || 0; } catch { return 0; } },
  save: (id) => writeFileSync('.offset', String(id)),
};

The loop swallows transient errors (timeouts, network blips, 404/408/502/503/504 during a server restart) and keeps retrying; everything else is surfaced to bot.on('error'). The last-seen update_id is persisted via offsetStorage so a restart resumes instead of replaying.

Webhooks

Construct the bot without polling, configure a webhook (the management endpoints aren't wrapped by a module yet — call them directly, see Webhook & updates), then verify + dispatch incoming POSTs:

import express from 'express';
import { Bot, dispatchWebhook } from '@cherrydotfun/bots-sdk';

const bot = new Bot({ baseUrl, token }); // no `polling` → webhook mode
const secret = process.env.CHERRY_WEBHOOK_SECRET!;

const app = express();
app.use(express.text({ type: '*/*' })); // raw body required for HMAC

app.post('/webhook', async (req, res) => {
  const result = await dispatchWebhook(req.body as string, req.headers, {
    secret,
    onUpdate: (u) => bot.dispatch(u), // re-uses your bot.on(...) handlers
  });
  res.status(result.status).end(result.error ?? 'ok');
});

app.listen(3210);

dispatchWebhook(rawBody, headers, opts)

Verifies the HMAC signature, parses the payload (Cherry envelope or bare BotUpdate), and invokes onUpdate. Returns { status: 200 | 400 | 401, error? }.

interface WebhookDispatcherOptions {
  secret: string;
  onUpdate: (u: BotUpdate) => void | Promise<void>;
  toleranceSeconds?: number; // default 300
}

verifyWebhook(opts) → boolean

Low-level signature check if you want to verify without dispatching.

interface VerifyWebhookOptions {
  rawBody: string;
  headers: Record<string, string | string[] | undefined>;
  secret: string;
  toleranceSeconds?: number; // default 300
}

Signature scheme (same as the Apps webhook surface):

X-Cherry-Timestamp: <unix seconds>
X-Cherry-Signature: sha256=<hex>     // HMAC-SHA256(secret, `${timestamp}.${rawBody}`)
X-Cherry-Delivery:  <uuid>
body: { event: 'bot_update', deliveryId, timestamp, data: <BotUpdate> }

Replay protection: requests outside toleranceSeconds (default 300) are rejected.


Error handling

Every method rejects with CherryBotsError, mirroring the server envelope:

import { CherryBotsError } from '@cherrydotfun/bots-sdk';

try {
  await bot.commands.set([/* … */]);
} catch (err) {
  if (err instanceof CherryBotsError) {
    console.error(err.code, err.status, err.message, err.details);
    if (err.isTransient()) { /* retry */ }
  }
}
class CherryBotsError extends Error {
  code: string;     // 'HTTP_ERROR', 'NETWORK_ERROR', … or the server's ERROR_CODE
  status: number;   // HTTP status (0 for network/timeout/abort)
  details?: unknown;
  isTransient(): boolean;
}

| Code | status | Meaning | | ----------------- | --------- | ------- | | REQUEST_TIMEOUT | 0 | No response within the request timeout (transient). | | NETWORK_ERROR | 0 | fetch threw before a response (DNS/TCP/TLS — transient). | | REQUEST_ABORTED | 0 | Caller aborted via signal. | | HTTP_ERROR | 4xx/5xx | Server returned an error with no/unknown error code. | | server code | 4xx/5xx | The server's own ERROR_CODE (e.g. DUPLICATE_COMMAND, BOT_NOT_PROVISIONED, WEBHOOK_CONFIGURED). |

isTransient() is true for REQUEST_TIMEOUT, NETWORK_ERROR, and statuses 404, 408, 502, 503, 504 (treated as "server momentarily unavailable" so a poll loop survives a reboot). A genuinely wrong baseUrl/path returns 404 forever — inspect status directly in one-shot startup probes.


Type reference

All types below are exported from the package root (export type * from './types'). Module-local option types (RequestSignatureOptions, HandleInfo, SetBlinkMiniAppRequest, LaunchMiniAppResponse, …) are documented inline with their module above.

BotConfig

See Configuration.

BotUser

interface BotUser {
  walletAddress: string;
  handle?: string;
  displayName?: string;
  avatarUrl?: string;
}

MessageAttachment

interface MessageAttachment {
  kind: 'image' | 'video' | 'audio' | 'file' | 'gif';
  url: string;
  mime?: string;
  width?: number;
  height?: number;
}

BotMessageRef

interface BotMessageRef {
  roomId: string;
  messageId: string;
  text?: string;
  attachments?: MessageAttachment[];
  createdAt: string;        // ISO-8601
  from: BotUser;
  replyToMessageId?: string;
}

InlineButton

Union of the variants below; each extends InlineButtonBase.

interface InlineButtonBase {
  text: string;
  once?: boolean;          // one-shot per user
  once_global?: boolean;   // one-shot for everyone
  expires_at?: number;     // unix seconds
}
type InlineButton =
  | (InlineButtonBase & { callback_data: string })                 // ≤ 64 bytes
  | (InlineButtonBase & { url: string })
  | (InlineButtonBase & { web_app: { route?: string; params?: Record<string, unknown> } })
  | (InlineButtonBase & { request_sign: { requestId: string; message: string } })
  | (InlineButtonBase & { request_tx: { requestId: string; transaction: string; expiresAt?: number; submit?: boolean } });

ReplyMarkup

interface ReplyMarkup { inline_keyboard: InlineButton[][] }

BlinkMessage

type BlinkMessage = BlinkMiniApp | BlinkUrl;

interface BlinkMiniApp {
  type?: 'miniapp';                          // default
  route: string;                             // /^/[a-zA-Z0-9_\-/.]{0,256}$/
  params?: Record<string, unknown>;          // ≤ 4 KB JSON, depth ≤ 8
  height?: 'compact' | 'medium' | 'tall';    // bucket: 96 | 220 | 420 px
  initialHeight?: number;                    // px the card opens at; ≤ bucket max
  interactive?: boolean;
}
interface BlinkUrl {
  type: 'url';
  url: string;                               // origin must be in blinkOrigins
  params?: Record<string, unknown>;
  height?: 'compact' | 'medium' | 'tall';
  initialHeight?: number;                    // px the card opens at; ≤ bucket max
  interactive?: boolean;
}

UpdateKind

type UpdateKind =
  | 'message' | 'group_message' | 'mention'
  | 'callback_query' | 'signature_response' | 'transaction_response'
  | 'member_event' | 'assignment_event';

BotUpdate

interface BotUpdate {
  update_id: number;        // per-bot, monotonically increasing
  kind: UpdateKind;
  at: string;               // ISO-8601
  message?: BotMessageRef;
  callback_query?: CallbackQuery;
  signature_response?: SignatureResponse;
  transaction_response?: TransactionResponse;
  member_event?: MemberEvent;
  assignment_event?: AssignmentEvent;
}

CallbackQuery

interface CallbackQuery {
  id: string;
  from: BotUser;
  roomId: string;
  messageId: string;
  callbackData: string;
  source: 'inline_keyboard' | 'blink_widget';
}

SignatureResponse

interface SignatureResponse {
  requestId: string;
  from: BotUser;
  signedMessage: string;  // base64
  signature: string;      // base64 Ed25519
  status: 'ok' | 'declined';
  verified: boolean;      // server-side Ed25519 verify vs from.walletAddress
}

TransactionResponse

interface TransactionResponse {
  requestId: string;
  from: BotUser;
  signedTransaction?: string; // base64 (present iff status='ok')
  signature?: string;         // on-chain signature once submitted/observed
  status: 'ok' | 'declined' | 'failed';
  errorCode?: string;
}

MemberEvent

interface MemberEvent {
  roomId: string;
  kind: 'joined' | 'left' | 'kicked' | 'banned' | 'unbanned' | 'role_changed' | 'muted' | 'unmuted';
  member: BotUser;
  actor?: BotUser;
  role?: 'owner' | 'admin' | 'moderator' | 'member';
  at: string;
}

AssignmentEvent

interface AssignmentEvent {
  roomId: string;
  kind: 'attached' | 'detached' | 'invite_accepted' | 'invite_rejected';
  ownerWallet: string;
  at: string;
  detachedBy?: 'owner' | 'admin' | 'app';
}

BotEventMap

Listener signatures for bot.on(...). See Events.

BotMeResponse

interface BotMeResponse {
  appId: string;
  botWallet: string;
  handle?: string;
  displayName?: string;
  avatarUrl?: string;
  scopes: string[];
  keyCustody: 'server' | 'self';
  webhookUrl?: string;
  blinkOrigins?: string[];  // read-only; workspace policy
}

SendDirectMessageRequest

interface SendDirectMessageRequest {
  toWallet: string;
  content?: string;                         // use this (server-custody)
  // Reserved for the not-yet-implemented self-custody flow. The SDK does not
  // build or populate this — leave it unset.
  encrypted?: { ciphertext: string; nonce: string; ephemeralPublicKey: string };
  metadata?: Record<string, unknown>;
}

SendMessageRequest

interface SendMessageRequest {
  content: string;          // ≤ 4096 chars
  replyToMessageId?: string;
  metadata?: Record<string, unknown>;
}

SendInteractiveMessageRequest

interface SendInteractiveMessageRequest extends SendMessageRequest {
  reply_markup?: ReplyMarkup;
  blink?: BlinkMessage;
}

AnswerCallbackQueryRequest

interface AnswerCallbackQueryRequest {
  text?: string;                                    // toast/alert text
  alert?: boolean;                                  // blocking dialog vs toast
  editMessage?: { content: string };               // atomic text edit
  editReplyMarkup?: ReplyMarkup | null;             // atomic markup edit (null clears)
  updateBlink?: { params: Record<string, unknown> }; // atomic blink re-render
}

BotCommand

interface BotCommand {
  command: string;     // "/start" — /^\/[a-z0-9_]{1,32}$/
  name?: string;       // ≤ 32 chars
  description: string; // ≤ 256 chars
  params?: string;     // "<amount> [token]" — ≤ 64 chars
}

Endpoint reference

Base path: /api/v1/bots. Auth: Authorization: Bearer cherry_bot_<botId>_<secret> (legacy cha_<appId>_<secret> accepted). All bodies are JSON; all responses are JSON unless noted. Errors use the CherryBotsError envelope { error, message, details? }.

Identity & config

| Method | Path | Scope | SDK | Response | | ------ | ---- | ----- | --- | -------- | | GET | /me | — | bot.me() | BotMeResponse | | GET | /getMyHandle | — | bot.handle.get() | HandleInfo \| null | | POST | /setMyCommands | bots:commands:manage | bot.commands.set(commands) | { commands: BotCommand[] } | | GET | /getMyCommands | — | bot.commands.get() | { commands: BotCommand[] } |

POST /setMyCommands — body { commands: BotCommand[] } (max 100, replace-not-merge; duplicate tokens → DUPLICATE_COMMAND). Command tokens must match /^\/[a-z0-9_]{1,32}$/.

Direct messages

| Method | Path | Scope | SDK | Response | | ------ | ---- | ----- | --- | -------- | | POST | /sendDirectMessage | bots:dm:send | bot.dm.send(req) | BotMessageRef | | GET | /getDirectChat | bots:dm:read | bot.dm.getChat(opts) | { messages: BotMessageRef[]; nextCursor? } |

POST /sendDirectMessage — body SendDirectMessageRequest. Requires a provisioned bot wallet (else BOT_NOT_PROVISIONED); sending to self → INVALID_TARGET. GET /getDirectChat — query withWallet, limit?, before?.

Group messages & edits

| Method | Path | Scope | SDK | Response | | ------ | ---- | ----- | --- | -------- | | POST | /sendGroupMessage | bots:groups:send | bot.messages.send(roomId, req) | BotMessageRef | | POST | /sendInteractiveMessage | bots:interactive | bot.messages.sendInteractive(roomId, req) | BotMessageRef | | POST | /editMessageText | messages:edit | bot.messages.editText(...) | BotMessageRef | | POST | /editMessageReplyMarkup | messages:edit | bot.messages.editReplyMarkup(...) | BotMessageRef | | POST | /deleteGroupMessage | bots:groups:moderate | bot.messages.delete(roomId, messageId) | { success: true } |

Bodies carry { roomId, ... }. sendInteractiveMessage accepts reply_markup and/or blink. Edits target { roomId, messageId }.

Callbacks

| Method | Path | Scope | SDK | Response | | ------ | ---- | ----- | --- | -------- | | POST | /answerCallbackQuery | bots:callback:answer | bot.callbacks.answer(id, payload) | 204 No Content |

Body { id, ...AnswerCallbackQueryRequest }. Re-answering a one-shot callback → 409.

Signature / transaction requests

| Method | Path | Scope | SDK | Response | | ------ | ---- | ----- | --- | -------- | | POST | /requestSignature | bots:sign:request | bot.auth.requestSignature(opts) | BotMessageRef | | POST | /requestTransaction | bots:tx:request | bot.auth.requestTransaction(opts) | BotMessageRef |

The user's reply arrives as a signature_response / transaction_response update.

Miniapps & blinks

| Method | Path | Scope | SDK | Response | | ------ | ---- | ----- | --- | -------- | | POST | /setBlinkMiniApp | bots:miniapp:link | bot.miniapp.setBlinkMiniApp(req) | BlinkMiniAppConfig | | POST | /launchMiniApp | bots:miniapp:link | bot.miniapp.launch(req) | { url, token, expiresAt } |

Webhook & updates

| Method | Path | Scope | SDK | Notes | | ------ | ---- | ----- | --- | ----- | | GET | /getUpdates | bots:updates:poll | bot.start() (internal) | Long-poll. Query offset, limit, timeout (s). Returns BotUpdate[]. 409 if a webhook is configured (WEBHOOK_CONFIGURED) or a concurrent poll exists (POLL_CONFLICT). | | POST | /setWebhook | bots:webhook:manage | (raw fetch) | Body { url, secret, events: string[] }. Disables polling. | | POST | /deleteWebhook | bots:webhook:manage | (raw fetch) | Re-enables polling. | | GET | /getWebhookInfo | — | (raw fetch) | Current webhook config + last delivery. |

setWebhook / deleteWebhook / getWebhookInfo aren't yet wrapped by a dedicated SDK module — call them directly with the Bearer token, e.g.:

await fetch(`${baseUrl}/api/v1/bots/setWebhook`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ url, secret, events: ['bot_update'] }),
});

Scopes reference

Granted per-app in the admin panel (Apps → API Keys). Read your effective set via (await bot.me()).scopes.

| Scope | Unlocks | | ----- | ------- | | bots:dm:send | bot.dm.send | | bots:dm:read | bot.dm.getChat | | bots:groups:send | bot.messages.send | | bots:interactive | bot.messages.sendInteractive | | messages:edit | bot.messages.editText / editReplyMarkup | | bots:groups:moderate | bot.messages.delete | | bots:callback:answer | bot.callbacks.answer | | bots:sign:request | bot.auth.requestSignature | | bots:tx:request | bot.auth.requestTransaction | | bots:miniapp:link | bot.miniapp.setBlinkMiniApp / launch | | bots:commands:manage | bot.commands.set | | bots:updates:poll | getUpdates (long-poll) | | bots:webhook:manage | setWebhook / deleteWebhook | | bots:handle:manage | handle administration (admin-reserved; read-only for bots) |

No scope is required for /me, /getMyHandle, /getMyCommands, /getWebhookInfo.


Example

A runnable showcase bot exercising every feature (DM commands, group mentions, inline keyboards, polling/webhooks, sign/tx, miniapp + blinks, the / menu) lives in example/ — see example/README.md.


License

MIT