ESC
Start typing to search...

Tutorial: Build an Inline Bot

Create an inline bot plugin with styled colored buttons, callback handlers, and inline query responses.

Goal

By the end of this tutorial, you will have a fully functional inline bot plugin that:

  • Responds to inline queries with searchable results
  • Displays styled colored buttons (green, red, blue) via GramJS Layer 222
  • Handles callback button presses with glob patterns
  • Tracks chosen inline results for analytics

We will build a TON Price Ticker — an inline bot that lets users search for token prices and share them in any chat with interactive buttons.

Prerequisites

  • Teleton Agent running with a Telegram bot connected
  • Inline mode enabled for your bot via @BotFather (/setinline)
  • Inline feedback enabled via @BotFather (/setinlinefeedback) if you want onChosenResult
  • TON wallet configured (for price data)

Step 1: Manifest with BotManifest

The manifest must declare bot capabilities. Without this, sdk.bot will be null at runtime.

ton-ticker/index.ts — Manifest
import type {
  PluginSDK,
  SimpleToolDef,
  PluginManifest,
  InlineResult,
  InlineQueryContext,
  CallbackContext,
  ChosenResultContext,
} from "@teleton-agent/sdk";

export const manifest: PluginManifest = {
  name: "ton-ticker",
  version: "1.0.0",
  description: "Inline TON price ticker — search tokens and share prices in any chat",
  bot: {
    inline: true,      // Enable inline query handling
    callbacks: true,   // Enable callback query handling
    rateLimits: {
      inlinePerMinute: 60,   // Higher limit for a price bot
      callbackPerMinute: 120,
    },
  },
};

Bot Properties: isAvailable and username

Before registering handlers, always check sdk.bot.isAvailable to confirm the bot client is connected. Use sdk.bot.username to construct @bot links for inline entry points.

Checking availability and reading username
export const start = async (sdk: PluginSDK) => {
  // Always guard against null first (no BotManifest declared)
  if (!sdk.bot) {
    sdk.log.warn("Bot SDK not available — manifest missing bot declaration");
    return;
  }

  // Then check if the bot client is actually connected
  if (!sdk.bot.isAvailable) {
    sdk.log.warn("Bot client is not yet connected — handlers will not fire");
    return;
  }

  // Use sdk.bot.username to construct inline links
  const botUsername = sdk.bot.username;
  sdk.log.info(`Inline bot ready: @${botUsername}`);
  // e.g. "Use @ton_ticker_bot TON to get Toncoin prices"

  sdk.bot.onInlineQuery(async (ctx) => {
    // ...
    // Reference username in message text:
    // `Share via @${sdk.bot.username}`
  });
};

Note: The ButtonDef interface supports a copy?: string field for native copy-to-clipboard buttons. When pressed, the specified text is copied to the user's clipboard without triggering a callback. This is ideal for wallet addresses or invite links:

ButtonDef.copy — native clipboard button
keyboard: [
  [
    { text: "Refresh", callback: "refresh:TON", style: "primary" },
    { text: "View Chart", url: "https://tonviewer.com/..." },
  ],
  [
    // No callback fired — text is copied directly to clipboard
    { text: "Copy Address", copy: "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs" },
  ],
]

Step 2: Inline Query Handler

The inline query handler is called whenever a user types @yourbot query in any Telegram chat. We will search for tokens and return price results.

Inline query handler
// Token database for lookup
const TOKENS: { symbol: string; name: string; address: string }[] = [
  { symbol: "TON", name: "Toncoin", address: "ton" },
  { symbol: "USDT", name: "Tether USD", address: "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs" },
  { symbol: "STON", name: "STON.fi", address: "EQA2kCVNwVsil2EM2mB0SkXytxCqQjS4mttjDpnXmwG9T6bO" },
  { symbol: "SCALE", name: "Scaleton", address: "EQBlqsm144Dq6SjbPI4jjZvlSHrzpwJKnL7MhRuRg1hIl-eH" },
];

sdk.bot.onInlineQuery(async (ctx: InlineQueryContext): Promise<InlineResult[]> => {
  const query = ctx.query.toLowerCase().trim();

  // Filter tokens matching the query
  const matches = query
    ? TOKENS.filter(
        (t) =>
          t.symbol.toLowerCase().includes(query) ||
          t.name.toLowerCase().includes(query)
      )
    : TOKENS; // Show all if empty query

  // Fetch prices for matching tokens
  const results: InlineResult[] = [];

  for (const token of matches.slice(0, 10)) {
    let priceText: string;
    let description: string;

    if (token.address === "ton") {
      const price = await sdk.ton.getPrice();
      priceText = price ? `$${price.usd.toFixed(2)}` : "Price unavailable";
      description = `Toncoin: ${priceText}`;
    } else {
      const price = await sdk.ton.getJettonPrice(token.address);
      priceText = price ? `$${price.usd.toFixed(6)}` : "Price unavailable";
      description = `${token.name}: ${priceText}`;
    }

    results.push({
      id: token.symbol.toLowerCase(),
      type: "article",
      title: `${token.symbol} ${priceText}`,
      description,
      content: {
        text: `${token.symbol} (${token.name})\nPrice: ${priceText}`,
        parseMode: "HTML",
      },
      keyboard: [
        [
          { text: "Refresh Price", callback: `refresh:${token.symbol}`, style: "primary" },
          { text: "View Chart", url: `https://tonviewer.com/${token.address}` },
        ],
        [
          { text: "Copy Address", copy: token.address },
        ],
      ],
    });
  }

  return results;
});

