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

aiomatrix

v0.9.0

Published

Aiogram-like framework for Matrix bots: routers, filters, FSM, inline keyboards, E2EE and MiniApps

Readme

aiomatrix

CI npm Socket Badge

An aiogram-style framework for Matrix bots: routers, filters, FSM, middleware, inline keyboards, end-to-end encryption, and a MiniApp platform modelled on Telegram WebApps.

npm install aiomatrix
# optional: end-to-end encryption (wasm; skip if you run with crypto: false)
npm install @matrix-org/matrix-sdk-crypto-wasm

Node >= 20, ESM only.

0.9

  • Crypto: @matrix-org/matrix-sdk-crypto-wasm, IndexedDB dump (not SQLite), Node >= 20
  • MSC4139 dual-emit/receive next to dev.aiomatrix.keyboard
  • Host handshake via to-device dev.aiomatrix.host (room state deprecated)
  • Docs: CHANGELOG, PUBLIC_API.md, AWARE_HOST.md, COMPAT.md, SCENES.md
  • Adapters: aiomatrix/redis, aiomatrix/otel (callback shims — no @opentelemetry/* dependency)
  • Application Service: external aiomatrix-appservice

0.8

Hello bot

import { Bot, Dispatcher, Router, Command, F } from "aiomatrix";

const bot = await Bot.create({
  homeserverUrl: "@mybot:example.org", // user id, server name, or full URL
  password: process.env.MATRIX_PASSWORD,
  crypto: true,
});

const router = new Router();

router.message(Command("start"), async (ctx) => {
  await ctx.reply("Hi! Send me anything and I'll echo it.");
});

router.message(F.text.startsWith("echo "), async (ctx) => {
  await ctx.reply(ctx.text.slice(5));
});

const dp = new Dispatcher();
dp.include(router);

await bot.run(dp); // syncs until SIGINT/SIGTERM, then shuts down cleanly

homeserverUrl accepts a URL, a bare server name, or the bot's user id; server names and user ids are resolved through /.well-known/matrix/client. With password the SDK logs in, persists the session under storagePath (default ./data), and reuses the same device id on restart — which is what E2EE requires.

Concepts

| Piece | Role | |---|---| | Bot | Owns the client, E2EE contract, callback/MiniApp registries, and scheduler | | Dispatcher | Global middleware, routing, stats, error handling, handler timeouts | | Router | Groups handlers per update type; nestable via include() | | Context | Typed per-update object with reply, answer, FSM state, room metadata | | Filter | Predicate over a context; compose with and / or / not | | FSMContext | Per-user/room conversation state with pluggable storage and TTL |

Update types

Handlers register per update type, so a reaction handler never sees a message:

router.message(F.text, onMessage);          // new messages
router.editedMessage(onEdit);               // m.replace edits
router.anyMessage(onEither);
router.callbackQuery(F.callback.startsWith("vote:"), onVote);
router.reaction(F.reaction.key("👍"), onThumbsUp);
router.miniAppData(onMiniAppData);
router.membership(F.membership.joined, onJoin);
router.invite(onInvite);
router.redaction(onRedaction);
router.pollResponse(onPollResponse);
router.toDevice(onToDevice);
router.rawEvent(onAnythingElse);            // custom event types
router.on(["message", "reaction"], onBoth); // explicit types

Filters

F composes fluently; every leaf is a plain function, so custom filters need no base class.

import { F, and, not, Command } from "aiomatrix";

F.text;                          // any non-empty body
F.text.contains("deploy");       // also .equals .startsWith .endsWith .in .len
F.text.regexp(/^(\d+)$/);        // match lands in ctx.data.match
F.image;                         // also .video .audio .file .location .emote .notice
F.hasAttachment;
F.reply;                         // is a rich reply
F.thread;
F.mentionsMe;                    // bot mentioned via m.mentions or plain text
F.room.dm;                       // also .group .is(id) .in(ids) .encrypted
F.from.user("@alice:example.org"); // also .users([...]) .server("example.org") .self
F.hasPower(50);                  // also F.isModerator, F.isAdmin
F.callback.startsWith("vote:");  // also .data(...) .regexp()
F.miniApp.action("submit");      // also .app(id) .field(name, value?)
F.membership.joined;             // also .left .banned .invited .isSelf .is(...)

and(F.room.dm, not(F.room.encrypted));

// Aliases are extra names; the first is canonical.
Command(["help", "помощь"], { prefixes: ["/", "!"], description: "Show help" });

Commands are Unicode-normalized (NFC), so /помощь works regardless of how the client composed the characters. Recognized forms are /name, !name, name@bot, bot: name, and — in direct chats — a bare name. Command also accepts description, args, minPowerLevel, scope, hidden, and category, which feed the generated help (bot.helpText()) and the command list advertised to clients (bot.advertiseCommands(roomId)).

FSM

import { createStates } from "aiomatrix";

const Form = createStates("form", ["name", "age"] as const);

router.message(Command("register"), async (ctx) => {
  await ctx.state.setState(Form.name);
  await ctx.reply("What's your name?");
});

router.message(Form.name, F.text, async (ctx) => {
  await ctx.state.updateData({ name: ctx.text });
  await ctx.state.setState(Form.age);
  await ctx.reply("How old are you?");
});

router.message(Form.age, F.text, async (ctx) => {
  const { name } = await ctx.state.getData<{ name: string }>();
  await ctx.state.clear();
  await ctx.reply(`Thanks, ${name}!`);
});

Storage defaults to memory. For state that survives restarts:

import { Dispatcher, JsonFileStorage } from "aiomatrix";

const dp = new Dispatcher({
  storage: new JsonFileStorage("./data/fsm.json"),
  fsmStrategy: "user_in_room", // or "room" | "user" | "global"
});

Middleware

import { throttle, accessControl, typingIndicator, errorReply, logging, i18n } from "aiomatrix";

dp.use(logging());
dp.use(throttle({ limit: 5, windowMs: 10_000 }));
dp.use(accessControl({ allowServers: ["example.org"] }));
dp.use(typingIndicator());
dp.use(errorReply({ text: "Something broke, try again." }));
router.use(i18n({ catalogs, defaultLocale: "en" }));

Middleware runs outside-in and can short-circuit by not calling next().

Inline keyboards

Matrix has no native inline keyboards, so this ships a convention (dev.aiomatrix.keyboard) plus a plain-text fallback so clients that don't understand it still show usable buttons.

import { InlineKeyboard } from "aiomatrix";

const kb = new InlineKeyboard()
  .text("Yes", "vote:yes")
  .text("No", "vote:no")
  .row()
  .url("Docs", "https://example.org/docs");

await ctx.reply("Ship it?", { keyboard: kb });

router.callbackQuery(F.callback.startsWith("vote:"), async (ctx) => {
  await ctx.answerCallback({ text: "Recorded" });
  await ctx.editMessageText(`You voted ${ctx.callbackData.split(":")[1]}`);
});

Callback tokens are random, single-use by default, bound to the room and to the user the keyboard was sent to, so a token leaked from one room cannot be replayed in another.

Media

await bot.client.sendFile(ctx.roomId, pngBytes, {
  filename: "plot.png",
  caption: "Latest numbers",
});
await bot.client.sendFileFromPath(ctx.roomId, "./report.pdf");

if (ctx.attachment) {
  const bytes = await ctx.downloadAttachment(); // decrypts when needed
}

Uploads and downloads handle encrypted attachments (m.file blocks with AES-CTR keys) transparently: in an encrypted room sendFile encrypts before upload, and downloadAttachment verifies the hash and decrypts.

Scheduler

bot.scheduler.every(60_000, async () => { /* ... */ }, { name: "poll-feed" });
bot.scheduler.dailyAt("09:00", async () => {
  await bot.sendMessage(roomId, "Standup time");
}, "standup");
bot.scheduler.after(5_000, () => bot.sendMessage(roomId, "Five seconds later"));

