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

supportmate

v0.0.10

Published

Founder-led customer support OS — Telegram bots, Discord cross-post, PWA push, multi-thread, status tracking. Built on Convex.

Readme

supportmate

Founder-led customer support OS. A Convex Component that gives you an in-app messenger, a Telegram bot bridge, Discord cross-post, PWA push, a public roadmap, and a changelog feed — all installed with one app.use(...) call.

npm install supportmate
# or
bun add supportmate

Features

  • In-app messenger — multi-thread, status-tracked, ready to render in any framework
  • Telegram bot bridge — every customer gets their own forum topic; you reply by typing
  • Discord cross-post — every broadcast and roadmap event lands in your Discord channel
  • PWA push notifications — OS-level alerts via web-push (Chrome / Safari / Firefox)
  • Public roadmap — Canny-style voting, comments, status pills (submitted → reviewing → in-progress → shipped → closed)
  • Changelog / broadcasts — WhatsApp-Channel-style rich update cards with reactions
  • Saved replies — pre-written canned responses fired from Telegram or admin UI
  • Ticket subscriptions — users get notified when their bug or feature request changes status

Why supportmate

Modern support tools (Plain, Crisp, Intercom) start at ~$89/seat and were built for support teams. The interface assumes you have a queue, a roster, an SLA dashboard. None of that matters when you're a solo founder running customer service from your phone.

supportmate is the opposite: one operator, one Telegram inbox, one source of truth in your own Convex deployment. You ship it as a Convex Component into your existing app, wrap a few mutations with your own auth, and you have a support loop that scales from your first user to your first thousand without adding another bill.

Setup

Step 1 — Install the component

// convex/convex.config.ts
import { defineApp } from "convex/server";
import supportmate from "supportmate/convex.config";

const app = defineApp();
app.use(supportmate);

export default app;

After running npx convex dev once, the component's API is reachable at components.supportmate.<file>.<func> from your app's Convex functions.

Step 2 — Wrap component functions with your auth

The component never reads process.env and never knows who the user is — your app is the source of truth for auth. Write thin wrappers in convex/support.ts that resolve the userId via your auth strategy and forward to the component.

// convex/support.ts
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { components } from "./_generated/api";
import { getAuthUserId } from "@convex-dev/auth/server"; // or any auth strategy

const APP_KEY = "myapp";

export const sendMessage = mutation({
  args: { body: v.string(), threadId: v.optional(v.id("threads")) },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Not signed in");
    return ctx.runMutation(components.supportmate.threads.sendMessage, {
      userId,
      app: APP_KEY,
      body: args.body,
      threadId: args.threadId,
    });
  },
});

export const submitBugReport = mutation({
  args: {
    title: v.string(),
    description: v.optional(v.string()),
    screenshotId: v.optional(v.string()),
    context: v.optional(v.object({ page: v.string(), browser: v.string() })),
  },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Not signed in");
    return ctx.runMutation(components.supportmate.tickets.submitBugReport, {
      userId,
      app: APP_KEY,
      ...args,
    });
  },
});

export const submitFeatureRequest = mutation({
  args: {
    title: v.string(),
    description: v.optional(v.string()),
    category: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Not signed in");
    return ctx.runMutation(components.supportmate.tickets.submitFeatureRequest, {
      userId,
      app: APP_KEY,
      ...args,
    });
  },
});

export const getMyUnreadCounts = query({
  args: {},
  handler: async (ctx) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) return { broadcasts: 0, tickets: 0 };
    return ctx.runQuery(components.supportmate.panelReads.getMyUnreadCounts, {
      userId,
      app: APP_KEY,
    });
  },
});

export const voteForTicket = mutation({
  args: { ticketId: v.string() },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Not signed in");
    return ctx.runMutation(components.supportmate.roadmap.voteForTicket, {
      userId,
      ticketId: args.ticketId,
    });
  },
});

export const addComment = mutation({
  args: { ticketId: v.string(), body: v.string() },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Not signed in");
    return ctx.runMutation(components.supportmate.roadmap.addComment, {
      userId,
      ticketId: args.ticketId,
      body: args.body,
    });
  },
});

export const subscribePush = mutation({
  args: {
    endpoint: v.string(),
    p256dh: v.string(),
    auth: v.string(),
    userAgent: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    const userId = await getAuthUserId(ctx);
    if (!userId) throw new Error("Not signed in");
    return ctx.runMutation(components.supportmate.push.subscribe, {
      userId,
      app: APP_KEY,
      ...args,
    });
  },
});

…and so on for the remaining surfaces (getMyThread, listMyThreads, markRead, clearMyThread, unvoteTicket, subscribeToTicket, unsubscribeFromTicket, getMyUnreadCounts, markBroadcastsRead, markTicketsRead, listPublicRoadmap, getRoadmapTicket, listComments, listRecent). The pattern is always the same — resolve the userId, forward the args.

Step 3 — Mount the Telegram webhook in convex/http.ts

// convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";

const http = httpRouter();

http.route({
  path: "/telegram/webhook/support",
  method: "POST",
  handler: httpAction(async (ctx, request) => {
    // Telegram webhook secret check
    const secret = request.headers.get("x-telegram-bot-api-secret-token");
    if (secret !== process.env.TELEGRAM_WEBHOOK_SECRET) {
      return new Response("forbidden", { status: 403 });
    }
    const update = await request.json();
    await ctx.runAction(internal.supportActions.handleTelegramUpdate, {
      update,
    });
    return new Response("ok");
  }),
});

export default http;

Step 4 — Build the bot in a "use node" action

The bot factory is callback-driven. You pass it a callbacks bag of shims that each call into components.supportmate.* via ctx.runMutation / ctx.runQuery. This keeps the bot code free of any specific Convex API import and lets you mix in your own logic per shim.

// convex/supportActions.ts
"use node";
import { v } from "convex/values";
import { internalAction } from "./_generated/server";
import { components } from "./_generated/api";
import { makeSupportBot } from "supportmate/server";

export const handleTelegramUpdate = internalAction({
  args: { update: v.any() },
  handler: async (ctx, { update }) => {
    const bot = makeSupportBot({
      token: process.env.TELEGRAM_BOT_TOKEN!,
      chatId: process.env.TELEGRAM_CHAT_ID!,
      adminBaseUrl: process.env.SITE_URL!,
      callbacks: {
        // Threads
        appendAdminReply: (args) =>
          ctx.runMutation(components.supportmate.threads.appendAdminReply, args),
        resolveThread: (args) =>
          ctx.runMutation(components.supportmate.threads.resolveThread, args),
        snoozeThread: (args) =>
          ctx.runMutation(components.supportmate.threads.snoozeThread, args),
        toggleMute: (args) =>
          ctx.runMutation(components.supportmate.threads.toggleMute, args),
        findThreadByTelegramAnchor: (args) =>
          ctx.runQuery(
            components.supportmate.threads.findThreadByTelegramAnchor,
            args,
          ),
        getThreadContext: async ({ threadId }) => {
          // Compose component thread + your own user lookup.
          // Return shape: { user: { email?, name? } | null }
          // (See package src/server/telegram/bots/shared.ts for the full type.)
          return { user: null };
        },
        listOpenThreads: () =>
          ctx.runQuery(components.supportmate.threads.listOpenThreads, {}),
        statsToday: () =>
          ctx.runQuery(components.supportmate.threads.statsToday, {}),
        // Tickets
        updateTicketStatus: (args) =>
          ctx.runMutation(components.supportmate.tickets.updateStatus, args),
        // Saved replies
        listSavedReplies: () =>
          ctx.runQuery(components.supportmate.savedReplies.listForBot, {}),
        markSavedReplyUsed: ({ id }) =>
          ctx.runMutation(components.supportmate.savedReplies.markUsed, { id }),
        // Broadcasts
        publishBroadcast: (args) =>
          ctx.runMutation(components.supportmate.broadcasts.publish, {
            ...args,
            kind: args.kind ?? "note",
          }),
        reactToBroadcast: (args) =>
          ctx.runMutation(components.supportmate.broadcasts.reactToBroadcast, args),
      },
    });

    await bot.handleUpdate(update);
  },
});

Step 5 — Wire push send in a separate "use node" action

sendPush takes a flat list of subscriptions and a VAPID config. Pull the subscriptions from the component's internal query, fire the push, then delete any stale endpoints the function returns.

// convex/pushActions.ts
"use node";
import { v } from "convex/values";
import { internalAction } from "./_generated/server";
import { components } from "./_generated/api";
import { sendPush } from "supportmate/server";

export const sendToUser = internalAction({
  args: { userId: v.string(), title: v.string(), body: v.string() },
  handler: async (ctx, args) => {
    const subs = await ctx.runQuery(
      components.supportmate.push.listForUser,
      { userId: args.userId },
    );
    const stale = await sendPush({
      subscriptions: subs,
      payload: { title: args.title, body: args.body },
      vapid: {
        publicKey: process.env.VAPID_PUBLIC_KEY!,
        privateKey: process.env.VAPID_PRIVATE_KEY!,
        subject: "mailto:[email protected]",
      },
    });
    for (const endpoint of stale) {
      await ctx.runMutation(
        components.supportmate.push.deleteByEndpoint,
        { endpoint },
      );
    }
  },
});

Step 6 — Wire Discord cross-post

// convex/discordActions.ts
"use node";
import { v } from "convex/values";
import { internalAction } from "./_generated/server";
import { components } from "./_generated/api";
import { crossPostTicketToDiscord } from "supportmate/server";