Step 3: Styled Keyboard

The Bot SDK supports three button colors via GramJS Layer 222. Colors are specified with the style property and render natively in Telegram clients that support Layer 222.

Building styled keyboards
// You can also build keyboards outside of inline results
// using sdk.bot.keyboard() for reuse

const priceKeyboard = sdk.bot.keyboard([
  [
    { text: "Refresh", callback: "refresh:TON", style: "primary" },   // Blue
    { text: "Buy", callback: "buy:TON", style: "success" },           // Green
    { text: "Sell", callback: "sell:TON", style: "danger" },           // Red
  ],
  [
    { text: "View on Tonviewer", url: "https://tonviewer.com" },
  ],
]);

// .toTL() returns GramJS MTProto markup (colored buttons)
const tlMarkup = priceKeyboard.toTL();

// .toGrammy() returns standard Bot API markup (no colors, graceful fallback)
const grammyMarkup = priceKeyboard.toGrammy();

// .rows gives you the raw ButtonDef[][] with prefixed callbacks
// e.g. "ton-ticker:refresh:TON" instead of "refresh:TON"
const rawRows = priceKeyboard.rows;

When you specify keyboard inside an InlineResult object, the platform automatically builds the keyboard for you — you pass raw ButtonDef[][] and it handles the rendering. The sdk.bot.keyboard() method is useful when you need to build keyboards outside of inline results, for example when editing messages via editInlineMessage().

Step 4: Callback Handlers

Register callback handlers with glob patterns. The plugin namespace prefix is automatically stripped, so your patterns match against your raw callback data.

Callback handlers with glob patterns
// Handle "Refresh Price" button — matches "refresh:TON", "refresh:USDT", etc.
sdk.bot.onCallback("refresh:*", async (ctx: CallbackContext) => {
  const symbol = ctx.data.split(":")[1];
  const token = TOKENS.find((t) => t.symbol === symbol);

  if (!token) {
    await ctx.answer("Token not found", true); // true = show as alert
    return;
  }

  let priceText: string;
  if (token.address === "ton") {
    const price = await sdk.ton.getPrice();
    priceText = price ? `$${price.usd.toFixed(2)}` : "Price unavailable";
  } else {
    const price = await sdk.ton.getJettonPrice(token.address);
    priceText = price ? `$${price.usd.toFixed(6)}` : "Price unavailable";
  }

  const now = new Date().toLocaleTimeString("en-US", { hour12: false });

  // Edit the inline message with updated price
  await ctx.editMessage(
    `${token.symbol} (${token.name})\nPrice: ${priceText}\nUpdated at ${now}`,
    {
      parseMode: "HTML",
      keyboard: [
        [
          { text: "Refresh Price", callback: `refresh:${token.symbol}`, style: "primary" },
          { text: "View Chart", url: `https://tonviewer.com/${token.address}` },
        ],
        [
          { text: "Copy Address", copy: token.address },
        ],
      ],
    }
  );

  await ctx.answer(`${token.symbol}: ${priceText}`);
});

// Handle buy/sell actions (example — these would trigger your trading logic)
sdk.bot.onCallback("buy:*", async (ctx: CallbackContext) => {
  const symbol = ctx.data.split(":")[1];
  await ctx.answer(`Buy ${symbol} — use /buy command in a direct chat with the bot`, true);
});

sdk.bot.onCallback("sell:*", async (ctx: CallbackContext) => {
  const symbol = ctx.data.split(":")[1];
  await ctx.answer(`Sell ${symbol} — use /sell command in a direct chat with the bot`, true);
});

Step 5: Chosen Result Handler

Track when users select an inline result. This is useful for analytics and for updating the message after the user sends it.