Jobs never overlap themselves, errors are logged rather than fatal, and everything stops with the bot.

End-to-end encryption

E2EE uses the Rust crypto machine via @matrix-org/matrix-sdk-crypto-wasm, which is an optional dependency: import "aiomatrix" works without it as long as you run with crypto: false. Requesting crypto without the package throws a ConfigurationError that says exactly what to install.

The Olm store is an IndexedDB dump at storagePath/crypto/idb-dump.json (Node uses fake-indexeddb). A leftover SQLite file from 0.8 cannot be imported — wipe crypto/ and re-login with the same device id (keep crypto-passphrase.json beside the store).

The contract the SDK enforces before dispatching anything, so a bot can never quietly leak plaintext:

  1. Device id must be stable. Password login persists it; crypto: true with a mismatched deviceId fails fast with DeviceMismatchError.
  2. Own device keys must be uploaded and queryable (assertOwnDeviceKeysReady, 5 attempts).
  3. Room encryption state is resolved through the room cache. If the homeserver's answer is unknown (429, network error) the send throws EncryptionStateUnknownError instead of falling back to plaintext.
  4. Megolm sessions are shared with tracked peer devices, excluding the bot's own device. Default rotateEveryMessage: true forces a fresh outbound session (and real to-device share) on every encrypt so peers who wiped crypto still decrypt the first bot reply. Large rooms may set rotateEveryMessage: false and rely on the share cache + reshareOnDeviceChange (see options below).
const bot = await Bot.create({
  homeserverUrl: "example.org",
  userId: "@mybot:example.org",
  password: process.env.MATRIX_PASSWORD,
  crypto: true,
  cryptoStorePassphrase: process.env.CRYPTO_PASSPHRASE, // encrypts the store at rest
  keyBackup: true,
  encryption: {
    onlyAllowTrustedDevices: false, // bots normally can't verify anyone
    rotateEveryMessage: true, // default — set false only for large rooms
    rotationPeriodMessages: 100,
    reshareOnDeviceChange: true,
  },
  onCryptoLog: (event) => console.log("[crypto]", event.type, event),
});