export const announceNewTicket = internalAction({
  args: { ticketId: v.string() },
  handler: async (ctx, { ticketId }) => {
    const ticket = await ctx.runQuery(
      components.supportmate.roadmap.getTicketForCrossPost,
      { ticketId },
    );
    if (!ticket) return;
    await crossPostTicketToDiscord({
      webhookUrl: process.env.DISCORD_BROADCAST_WEBHOOK_URL!,
      ticket,
      event: "submitted",
      publicAppUrl: process.env.SITE_URL,
    });
  },
});

crossPostBroadcast works the same way for changelog announcements.

Step 7 — Mount the React widget

// app/layout.tsx (or your root layout)
import { SupportmateProvider, SupportFab } from "supportmate/react";
import { api, components } from "@/convex/_generated/api";

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <SupportmateProvider api={api} components={components}>
      {children}
      <SupportFab app="your-app-key" />
    </SupportmateProvider>
  );
}

Note: React components are coming in v0.1.0 — for v0.0.x, the component + server helpers are stable; build your own UI against the component API for now. Every read/write is a regular Convex query/mutation, so any Convex React hook (useQuery, useMutation) over your wrapper functions works.

Step 8 — Add supportmate's classes to your Tailwind content scan

supportmate ships unbundled Tailwind class strings (size-11, bottom-4, fixed, etc.). Without scanning the published bundle, those classes don't get emitted into your stylesheet and the widgets render un-styled (e.g., the floating FAB collapses to 100% width).

Tailwind v4 (in your global CSS):

@import "tailwindcss";
@source "../node_modules/supportmate/dist/react.{js,cjs}";

Adjust the relative path to point at node_modules/supportmate/dist/react.js from the file containing the directive.

Tailwind v3 (in tailwind.config.{js,ts}):

content: [
  // ...your existing entries
  "./node_modules/supportmate/dist/react.{js,cjs}",
],

Step 9 — Copy the service worker

cp node_modules/supportmate/public/support-sw.js public/support-sw.js

The service worker registers push handlers and forwards click events to your app. It's framework-agnostic.

Step 10 — Required env vars

Set these in your Convex deployment via npx convex env set <KEY> <VALUE>:

| Variable | Required | Notes | | --- | :---: | --- | | TELEGRAM_BOT_TOKEN | ✓ | Token from BotFather | | TELEGRAM_CHAT_ID | ✓ | ID of the support forum supergroup | | TELEGRAM_WEBHOOK_SECRET | ✓ | Generate: openssl rand -hex 32 | | SUPPORTMATE_ADMIN_USER_ID | ✓ | Your Telegram user ID (admin / founder) | | VAPID_PUBLIC_KEY | optional | Generate: npx web-push generate-vapid-keys | | VAPID_PRIVATE_KEY | optional | Same command — keep paired with the public key | | DISCORD_BROADCAST_WEBHOOK_URL | optional | Discord channel webhook for cross-post | | SITE_URL | ✓ | Your app's base URL — used in admin links from Telegram |

After setting TELEGRAM_BOT_TOKEN and the webhook secret, register the webhook with Telegram:

curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
  -d "url=https://<your-convex-deployment>.convex.site/telegram/webhook/support" \
  -d "secret_token=$TELEGRAM_WEBHOOK_SECRET"

Architecture

                                    ┌─────────────────┐
       ┌─────────────────────────►  │  Telegram bot   │  ◄──── admin
       │                            │  (grammy)       │
       │                            └────────┬────────┘
       │                                     │
       │  ┌──────────────────────────────────┼─────────────────────┐
       │  │  Your Convex deployment          │                     │
       │  │  ┌──────────────────────────┐    │ webhook             │
   ┌───┴──┴──┤  convex/support.ts        │ ◄──┘                     │
   │ User    │  (your auth + env wraps)  │                          │
   │ widget  │  └──────────────┬────────┘                           │
   │ (React) │                 │ ctx.runMutation                    │
   └───┬─────┘   ┌─────────────▼─────────────────┐                  │
       │         │ components.supportmate.*       │                 │
       │         │ • threads.sendMessage          │                 │
       │         │ • tickets.submitBugReport      │                 │
       │         │ • roadmap.voteForTicket        │  ──► Discord    │
       │         │ • broadcasts.publish           │  ──► Web Push   │
       │         │ • push.subscribe               │  ──► Email (opt)│
       │         │ • panelReads.markRead          │                 │
       │         └────────────────────────────────┘                 │
       └──── push ─────────────────────────────────────────────────┘

Sub-exports

  • supportmate — core types
  • supportmate/convex.config — component install path (app.use(supportmate))
  • supportmate/servermakeSupportBot, makeAnnounceBot, telegramWebhookHandler, sendPush, crossPostTicketToDiscord, crossPostBroadcast
  • supportmate/react — UI widgets (coming in v0.1.0)
  • supportmate/public/support-sw.js — service worker file

Versioning + status

  • v0.0.x — alpha. Expect breaking changes between patch versions.
  • React widgets — pending (Phase 3). The component API and server helpers are stable; build your own UI against the wrapper functions for now.
  • v0.1.0 — ships once the first pilot integration (pixbox) runs clean for a week.

License

MIT