Chosen result tracking
sdk.bot.onChosenResult(async (ctx: ChosenResultContext) => {
  sdk.log.info(
    `User chose "${ctx.resultId}" from query "${ctx.query}" ` +
    `(inline msg: ${ctx.inlineMessageId ?? "N/A"})`
  );

  // Optionally update the message immediately after selection
  // to add a "Shared by @username" footer
  if (ctx.inlineMessageId) {
    const token = TOKENS.find((t) => t.symbol.toLowerCase() === ctx.resultId);
    if (token) {
      let priceText: string;
      if (token.address === "ton") {
        const price = await sdk.ton.getPrice();
        priceText = price ? `$${price.usd.toFixed(2)}` : "N/A";
      } else {
        const price = await sdk.ton.getJettonPrice(token.address);
        priceText = price ? `$${price.usd.toFixed(6)}` : "N/A";
      }

      await sdk.bot.editInlineMessage(
        ctx.inlineMessageId,
        `${token.symbol} (${token.name})\nPrice: ${priceText}\n\nShared via @${sdk.bot.username}`,
        {
          parseMode: "HTML",
          keyboard: [
            [
              { text: "Refresh", callback: `refresh:${token.symbol}`, style: "primary" },
              { text: "View Chart", url: `https://tonviewer.com/${token.address}` },
            ],
          ],
        }
      );
    }
  }
});

Step 6: Complete Plugin Code

Here is the entire plugin in one file, ready to copy into your plugins directory:

ton-ticker/index.ts — Complete Plugin
import type {
  PluginSDK,
  SimpleToolDef,
  PluginManifest,
  InlineResult,
  InlineQueryContext,
  CallbackContext,
  ChosenResultContext,
} from "@teleton-agent/sdk";

export const manifest: PluginManifest = {
  name: "ton-ticker",
  version: "1.0.0",
  description: "Inline TON price ticker — search tokens and share prices in any chat",
  bot: {
    inline: true,
    callbacks: true,
    rateLimits: {
      inlinePerMinute: 60,
      callbackPerMinute: 120,
    },
  },
};

const TOKENS: { symbol: string; name: string; address: string }[] = [
  { symbol: "TON", name: "Toncoin", address: "ton" },
  { symbol: "USDT", name: "Tether USD", address: "EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs" },
  { symbol: "STON", name: "STON.fi", address: "EQA2kCVNwVsil2EM2mB0SkXytxCqQjS4mttjDpnXmwG9T6bO" },
  { symbol: "SCALE", name: "Scaleton", address: "EQBlqsm144Dq6SjbPI4jjZvlSHrzpwJKnL7MhRuRg1hIl-eH" },
  { symbol: "BOLT", name: "Huebel Bolt", address: "EQD0vdSA_NedR9uvbgN9EikRX-suesDxGeFg69XQMavfLqIw" },
  { symbol: "GRAM", name: "Gram", address: "EQC47093oX5Xhb2xiBSdRGOEpANw3G7YDE0Fys6EoSDq9J1Y" },
];

async function getTokenPrice(
  sdk: PluginSDK,
  token: { symbol: string; address: string }
): Promise<string> {
  if (token.address === "ton") {
    const price = await sdk.ton.getPrice();
    return price ? `$${price.usd.toFixed(2)}` : "Price unavailable";
  }
  const price = await sdk.ton.getJettonPrice(token.address);
  return price ? `$${price.usd.toFixed(6)}` : "Price unavailable";
}

