Skip to content

Repository files navigation

@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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages