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

dotakit

v0.4.0

Published

Steam login + Dota 2 Game Coordinator multitool for Bun/Node: guild, profile, live matches, typed GC client, protobuf codegen

Readme

dotakit

TypeScript Bun Tests License

🇬🇧 English · 🇷🇺 Русский


English

Steam login and Dota 2 Game Coordinator in one library: log in, get a GC session, and read your guild, look up medals or follow a live match — without learning a single message id first. Bun-first, ESM + CJS + type declarations, the whole protocol surface generated from Valve's protobufs.

When you outgrow the helpers, the typed low-level client is right underneath, unchanged and fully exported. Nothing here is a wall.

Features

  • 🔑 Steam login included — password, refresh token or a session file that survives restarts; Steam Guard answered from a callback, an event, or a code you already have.
  • 🐰 Bun-first, honestly — the one Bun trap (websocket13) is checked before the login, with the exact fix in the error message and a TCP escape hatch.
  • 🏰 Guilds — roster, roles, invites, kicks, MOTD, role editing and reordering. Every write is confirmed by the GC and serialised, so two edits cannot silently overwrite each other.
  • 🎖️ Medals — profile cards decoded into Legend 5 / Immortal #412, in batches that pace themselves.
  • 📺 Live matches — the SourceTV list, spectating a friend, and an async iterator that follows one game until it ends.
  • 🧾 Typed message maps — 527 message ids wired to their protobuf codecs, 143 request/response pairs, regenerated by a script you can re-run.
  • ⏱️ Nothing hangs — every job settles: decoded reply, timeout, abort, session loss, or an error with the raw bytes attached.
  • 🧪 Tested offline — 152 tests, no network, no real time, driven by fakes and an injectable clock.

60-second quick start

bun add dotakit          # 1. the library
bunx dotakit init        # 2. steam-user, the Bun override, bot.example.ts, .env.example
bun run bot.example.ts   # 3. log in and print your guild

dotakit init reads your package.json, adds the websocket13 override in the field your package manager actually reads (bun/npm overrides, pnpm pnpm.overrides, yarn resolutions), adds steam-user, writes a runnable example and a .env template, installs, and finishes with dotakit doctor. Run it twice and the second run changes nothing.

bunx dotakit init --no-install     # write the files, install later
bunx dotakit init --pm pnpm        # force a package manager instead of detecting one

Works on Node 18+ and Bun (see the Bun note); npx dotakit init does the same thing. steam-user is a peer dependency — you install it, so you control its version.

The example init writes is this:

import { Dota } from "dotakit";

const bot = await Dota.login({
    accountName: process.env.STEAM_ACCOUNT!,
    password: process.env.STEAM_PASSWORD,
    sessionFile: "./data/session.json",       // next run needs no password
});

// Which guild is this account in?
const guild = await bot.guild.find();
console.log(guild?.guildTag, guild?.guildId);

// Who is in it, and since when?
for (const member of await bot.guild.members()) {
    console.log(member.accountId, member.roleName, member.lastActiveAt);
}

// What medal does someone have?
console.log(await bot.profile.rank(52079950));
// → { accountId: 52079950, tier: "Legend", stars: 5, rankTier: 55, leaderboardRank: 0 }

bot.logout();

The first login asks for a Steam Guard code in the terminal and writes the refresh token to sessionFile; every run after that logs in silently. That terminal prompt only appears when stdin is a TTY — on a server pass onGuard (or listen for the guard event) so something can answer, otherwise the login fails with GuardRequiredError instead of waiting on a terminal that is not there:

await Dota.login({ /* … */, onGuard: async () => fetchCodeFromSomewhere() });

Dota.login waits for the first GC session before it returns, so the modules work immediately. The GC drops clients routinely (restarts, logon queue, suspension) and the handshake restarts on its own — listen to gc:up to re-read your state on every new session, not just the first.

bot.on("gc:up", () => console.log("GC session"));
bot.on("gc:down", (status) => console.log("GC gone", status));
bot.on("token", (t) => console.log("new refresh token saved"));
bot.on("error", (e) => console.error(e));

The Bun note

steam-user talks to Steam over WebSockets, and on Bun the stock websocket13 never completes the handshake — the login hangs rather than failing. Two ways out:

1. Use the published fork (recommended — full WebSocket transport). bunx dotakit init does this for you; by hand it is one override plus a reinstall:

{
  "overrides": {
    "websocket13": "npm:@beekamai/[email protected]"
  }
}

@beekamai/websocket13 is a drop-in replacement for websocket13. Bun and npm read overrides, pnpm reads pnpm.overrides, yarn reads resolutions — dotakit init picks the right one from your lockfile.

2. Skip WebSockets entirely:

await Dota.login({ accountName: "…", password: "…", transport: "tcp" });

dotakit checks this for you before it touches the network and throws BunTransportError with both options spelled out. To check by hand:

bunx dotakit doctor      # runtime, the websocket13 in *your* node_modules, protoc

On Node nothing is needed — the stock websocket13 is fine.

Modules

Everything below hangs off the bot: bot.steam, bot.gc, bot.guild, bot.profile, bot.live. Each can also be imported and constructed on its own.

Dota.login(options) → DotaBot

| Option | Meaning | |---|---| | accountName | Steam account name. Required. | | password | Needed on the first login, or whenever no valid token exists. | | refreshToken | Log in with a token instead of a password. | | sessionFile | JSON file holding { refreshToken }. Read on login, rewritten on every new token. | | guardCode | Answers the first Steam Guard prompt outright. | | onGuard(prompt) | Asked for a code on demand; return it, or reject to fail the login. Pass this on a server. | | interactiveGuard | Terminal prompt for the Guard code when nothing else can answer. Defaults to true only on a TTY. | | transport | "auto" (default), "websocket", "tcp". | | waitForGC | Wait for the first GC session before resolving. Default true. | | gcTimeoutMs | Deadline for it. Default 60000; 0 waits forever. | | gc, guild, profile, live | Options passed through to each module. | | logger | { debug?, warn?, error? }. |

| DotaBot member | What it does | |---|---| | steam / gc / guild / profile / live | The session and the four modules. | | connected | true while a GC session exists. | | logout() | Destroys the GC session and logs out of Steam. | | on("ready" \| "gc:up" \| "gc:down" \| "guard" \| "token" \| "error", …) | Events. |

bot.steam — SteamSession

| Member | What it does | |---|---| | user | The raw steam-user instance. | | steamID / accountId | 64-bit id as a string, and the 32-bit account id GC messages use. | | logoff() | Logs out. | | on("token" \| "guard" \| "disconnected" \| "error", …) | Events. |

disconnected carries { eresult, eresultName, critical, message }. For a reconnect loop classifyEResult(eresult) gives three answers: critical — a person has to fix something (wrong password, ban, session replaced, Guard mismatch); rate_limited — Steam asked for a pause, wait about half an hour before the next login; retryable — try again with a growing delay. isCriticalEResult and isRateLimitedEResult are the same checks on their own.

If you run your own reconnect loop, pass autoRelogin: false to login(). Otherwise steam-user relogs on its own and your loop logs in on top, two sessions of one account kick each other every few seconds, and Steam ends it with RateLimitExceeded.

bot.guild — Guild

| Member | What it does | |---|---| | find() | Which guild the account is in → { guildId, guildTag, guildFlags } or null. Remembers it. | | data() | The raw GC snapshot: info, roles, members, invites. | | members() | { accountId, roleId, roleName, joinedAt, lastActiveAt }[] — dates, not epoch seconds. | | roles() | Roles sorted top of the ladder first. | | myRole() | The role the logged-in account holds, or null. | | setRole(accountId, roleIdOrName) | Moves a member. A role name is resolved against a fresh snapshot. | | addRole({ name, flags }) | Creates a role → the id the GC assigned. | | modifyRole(roleId, patch, expected?) | Edits a role. See below. | | removeRole(roleId) | Deletes a role. The GC refuses while anyone holds it. | | setRoleOrder(ids) | Reorders every role → the order the GC confirmed. | | invite / cancelInvite / kick | Membership, by account id. | | setMotd(text) / setDescription(text) | Guild info, patched — the other fields survive. | | eventMembers(eventId?) | Guild points per member for an event. | | onChange(cb) | Subscribes to the GC's three push notifications, debounced 5 s → unsubscribe function. |

Two things this module refuses to do quietly:

  • Writes are serialised. The GC answers a role edit with a whole confirmed ordering, so two edits built on the same snapshot overwrite each other without an error. Every write goes through one queue per Guild.
  • modifyRole replaces the role wholesale, because the GC message does. dotakit reads the current role and patches it, and expected makes the write fail (GC_RESULT_STALE) instead of clobbering a change made in the game meanwhile:
const [officer] = (await bot.guild.roles()).filter((r) => r.roleName === "Officer");
await bot.guild.modifyRole(officer.roleId, { name: "Captain" }, { flags: officer.roleFlags });

Failures are GCOperationError with .op, .result and .resultName (the GC's own enum name, e.g. k_eNoPermission). .result is -1 for a timeout and -2 for the stale guard.

Role flags. GUILD_ROLE_FLAG.INVITE = 1 and GUILD_ROLE_FLAG.KICK = 2 are the only bits confirmed against a live guild. Roles carry bits up to 16; what 4, 8 and 16 grant is unverified, so they get no names here — pass them as numbers. The GC decides what a role may actually do and answers NoPermissions when it disagrees. A role can only be granted rights the bot's own role has: adding a bit the bot lacks fails with k_eInvalidFlags (the guild master is exempt); removing bits always works.

Lobbies. The CMsgPracticeLobby* codecs are generated (create 7038, setDetails 7046, launch 7041, leave 7040, setTeamSlot 7047, kick 7081, list 7042, join 7044), and CSODOTALobby decodes the lobby object. There is no high-level bot.lobby helper yet: the lobby state does not come back as a job response — the GC answers with a bare result and the object itself arrives through the SO cache (k_ESOMsg_*, type id 2004), so a caller must track that cache. Note that id 7055 (PracticeLobbyResponse) has no message body in Valve's protobufs, so it stays undecoded and surfaces on the unknown channel.

bot.profile — Profile

| Member | What it does | |---|---| | card(accountId) | The raw CMsgDOTAProfileCard, or null. | | rank(accountId) | { tier, stars, rankTier, leaderboardRank } — tier is "Herald"…"Immortal" or null. | | ranks(ids, { gapMs }) | Many accounts, one at a time with a pause (default 1500 ms). | | decodeRankTier(n) | The raw decode, standalone: tens are the tier, units are the stars. |

Sequential on purpose: the GC's rate limit on profile cards is undocumented and the session is shared with everything else your bot does. Accounts that fail come back null rather than aborting the batch.

bot.live — Live

| Member | What it does | |---|---| | topGames(filter?) | The SourceTV broadcast list, or specific lobbyIds. | | spectateFriend(steamId64, { live? }) | → { ok: true, serverSteamId } or { ok: false, result, resultName }. | | findGameByServerId(id, { maxPages? }) | Walks the broadcast list looking for one server. | | watch(matchIdOrServerId, opts?) | Async iterator of game states until the game ends. |

for await (const state of bot.live.watch(matchId)) {
    console.log(state.gameTime, state.radiantScore, state.direScore, state.radiantLead);
}

Constants, all overridable per call: poll every POLL_INTERVAL_MS (20 s), back off by doubling up to MAX_BACKOFF_MS (60 s) while the game is not listed, stop after MAX_MISSES (3) consecutive misses, scan DEFAULT_MAX_PAGES (3) pages of the list.

What the GC will not give you, so you do not spend a day finding out: FindTopSourceTVGames returns the top ~100 broadcasts and an ordinary pub match is not in it; SpectateFriendGame answers with a server id while the list filters by lobby id; live: true needs Dota Plus (ERROR_NO_PLUS) while live: false works for any friend. For a pub game's live numbers, Game State Integration on the player's own machine is the route that works.

Escape hatches

The helpers are a convenience, not a boundary.

// Any GC message, typed from its id — the full protocol, not a curated subset
import { EDOTAGCMsg } from "dotakit";
const reply = await bot.gc.sendJob(EDOTAGCMsg.k_EMsgClientToGCGetProfileCard, { accountId });

// Pushes the GC sends on its own
bot.gc.router.on(EDOTAGCMsg.k_EMsgGCToClientGuildMembersDataUpdated, (u) => console.log(u));

// Raw bytes, when even that is too much structure
const bytes = await bot.gc.sendJobRaw(7534, someBuffer);

// The steam-user instance itself: friends, personas, chat, anything dotakit does not wrap
bot.steam.user.setPersona(2 /* Invisible */);

Only the low-level client, no facade:

import { Dota2GC } from "dotakit/gc";
const gc = new Dota2GC(mySteamUser, { autoGamesPlayed: true });
await gc.connect({ timeoutMs: 30_000 });

Message types live behind their own subpath:

import type { CMsgDOTAProfileCard, CSourceTVGameSmall } from "dotakit/protobufs";

The low-level client (dotakit/gc)

The handshake, the router and promise jobs, exactly as the helpers use them.

| Member | What it does | |---|---| | new Dota2GC(steam, options?) | Hooks the transport and watches for the app launch. | | connect({ timeoutMs? }) | Promise for the GC welcome. Resolves at once if a session exists. | | disconnect() / destroy() | Stop the handshake / also detach every transport listener. | | send(msgId, partialBody) | Encodes via fromPartial and sends. Every field optional. | | sendJob(msgId, partialBody, opts?) | Sends and resolves with the decoded reply. | | sendRaw / sendJobRaw | Raw bytes in, raw bytes out. | | router | Inbound traffic: on(msgId), onAny, on("unknown"), on("decodeError"). | | haveGCSession / inApp / welcome | Current state. |

Dota2GCOptions: appid (570), autoGamesPlayed (true via Dota.login, false standalone), jobTimeoutMs (10000), helloInitialMs (500), helloBaseMs (1000), helloBackoffMaxMs (60000), logger, timers.

Errors, all extending Dota2GCError: NotConnectedError, NoEncoderError, NoDecoderError (carries the raw payload), UnexpectedResponseError (the GC answered with a different id — refused, not decoded into a plausible-looking object), JobTimeoutError, JobAbortedError.

Message maps, exported from dotakit and dotakit/gc:

| Export | Contents | |---|---| | clientMessages | 250 ids a client sends. | | gcMessages | 236 ids the GC sends. | | allMessages | Both, 527 ids in total. | | jobResponses | 143 request id → response id pairs. | | messageNames | Numeric id → k_EMsg… enum member name. |

CLI and codegen

dotakit init is the one command a new project needs. The rest is codegen: the generated protobufs are committed, so installing dotakit never runs protoc — refresh them only when Valve ships new GC messages.

bunx dotakit init                    # set this project up: override, steam-user, example, .env
bunx dotakit doctor                  # runtime, transport, protoc — what works and what does not
bunx dotakit protos sync             # re-pull Valve's protos, regenerate src/protobufs + messages
bunx dotakit protos add x.proto      # same, plus one more .proto root
bunx dotakit messages                # only rebuild src/gc/messages.ts from src/protobufs

init and doctor run on Node too (npx dotakit …). protos and messages need Bun.

protos needs protoc on PATH and ts-proto installed (bun add -d ts-proto); the CLI says so plainly when either is missing. It runs under Bun.

scripts/gen-messages.ts scans the generated output: it reads the four message-id enums, pairs each k_EMsg<Name> with the exported CMsg<Name> codec (trying the GC/DOTA infix spellings Valve uses inconsistently), derives <Name>Response and ClientToGC… → GCToClient…Response pairs, and applies scripts/overrides.json for the irregular ones. It prints coverage counts, so a regression is visible.

src/
  index.ts        Dota.login facade, DotaBot, root re-exports
  shared.ts       GCLike, the one-shot request helper
  steam/          login, SteamSession, EResult classification, Bun transport doctor
  guild/          Guild: roster, roles, invites, MOTD, change notifications
  profile/        Profile: cards and medals
  live/           Live: SourceTV list, spectate, watch iterator
  gc/             the low-level client
    client.ts     Dota2GC: handshake, session state, send/sendJob
    router.ts     GCRouter: decode and dispatch inbound messages
    messages.ts   GENERATED id → codec maps, job pairs, names
    errors.ts     Dota2GCError and its subclasses
    types.ts      SteamTransport, MessageFns, DeepPartial, Logger, TimerSource
  protobufs/      GENERATED ts-proto output (SteamDatabase/Protobufs)
  cli/            init and doctor, the commands a new project starts with
bin/
  dotakit.js      published entry point (node and bun)
  dotakit.ts      the same CLI straight from src, for this repo
scripts/
  gen-protos.ts   protoc + ts-proto (optional, needs both installed)
  gen-messages.ts message-map generator
  overrides.json  hand-written fixes for irregular id → message pairs
test/
  helpers.ts      fake Steam transport, fake GC, hand-driven clock
  steam.test.ts   transport doctor, login options, sessions, Guard, EResults
  guild.test.ts   role resolution, stale guard, write queue, notifications
  profile.test.ts medal decoding and batch pacing
  live.test.ts    broadcast list, spectate, watch iterator and backoff
  cli.test.ts     init per package manager, idempotency, doctor guidance
  gc/             the original client, router and message-map tests

Origins

Thanks to kashakov, who helped work out the GC protobufs, and to itsjfx's node-dota2-user (GPL-3.0), whose behaviour the low-level client re-implements independently under MIT. Protobuf definitions come from SteamDatabase/Protobufs and originate from Valve's Dota 2 client. The Bun WebSocket fix lives in beekamai/node-websocket13, published as @beekamai/websocket13. See NOTICE.

Not affiliated with Valve Corporation.

License

MIT.


Русский

Логин в Steam и Game Coordinator Dota 2 в одной библиотеке: залогинился, получил GC-сессию — и читаешь гильдию, смотришь медали или следишь за живым матчем, не выучив предварительно ни одного id сообщения. На Bun, поставляется ESM + CJS + декларации типов, вся поверхность протокола генерируется из протобафов Valve.

Когда обёрток станет мало — типизированный низкоуровневый клиент лежит прямо под ними, неизменный и полностью экспортированный. Стен здесь нет.

Возможности

  • 🔑 Логин в Steam из коробки — пароль, refresh-токен или файл сессии, переживающий рестарты; Steam Guard отвечается колбэком, событием или заранее известным кодом.
  • 🐰 Честный Bun — единственную граблю Bun (websocket13) проверяем до логина, с точным рецептом в тексте ошибки и запасным TCP-транспортом.
  • 🏰 Гильдии — состав, роли, инвайты, кики, MOTD, правка и порядок ролей. Каждая запись подтверждается GC и идёт по одной, так что две правки не затрут друг друга молча.
  • 🎖️ Медали — карточки профиля, разобранные в Legend 5 / Immortal #412, пачками с паузами.
  • 📺 Живые матчи — список SourceTV, спектейт друга и асинхронный итератор состояний до конца игры.
  • 🧾 Типизированные карты сообщений — 527 id, связанных с протобаф-кодеками, 143 пар запрос/ответ, генерируются скриптом, который можно перезапустить.
  • ⏱️ Ничего не висит — любая джоба завершается: декодированный ответ, таймаут, отмена, потеря сессии или ошибка с приложенными сырыми байтами.
  • 🧪 Тесты без сети — 152 теста, без сети и без реального времени, на фейках и управляемых часах.

Быстрый старт за 60 секунд

bun add dotakit          # 1. библиотека
bunx dotakit init        # 2. steam-user, Bun-override, bot.example.ts, .env.example
bun run bot.example.ts   # 3. логин и вывод гильдии

dotakit init читает ваш package.json, кладёт override для websocket13 в то поле, которое реально читает ваш пакетный менеджер (bun/npm — overrides, pnpm — pnpm.overrides, yarn — resolutions), добавляет steam-user, пишет рабочий пример и шаблон .env, ставит зависимости и заканчивает командой dotakit doctor. Запуск второй раз ничего не меняет.

bunx dotakit init --no-install     # только файлы, установка потом
bunx dotakit init --pm pnpm        # задать пакетный менеджер вместо автоопределения

Работает на Node 18+ и Bun (см. про Bun); npx dotakit init делает то же самое. steam-user — peer-зависимость: ставите его вы, версией управляете тоже вы.

Пример, который пишет init:

import { Dota } from "dotakit";

const bot = await Dota.login({
    accountName: process.env.STEAM_ACCOUNT!,
    password: process.env.STEAM_PASSWORD,
    sessionFile: "./data/session.json",       // следующий запуск обойдётся без пароля
});

// В какой гильдии этот аккаунт?
const guild = await bot.guild.find();
console.log(guild?.guildTag, guild?.guildId);

// Кто в ней и когда заходил?
for (const member of await bot.guild.members()) {
    console.log(member.accountId, member.roleName, member.lastActiveAt);
}

// Какая у человека медаль?
console.log(await bot.profile.rank(52079950));
// → { accountId: 52079950, tier: "Legend", stars: 5, rankTier: 55, leaderboardRank: 0 }

bot.logout();

Первый вход спросит код Steam Guard прямо в терминале и запишет refresh-токен в sessionFile; все следующие запуски логинятся молча. Терминальный запрос появляется только если stdin — TTY. На сервере передавайте onGuard (или слушайте событие guard), иначе логин упадёт с GuardRequiredError, а не зависнет в ожидании несуществующего терминала:

await Dota.login({ /* … */, onGuard: async () => fetchCodeFromSomewhere() });

Dota.login дожидается первой GC-сессии, поэтому модули работают сразу. GC регулярно отключает клиентов (рестарты, очередь логина, техработы), и хендшейк перезапускается сам — слушайте gc:up, чтобы перечитывать состояние на каждой новой сессии, а не только на первой.

bot.on("gc:up", () => console.log("GC-сессия"));
bot.on("gc:down", (status) => console.log("GC отвалился", status));
bot.on("token", (t) => console.log("сохранён новый refresh-токен"));
bot.on("error", (e) => console.error(e));

Про Bun

steam-user ходит в Steam по WebSocket, и на Bun обычный websocket13 не доводит хендшейк до конца — логин висит, а не падает. Два выхода:

1. Опубликованный форк (рекомендуется — полноценный WebSocket-транспорт). bunx dotakit init делает это за вас; руками — один override и переустановка:

{
  "overrides": {
    "websocket13": "npm:@beekamai/[email protected]"
  }
}

@beekamai/websocket13 — drop-in-замена websocket13. Bun и npm читают overrides, pnpm — pnpm.overrides, yarn — resolutions; dotakit init выбирает нужное поле по вашему локфайлу.

2. Обойтись без WebSocket:

await Dota.login({ accountName: "…", password: "…", transport: "tcp" });

dotakit проверяет это до всякой сети и кидает BunTransportError с обоими вариантами в тексте. Проверить руками:

bunx dotakit doctor      # рантайм, websocket13 в *вашем* node_modules, protoc

На Node ничего делать не нужно — обычный websocket13 работает.

Модули

Всё ниже висит на боте: bot.steam, bot.gc, bot.guild, bot.profile, bot.live. Каждый модуль можно импортировать и создать отдельно.

Dota.login(options) → DotaBot

| Опция | Что делает | |---|---| | accountName | Имя аккаунта Steam. Обязательна. | | password | Нужен на первом логине или когда живого токена нет. | | refreshToken | Войти по токену вместо пароля. | | sessionFile | JSON-файл с { refreshToken }. Читается при логине, перезаписывается на каждый новый токен. | | guardCode | Отвечает на первый запрос Steam Guard сразу. | | onGuard(prompt) | Спрашивается по требованию; верните код или отклоните промис, чтобы провалить логин. На сервере — обязательно. | | interactiveGuard | Спросить код Guard в терминале, когда ответить больше некому. По умолчанию true только на TTY. | | transport | "auto" (по умолчанию), "websocket", "tcp". | | waitForGC | Ждать первую GC-сессию перед возвратом. По умолчанию true. | | gcTimeoutMs | Дедлайн ожидания. По умолчанию 60000; 0 — ждать вечно. | | gc, guild, profile, live | Опции, пробрасываемые в модули. | | logger | { debug?, warn?, error? }. |

| Член DotaBot | Что делает | |---|---| | steam / gc / guild / profile / live | Сессия и четыре модуля. | | connected | true, пока GC-сессия жива. | | logout() | Гасит GC-сессию и выходит из Steam. | | on("ready" \| "gc:up" \| "gc:down" \| "guard" \| "token" \| "error", …) | События. |

bot.steam — SteamSession

| Член | Что делает | |---|---| | user | Сам экземпляр steam-user. | | steamID / accountId | 64-битный id строкой и 32-битный account id, которым оперируют сообщения GC. | | logoff() | Выход. | | on("token" \| "guard" \| "disconnected" \| "error", …) | События. |

disconnected несёт { eresult, eresultName, critical, message }. Для реконнект-логики classifyEResult(eresult) даёт три ответа: critical — нужен человек (неверный пароль, бан, сессия перехвачена, код Guard не подошёл); rate_limited — Steam попросил паузу, следующий логин не раньше чем через полчаса; retryable — пробовать снова с растущей задержкой. isCriticalEResult и isRateLimitedEResult — те же проверки по отдельности.

Если у вас свой реконнект-цикл, передайте в login() autoRelogin: false. Иначе steam-user перелогинится сам и ваш цикл залогинится поверх: две сессии одного аккаунта выбивают друг друга каждые несколько секунд, пока Steam не ответит RateLimitExceeded.

bot.guild — Guild

| Член | Что делает | |---|---| | find() | В какой гильдии аккаунт → { guildId, guildTag, guildFlags } или null. Запоминает. | | data() | Сырой снимок GC: info, роли, участники, инвайты. | | members() | { accountId, roleId, roleName, joinedAt, lastActiveAt }[] — даты, а не секунды эпохи. | | roles() | Роли, отсортированные сверху лестницы. | | myRole() | Роль залогиненного аккаунта или null. | | setRole(accountId, roleIdOrName) | Переводит участника. Имя роли резолвится по свежему снимку. | | addRole({ name, flags }) | Создаёт роль → id, выданный GC. | | modifyRole(roleId, patch, expected?) | Правит роль. См. ниже. | | removeRole(roleId) | Удаляет роль. GC откажет, пока в ней есть участники. | | setRoleOrder(ids) | Меняет порядок всех ролей → порядок, подтверждённый GC. | | invite / cancelInvite / kick | Членство, по account id. | | setMotd(text) / setDescription(text) | Инфо гильдии, точечно — остальные поля выживают. | | eventMembers(eventId?) | Очки гильдии по участникам за событие. | | onChange(cb) | Подписка на три push-уведомления GC, схлопывание 5 с → функция отписки. |

Две вещи, которые этот модуль отказывается делать молча:

  • Записи идут по одной. GC отвечает на правку роли целым подтверждённым порядком, поэтому две правки поверх одного снимка затирают друг друга без всякой ошибки. Все записи проходят через одну очередь на Guild.
  • modifyRole заменяет роль целиком, потому что так устроено само сообщение GC. dotakit читает текущую роль и патчит её, а expected заставляет запись упасть (GC_RESULT_STALE) вместо затирания правки, сделанной в игре:
const [officer] = (await bot.guild.roles()).filter((r) => r.roleName === "Officer");
await bot.guild.modifyRole(officer.roleId, { name: "Captain" }, { flags: officer.roleFlags });

Отказы — это GCOperationError с .op, .result и .resultName (имя из перечисления самого GC, например k_eNoPermission). .result равен -1 при таймауте и -2 при защите от устаревшего снимка.

Флаги ролей. GUILD_ROLE_FLAG.INVITE = 1 и GUILD_ROLE_FLAG.KICK = 2 — единственные биты, подтверждённые на живой гильдии. У ролей встречаются биты до 16 включительно; что дают 4, 8 и 16 — не подтверждено, поэтому имён им здесь не даём, передавайте числами. Что роли реально позволено, решает GC и отвечает NoPermissions, если не согласен. Роли можно выдать только те права, которые есть у роли самого бота: добавление чужого бита заканчивается k_eInvalidFlags (гильдмастер — исключение), снимать биты можно всегда.

Лобби. Кодеки CMsgPracticeLobby* сгенерированы (create 7038, setDetails 7046, launch 7041, leave 7040, setTeamSlot 7047, kick 7081, list 7042, join 7044), а CSODOTALobby разбирает сам объект лобби. Высокоуровневого bot.lobby пока нет: состояние лобби не приходит ответом на запрос — GC отвечает только результатом, а объект приезжает через SO-кэш (k_ESOMsg_*, тип 2004), и его нужно вести самому. У id 7055 (PracticeLobbyResponse) в протобафах Valve нет тела сообщения, поэтому он остаётся неразобранным и попадает в канал unknown.

bot.profile — Profile

| Член | Что делает | |---|---| | card(accountId) | Сырая CMsgDOTAProfileCard или null. | | rank(accountId) | { tier, stars, rankTier, leaderboardRank } — tier это "Herald"…"Immortal" либо null. | | ranks(ids, { gapMs }) | Много аккаунтов, по одному с паузой (по умолчанию 1500 мс). | | decodeRankTier(n) | Тот же разбор отдельной функцией: десятки — ступень, единицы — звёзды. |

Последовательно намеренно: лимиты GC на карточки профиля не документированы, а сессия одна на всё, что делает бот. Неудачные аккаунты возвращаются как null, а не рушат пачку.

bot.live — Live

| Член | Что делает | |---|---| | topGames(filter?) | Список трансляций SourceTV или конкретные lobbyIds. | | spectateFriend(steamId64, { live? }) | → { ok: true, serverSteamId } либо { ok: false, result, resultName }. | | findGameByServerId(id, { maxPages? }) | Листает список трансляций в поисках сервера. | | watch(matchIdOrServerId, opts?) | Асинхронный итератор состояний игры до её конца. |

for await (const state of bot.live.watch(matchId)) {
    console.log(state.gameTime, state.radiantScore, state.direScore, state.radiantLead);
}

Константы, все переопределяются на вызов: опрос каждые POLL_INTERVAL_MS (20 с), удвоение паузы до MAX_BACKOFF_MS (60 с), пока игры нет в списке, остановка после MAX_MISSES (3) промахов подряд, просмотр DEFAULT_MAX_PAGES (3) страниц списка.

Чего GC не отдаст — чтобы вы не потратили на это день: FindTopSourceTVGames возвращает топ ≈100 трансляций, и обычного паба там нет; SpectateFriendGame отвечает id сервера, а список фильтруется по id лобби; live: true требует Dota Plus (ERROR_NO_PLUS), тогда как live: false работает для любого друга. За живыми цифрами обычного паба идти надо в Game State Integration на машине самого игрока.

Запасные выходы

Обёртки — это удобство, а не граница.

// Любое сообщение GC, типизированное по id — весь протокол, а не выбранный набор
import { EDOTAGCMsg } from "dotakit";
const reply = await bot.gc.sendJob(EDOTAGCMsg.k_EMsgClientToGCGetProfileCard, { accountId });

// Пуши, которые GC шлёт сам
bot.gc.router.on(EDOTAGCMsg.k_EMsgGCToClientGuildMembersDataUpdated, (u) => console.log(u));

// Сырые байты, когда и такой структуры много
const bytes = await bot.gc.sendJobRaw(7534, someBuffer);

// Сам steam-user: друзья, персоны, чат — всё, что dotakit не оборачивает
bot.steam.user.setPersona(2 /* Invisible */);

Только низкоуровневый клиент, без фасада:

import { Dota2GC } from "dotakit/gc";
const gc = new Dota2GC(mySteamUser, { autoGamesPlayed: true });
await gc.connect({ timeoutMs: 30_000 });

Типы сообщений живут в своём сабпате:

import type { CMsgDOTAProfileCard, CSourceTVGameSmall } from "dotakit/protobufs";

Низкоуровневый клиент (dotakit/gc)

Хендшейк, роутер и промис-джобы — ровно то, чем пользуются хелперы.

| Член | Что делает | |---|---| | new Dota2GC(steam, options?) | Подписывается на транспорт и ждёт запуска приложения. | | connect({ timeoutMs? }) | Промис на welcome от GC. Резолвится сразу, если сессия уже есть. | | disconnect() / destroy() | Остановить хендшейк / плюс отцепить все слушатели транспорта. | | send(msgId, partialBody) | Кодирует через fromPartial и шлёт. Все поля опциональны. | | sendJob(msgId, partialBody, opts?) | Шлёт и резолвится декодированным ответом. | | sendRaw / sendJobRaw | Сырые байты внутрь, сырые наружу. | | router | Входящий трафик: on(msgId), onAny, on("unknown"), on("decodeError"). | | haveGCSession / inApp / welcome | Текущее состояние. |

Dota2GCOptions: appid (570), autoGamesPlayed (true через Dota.login, false самостоятельно), jobTimeoutMs (10000), helloInitialMs (500), helloBaseMs (1000), helloBackoffMaxMs (60000), logger, timers.

Ошибки, все наследуют Dota2GCError: NotConnectedError, NoEncoderError, NoDecoderError (несёт сырой payload), UnexpectedResponseError (GC ответил другим id — отказ, а не декодирование в правдоподобно выглядящий объект), JobTimeoutError, JobAbortedError.

Карты сообщений, экспортируются из dotakit и dotakit/gc:

| Экспорт | Содержимое | |---|---| | clientMessages | 250 id, которые шлёт клиент. | | gcMessages | 236 id, которые шлёт GC. | | allMessages | Оба набора, 527 id. | | jobResponses | 143 пар id запроса → id ответа. | | messageNames | Числовой id → имя члена перечисления k_EMsg…. |

CLI и кодогенерация

dotakit init — единственная команда, которая нужна новому проекту. Остальное про кодогенерацию: протобафы закоммичены, поэтому установка dotakit никогда не запускает protoc — обновлять их нужно, только когда Valve выкатывает новые сообщения GC.

bunx dotakit init                    # настроить проект: override, steam-user, пример, .env
bunx dotakit doctor                  # рантайм, транспорт, protoc — что работает, а что нет
bunx dotakit protos sync             # перекачать протобафы Valve, перегенерировать src/protobufs + messages
bunx dotakit protos add x.proto      # то же плюс ещё один корневой .proto
bunx dotakit messages                # только пересобрать src/gc/messages.ts из src/protobufs

init и doctor работают и на Node (npx dotakit …). protos и messages требуют Bun.

protos требует protoc в PATH и установленного ts-proto (bun add -d ts-proto); CLI прямо об этом скажет, если чего-то нет. Запускается под Bun.

scripts/gen-messages.ts разбирает сгенерированный вывод: читает четыре перечисления с id сообщений, сопоставляет каждый k_EMsg<Name> с экспортированным кодеком CMsg<Name> (перебирая написания с инфиксами GC/DOTA, которые Valve ставит непоследовательно), выводит пары <Name>Response и ClientToGC… → GCToClient…Response и применяет scripts/overrides.json для нерегулярных случаев. Печатает счётчики покрытия, чтобы регрессия была видна.

src/
  index.ts        фасад Dota.login, DotaBot, корневые ре-экспорты
  shared.ts       GCLike, хелпер одноразового запроса
  steam/          логин, SteamSession, классификация EResult, проверка транспорта на Bun
  guild/          Guild: состав, роли, инвайты, MOTD, уведомления об изменениях
  profile/        Profile: карточки и медали
  live/           Live: список SourceTV, спектейт, итератор watch
  gc/             низкоуровневый клиент
    client.ts     Dota2GC: хендшейк, состояние сессии, send/sendJob
    router.ts     GCRouter: декодирование и раздача входящих
    messages.ts   ГЕНЕРИРУЕТСЯ: карты id → кодек, пары джоб, имена
    errors.ts     Dota2GCError и наследники
    types.ts      SteamTransport, MessageFns, DeepPartial, Logger, TimerSource
  protobufs/      ГЕНЕРИРУЕТСЯ ts-proto (SteamDatabase/Protobufs)
  cli/            init и doctor — то, с чего начинается новый проект
bin/
  dotakit.js      опубликованная точка входа (node и bun)
  dotakit.ts      тот же CLI прямо из src, для этого репозитория
scripts/
  gen-protos.ts   protoc + ts-proto (опционально, нужны оба)
  gen-messages.ts генератор карт сообщений
  overrides.json  ручные правки для нерегулярных пар id → сообщение
test/
  helpers.ts      фейковый транспорт Steam, фейковый GC, управляемые часы
  steam.test.ts   проверка транспорта, опции логина, сессии, Guard, EResult
  guild.test.ts   резолв ролей, защита от устаревшего снимка, очередь записей, уведомления
  profile.test.ts разбор медалей и темп пачки
  live.test.ts    список трансляций, спектейт, итератор watch и бэкофф
  cli.test.ts     init по каждому пакетному менеджеру, идемпотентность, подсказки doctor
  gc/             тесты исходного клиента, роутера и карт сообщений

Происхождение

Спасибо kashakov, который помог разобраться с протобафами GC, и itsjfx с node-dota2-user (GPL-3.0), чьё поведение низкоуровневый клиент независимо переизобретает под MIT. Определения протобафов взяты из SteamDatabase/Protobufs и происходят из клиента Dota 2 от Valve. Фикс WebSocket для Bun живёт в beekamai/node-websocket13, опубликован как @beekamai/websocket13. См. NOTICE.

Не связано с Valve Corporation.

Лицензия

MIT.