export const start = async (sdk: PluginSDK) => {
  if (!sdk.bot) {
    sdk.log.warn("Bot SDK not available — skipping inline setup");
    return;
  }

  // ── Inline Query Handler ───────────────────────────────────────
  sdk.bot.onInlineQuery(async (ctx: InlineQueryContext): Promise<InlineResult[]> => {
    const query = ctx.query.toLowerCase().trim();

    const matches = query
      ? TOKENS.filter(
          (t) =>
            t.symbol.toLowerCase().includes(query) ||
            t.name.toLowerCase().includes(query)
        )
      : TOKENS;

    const results: InlineResult[] = [];

    for (const token of matches.slice(0, 10)) {
      const priceText = await getTokenPrice(sdk, token);

      results.push({
        id: token.symbol.toLowerCase(),
        type: "article",
        title: `${token.symbol} ${priceText}`,
        description: `${token.name}: ${priceText}`,
        content: {
          text: `${token.symbol} (${token.name})\nPrice: ${priceText}`,
          parseMode: "HTML",
        },
        keyboard: [
          [
            { text: "Refresh Price", callback: `refresh:${token.symbol}`, style: "primary" },
            { text: "View Chart", url: `https://tonviewer.com/${token.address}` },
          ],
          [
            { text: "Copy Address", copy: token.address },
          ],
        ],
      });
    }

    return results;
  });

  // ── Callback Handlers ──────────────────────────────────────────

  // Refresh price button
  sdk.bot.onCallback("refresh:*", async (ctx: CallbackContext) => {
    const symbol = ctx.data.split(":")[1];
    const token = TOKENS.find((t) => t.symbol === symbol);

    if (!token) {
      await ctx.answer("Token not found", true);
      return;
    }

    const priceText = await getTokenPrice(sdk, token);
    const now = new Date().toLocaleTimeString("en-US", { hour12: false });

    await ctx.editMessage(
      `${token.symbol} (${token.name})\nPrice: ${priceText}\nUpdated at ${now}`,
      {
        parseMode: "HTML",
        keyboard: [
          [
            { text: "Refresh Price", callback: `refresh:${token.symbol}`, style: "primary" },
            { text: "View Chart", url: `https://tonviewer.com/${token.address}` },
          ],
          [
            { text: "Copy Address", copy: token.address },
          ],
        ],
      }
    );

    await ctx.answer(`${token.symbol}: ${priceText}`);
  });

  // ── Chosen Result Handler ──────────────────────────────────────
  sdk.bot.onChosenResult(async (ctx: ChosenResultContext) => {
    sdk.log.info(
      `User chose "${ctx.resultId}" from query "${ctx.query}" ` +
      `(inline msg: ${ctx.inlineMessageId ?? "N/A"})`
    );

    if (ctx.inlineMessageId) {
      const token = TOKENS.find((t) => t.symbol.toLowerCase() === ctx.resultId);
      if (token) {
        const priceText = await getTokenPrice(sdk, token);

        await sdk.bot.editInlineMessage(
          ctx.inlineMessageId,
          `${token.symbol} (${token.name})\nPrice: ${priceText}\n\nShared via @${sdk.bot.username}`,
          {
            parseMode: "HTML",
            keyboard: [
              [
                { text: "Refresh", callback: `refresh:${token.symbol}`, style: "primary" },
                { text: "Chart", url: `https://tonviewer.com/${token.address}` },
              ],
            ],
          }
        );
      }
    }
  });

  sdk.log.info(`TON Ticker inline bot ready: @${sdk.bot.username}`);
};

// Optional: expose a tool for direct chat usage
export const tools = (sdk: PluginSDK): SimpleToolDef[] => [
  {
    name: "ticker_price",
    description: "Get current price for a TON token",
    parameters: {
      type: "object",
      properties: {
        symbol: { type: "string", description: "Token symbol (e.g. TON, USDT, STON)" },
      },
      required: ["symbol"],
    },
    async execute(params) {
      const token = TOKENS.find(
        (t) => t.symbol.toLowerCase() === params.symbol.toLowerCase()
      );
      if (!token) {
        return { error: `Unknown token: ${params.symbol}. Available: ${TOKENS.map((t) => t.symbol).join(", ")}` };
      }

      const priceText = await getTokenPrice(sdk, token);
      return {
        symbol: token.symbol,
        name: token.name,
        price: priceText,
        address: token.address,
        inlineUsage: `Type @${sdk.bot?.username ?? "yourbot"} ${token.symbol.toLowerCase()} in any chat`,
      };
    },
  },
];

Testing

  1. Place the plugin in plugins/ton-ticker/ and restart the agent.
  2. Open any Telegram chat and type @yourbot followed by a space.
  3. You should see a list of all supported tokens with their current prices.
  4. Type @yourbot ton to filter results to Toncoin.
  5. Select a result to share it in the chat.
  6. Press the blue "Refresh Price" button to update the price in place.
  7. Press "Copy Address" to copy the token contract address to your clipboard.
  8. In a direct message to the bot, try asking: "What's the price of STON?" to test the agent tool.

Gotchas

IssueExplanation
sdk.bot is null Your manifest must declare bot: { inline: true }. Without this, the platform does not initialize the Bot SDK for your plugin.
Callback data auto-prefixed If your plugin is "ton-ticker" and you set callback: "refresh:TON", the actual data sent to Telegram is "ton-ticker:refresh:TON". This is stripped back before your handler sees it, so match against "refresh:*", not "ton-ticker:refresh:*".
Colored buttons only with .toTL() Button styles ("success", "danger", "primary") only render on Telegram clients that support GramJS Layer 222 (MTProto). When using .toGrammy() (Bot API), colors are silently ignored and buttons appear in the default style.
Rate limits exceeded If your bot gets rate-limited, handlers are silently skipped. The user sees no inline results or no callback response. Increase rateLimits in the manifest or add caching to reduce API calls.
onChosenResult never fires You must enable inline feedback via @BotFather (/setinlinefeedback). Without this, Telegram does not send chosen result updates.
Register handlers in start(), not tools() The tools() function is called to register agent tools. Bot event handlers must be registered in the start() lifecycle hook, which runs once when the plugin starts.
editInlineMessage fails You can only edit inline messages that were sent via your bot. The inlineMessageId comes from CallbackContext or ChosenResultContext.