Resetting crypto. Delete storagePath/crypto (wipeCryptoStore) and log in with the same device id (or pass deviceId). Keep crypto-passphrase.json (it lives next to crypto/, not inside it). A dump from a different device id is unusable. After wipe, pruneOtherDevices is recommended so Megolm is not shared to ghost devices.

MiniApps

The MiniApp platform gives Matrix the Telegram WebApp developer experience: a signed launch payload, a window.MatrixMiniApp bridge (aliased as window.Telegram.WebApp so existing mini apps mostly just work), a framework-agnostic backend, and Matrix widgets for clients that embed apps inline.

// bot side: post a launch card with a signed, per-user URL
await bot.sendMiniApp(ctx.roomId, {
  userId: ctx.senderId,
  title: "Order form",
  url: "https://app.example.org/order",
});

// receive what the mini app sent back
router.miniAppData(F.miniApp.action("submit"), async (ctx) => {
  const { items } = ctx.payload as { items: string[] };
  await ctx.answerWebAppQuery(`Got ${items.length} items`);
});
// backend: validate the launch, mint a session, route sendData into the dispatcher
const server = bot.createMiniAppServer({ allowedOrigins: ["https://app.example.org"] });
http.createServer(server.nodeHandler()).listen(8080);

Launch data is HMAC-SHA256 signed with the bot's secret (auto-generated into storagePath/miniapp.json if you don't supply one), carries a TTL, and is single-use by default so a copied URL cannot be replayed. The browser bridge pins the host origin rather than posting to *.

Full walkthrough, protocol details, and a client example: MINIAPP.md.

Security notes

Report vulnerabilities privately: SECURITY.md. Hardening log: AUDIT.md.

  • HTML is a trust boundary. ctx.reply(text) uses parseMode: "markdown" by default (since 0.6.2) so **bold** becomes formatted_body. Set messageDefaults: { parseMode: "plain" } for literal text. ctx.replyHtml sends HTML — run untrusted input through sanitizeMatrixHtml(), or build it with the html tagged template. Aware clients can set messageDefaults: { keyboardFallback: false } to skip !cb dumps.
  • Plain HTTP is refused. The access token travels on every request, so a non-localhost http:// homeserver throws ConfigurationError unless you set allowInsecureHomeserver: true.
  • No secrets are logged. Access tokens, Authorization headers, and full sync bodies never reach the logger, at any level.
  • Storage holds credentials. storagePath contains the session, device id, crypto store, and MiniApp secret. Keep it out of version control and off shared volumes.
  • One sync + crypto writer per device. Callback and MiniApp query tokens are HMAC-signed by default (shared secret). Scale MiniApp HTTP with a shared secret and a shared async nonce/used-token store (callbackAsyncUsedStore / miniApp.asyncQueryUsedStore, examples/redis-stores) — do not use the legacy sync Redis adapter for atomic claims. Do not run two syncers against the same crypto store.

Operations

bot.getHealth();
// { running, cryptoEnabled, cryptoReady, userId, deviceId,
//   lastSyncAtMs, syncAgeMs, roomsCached, pendingCallbacks,
//   pendingMiniAppQueries, scheduledJobs }

dp.getStats(); // { received, handled, unhandled, errors, timeouts }

Handler failures go to the dispatcher's error handler; return true to mark one handled:

dp.errors((err, ctx) => {
  metrics.increment("handler_error", { update: ctx?.updateType });
  return true;
});
dp.fallback(async (ctx) => ctx.answer("I didn't understand that."));

syncAgeMs is the liveness signal worth alerting on: a running bot that has not synced in several minutes is wedged even though the process is up.

Wire onFatal for unrecoverable states (revoked token, deleted device); the sync loop stops instead of spinning:

await Bot.create({ /* ... */, onFatal: (err) => { console.error(err); process.exit(1); } });

Password-login bots refresh access tokens automatically when the homeserver issued a refresh_token (wired through MatrixHttp.onTokenExpired). If the stored session is already dead at startup, autoReloginOnAuthFailure (default when password is set) password-logins again with the same device id. For ops recovery:

import { diagnoseSession, relocateSession, wipeCryptoStore } from "aiomatrix";

console.log(diagnoseSession("./data"));
// Soft recovery after DeviceMismatchError / spoiled crypto store:
await relocateSession({
  storagePath: "./data",
  homeserverUrl: "example.org",
  user: "@bot:example.org",
  password: process.env.MATRIX_PASSWORD!,
  wipeCrypto: true,
  // Recommended ops default — drop ghost devices so Megolm fanout stays small:
  pruneOtherDevices: true,
});

Aware Matrix hosts: AWARE_HOST.md (keyboard/MiniApp flags, preview helpers, sendData vs bot answers, legacy body cleanup).

Subpath exports

import { Bot } from "aiomatrix";
import { CryptoEngine } from "aiomatrix/crypto";
import { validateInitData } from "aiomatrix/miniapp";
import { createRedisSharedTokenStores } from "aiomatrix/redis";
import { createOtelMetricHandler } from "aiomatrix/otel";

Docs

License

MIT