SLOPSHOPPER

vibe-gambling

🎰 Every prompt is a spin. A PS1-style roulette that pops up while Claude Code thinks. You can lose 100%, but you could also win infinity (in fake coins only).

newguardcommandtoaststatusprocess
v0.4.0AGPL-3.0-or-laterupdated 2026-10-14Aimarmndz/vibe-gambling/packages/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vibe-gambling
› fix the failing auth test and add an audit log call ● vibe-gambling: /api/auth/device/start: HTTP 0 ● vibe-gambling: /api/meta: HTTP 0 ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /roulette ⎿ vibe-gambling: You're not logged in. Run /vibe-login first. No seat, no spin. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ vibe-gambling: 🎰 /vibe-login
README

vibe-gambling — Claude Code mod

The Claude Code side of vibe-gambling: every prompt is a spin. It opens the PS1-style roulette window when you send a prompt, closes the round when Claude finishes, and shows the result in a toast and your balance in the status line. You can lose 100 % of your coins, but you can also win infinity (statistically, you won't). The coins are fake; the dopamine is the only thing that's real.

With no game window open and visible when the turn ends, the server keeps the spin as a pending spin instead of rolling it: the mod says so in a toast (🎰 Spin saved for later · 20 pending), adds the count to the status line (🎰 750 🪙 · 20 pending) and /balance reminds you. You spend them in the window with SPIN / SPIN ALL. Past the cap (pendingSpins.max, 100 by default) the prompt is dropped with a joke. Nothing new is sent for this: the server decides from the window's visibility.

/plugin install vibe-gambling --marketplace Aimarmndz/vibe-gambling
/vibe-login

Commands

CommandAliasWhat it does
/roulette/ruletaOpen the window
/bet <amount> <square>/apostarBet on the current or next spin (/bet 200 red, /bet 100 1st12)
/all-in [square]Your whole balance (red by default)
/balance/saldoBalance, debt, net worth, rank and pending spins
/vibe-login, /vibe-logoutGitHub device-code login / log out
/vibe-hide, /vibe-showHide from / show on the public leaderboard
`/vibe-server [url\default]`Show or change the server (self-hosting); checks GET /api/meta
/vibe-delete-account <login>Delete your account and all its data (repeat your login to confirm)
/vibe-shop [buy <item>]/vibe-tiendaThe SHOP: prices, caps, the fine print with the real house edge, your pocket; buy with Spanish or English aliases (pata, rabbit, seguro, ctrl+z…)
/vibe-use <item> [off]/vibe-usarArm or disarm a pocket item for the next spin; armed items show as glyphs in the status line (🎰 750 🪙 · 🐇🛡)

Privacy

The mod sends only a random turn id, the turn duration, two booleans (gitPushOk, testFailed), the plugin version, the generic OS (linux/darwin/win32/other) and your session token — plus what you type in /bet, and for the SHOP only the item id, a random purchase id and the armed flag. Never prompts, answers, code, commands, paths, file or repo names. Bash commands are inspected locally to compute the two booleans and then forgotten. tests/privacy.test.ts enforces it for turns, every command, the login and the error paths, and checks the debug log too.

Text that comes from the server (device code, login, server name, error messages) is stripped of control characters and escape sequences before it reaches your terminal, so a compromised self-hosted server cannot write to your clipboard or redraw your screen. Servers must use https; plain http is accepted only for this machine (localhost, 127.0.0.1, [::1]), because your session token would otherwise travel in clear text.

What the mod does on your machine

A plain list for the plugin directory reviewers and for anyone who wants to know before installing.

  • Network: one host. Every request goes to the vibe-gambling server set in the serverUrl option: https://vibe.aimar.dev by default, or the one you chose with /vibe-server (https only; http just for localhost). Nothing else is contacted: the GitHub login goes through that server (Device Flow) and the mod never talks to GitHub itself. What it sends is listed in Privacy: a random turn id, the turn duration, two booleans, the plugin version, the generic OS and your session token, plus what you type in the slash commands. Never prompts, answers, code, commands, paths or repo names.
  • What it reads from the conversation. It hooks turn.start and turn.complete to open and close the round, and tool.call for the Bash tool only, after the command has run, to compute those two booleans: whether a git push succeeded and whether a test command failed. The command and its output are inspected in memory and forgotten; the hook never changes, blocks or re-runs the call and always returns its result untouched.
  • Programs it starts, and why. Only two kinds, never anything that comes from the server:
  • uname -s, once per session, to tell Linux from macOS (the generic OS above).
  • A browser, to open the game window: a Chromium-based browser installed on your machine (Chrome, Chromium, Edge, Brave…) in app mode with --app=<one-time link> --window-size=<W>,<H>, or else the default browser with xdg-open (Linux, through sh -c '"$@" &' so it does not block), open (macOS) or cmd /d /c start (Windows). The link is a one-time URL of your own serverUrl, checked before launching. Turn it off with the autoOpen option and open the window yourself with /roulette.
  • Local files and environment. It checks whether those browser executables exist at their usual paths, and reads HOME, OS, ProgramFiles, ProgramFiles(x86), LOCALAPPDATA (to find them) and LC_ALL, LC_MESSAGES, LANG, LANGUAGE (to pick the language). It reads no other files.
  • Credentials. The only secret is the vibe-gambling session token the server gives you after /vibe-login. It is kept in the plugin's own store in Claude Code, one per server, sent only to that server and deleted with /vibe-logout. The mod never reads your GitHub token, SSH keys, git credentials or any other credential on your machine.
  • Settings it changes. One: /vibe-server <url> writes the plugin's own vibe-gambling.serverUrl option, after you ask for it and after checking that the server answers. It never touches permission modes or any other setting.
  • Commands it adds. The slash commands in Commands, with their Spanish aliases. It adds no tools, agents or MCP servers.

Options

Set them in /config (or pluginConfigs["vibe-gambling"].options in your settings). The defaults and the allowed ranges (min/max) live in .claude-plugin/plugin.json; Claude Code refuses to load the mod with a value outside them and says which one.

OptionDefaultMeaning
serverUrlhttps://vibe.aimar.devServer to play on (/vibe-server changes it)
autoOpentrueOpen the window on each prompt if it is not open
languageautoauto (system locale), en, es, fr or de
turnTimeoutMs1500Timeout of the requests made during a turn (100–1500)
commandTimeoutMs5000Timeout of the slash commands' requests (500–30000)
windowReopenCooldownMs60000Wait for an opened window to connect before opening another
windowSize960,720App-mode window size
launchTimeoutMs5000How long the browser launcher may take (500–30000)
revealMaxDelayMs30000Longest wait before the result toast (0–120000)

A turn never waits for the casino: every request runs in the background. In claude -p the mod waits for turn/end before the process exits, at most turnTimeoutMs in total, and never opens a window (nobody is watching).

Development

claude --plugin-dir packages/mod                   # load it in a session
pnpm --filter @vibe-gambling/mod validate          # claude plugin validate .
pnpm --filter @vibe-gambling/mod test              # claude plugin test . (one *.test.ts per behaviour)
pnpm --filter @vibe-gambling/mod typecheck         # lays the engine's types, then tsc -p .
pnpm sync:mod                                      # regenerate hooks/generated from packages/shared
  • hooks/register.ts wires the engine events; it is the only file that touches $ (the engine requires it) and adapts it to the small Io interface in hooks/lib/io.ts. Everything in hooks/lib is plain TypeScript.
  • hooks/generated/ is a copy of the phrases and protocol constants from packages/shared (the mod is installed alone, so it cannot import them). packages/shared/test/mod-contract.test.ts fails if the copy is stale and validates the mod's request bodies against the shared zod schemas.
  • The engine's type declarations (.claude-plugin/types/) belong to the installed Claude Code and are not committed: scripts/mod-engine-types.ts asks the installed claude to write them (headless, isolated config, no API key needed).
  • The plugin API is early access: if a new Claude Code release changes it, re-run typecheck, validate and test.
Source 28 files
hooks/register.ts 484 lines
1// SPDX-License-Identifier: AGPL-3.0-or-later
2// vibe-gambling: cada prompt es una tirada de ruleta. Este módulo solo conecta los eventos del
3// engine con la lógica de `./lib`. Reglas de oro: ningún hook espera al casino (todo va en segundo
4// plano y con timeout ≤ 1,5 s), ningún error escapa y todos llaman a `next(e)`.
5import type { EngineInterface, Register } from 'claude-code'
6
7import { debug, withTimeout } from './lib/api.ts'
8import { createAuthState } from './lib/auth.ts'
9import { MAX_TURN_TIMEOUT_MS } from './lib/config.ts'
10import type { EnvName, Io } from './lib/io.ts'
11import { isFailedTestRun, isSuccessfulGitPush, saysNothingPushed } from './lib/bash.ts'
12import * as commands from './lib/commands.ts'
13import type { CommandContext } from './lib/commands.ts'
14import { MESSAGES } from './lib/messages.ts'
15import type { CommandId } from './lib/messages.ts'
16import {
17  createRuntime,
18  detectOs,
19  loadSession,
20  refreshMe,
21  resolveLocale,
22  setStatus,
23} from './lib/runtime.ts'
24import { beginTurn, finishTurn } from './lib/turns.ts'
25import type { TurnState } from './lib/turns.ts'
26import { createWindowState } from './lib/window.ts'
27
28/**
29 * Adapta `$` a la interfaz `Io` que usa `./lib`. El engine solo deja usar `$` en este archivo y
30 * siempre como `$.noun.event(...)`; por eso cada variable de entorno va escrita de forma literal.
31 */
32function ioOf($: EngineInterface): Io {
33  const env = (name: EnvName): Promise<string | undefined> => {
34    switch (name) {
35      case 'OS':
36        return $.env.get('OS')
37      case 'HOME':
38        return $.env.get('HOME')
39      case 'ProgramFiles':
40        return $.env.get('ProgramFiles')
41      case 'ProgramFiles(x86)':
42        return $.env.get('ProgramFiles(x86)')
43      case 'LOCALAPPDATA':
44        return $.env.get('LOCALAPPDATA')
45      case 'LC_ALL':
46        return $.env.get('LC_ALL')
47      case 'LC_MESSAGES':
48        return $.env.get('LC_MESSAGES')
49      case 'LANG':
50        return $.env.get('LANG')
51      case 'LANGUAGE':
52        return $.env.get('LANGUAGE')
53    }
54  }
55  return {
56    pluginName: $.plugin.name,
57    fetch: (url, init) => $.http.fetch(url, { ...init, headers: { ...init.headers } }),
58    now: () => $.clock.now(),
59    after: (ms, fn) => $.clock.after(ms, fn),
60    log: text => $.ui.log(text, { to: 'debug' }),
61    status: text => $.ui.status(text),
62    toast: text => $.ui.toast(text),
63    storeGet: key => $.store.get(key),
64    storeSet: (key, value) => $.store.set(key, value),
65    storeDelete: key => $.store.delete(key),
66    env,
67    run: (argv, timeoutMs) => $.process.run(argv, { timeoutMs }),
68    exists: path => $.fs.exists(path),
69  }
70}
71
72/**
73 * Espera el registro de un comando sin que un rechazo (p. ej. un nombre que ya usa otro plugin)
74 * deje sin registrar los demás ni sin status line a la sesión.
75 */
76async function registerSafely(io: Io, name: string, registering: Promise<unknown>): Promise<void> {
77  try {
78    await registering
79  } catch (error) {
80    debug(io, `no he podido registrar /${name}: ${String(error)}`)
81  }
82}
83
84/** Argumentos del comando (el engine manda `""` sin argumentos; se toleran también ausentes). */
85function argsOf(e: { readonly args?: string | undefined }): string {
86  return typeof e.args === 'string' ? e.args : ''
87}
88
89export const register: Register = (on, options) => {
90  const runtime = createRuntime(options)
91  const auth = createAuthState()
92  const turns: TurnState = { turns: new Map(), active: null, window: createWindowState() }
93
94  /** Descripción y pista de argumentos en el idioma del sistema. */
95  const describe = (id: CommandId): string => runtime.messages.commands[id].description
96  const hint = (id: CommandId): string | undefined => runtime.messages.commands[id].argumentHint
97
98  /** Ejecuta un comando con la configuración válida, o explica por qué no la hay. */
99  const run = async (
100    io: Io,
101    id: CommandId,
102    action: (ctx: CommandContext) => Promise<string>,
103  ): Promise<{ text: string }> => {
104    const config = runtime.config
105    if (config === null)
106      return { text: runtime.messages.badConfig(runtime.configErrors.join('; ')) }
107    try {
108      return { text: await action({ io, runtime, config, auth, window: turns.window }) }
109    } catch (error) {
110      debug(io, `/${id}: ${String(error)}`)
111      return { text: runtime.messages.offline }
112    }
113  }
114
115  on('session.start', async ($, e, next) => {
116    const io = ioOf($)
117    try {
118      runtime.isInteractive = e.isInteractive
119      runtime.locale = await resolveLocale(io, runtime)
120      runtime.messages = MESSAGES[runtime.locale]
121      // `uname` va con el timeout del turno: `turn/start` espera al SO y no puede esperar más.
122      runtime.os = detectOs(io, runtime.config?.turnTimeoutMs ?? MAX_TURN_TIMEOUT_MS)
123
124      // Nombres literales: así el engine sabe que cada comando lo responde este módulo. Cada
125      // registro va por separado: si el engine rechaza uno, los demás siguen.
126      await registerSafely(
127        io,
128        'roulette',
129        $.command.register({
130          name: 'roulette',
131          description: describe('roulette'),
132          argumentHint: hint('roulette'),
133          immediate: true,
134        }),
135      )
136      await registerSafely(
137        io,
138        'ruleta',
139        $.command.register({
140          name: 'ruleta',
141          description: describe('roulette'),
142          argumentHint: hint('roulette'),
143          immediate: true,
144        }),
145      )
146      await registerSafely(
147        io,
148        'bet',
149        $.command.register({
150          name: 'bet',
151          description: describe('bet'),
152          argumentHint: hint('bet'),
153          immediate: true,
154        }),
155      )
156      await registerSafely(
157        io,
158        'apostar',
159        $.command.register({
160          name: 'apostar',
161          description: describe('bet'),
162          argumentHint: hint('bet'),
163          immediate: true,
164        }),
165      )
166      await registerSafely(
167        io,
168        'all-in',
169        $.command.register({
170          name: 'all-in',
171          description: describe('allIn'),
172          argumentHint: hint('allIn'),
173          immediate: true,
174        }),
175      )
176      await registerSafely(
177        io,
178        'balance',
179        $.command.register({
180          name: 'balance',
181          description: describe('balance'),
182          argumentHint: hint('balance'),
183          immediate: true,
184        }),
185      )
186      await registerSafely(
187        io,
188        'saldo',
189        $.command.register({
190          name: 'saldo',
191          description: describe('balance'),
192          argumentHint: hint('balance'),
193          immediate: true,
194        }),
195      )
196      await registerSafely(
197        io,
198        'vibe-login',
199        $.command.register({
200          name: 'vibe-login',
201          description: describe('login'),
202          argumentHint: hint('login'),
203          immediate: true,
204        }),
205      )
206      await registerSafely(
207        io,
208        'vibe-logout',
209        $.command.register({
210          name: 'vibe-logout',
211          description: describe('logout'),
212          argumentHint: hint('logout'),
213          immediate: true,
214        }),
215      )
216      await registerSafely(
217        io,
218        'vibe-hide',
219        $.command.register({
220          name: 'vibe-hide',
221          description: describe('hide'),
222          argumentHint: hint('hide'),
223          immediate: true,
224        }),
225      )
226      await registerSafely(
227        io,
228        'vibe-show',
229        $.command.register({
230          name: 'vibe-show',
231          description: describe('show'),
232          argumentHint: hint('show'),
233          immediate: true,
234        }),
235      )
236      await registerSafely(
237        io,
238        'vibe-server',
239        $.command.register({
240          name: 'vibe-server',
241          description: describe('server'),
242          argumentHint: hint('server'),
243          immediate: true,
244        }),
245      )
246      await registerSafely(
247        io,
248        'vibe-delete-account',
249        $.command.register({
250          name: 'vibe-delete-account',
251          description: describe('deleteAccount'),
252          argumentHint: hint('deleteAccount'),
253          immediate: true,
254        }),
255      )
256      await registerSafely(
257        io,
258        'vibe-invite',
259        $.command.register({
260          name: 'vibe-invite',
261          description: describe('invite'),
262          argumentHint: hint('invite'),
263          immediate: true,
264        }),
265      )
266      await registerSafely(
267        io,
268        'vibe-invitar',
269        $.command.register({
270          name: 'vibe-invitar',
271          description: describe('invite'),
272          argumentHint: hint('invite'),
273          immediate: true,
274        }),
275      )
276      await registerSafely(
277        io,
278        'vibe-redeem',
279        $.command.register({
280          name: 'vibe-redeem',
281          description: describe('redeem'),
282          argumentHint: hint('redeem'),
283          immediate: true,
284        }),
285      )
286      await registerSafely(
287        io,
288        'vibe-canjear',
289        $.command.register({
290          name: 'vibe-canjear',
291          description: describe('redeem'),
292          argumentHint: hint('redeem'),
293          immediate: true,
294        }),
295      )
296      await registerSafely(
297        io,
298        'vibe-shop',
299        $.command.register({
300          name: 'vibe-shop',
301          description: describe('shop'),
302          argumentHint: hint('shop'),
303          immediate: true,
304        }),
305      )
306      await registerSafely(
307        io,
308        'vibe-tienda',
309        $.command.register({
310          name: 'vibe-tienda',
311          description: describe('shop'),
312          argumentHint: hint('shop'),
313          immediate: true,
314        }),
315      )
316      await registerSafely(
317        io,
318        'vibe-use',
319        $.command.register({
320          name: 'vibe-use',
321          description: describe('use'),
322          argumentHint: hint('use'),
323          immediate: true,
324        }),
325      )
326      await registerSafely(
327        io,
328        'vibe-usar',
329        $.command.register({
330          name: 'vibe-usar',
331          description: describe('use'),
332          argumentHint: hint('use'),
333          immediate: true,
334        }),
335      )
336
337      const config = runtime.config
338      if (config === null) {
339        debug(io, `configuración no válida: ${runtime.configErrors.join('; ')}`)
340        setStatus(io, runtime.messages.statusBadConfig)
341      } else {
342        const session = await loadSession(io, config.serverUrl)
343        if (session === null) {
344          setStatus(io, runtime.messages.statusLogin)
345        } else {
346          // El saldo llega cuando llegue: la sesión no espera al servidor.
347          void refreshMe(io, runtime, config, session, config.turnTimeoutMs)
348        }
349      }
350    } catch (error) {
351      debug(io, `session.start: ${String(error)}`)
352    }
353    return next(e)
354  })
355
356  on('turn.start', ($, e, next) => {
357    const io = ioOf($)
358    // Un turno sin texto es una continuación del engine, no un prompt: no es una tirada.
359    const config = runtime.config
360    if (config !== null && e.text.trim() !== '') {
361      try {
362        beginTurn(io, runtime, config, turns, e.turnId)
363      } catch (error) {
364        debug(io, `turn.start: ${String(error)}`)
365      }
366    }
367    return next(e)
368  })
369
370  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
371    const io = ioOf($)
372    const ran = await next(e)
373    try {
374      const record = turns.active
375      if (record !== null && e.tool === 'Bash') {
376        // Se mira el comando y su salida aquí mismo y se olvidan: solo quedan dos booleanos.
377        const result = ran.isError === true || ran.deny !== undefined ? undefined : ran.result
378        // Una orden que sigue en segundo plano (`run_in_background` o cortada por su timeout)
379        // aún no tiene código de salida: no cuenta ni como push ni como test.
380        const finished = result?.backgroundTaskId === undefined
381        const outcome = {
382          succeeded:
383            finished &&
384            ran.deny === undefined &&
385            ran.isError !== true &&
386            result?.interrupted !== true,
387          failed: ran.isError === true,
388          nothingPushed:
389            result !== undefined && saysNothingPushed(`${result.stdout}\n${result.stderr}`),
390        }
391        if (isSuccessfulGitPush(e.command, outcome)) record.gitPushOk = true
392        if (isFailedTestRun(e.command, outcome)) record.testFailed = true
393      }
394    } catch (error) {
395      debug(io, `tool.call: ${String(error)}`)
396    }
397    return ran
398  }).catch((_$, e, next) => next(e))
399
400  on('turn.complete', async ($, e, next) => {
401    const io = ioOf($)
402    const config = runtime.config
403    // Los turnos de los subagentes no son tiradas.
404    if (config !== null && e.agentId === undefined) {
405      try {
406        const sending = finishTurn(io, runtime, config, turns, e.turnId, e.durationMs)
407        // En `claude -p` el proceso acaba con el turno: se espera el envío, pero nunca más de un
408        // timeout de turno en total (aunque `turn/start` siga en vuelo). En sesión interactiva, no.
409        if (sending !== null && !runtime.isInteractive) {
410          await withTimeout(io, sending, config.turnTimeoutMs)
411        }
412      } catch (error) {
413        debug(io, `turn.complete: ${String(error)}`)
414      }
415    }
416    return next(e)
417  })
418
419  on('command.run', { command: 'roulette' }, $ => run(ioOf($), 'roulette', commands.roulette))
420  on('command.run', { command: 'ruleta' }, $ => run(ioOf($), 'roulette', commands.roulette))
421  on('command.run', { command: 'bet' }, ($, e) =>
422    run(ioOf($), 'bet', ctx => commands.bet(ctx, argsOf(e))),
423  )
424  on('command.run', { command: 'apostar' }, ($, e) =>
425    run(ioOf($), 'bet', ctx => commands.bet(ctx, argsOf(e))),
426  )
427  on('command.run', { command: 'all-in' }, ($, e) =>
428    run(ioOf($), 'allIn', ctx => commands.allIn(ctx, argsOf(e))),
429  )
430  on('command.run', { command: 'balance' }, $ => run(ioOf($), 'balance', commands.balance))
431  on('command.run', { command: 'saldo' }, $ => run(ioOf($), 'balance', commands.balance))
432  on('command.run', { command: 'vibe-login' }, $ => run(ioOf($), 'login', commands.login))
433  on('command.run', { command: 'vibe-logout' }, $ => run(ioOf($), 'logout', commands.logout))
434  on('command.run', { command: 'vibe-hide' }, $ =>
435    run(ioOf($), 'hide', ctx => commands.setHidden(ctx, true)),
436  )
437  on('command.run', { command: 'vibe-show' }, $ =>
438    run(ioOf($), 'show', ctx => commands.setHidden(ctx, false)),
439  )
440  // La única escritura de configuración del mod: la URL del servidor, con la clave escrita tal cual
441  // en el propio hook para que el directorio de plugins vea exactamente qué ajuste cambia.
442  on('command.run', { command: 'vibe-server' }, async ($, e) => {
443    const pending: { change?: commands.ServerChange } = {}
444    const reply = await run(ioOf($), 'server', async ctx => {
445      const outcome = await commands.server(ctx, argsOf(e))
446      if (typeof outcome === 'string') return outcome
447      pending.change = outcome
448      return ''
449    })
450    const change = pending.change
451    if (change === undefined) return reply
452    try {
453      const saved = await $.config.set({ key: 'vibe-gambling.serverUrl', value: change.url })
454      return { text: change.reply(saved.deny) }
455    } catch (error) {
456      debug(ioOf($), `/server: ${String(error)}`)
457      return { text: runtime.messages.offline }
458    }
459  })
460  on('command.run', { command: 'vibe-delete-account' }, ($, e) =>
461    run(ioOf($), 'deleteAccount', ctx => commands.deleteAccount(ctx, argsOf(e))),
462  )
463  on('command.run', { command: 'vibe-invite' }, $ => run(ioOf($), 'invite', commands.invite))
464  on('command.run', { command: 'vibe-invitar' }, $ => run(ioOf($), 'invite', commands.invite))
465  on('command.run', { command: 'vibe-redeem' }, ($, e) =>
466    run(ioOf($), 'redeem', ctx => commands.redeem(ctx, argsOf(e))),
467  )
468  on('command.run', { command: 'vibe-canjear' }, ($, e) =>
469    run(ioOf($), 'redeem', ctx => commands.redeem(ctx, argsOf(e))),
470  )
471  on('command.run', { command: 'vibe-shop' }, ($, e) =>
472    run(ioOf($), 'shop', ctx => commands.shop(ctx, argsOf(e))),
473  )
474  on('command.run', { command: 'vibe-tienda' }, ($, e) =>
475    run(ioOf($), 'shop', ctx => commands.shop(ctx, argsOf(e))),
476  )
477  on('command.run', { command: 'vibe-use' }, ($, e) =>
478    run(ioOf($), 'use', ctx => commands.use(ctx, argsOf(e))),
479  )
480  on('command.run', { command: 'vibe-usar' }, ($, e) =>
481    run(ioOf($), 'use', ctx => commands.use(ctx, argsOf(e))),
482  )
483}
484
hooks/lib/api.ts 124 lines
1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Cliente HTTP del mod: `$.http.fetch` con timeout, errores explícitos y nada más que lo
3// imprescindible en la petición (método, JSON y, si hay sesión, `Authorization`).
4
5import type { Io } from './io.ts'
6import { API_ROUTES } from '../generated/protocol.ts'
7import type { ApiRouteName, ErrorCode } from '../generated/protocol.ts'
8import { TEXT_LIMITS } from '../generated/protocol.ts'
9import { cleanText, errorCodeOf } from './responses.ts'
10
11export type ApiFailure =
12  | { readonly ok: false; readonly reason: 'timeout' | 'offline' | 'invalid' }
13  | {
14      readonly ok: false
15      readonly reason: 'http'
16      readonly status: number
17      readonly error: ErrorCode | null
18      readonly message: string | null
19    }
20
21export type ApiResult<T> = { readonly ok: true; readonly data: T } | ApiFailure
22
23export interface ApiRequest<T> {
24  readonly baseUrl: string
25  readonly route: ApiRouteName
26  readonly guard: (value: unknown) => value is T
27  readonly timeoutMs: number
28  readonly token?: string | undefined
29  readonly body?: object | undefined
30}
31
32const TIMED_OUT = Symbol('timeout')
33
34/**
35 * Espera `promise` como mucho `ms` milisegundos con un temporizador de `$.clock` (el entorno del
36 * mod no tiene `setTimeout`). Si vence, resuelve `TIMED_OUT`; el temporizador se cancela siempre.
37 */
38export async function withTimeout<T>(
39  io: Io,
40  promise: Promise<T>,
41  ms: number,
42): Promise<T | typeof TIMED_OUT> {
43  let timer: { cancel: () => void } | undefined
44  const expired = new Promise<typeof TIMED_OUT>(resolve => {
45    timer = io.after(ms, () => resolve(TIMED_OUT))
46  })
47  try {
48    return await Promise.race([promise, expired])
49  } finally {
50    timer?.cancel()
51  }
52}
53
54/** Escribe en el log de depuración del engine (`claude --debug`), nunca en pantalla. */
55export function debug(io: Io, text: string): void {
56  try {
57    io.log(text)
58  } catch {
59    // El propio log ha fallado: no hay otro sitio donde dejarlo y nunca debe romper un hook.
60  }
61}
62
63function errorText(error: unknown): string {
64  return error instanceof Error ? `${error.name}: ${error.message}` : String(error)
65}
66
67/** Llama a una ruta de la API. Nunca lanza: devuelve el dato o el motivo del fallo. */
68export async function callApi<T>(io: Io, request: ApiRequest<T>): Promise<ApiResult<T>> {
69  const route = API_ROUTES[request.route]
70  const headers: Record<string, string> = { accept: 'application/json' }
71  if (request.body !== undefined) headers['content-type'] = 'application/json'
72  if (request.token !== undefined) headers.authorization = `Bearer ${request.token}`
73  const init = {
74    method: route.method,
75    headers,
76    ...(request.body === undefined ? {} : { body: JSON.stringify(request.body) }),
77  }
78  try {
79    const response = await withTimeout(
80      io,
81      io.fetch(`${request.baseUrl}${route.path}`, init),
82      request.timeoutMs,
83    )
84    if (response === TIMED_OUT) {
85      debug(io, `${route.path}: sin respuesta en ${request.timeoutMs} ms`)
86      return { ok: false, reason: 'timeout' }
87    }
88    let data: unknown = null
89    try {
90      data = response.text === '' ? null : JSON.parse(response.text)
91    } catch (error) {
92      debug(io, `${route.path}: respuesta que no es JSON (${response.status}): ${errorText(error)}`)
93      return { ok: false, reason: 'invalid' }
94    }
95    if (!response.ok) {
96      const message =
97        typeof data === 'object' && data !== null && 'message' in data
98          ? cleanText(String(data.message), TEXT_LIMITS.errorMessage)
99          : null
100      debug(io, `${route.path}: HTTP ${response.status}`)
101      return {
102        ok: false,
103        reason: 'http',
104        status: response.status,
105        error: errorCodeOf(data),
106        message,
107      }
108    }
109    if (!request.guard(data)) {
110      debug(io, `${route.path}: la respuesta no tiene la forma esperada`)
111      return { ok: false, reason: 'invalid' }
112    }
113    return { ok: true, data }
114  } catch (error) {
115    debug(io, `${route.path}: ${errorText(error)}`)
116    return { ok: false, reason: 'offline' }
117  }
118}
119
120/** ¿El servidor ha dicho que la sesión no vale? */
121export function isUnauthorized(result: ApiFailure): boolean {
122  return result.reason === 'http' && (result.status === 401 || result.error === 'unauthorized')
123}
124
hooks/lib/auth.ts 208 lines
1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Login con GitHub por Device Flow, mediado por el servidor: el mod enseña el código y consulta
3// en segundo plano (con `$.clock`, respetando `interval` y `slow_down`) sin bloquear la sesión.
4
5import type { Io } from './io.ts'
6import { callApi, debug } from './api.ts'
7import type { ModConfig } from './config.ts'
8import { devicePollBody, emptyBody } from './payloads.ts'
9import { TEXT_LIMITS } from '../generated/protocol.ts'
10import { cleanText, isDevicePoll, isDeviceStart } from './responses.ts'
11import { refreshMe, saveSession, setStatus, toast } from './runtime.ts'
12import type { Runtime } from './runtime.ts'
13
14interface LoginFlow {
15  readonly serverUrl: string
16  readonly pollId: string
17  readonly userCode: string
18  readonly verificationUri: string
19  readonly deadline: number
20  timer: { cancel: () => void } | null
21}
22
23export interface AuthState {
24  flow: LoginFlow | null
25  /** Petición de código en vuelo: un segundo `/vibe-login` a la vez espera a la misma. */
26  starting: Promise<string> | null
27}
28
29export function createAuthState(): AuthState {
30  return { flow: null, starting: null }
31}
32
33/** Para el polling en curso (logout, cambio de servidor, recarga). */
34export function cancelLogin(state: AuthState): void {
35  state.flow?.timer?.cancel()
36  state.flow = null
37}
38
39/**
40 * Programa la siguiente consulta a `seconds` segundos, pero nunca más allá de la caducidad del
41 * código: con un intervalo absurdo del servidor, el aviso de "caducado" llega igual a su hora.
42 */
43async function schedule(
44  io: Io,
45  runtime: Runtime,
46  config: ModConfig,
47  state: AuthState,
48  flow: LoginFlow,
49  seconds: number,
50): Promise<void> {
51  const left = Math.max(0, flow.deadline - (await io.now()))
52  if (state.flow !== flow) return
53  flow.timer = io.after(Math.min(seconds * 1000, left), () => {
54    void poll(io, runtime, config, state, flow, seconds)
55  })
56}
57
58/** ¿Un error HTTP del polling es pasajero (se reintenta) o definitivo (se acaba el login)? */
59function isRetryableStatus(status: number): boolean {
60  return status === 408 || status === 429 || status >= 500
61}
62
63async function poll(
64  io: Io,
65  runtime: Runtime,
66  config: ModConfig,
67  state: AuthState,
68  flow: LoginFlow,
69  interval: number,
70): Promise<void> {
71  if (state.flow !== flow) return
72  try {
73    if ((await io.now()) >= flow.deadline) {
74      state.flow = null
75      toast(io, runtime.messages.loginExpired)
76      return
77    }
78    const result = await callApi(io, {
79      baseUrl: flow.serverUrl,
80      route: 'devicePoll',
81      guard: isDevicePoll,
82      timeoutMs: config.commandTimeoutMs,
83      body: devicePollBody(flow.pollId),
84    })
85    if (state.flow !== flow) return
86    if (!result.ok) {
87      if (result.reason !== 'http' || isRetryableStatus(result.status)) {
88        // Un fallo de red, un 5xx o un 429 no cortan el login: se reintenta al siguiente intervalo.
89        await schedule(io, runtime, config, state, flow, interval)
90        return
91      }
92      // Un 4xx no se arregla esperando: se acaba el login y se dice por qué.
93      state.flow = null
94      toast(io, runtime.messages.loginFailed(result.message ?? `HTTP ${result.status}`))
95      return
96    }
97    const answer = result.data
98    switch (answer.status) {
99      case 'pending':
100      case 'slow_down':
101        // Con `slow_down` el servidor ya manda el intervalo nuevo (más largo).
102        await schedule(io, runtime, config, state, flow, answer.interval)
103        return
104      case 'expired':
105        state.flow = null
106        toast(io, runtime.messages.loginExpired)
107        return
108      case 'denied':
109        state.flow = null
110        toast(io, runtime.messages.loginDenied)
111        return
112      case 'ok': {
113        state.flow = null
114        const session = {
115          token: answer.token,
116          login: cleanText(answer.user.login, TEXT_LIMITS.login),
117        }
118        await saveSession(io, flow.serverUrl, session)
119        // Cuenta de GitHub nueva: juega igual, pero sale en el ranking más tarde (y se dice cuándo).
120        const wait = answer.user.hidden ? null : (answer.user.newAccount ?? null)
121        const notice =
122          wait === null
123            ? runtime.messages.loginPublicNotice
124            : runtime.messages.loginNewAccount(wait.minAgeDays, wait.rankedFrom)
125        toast(io, `${runtime.messages.loginWelcome(session.login)} ${notice}`)
126        await refreshMe(io, runtime, config, session, config.commandTimeoutMs)
127        return
128      }
129    }
130  } catch (error) {
131    debug(io, `device/poll: ${String(error)}`)
132    if (state.flow === flow) {
133      await schedule(io, runtime, config, state, flow, interval).catch((failure: unknown) => {
134        debug(io, `device/poll: no he podido reprogramar la consulta: ${String(failure)}`)
135      })
136    }
137  }
138}
139
140/**
141 * `/vibe-login`: pide el código y empieza a consultar. Devuelve el texto del comando. Dos
142 * `/vibe-login` a la vez comparten la misma petición: si no, se enseñarían dos códigos y solo se
143 * consultaría el segundo.
144 */
145export async function startLogin(
146  io: Io,
147  runtime: Runtime,
148  config: ModConfig,
149  state: AuthState,
150): Promise<string> {
151  if (state.starting !== null) return state.starting
152  const starting = requestCode(io, runtime, config, state)
153  state.starting = starting
154  try {
155    return await starting
156  } finally {
157    state.starting = null
158  }
159}
160
161async function requestCode(
162  io: Io,
163  runtime: Runtime,
164  config: ModConfig,
165  state: AuthState,
166): Promise<string> {
167  const current = state.flow
168  if (
169    current !== null &&
170    current.serverUrl === config.serverUrl &&
171    (await io.now()) < current.deadline
172  ) {
173    return runtime.messages.loginInProgress(current.verificationUri, current.userCode)
174  }
175  cancelLogin(state)
176  const result = await callApi(io, {
177    baseUrl: config.serverUrl,
178    route: 'deviceStart',
179    guard: isDeviceStart,
180    timeoutMs: config.commandTimeoutMs,
181    body: emptyBody(),
182  })
183  if (!result.ok) {
184    if (result.reason !== 'http') setStatus(io, runtime.messages.statusOffline)
185    return result.reason === 'http'
186      ? runtime.messages.serverError(result.message ?? String(result.status))
187      : runtime.messages.offline
188  }
189  const start = result.data
190  const flow: LoginFlow = {
191    serverUrl: config.serverUrl,
192    pollId: start.pollId,
193    // Lo que se enseña en el terminal: el código sin caracteres de control y la URL normalizada
194    // (el parser codifica cualquier carácter raro; el guard ya exige http(s)).
195    userCode: cleanText(start.userCode, TEXT_LIMITS.userCode),
196    verificationUri: new URL(start.verificationUri).href,
197    deadline: (await io.now()) + start.expiresIn * 1000,
198    timer: null,
199  }
200  state.flow = flow
201  await schedule(io, runtime, config, state, flow, start.interval)
202  return runtime.messages.loginPrompt(
203    flow.verificationUri,
204    flow.userCode,
205    Math.max(1, Math.round(start.expiresIn / 60)),
206  )
207}
208
hooks/lib/config.ts 162 lines
1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Configuración del mod: valida las opciones que el engine lee del `userConfig` del manifiesto.
3// Los valores por defecto viven en `.claude-plugin/plugin.json`, nunca aquí. Módulo puro (sin `$`).
4import { LOCALES } from '../generated/protocol.ts'
5
6/** Tope de las llamadas de red durante un turno: regla de oro del plan (≤ 1,5 s), no configuración. */
7export const MAX_TURN_TIMEOUT_MS = 1500
8
9/** `auto` (el del sistema) y los idiomas registrados en `locales/` (copia generada). */
10export const LANGUAGE_OPTIONS = ['auto', ...LOCALES] as const
11export type LanguageOption = (typeof LANGUAGE_OPTIONS)[number]
12
13export interface WindowSize {
14  readonly width: number
15  readonly height: number
16}
17
18export interface ModConfig {
19  /** URL base del servidor, sin barra final. */
20  readonly serverUrl: string
21  /** Abrir la ventana en cada prompt si está cerrada. */
22  readonly autoOpen: boolean
23  /** Timeout de las llamadas del turno (turn/start, turn/end, enlace). */
24  readonly turnTimeoutMs: number
25  /** Timeout de las llamadas de los comandos (login, apuestas, ajustes…). */
26  readonly commandTimeoutMs: number
27  /** Tras abrir la ventana, cuánto esperar a que conecte antes de volver a abrirla. */
28  readonly windowReopenCooldownMs: number
29  /** Tamaño de la ventana en modo app. */
30  readonly windowSize: WindowSize
31  /** Cuánto puede tardar el lanzador del navegador en volver. */
32  readonly launchTimeoutMs: number
33  /** Espera máxima para enseñar el resultado (el giro mínimo lo marca el servidor con `revealAt`). */
34  readonly revealMaxDelayMs: number
35  /** Idioma de los mensajes: el del sistema (`auto`) o uno de los registrados. */
36  readonly language: LanguageOption
37}
38
39export type ConfigResult =
40  | { readonly ok: true; readonly config: ModConfig }
41  | { readonly ok: false; readonly errors: readonly string[] }
42
43/**
44 * ¿Es un host de esta misma máquina? (`localhost`, `*.localhost`, `127.0.0.0/8`, `[::1]`).
45 * Recibe el `hostname` ya normalizado por `URL` (`127.1` → `127.0.0.1`).
46 */
47export function isLoopbackHost(hostname: string): boolean {
48  return (
49    hostname === 'localhost' ||
50    hostname.endsWith('.localhost') ||
51    /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname) ||
52    hostname === '[::1]'
53  )
54}
55
56/** ¿Es una URL `http:` a otra máquina? (el token viajaría en claro). */
57export function isPlainHttpRemote(raw: string): boolean {
58  const text = raw.trim()
59  if (!URL.canParse(text)) return false
60  const url = new URL(text)
61  return url.protocol === 'http:' && !isLoopbackHost(url.hostname)
62}
63
64/**
65 * Normaliza la URL del servidor: https (o http solo hacia esta máquina, porque el token de sesión
66 * viajaría en claro y la cookie `Secure` de la web no funcionaría), un host con caracteres de
67 * nombre de dominio o IP, sin credenciales, query ni fragmento, y sin la barra final. Devuelve
68 * `null` si no vale.
69 */
70export function normalizeServerUrl(raw: string): string | null {
71  const text = raw.trim()
72  if (!URL.canParse(text)) return null
73  const url = new URL(text)
74  if (url.protocol !== 'https:' && url.protocol !== 'http:') return null
75  if (url.protocol === 'http:' && !isLoopbackHost(url.hostname)) return null
76  // El parser ya pasa los dominios Unicode a punycode: aquí solo quedan letras, cifras, `.` y `-`.
77  if (!/^(?:[a-z0-9.-]+|\[[0-9a-f:.]+\])$/.test(url.hostname)) return null
78  if (url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== '') {
79    return null
80  }
81  return `${url.origin}${url.pathname}`.replace(/\/+$/, '')
82}
83
84/** Origen (`protocolo://host:puerto`) de una URL ya normalizada. */
85export function originOf(serverUrl: string): string {
86  return new URL(serverUrl).origin
87}
88
89function parseWindowSize(raw: string): WindowSize | null {
90  const match = /^\s*(\d{3,4})\s*[,x]\s*(\d{3,4})\s*$/.exec(raw)
91  if (!match) return null
92  return { width: Number(match[1]), height: Number(match[2]) }
93}
94
95type Options = Readonly<Record<string, unknown>>
96
97/** Lee y valida las opciones. Si falta algo o no cuadra, devuelve los errores (nunca inventa). */
98export function parseConfig(options: Options): ConfigResult {
99  const errors: string[] = []
100
101  const text = (key: string): string | null => {
102    const value = options[key]
103    if (typeof value === 'string') return value
104    errors.push(`${key}: falta o no es texto`)
105    return null
106  }
107  const flag = (key: string): boolean => {
108    const value = options[key]
109    if (typeof value === 'boolean') return value
110    errors.push(`${key}: falta o no es booleano`)
111    return false
112  }
113  // Los rangos de cada opción (`min`/`max`) viven en el `userConfig` del manifiesto y el engine
114  // los hace cumplir antes de cargar el mod; aquí solo se comprueba el tipo, por si acaso.
115  const integer = (key: string, min: number): number => {
116    const value = options[key]
117    if (typeof value === 'number' && Number.isSafeInteger(value) && value >= min) return value
118    errors.push(`${key}: debe ser un entero mayor o igual que ${min}`)
119    return min
120  }
121
122  const rawUrl = text('serverUrl')
123  const serverUrl = rawUrl === null ? null : normalizeServerUrl(rawUrl)
124  if (rawUrl !== null && serverUrl === null) {
125    errors.push(
126      isPlainHttpRemote(rawUrl)
127        ? 'serverUrl: usa https (http solo vale para localhost)'
128        : 'serverUrl: no es una URL http(s) válida',
129    )
130  }
131
132  const rawSize = text('windowSize')
133  const windowSize = rawSize === null ? null : parseWindowSize(rawSize)
134  if (rawSize !== null && windowSize === null)
135    errors.push('windowSize: usa ANCHOxALTO, p. ej. 960,720')
136
137  const rawLanguage = text('language')
138  const language = LANGUAGE_OPTIONS.find(option => option === rawLanguage)
139  if (rawLanguage !== null && language === undefined) {
140    errors.push(`language: debe ser ${LANGUAGE_OPTIONS.join(', ')}`)
141  }
142
143  const config = {
144    serverUrl: serverUrl ?? '',
145    autoOpen: flag('autoOpen'),
146    turnTimeoutMs: integer('turnTimeoutMs', 1),
147    commandTimeoutMs: integer('commandTimeoutMs', 1),
148    windowReopenCooldownMs: integer('windowReopenCooldownMs', 0),
149    windowSize: windowSize ?? { width: 0, height: 0 },
150    launchTimeoutMs: integer('launchTimeoutMs', 1),
151    revealMaxDelayMs: integer('revealMaxDelayMs', 0),
152    language: language ?? 'auto',
153  } satisfies ModConfig
154
155  // Regla de oro del plan, no configuración: una llamada del turno nunca espera más de 1,5 s.
156  if (config.turnTimeoutMs > MAX_TURN_TIMEOUT_MS) {
157    errors.push(`turnTimeoutMs: nunca más de ${MAX_TURN_TIMEOUT_MS} ms`)
158  }
159
160  return errors.length > 0 ? { ok: false, errors } : { ok: true, config }
161}
162
hooks/lib/io.ts 57 lines
1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Lo que la lógica del mod necesita del mundo exterior. `register.ts` lo construye sobre `$`
3// (el engine solo deja usar `$` en el archivo del hook), y así todo `./lib` es TypeScript puro.
4
5/** Variables de entorno que el mod lee (el engine exige nombrarlas de forma literal). */
6export const ENV_NAMES = [
7  'OS',
8  'HOME',
9  'ProgramFiles',
10  'ProgramFiles(x86)',
11  'LOCALAPPDATA',
12  'LC_ALL',
13  'LC_MESSAGES',
14  'LANG',
15  'LANGUAGE',
16] as const
17export type EnvName = (typeof ENV_NAMES)[number]
18
19export interface HttpRequestInit {
20  readonly method: string
21  readonly headers: Readonly<Record<string, string>>
22  readonly body?: string
23}
24
25export interface HttpReply {
26  readonly status: number
27  readonly ok: boolean
28  readonly text: string
29}
30
31export interface ProcessReply {
32  readonly exitCode: number
33  readonly stdout: string
34  readonly stderr: string
35}
36
37export interface Timer {
38  cancel(): void
39}
40
41export interface Io {
42  readonly pluginName: string
43  fetch(url: string, init: HttpRequestInit): Promise<HttpReply>
44  now(): Promise<number>
45  after(ms: number, fn: () => void): Timer
46  /** Línea en el log de depuración del engine (`claude --debug`); nunca en pantalla. */
47  log(text: string): void
48  status(text: string | undefined): void
49  toast(text: string): void
50  storeGet(key: string): Promise<unknown>
51  storeSet(key: string, value: unknown): Promise<void>
52  storeDelete(key: string): Promise<void>
53  env(name: EnvName): Promise<string | undefined>
54  run(argv: readonly string[], timeoutMs: number): Promise<ProcessReply>
55  exists(path: string): Promise<boolean>
56}
57
hooks/lib/bash.ts 351 lines
1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Detección de `git push` con éxito y de tests fallidos a partir de un comando de Bash.
3// El comando se mira aquí, en local, y se olvida: de cada turno solo quedan dos booleanos.
4// Módulo puro (sin `$`).
5
6/** Lo que el mod sabe del resultado de una llamada a Bash (sin guardar su salida). */
7export interface BashOutcome {
8  /** El comando terminó con código 0 (ni error, ni denegado, ni interrumpido). */
9  readonly succeeded: boolean
10  /** Terminó con error (código distinto de 0). */
11  readonly failed: boolean
12  /** La salida dice que no había nada que subir. */
13  readonly nothingPushed: boolean
14}
15
16interface Segment {
17  readonly text: string
18  /** Operador que separa este trozo del siguiente (`null` en el último). */
19  readonly next: string | null
20}
21
22/**
23 * Parte una línea de shell en comandos simples por `&&`, `||`, `;`, `|`, `&` y saltos de línea,
24 * respetando las comillas. No es un parser de shell completo: basta para reconocer comandos.
25 */
26export function splitCommands(command: string): Segment[] {
27  const segments: Segment[] = []
28  let current = ''
29  let quote: '"' | "'" | null = null
30  let i = 0
31  const push = (next: string | null): void => {
32    if (current.trim() !== '' || next !== null) segments.push({ text: current.trim(), next })
33    current = ''
34  }
35  while (i < command.length) {
36    const char = command[i]!
37    if (quote !== null) {
38      if (char === '\\' && quote === '"' && i + 1 < command.length) {
39        current += char + command[i + 1]!
40        i += 2
41        continue
42      }
43      if (char === quote) quote = null
44      current += char
45      i += 1
46      continue
47    }
48    if (char === '"' || char === "'") {
49      quote = char
50      current += char
51      i += 1
52      continue
53    }
54    if (char === '\\' && i + 1 < command.length) {
55      current += char + command[i + 1]!
56      i += 2
57      continue
58    }
59    const two = command.slice(i, i + 2)
60    if (two === '&&' || two === '||') {
61      push(two)
62      i += 2
63      continue
64    }
65    // `2>&1`, `&>` y `>&` son redirecciones, no separadores.
66    const isRedirect = char === '&' && (command[i - 1] === '>' || command[i + 1] === '>')
67    if (!isRedirect && (char === ';' || char === '|' || char === '&' || char === '\n')) {
68      push(char === '\n' ? ';' : char)
69      i += 1
70      continue
71    }
72    current += char
73    i += 1
74  }
75  push(null)
76  return segments.filter(segment => segment.text !== '')
77}
78
79/** Parte un comando simple en palabras respetando comillas y escapes (sin expandir nada). */
80function tokenize(segment: string): string[] {
81  const tokens: string[] = []
82  let current: string | null = null
83  let quote: '"' | "'" | null = null
84  for (let i = 0; i < segment.length; i++) {
85    const char = segment[i]!
86    if (quote !== null) {
87      if (char === quote) quote = null
88      else if (char === '\\' && quote === '"' && i + 1 < segment.length) current += segment[++i]!
89      else current += char
90      continue
91    }
92    if (/\s/.test(char)) {
93      if (current !== null) tokens.push(current)
94      current = null
95      continue
96    }
97    current ??= ''
98    if (char === '"' || char === "'") quote = char
99    else if (char === '\\' && i + 1 < segment.length) current += segment[++i]!
100    else current += char
101  }
102  if (current !== null) tokens.push(current)
103  return tokens
104}
105
106/** Envoltorios que lanzan otro comando: sus opciones con valor aparte y sus argumentos fijos. */
107interface Wrapper {
108  readonly optionsWithValue: ReadonlySet<string>
109  /** Argumentos posicionales antes del comando (la duración de `timeout`). */
110  readonly positional: number
111}
112
113const WRAPPERS: ReadonlyMap<string, Wrapper> = new Map([
114  [
115    'sudo',
116    {
117      optionsWithValue: new Set(['-u', '-g', '-C', '-h', '-p', '-U', '-r', '-t', '-D']),
118      positional: 0,
119    },
120  ],
121  ['env', { optionsWithValue: new Set(['-u', '-C', '-S']), positional: 0 }],
122  ['command', { optionsWithValue: new Set<string>(), positional: 0 }],
123  ['time', { optionsWithValue: new Set(['-f', '-o']), positional: 0 }],
124  ['nice', { optionsWithValue: new Set(['-n']), positional: 0 }],
125  ['nohup', { optionsWithValue: new Set<string>(), positional: 0 }],
126  ['exec', { optionsWithValue: new Set(['-a']), positional: 0 }],
127  ['timeout', { optionsWithValue: new Set(['-s', '-k']), positional: 1 }],
128])
129
130/**
131 * Palabras de un comando simple, sin asignaciones de entorno, envoltorios (`sudo`, `timeout 60`…)
132 * ni los paréntesis o llaves de un subshell o un grupo.
133 */
134function words(segment: string): string[] {
135  const tokens = tokenize(segment.replace(/^[({\s]+/, '').replace(/[)}\s]+$/, ''))
136  for (;;) {
137    const first = tokens[0]
138    if (first === undefined) break
139    if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(first)) {
140      tokens.shift()
141      continue
142    }
143    const wrapper = WRAPPERS.get(first)
144    if (wrapper === undefined) break
145    tokens.shift()
146    while (tokens[0]?.startsWith('-') === true) {
147      const option = tokens.shift()!
148      if (wrapper.optionsWithValue.has(option)) tokens.shift()
149    }
150    tokens.splice(0, wrapper.positional)
151  }
152  return tokens
153}
154
155/** Opciones globales de git que llevan valor aparte (`git -C dir push`). */
156const GIT_OPTIONS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace'])
157
158/** Subcomando de git de un comando simple, o `null` si no es git. */
159function gitSubcommand(tokens: readonly string[]): { name: string; args: string[] } | null {
160  const [program, ...rest] = tokens
161  if (program === undefined || !/(^|[/\\])git(\.exe)?$/.test(program)) return null
162  let i = 0
163  while (i < rest.length && rest[i]!.startsWith('-')) {
164    i += GIT_OPTIONS_WITH_VALUE.has(rest[i]!) ? 2 : 1
165  }
166  const name = rest[i]
167  return name === undefined ? null : { name, args: rest.slice(i + 1) }
168}
169
170/** ¿Este argumento de `git push` lo convierte en algo que no sube nada (prueba o ayuda)? */
171function isNoopPushArg(arg: string): boolean {
172  // `-n` también dentro de un grupo de opciones cortas (`-fn`, `-un`).
173  return (
174    arg === '--dry-run' ||
175    arg === '--help' ||
176    arg === '-h' ||
177    /^-[A-Za-z0-9]*n[A-Za-z0-9]*$/.test(arg)
178  )
179}
180
181/** Índice del `git push` real del comando (no `--dry-run` ni `--help`), o -1. */
182function gitPushIndex(segments: readonly Segment[]): number {
183  return segments.findIndex(segment => {
184    const git = gitSubcommand(words(segment.text))
185    return git !== null && git.name === 'push' && !git.args.some(isNoopPushArg)
186  })
187}
188
189/** ¿El comando incluye un `git push` (que no sea de prueba)? */
190export function isGitPush(command: string): boolean {
191  return gitPushIndex(splitCommands(command)) !== -1
192}
193
194/**
195 * ¿El comando hizo un `git push` que subió algo y terminó bien?
196 *
197 * El código de salida es el de la línea entera, así que solo cuenta si después del push todo va
198 * encadenado con `&&` (con `;`, `||` o una tubería el código final no habla del push).
199 */
200export function isSuccessfulGitPush(command: string, outcome: BashOutcome): boolean {
201  if (!outcome.succeeded || outcome.nothingPushed) return false
202  const segments = splitCommands(command)
203  const index = gitPushIndex(segments)
204  if (index === -1) return false
205  return segments
206    .slice(index)
207    .every((segment, k, rest) =>
208      k === rest.length - 1 ? segment.next === null : segment.next === '&&',
209    )
210}
211
212/** Ejecutables que son, por sí mismos, un lanzador de tests. */
213const TEST_RUNNERS = new Set([
214  'vitest',
215  'jest',
216  'mocha',
217  'ava',
218  'tap',
219  'pytest',
220  'py.test',
221  'tox',
222  'nox',
223  'rspec',
224  'phpunit',
225  'pest',
226  'ctest',
227])
228
229/** Gestores en los que `<gestor> test` o `<gestor> run test…` lanzan los tests. */
230const TEST_SUBCOMMAND_TOOLS = new Set([
231  'npm',
232  'pnpm',
233  'yarn',
234  'bun',
235  'deno',
236  'go',
237  'cargo',
238  'dotnet',
239  'mvn',
240  'gradle',
241  'gradlew',
242  'mix',
243  'make',
244  'swift',
245  'zig',
246  'flutter',
247  'dart',
248  'lein',
249  'sbt',
250  'stack',
251  'cabal',
252])
253
254function basename(program: string): string {
255  return (
256    program
257      .split(/[/\\]/)
258      .pop()
259      ?.replace(/\.(exe|cmd|bat)$/i, '') ?? program
260  )
261}
262
263function isTestScript(name: string | undefined): boolean {
264  return name !== undefined && /^(test|tests|e2e|spec)([:-]\S*)?$/.test(name)
265}
266
267/** Lanzadores que ejecutan otro programa: `npx jest`, `uv run pytest`, `bundle exec rspec`… */
268const RUNNER_PREFIXES: ReadonlyMap<string, string | null> = new Map([
269  ['npx', null],
270  ['bunx', null],
271  ['pnpx', null],
272  ['uv', 'run'],
273  ['poetry', 'run'],
274  ['pipenv', 'run'],
275  ['pdm', 'run'],
276  ['hatch', 'run'],
277  ['rye', 'run'],
278  ['bundle', 'exec'],
279])
280
281/** Subcomandos de un gestor de paquetes que ejecutan el programa que va detrás. */
282const EXEC_SUBCOMMANDS = new Set(['run', 'exec', 'x', 'dlx'])
283
284/** ¿Este comando simple lanza tests? */
285function isTestSegment(segment: string): boolean {
286  const tokens = words(segment)
287  let program = basename(tokens[0] ?? '')
288  let args = tokens.slice(1)
289  // npx / uv run / bundle exec … delegan en el lanzador real.
290  if (RUNNER_PREFIXES.has(program)) {
291    const subcommand = RUNNER_PREFIXES.get(program)
292    if (subcommand !== null) {
293      if (args[0] !== subcommand) return false
294      args = args.slice(1)
295    }
296    const index = args.findIndex(arg => !arg.startsWith('-'))
297    if (index === -1) return false
298    program = basename(args[index]!)
299    args = args.slice(index + 1)
300  }
301  if (/^python(3(\.\d+)?)?$/.test(program) && args[0] === '-m') {
302    program = args[1] ?? ''
303    args = args.slice(2)
304  }
305  if (TEST_RUNNERS.has(program)) return true
306  if (program === 'playwright' || program === 'cypress')
307    return args.includes('test') || args.includes('run')
308  if (program === 'claude') return args[0] === 'plugin' && args[1] === 'test'
309  if (!TEST_SUBCOMMAND_TOOLS.has(program)) return false
310  // Quita opciones como `-r`, `--filter pkg`, `-C dir` antes del subcomando.
311  const positional: string[] = []
312  for (let i = 0; i < args.length; i++) {
313    const arg = args[i]!
314    if (arg === '--filter' || arg === '-F' || arg === '-C' || arg === '--dir' || arg === '-p') {
315      i += 1
316      continue
317    }
318    if (!arg.startsWith('-')) positional.push(arg)
319  }
320  const [first, second, third] = positional
321  // `npm t` y `npm tst` son alias de `npm test`.
322  if (program === 'npm' && (first === 't' || first === 'tst')) return true
323  if (isTestScript(first)) return true
324  // `pnpm vitest`, `yarn jest`: el gestor ejecuta el binario del lanzador.
325  if (first !== undefined && TEST_RUNNERS.has(first)) return true
326  if (first !== undefined && EXEC_SUBCOMMANDS.has(first)) {
327    if (isTestScript(second)) return true
328    if (second !== undefined && TEST_RUNNERS.has(second)) return true
329    // `pnpm exec playwright test`
330    if (second === 'playwright' || second === 'cypress') return third === 'test' || third === 'run'
331    return false
332  }
333  // `pnpm -r run test`, `yarn workspace web test`
334  return first === 'workspace' && (isTestScript(third) || isTestScript(second))
335}
336
337/** ¿El comando lanza tests? */
338export function isTestCommand(command: string): boolean {
339  return splitCommands(command).some(segment => isTestSegment(segment.text))
340}
341
342/** ¿Un comando de tests terminó con fallo? */
343export function isFailedTestRun(command: string, outcome: BashOutcome): boolean {
344  return outcome.failed && isTestCommand(command)
345}
346
347/** Lo que git escribe cuando no hay nada que subir. */
348export function saysNothingPushed(output: string): boolean {
349  return /Everything up-to-date/i.test(output)
350}
351
hooks/lib/commands.ts 629 lines
1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Comandos del mod: validan la entrada, llaman al servidor y responden corto y con gracia.
3
4import type { Io } from './io.ts'
5import { PROTOCOL_VERSION, TEXT_LIMITS } from '../generated/protocol.ts'
6import type { ItemId } from '../generated/protocol.ts'
7import { callApi, debug, isUnauthorized } from './api.ts'
8import type { ApiFailure } from './api.ts'
9import { cancelLogin, startLogin } from './auth.ts'
10import type { AuthState } from './auth.ts'
11import { isPlainHttpRemote, normalizeServerUrl } from './config.ts'
12import type { ModConfig } from './config.ts'
13import { formatCoins } from './format.ts'
14import { ITEM_GLYPHS, inventoryOf, isArmed, isConsumableId, resolveItem } from './items.ts'
15import type { InventoryView, PocketItem } from './items.ts'
16import {
17  MAX_BET_TARGET_LENGTH,
18  betBody,
19  deleteAccountBody,
20  emptyBody,
21  itemArmBody,
22  newRequestId,
23  normalizeReferralCode,
24  parseBetAmount,
25  referralRedeemBody,
26  settingsBody,
27  shopBuyBody,
28} from './payloads.ts'
29import type { BetAmount } from './payloads.ts'
30import {
31  cleanText,
32  isBet,
33  isItemArm,
34  isMeta,
35  isOk,
36  isReferral,
37  isReferralRedeem,
38  isSettings,
39  isShop,
40  isShopBuy,
41} from './responses.ts'
42import type { ShopItemView } from './responses.ts'
43import {
44  clearSession,
45  loadSession,
46  manifestDefault,
47  refreshMe,
48  refreshStatus,
49  rememberInventory,
50  setStatus,
51  showWallet,
52} from './runtime.ts'
53import type { Runtime, StoredSession } from './runtime.ts'
54import { openWindow } from './window.ts'
55import type { WindowState } from './window.ts'
56
57export interface CommandContext {
58  readonly io: Io
59  readonly runtime: Runtime
60  readonly config: ModConfig
61  readonly auth: AuthState
62  readonly window: WindowState
63}
64
65/** Texto para un fallo de la API; si la sesión ya no vale, la olvida. */
66async function failureText(ctx: CommandContext, failure: ApiFailure): Promise<string> {
67  const { io, runtime, config } = ctx
68  const m = runtime.messages
69  if (isUnauthorized(failure)) {
70    await clearSession(io, config.serverUrl)
71    setStatus(io, m.statusLogin)
72    return m.sessionExpired
73  }
74  if (failure.reason !== 'http') {
75    setStatus(io, m.statusOffline)
76    return m.offline
77  }
78  switch (failure.error) {
79    case 'insufficient_funds':
80      return m.insufficientFunds
81    case 'too_many_bets':
82      return m.tooManyBets
83    default:
84      return m.serverError(failure.message ?? `HTTP ${failure.status}`)
85  }
86}
87
88async function requireSession(ctx: CommandContext): Promise<StoredSession | null> {
89  return loadSession(ctx.io, ctx.config.serverUrl)
90}
91
92/** Separa los argumentos por espacios. */
93function argsOf(raw: string): string[] {
94  return raw
95    .trim()
96    .split(/\s+/)
97    .filter(arg => arg !== '')
98}
99
100// ------------------------------------------------------------------ /roulette
101
102export async function roulette(ctx: CommandContext): Promise<string> {
103  const session = await requireSession(ctx)
104  if (session === null) return ctx.runtime.messages.notLoggedIn
105  const result = await openWindow(ctx.io, ctx.runtime, ctx.config, session, ctx.window, {
106    force: true,
107    timeoutMs: ctx.config.commandTimeoutMs,
108  })
109  switch (result.status) {
110    case 'link-failed':
111      // Sin enlace no hay ventana: sesión caducada, servidor caído… se dice lo que pasa de verdad.
112      return failureText(ctx, result.failure)
113    case 'opened':
114    case 'skipped': // ya se estaba abriendo una
115      return ctx.runtime.messages.openingWindow
116    case 'failed':
117      return ctx.runtime.messages.windowFailed
118  }
119}
120
121// ------------------------------------------------------------------ /bet, /all-in
122
123async function placeBet(ctx: CommandContext, amount: BetAmount, target: string): Promise<string> {
124  const { io, runtime, config } = ctx
125  const m = runtime.messages
126  if (target.length > MAX_BET_TARGET_LENGTH) return m.betBadTarget(target.slice(0, 40))
127  const session = await requireSession(ctx)
128  if (session === null) return m.notLoggedIn
129  const result = await callApi(io, {
130    baseUrl: config.serverUrl,
131    route: 'bet',
132    guard: isBet,
133    timeoutMs: config.commandTimeoutMs,
134    token: session.token,
135    body: betBody(amount, target),
136  })
137  if (!result.ok) {
138    if (result.reason === 'http' && result.error === 'invalid_bet') return m.invalidBet(target)
139    return failureText(ctx, result)
140  }
141  showWallet(io, runtime, result.data.wallet)
142  const shown = amount === 'all-in' ? 'ALL-IN' : `${formatCoins(amount, runtime.locale)} ${m.coin}`
143  return m.betPlaced(
144    shown,
145    target,
146    result.data.forRound,
147    formatCoins(result.data.wallet.balance, runtime.locale),
148  )
149}
150
151/** `/bet <cantidad> <casilla>` (la casilla puede llevar espacios: `/bet 50 8 9 11 12`). */
152export async function bet(ctx: CommandContext, raw: string): Promise<string> {
153  const m = ctx.runtime.messages
154  const [amountText, ...rest] = argsOf(raw)
155  if (amountText === undefined || rest.length === 0) return m.betUsage
156  const amount = parseBetAmount(amountText)
157  if (amount === null) return m.betBadAmount(amountText.slice(0, 40))
158  return placeBet(ctx, amount, rest.join(' '))
159}
160
161/** `/all-in [casilla]`: todo el saldo, al rojo si no se dice nada. */
162export async function allIn(ctx: CommandContext, raw: string): Promise<string> {
163  const target = argsOf(raw).join(' ')
164  return placeBet(ctx, 'all-in', target === '' ? 'red' : target)
165}
166
167// ------------------------------------------------------------------ /balance
168
169export async function balance(ctx: CommandContext): Promise<string> {
170  const { runtime, config } = ctx
171  const m = runtime.messages
172  const session = await requireSession(ctx)
173  if (session === null) return m.notLoggedIn
174  const me = await refreshMe(ctx.io, runtime, config, session, config.commandTimeoutMs)
175  if (me === null) {
176    const stillThere = await requireSession(ctx)
177    return stillThere === null ? m.sessionExpired : m.offline
178  }
179  // Cuenta de GitHub aún demasiado nueva: ni puesto ni "sin puesto", sino desde cuándo sale.
180  const wait = me.user.newAccount ?? null
181  const rank = me.user.hidden
182    ? m.rankHidden
183    : wait !== null
184      ? m.rankNewAccount(wait.rankedFrom)
185      : me.brokeRank === null
186        ? m.rankNone
187        : `#${me.brokeRank}`
188  const coins = (n: number): string => formatCoins(n, runtime.locale)
189  const text = m.balance(
190    coins(me.wallet.balance),
191    coins(me.wallet.debt),
192    coins(me.wallet.netWorth),
193    rank,
194  )
195  const lines = [text]
196  const pending = me.wallet.pendingSpins ?? 0
197  if (pending > 0) lines.push(m.pendingHint(pending, coins(pending)))
198  const pocket = pocketLine(runtime, inventoryOf(me.inventory), 'balance')
199  if (pocket !== null) lines.push(pocket)
200  return lines.join('\n')
201}
202
203/** `🐇 Pata de Conejo™ ×36` (y "armado" en la tienda). */
204function pocketItemText(runtime: Runtime, item: PocketItem, markArmed: boolean): string {
205  const m = runtime.messages.shop
206  const count = formatCoins(item.charges, runtime.locale)
207  const text = `${ITEM_GLYPHS[item.id]} ${m.itemName(item.id)} ×${count}`
208  return markArmed && isArmed(item) ? `${text} (${m.pocketArmed})` : text
209}
210
211/**
212 * Línea del bolsillo: en `/balance`, lo armado (o lo que tienes sin armar); en `/vibe-shop`, todo
213 * con su marca de armado. null si no hay nada que contar.
214 */
215function pocketLine(
216  runtime: Runtime,
217  inventory: InventoryView | null,
218  where: 'balance' | 'shop',
219): string | null {
220  const m = runtime.messages.shop
221  const items = (inventory?.items ?? []).filter(item => item.charges > 0)
222  if (where === 'shop') {
223    if (items.length === 0) return m.pocketEmpty
224    return m.pocket(items.map(item => pocketItemText(runtime, item, true)).join(' · '))
225  }
226  if (items.length === 0) return null
227  const armed = items.filter(isArmed)
228  const list = (xs: readonly PocketItem[]) =>
229    xs.map(item => pocketItemText(runtime, item, false)).join(' · ')
230  return armed.length > 0 ? m.balanceArmed(list(armed)) : m.balancePocket(list(items))
231}
232
233// ------------------------------------------------------------------ /vibe-login, /vibe-logout
234
235export async function login(ctx: CommandContext): Promise<string> {
236  const session = await requireSession(ctx)
237  if (session !== null) return ctx.runtime.messages.alreadyLoggedIn(session.login)
238  return startLogin(ctx.io, ctx.runtime, ctx.config, ctx.auth)
239}
240
241export async function logout(ctx: CommandContext): Promise<string> {
242  const { io, runtime, config } = ctx
243  cancelLogin(ctx.auth)
244  const session = await requireSession(ctx)
245  if (session === null) return runtime.messages.notLoggedInLogout
246  // Se olvida el token pase lo que pase; avisar al servidor es para revocarlo allí también.
247  await clearSession(io, config.serverUrl)
248  setStatus(io, runtime.messages.statusLogin)
249  const result = await callApi(io, {
250    baseUrl: config.serverUrl,
251    route: 'logout',
252    guard: isOk,
253    timeoutMs: config.commandTimeoutMs,
254    token: session.token,
255    body: emptyBody(),
256  })
257  if (!result.ok) debug(io, 'logout: el servidor no ha confirmado la revocación del token')
258  return runtime.messages.loggedOut
259}
260
261// ------------------------------------------------------------------ /vibe-hide, /vibe-show
262
263export async function setHidden(ctx: CommandContext, hidden: boolean): Promise<string> {
264  const m = ctx.runtime.messages
265  const session = await requireSession(ctx)
266  if (session === null) return m.notLoggedIn
267  const result = await callApi(ctx.io, {
268    baseUrl: ctx.config.serverUrl,
269    route: 'settings',
270    guard: isSettings,
271    timeoutMs: ctx.config.commandTimeoutMs,
272    token: session.token,
273    body: settingsBody(hidden),
274  })
275  if (!result.ok) return failureText(ctx, result)
276  return result.data.user.hidden ? m.hidden : m.shown
277}
278
279// ------------------------------------------------------------------ /vibe-server
280
281/**
282 * Cambio de servidor ya comprobado: el hook de `/vibe-server` guarda `url` en la opción
283 * `vibe-gambling.serverUrl` y contesta con `reply`, que recibe el `deny` del engine si lo hay.
284 */
285export interface ServerChange {
286  readonly url: string
287  readonly reply: (deny: string | undefined) => string
288}
289
290/**
291 * `/vibe-server [url|default]`. Sin argumento enseña el actual; con una URL comprueba
292 * `/api/meta` (protocolo compatible) y devuelve el cambio para que el hook lo guarde con
293 * `$.config.set`, que recarga el mod con la nueva configuración. Las sesiones van por servidor.
294 */
295export async function server(ctx: CommandContext, raw: string): Promise<string | ServerChange> {
296  const { io, runtime, config } = ctx
297  const m = runtime.messages
298  const [arg] = argsOf(raw)
299  const metaOf = async (baseUrl: string) =>
300    callApi(io, { baseUrl, route: 'meta', guard: isMeta, timeoutMs: config.commandTimeoutMs })
301  // El nombre lo elige quien monta el servidor: se limpia antes de enseñarlo.
302  const nameOf = (name: string): string => cleanText(name, TEXT_LIMITS.serverName)
303
304  if (arg === undefined) {
305    const meta = await metaOf(config.serverUrl)
306    return meta.ok
307      ? m.serverCurrent(config.serverUrl, nameOf(meta.data.name), meta.data.isOfficial)
308      : m.serverCurrent(config.serverUrl, '', null)
309  }
310
311  const wanted = arg === 'default' ? manifestDefault('serverUrl') : arg
312  if (typeof wanted === 'string' && isPlainHttpRemote(wanted)) {
313    return m.serverNeedsHttps(arg.slice(0, 200))
314  }
315  const url = typeof wanted === 'string' ? normalizeServerUrl(wanted) : null
316  if (url === null) return m.serverBadUrl(arg.slice(0, 200))
317  const meta = await metaOf(url)
318  if (!meta.ok) return m.serverUnreachable(url)
319  if (meta.data.protocol !== PROTOCOL_VERSION) {
320    return m.serverIncompatible(url, meta.data.protocol, PROTOCOL_VERSION)
321  }
322  cancelLogin(ctx.auth)
323  const name = nameOf(meta.data.name)
324  const isOfficial = meta.data.isOfficial
325  return {
326    url,
327    reply: deny =>
328      deny !== undefined ? m.serverChangeDenied(deny) : m.serverChanged(url, name, isOfficial),
329  }
330}
331
332// ------------------------------------------------------------------ /vibe-delete-account
333
334/** Borra la cuenta. Hay que repetir el login como confirmación (el servidor lo vuelve a mirar). */
335export async function deleteAccount(ctx: CommandContext, raw: string): Promise<string> {
336  const { io, runtime, config } = ctx
337  const m = runtime.messages
338  const session = await requireSession(ctx)
339  if (session === null) return m.notLoggedIn
340  const [confirm] = argsOf(raw)
341  if (confirm === undefined) return m.deleteUsage(session.login)
342  if (confirm.replace(/^@/, '').toLowerCase() !== session.login.toLowerCase()) {
343    return m.deleteMismatch
344  }
345  const result = await callApi(io, {
346    baseUrl: config.serverUrl,
347    route: 'deleteAccount',
348    guard: isOk,
349    timeoutMs: config.commandTimeoutMs,
350    token: session.token,
351    body: deleteAccountBody(session.login),
352  })
353  if (!result.ok) return failureText(ctx, result)
354  cancelLogin(ctx.auth)
355  await clearSession(io, config.serverUrl)
356  setStatus(io, m.statusLogin)
357  return m.deleted
358}
359
360// ------------------------------------------------------------------ /vibe-invite, /vibe-redeem
361
362/** `/vibe-invite`: tu enlace de afiliado, tu código y las cuentas de tu pirámide. */
363export async function invite(ctx: CommandContext): Promise<string> {
364  const { io, runtime, config } = ctx
365  const m = runtime.messages
366  const session = await requireSession(ctx)
367  if (session === null) return m.notLoggedIn
368  const result = await callApi(io, {
369    baseUrl: config.serverUrl,
370    route: 'referral',
371    guard: isReferral,
372    timeoutMs: config.commandTimeoutMs,
373    token: session.token,
374  })
375  if (!result.ok) return failureText(ctx, result)
376  const r = result.data.referral
377  const coins = (n: number): string => formatCoins(n, runtime.locale)
378  // El enlace y el código vienen del servidor: se limpian antes de enseñarlos en el terminal.
379  const code = cleanText(r.code, 16)
380  const lines = [
381    m.inviteHeader,
382    m.inviteLink(cleanText(r.url, 200), code),
383    m.inviteStats({
384      invited: coins(r.invited),
385      activated: coins(r.activated),
386      waiting: coins(r.waiting),
387      earned: coins(r.earned),
388      commissions: coins(r.season.commissions),
389      cap: coins(r.season.cap),
390    }),
391    m.inviteActivation({
392      bonus: coins(r.rules.welcomeBonus),
393      code,
394      commission: coins(r.rules.commission),
395      spins: coins(r.rules.activationSpins),
396      days: r.rules.minAccountAgeDays,
397    }),
398  ]
399  if (r.invitedBy !== null) lines.push(m.inviteRedeemed(coins(r.invitedBy.bonus)))
400  else if (r.canRedeem) lines.push(m.inviteCanRedeem(r.redeemUntil, coins(r.rules.welcomeBonus)))
401  return lines.join('\n')
402}
403
404/** `/vibe-redeem <código>`: canjea el código de quien te invitó (una vez en la vida). */
405export async function redeem(ctx: CommandContext, raw: string): Promise<string> {
406  const { io, runtime, config } = ctx
407  const m = runtime.messages
408  const [arg] = argsOf(raw)
409  if (arg === undefined) return m.redeemUsage
410  const code = normalizeReferralCode(arg)
411  if (code === null) return m.redeemBadCode(arg.slice(0, 40))
412  const session = await requireSession(ctx)
413  if (session === null) return m.notLoggedIn
414  const result = await callApi(io, {
415    baseUrl: config.serverUrl,
416    route: 'referralRedeem',
417    guard: isReferralRedeem,
418    timeoutMs: config.commandTimeoutMs,
419    token: session.token,
420    body: referralRedeemBody(code),
421  })
422  if (!result.ok) return failureText(ctx, result)
423  const data = result.data
424  if (data.status === 'rejected') return m.redeemRejected(data.reason)
425  showWallet(io, runtime, data.wallet)
426  const coins = (n: number): string => formatCoins(n, runtime.locale)
427  const login = data.inviter === null ? null : cleanText(data.inviter.login, TEXT_LIMITS.login)
428  return m.redeemed(coins(data.bonus), coins(data.wallet.balance), login)
429}
430// ------------------------------------------------------------------ /vibe-shop, /vibe-use
431
432/** Palabras que piden comprar en `/vibe-shop`. */
433const BUY_WORDS = new Set(['buy', 'comprar', 'compra', 'acheter', 'kaufen'])
434/** Palabras que desarman en `/vibe-use`. */
435const OFF_WORDS = new Set(['off', 'no', 'desarmar', 'disarm', 'aus'])
436
437/** Cifra con hasta dos decimales en el formato del idioma (la letra pequeña). */
438function decimal(runtime: Runtime, value: number): string {
439  return new Intl.NumberFormat(runtime.locale, {
440    maximumFractionDigits: 2,
441  }).format(value)
442}
443
444/** La letra pequeña de un artículo, con los números que manda el servidor. */
445function finePrintText(runtime: Runtime, item: ShopItemView): string {
446  const m = runtime.messages.shop
447  const edge = decimal(runtime, item.finePrint.minEdge)
448  if (item.kind === 'cosmetic') return m.edgeCosmetic(edge)
449  if (item.kind === 'service') {
450    return m.edgeRevert(decimal(runtime, item.finePrint.maxExpected), edge)
451  }
452  if (item.finePrint.maxExpected === 0) return m.edgePlacebo(edge)
453  return m.edge(decimal(runtime, item.finePrint.maxExpected), edge)
454}
455
456function shopLine(runtime: Runtime, item: ShopItemView): string {
457  const m = runtime.messages.shop
458  const coins = (n: number): string => formatCoins(n, runtime.locale)
459  const parts = [
460    `${ITEM_GLYPHS[item.id]} ${m.itemName(item.id)} — ${coins(item.price)} ${runtime.messages.coin}`,
461  ]
462  if (item.charges > 0) parts.push(m.charges(item.charges, coins(item.charges)))
463  if (item.kind === 'cosmetic') {
464    if (item.owned) parts.push(m.owned)
465  } else if (item.maxPerSeason !== null) {
466    parts.push(
467      item.bought >= item.maxPerSeason
468        ? m.soldOut
469        : m.bought(coins(item.bought), coins(item.maxPerSeason)),
470    )
471  }
472  parts.push(m.itemTag(item.id), finePrintText(runtime, item))
473  return parts.join(' · ')
474}
475
476/** `/vibe-shop`: catálogo, letra pequeña y bolsillo. `/vibe-shop buy <objeto>`: compra. */
477export async function shop(ctx: CommandContext, raw: string): Promise<string> {
478  const m = ctx.runtime.messages.shop
479  const [first, ...rest] = argsOf(raw)
480  if (first === undefined) return catalogue(ctx)
481  if (!BUY_WORDS.has(first.toLowerCase()) || rest.length === 0) return m.usage
482  const wanted = rest.join(' ')
483  const itemId = resolveItem(wanted)
484  if (itemId === null) return m.unknownItem(wanted.slice(0, 40))
485  return buy(ctx, itemId, rest.join(' '))
486}
487
488async function catalogue(ctx: CommandContext): Promise<string> {
489  const { io, runtime, config } = ctx
490  const m = runtime.messages.shop
491  const session = await requireSession(ctx)
492  if (session === null) return runtime.messages.notLoggedIn
493  const result = await callApi(io, {
494    baseUrl: config.serverUrl,
495    route: 'shop',
496    guard: isShop,
497    timeoutMs: config.commandTimeoutMs,
498    token: session.token,
499  })
500  if (!result.ok) return failureText(ctx, result)
501  const data = result.data.shop
502  const inventory = rememberInventory(runtime, data.inventory)
503  const lines = [m.header(cleanText(data.seasonId, 7), data.seasonEndsAt)]
504  if (inventory !== null && !inventory.enabled) lines.push(m.closedNote)
505  const goods = data.items.filter(item => item.kind !== 'cosmetic')
506  const cosmetics = data.items.filter(item => item.kind === 'cosmetic')
507  lines.push(...goods.map(item => shopLine(runtime, item)))
508  if (cosmetics.length > 0) {
509    lines.push(m.cosmetics, ...cosmetics.map(item => `  ${shopLine(runtime, item)}`))
510  }
511  lines.push(pocketLine(runtime, inventory, 'shop') ?? m.pocketEmpty)
512  const undo = inventory?.revert ?? null
513  const undoPrice = data.items.find(item => item.kind === 'service')?.price
514  if (undo !== null && undoPrice !== undefined) {
515    const coins = (n: number): string => formatCoins(n, runtime.locale)
516    lines.push(m.revertAvailable(coins(undo.refund), coins(undoPrice), undo.until))
517  }
518  if (data.canBorrow) lines.push(m.borrow)
519  lines.push(m.footer)
520  return lines.join('\n')
521}
522
523/** Motivo de un `conflict` al comprar, según lo que se compra. */
524function conflictText(runtime: Runtime, itemId: ItemId): string {
525  const m = runtime.messages.shop
526  const name = m.itemName(itemId)
527  if (isConsumableId(itemId)) return m.soldOutFor(name)
528  if (itemId.startsWith('felt_') || itemId.startsWith('chips_')) return m.alreadyOwned(name)
529  return m.nothingToRevert
530}
531
532async function buy(ctx: CommandContext, itemId: ItemId, alias: string): Promise<string> {
533  const { io, runtime, config } = ctx
534  const m = runtime.messages.shop
535  const session = await requireSession(ctx)
536  if (session === null) return runtime.messages.notLoggedIn
537  // Un id por compra: si la red falla y se reintenta, el servidor ve la misma compra y no cobra
538  // dos veces.
539  const body = shopBuyBody(itemId, newRequestId())
540  const send = () =>
541    callApi(io, {
542      baseUrl: config.serverUrl,
543      route: 'shopBuy',
544      guard: isShopBuy,
545      timeoutMs: config.commandTimeoutMs,
546      token: session.token,
547      body,
548    })
549  let result = await send()
550  if (!result.ok && (result.reason === 'timeout' || result.reason === 'offline')) {
551    debug(io, 'shop/buy: sin respuesta; reintento con el mismo requestId')
552    result = await send()
553  }
554  if (!result.ok) {
555    if (result.reason === 'http') {
556      const name = m.itemName(itemId)
557      switch (result.error) {
558        case 'insufficient_funds':
559          return m.noFunds(name)
560        case 'conflict':
561          return conflictText(runtime, itemId)
562        case 'not_found':
563          return m.closed
564        case 'rate_limited':
565          return m.cooldown
566        default:
567          break
568      }
569    }
570    return failureText(ctx, result)
571  }
572  const data = result.data
573  rememberInventory(runtime, data.inventory)
574  showWallet(io, runtime, data.wallet)
575  const coins = (n: number): string => formatCoins(n, runtime.locale)
576  const balance = coins(data.wallet.balance)
577  const name = m.itemName(data.itemId)
578  if (isConsumableId(data.itemId)) {
579    const charges = runtime.inventory?.items.find(item => item.id === data.itemId)?.charges ?? 0
580    return m.boughtConsumable({
581      name,
582      charges: coins(charges),
583      price: coins(data.price),
584      balance,
585      alias: cleanText(alias, 24),
586    })
587  }
588  if (data.itemId.startsWith('felt_') || data.itemId.startsWith('chips_')) {
589    return m.boughtCosmetic(name, coins(data.price), balance)
590  }
591  return m.boughtRevert(coins(data.price), coins(data.refund), balance)
592}
593
594/** `/vibe-use <objeto> [off]`: arma (o desarma) un consumible para la próxima tirada. */
595export async function use(ctx: CommandContext, raw: string): Promise<string> {
596  const { io, runtime, config } = ctx
597  const m = runtime.messages.shop
598  const words = argsOf(raw)
599  const last = words.at(-1)?.toLowerCase()
600  const off = last !== undefined && OFF_WORDS.has(last) && words.length > 1
601  const wanted = (off ? words.slice(0, -1) : words).join(' ')
602  if (wanted === '') return m.useUsage
603  const itemId = resolveItem(wanted)
604  if (itemId === null) return m.unknownItem(wanted.slice(0, 40))
605  const name = m.itemName(itemId)
606  if (!isConsumableId(itemId)) return m.notConsumable(name)
607  const session = await requireSession(ctx)
608  if (session === null) return runtime.messages.notLoggedIn
609  const result = await callApi(io, {
610    baseUrl: config.serverUrl,
611    route: 'itemArm',
612    guard: isItemArm,
613    timeoutMs: config.commandTimeoutMs,
614    token: session.token,
615    body: itemArmBody(itemId, !off),
616  })
617  if (!result.ok) {
618    if (result.reason === 'http' && result.error === 'conflict') return m.armConflict(name)
619    if (result.reason === 'http' && result.error === 'not_found') return m.closed
620    return failureText(ctx, result)
621  }
622  const inventory = rememberInventory(runtime, result.data.inventory)
623  // La status line enseña el glifo ya, sin esperar a la siguiente tirada.
624  refreshStatus(io, runtime)
625  if (off) return m.disarmed(name)
626  const charges = inventory?.items.find(item => item.id === itemId)?.charges ?? 0
627  return m.armed(name, formatCoins(charges, runtime.locale))
628}
629
hooks/lib/messages.ts 432 lines
1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Textos del mod en todos los idiomas de `locales/` (el del sistema; inglés por defecto). Los
3// textos y las frases son la copia generada en `../generated` (`pnpm gen:locales`). Módulo puro.
4import { LOCALE_INFO, MOD_TEXTS, REFERENCE_LOCALE } from '../generated/locales.ts'
5import type { ModTextKey, ModTexts } from '../generated/locales.ts'
6import { fillTemplate } from '../generated/phrases.ts'
7import { LOCALES } from '../generated/protocol.ts'
8import type { ItemId, Locale, ReferralRejection } from '../generated/protocol.ts'
9import type { PocketColor } from './responses.ts'
10
11export interface Messages {
12  readonly coin: string
13  readonly colors: Readonly<Record<PocketColor, string>>
14  readonly debtLabel: string
15  readonly statusOffline: string
16  readonly statusLogin: string
17  readonly statusBadConfig: string
18  readonly loginHint: string
19  readonly jackpotTag: string
20  readonly noRound: string
21  /**
22   * Trozo de la status line con las tiradas pendientes. Estos cuatro reciben el número (elige el
23   * singular o el plural) y la cifra ya formateada con el separador de miles del idioma.
24   */
25  readonly pendingCount: (count: number, formatted: string) => string
26  /** Toast: el prompt terminó sin ventana visible y la tirada se guardó. */
27  readonly spinSaved: (count: number, formatted: string) => string
28  /** Toast: tope de tiradas pendientes alcanzado, este prompt se descarta. */
29  readonly spinDropped: (count: number, formatted: string) => string
30  /** Toast: tope diario de tiradas; recibe el instante (ISO) en que reabre la mesa. */
31  readonly dailyLimit: (reopensAt: string) => string
32  /** `/balance`: cuántas hay y cómo se gastan. */
33  readonly pendingHint: (count: number, formatted: string) => string
34
35  readonly commands: Readonly<Record<CommandId, { description: string; argumentHint?: string }>>
36
37  readonly notLoggedIn: string
38  readonly offline: string
39  readonly serverError: (detail: string) => string
40  readonly badConfig: (errors: string) => string
41  readonly sessionExpired: string
42
43  readonly openingWindow: string
44  readonly windowFailed: string
45
46  readonly betUsage: string
47  readonly allInUsage: string
48  readonly betBadAmount: (raw: string) => string
49  readonly betBadTarget: (raw: string) => string
50  readonly betPlaced: (
51    amount: string,
52    target: string,
53    round: 'current' | 'next',
54    balance: string,
55  ) => string
56  readonly insufficientFunds: string
57  readonly tooManyBets: string
58  readonly invalidBet: (target: string) => string
59
60  readonly balance: (balance: string, debt: string, netWorth: string, rank: string) => string
61  readonly rankHidden: string
62  readonly rankNone: string
63  /** `/balance` con la cuenta de GitHub aún demasiado nueva: desde cuándo sale en el ranking. */
64  readonly rankNewAccount: (rankedFrom: string) => string
65
66  readonly alreadyLoggedIn: (login: string) => string
67  readonly loginPrompt: (uri: string, code: string, minutes: number) => string
68  readonly loginInProgress: (uri: string, code: string) => string
69  readonly loginWelcome: (login: string) => string
70  readonly loginPublicNotice: string
71  /** En lugar de `loginPublicNotice` si la cuenta de GitHub aún es demasiado nueva. */
72  readonly loginNewAccount: (minAgeDays: number, rankedFrom: string) => string
73  readonly loginExpired: string
74  readonly loginDenied: string
75  readonly loginFailed: (detail: string) => string
76  readonly loggedOut: string
77  readonly notLoggedInLogout: string
78
79  readonly hidden: string
80  readonly shown: string
81
82  readonly serverCurrent: (url: string, name: string, official: boolean | null) => string
83  readonly serverBadUrl: (raw: string) => string
84  readonly serverNeedsHttps: (raw: string) => string
85  readonly serverUnreachable: (url: string) => string
86  readonly serverIncompatible: (url: string, protocol: number, expected: number) => string
87  readonly serverChanged: (url: string, name: string, official: boolean) => string
88  readonly serverChangeDenied: (reason: string) => string
89  readonly officialTag: string
90  readonly selfHostedTag: string
91
92  readonly deleteUsage: (login: string) => string
93  readonly deleteMismatch: string
94  readonly deleted: string
95
96  /** `/vibe-invite`: cabecera, enlace, cuentas, reglas y si aún puedes canjear tú. */
97  readonly inviteHeader: string
98  readonly inviteLink: (url: string, code: string) => string
99  readonly inviteStats: (s: {
100    invited: string
101    activated: string
102    waiting: string
103    earned: string
104    commissions: string
105    cap: string
106  }) => string
107  /** `days` es 0 si la instancia no pide edad a la cuenta de GitHub del invitado. */
108  readonly inviteActivation: (r: {
109    bonus: string
110    code: string
111    commission: string
112    spins: string
113    days: number
114  }) => string
115  readonly inviteCanRedeem: (until: string, bonus: string) => string
116  readonly inviteRedeemed: (bonus: string) => string
117
118  /** `/vibe-redeem`. `login` null: quien invitó está oculto del ranking. */
119  readonly redeemUsage: string
120  readonly redeemBadCode: (raw: string) => string
121  readonly redeemed: (bonus: string, balance: string, login: string | null) => string
122  readonly redeemRejected: (reason: ReferralRejection) => string
123
124  /** TIENDA: `/vibe-shop`, `/vibe-use`, la línea de `/balance` y los efectos del toast. */
125  readonly shop: ShopMessages
126}
127
128export interface ShopMessages {
129  readonly itemName: (id: ItemId) => string
130  readonly itemTag: (id: ItemId) => string
131  readonly header: (season: string, endsAt: string) => string
132  readonly closedNote: string
133  readonly charges: (count: number, formatted: string) => string
134  readonly bought: (bought: string, max: string) => string
135  readonly soldOut: string
136  readonly owned: string
137  /** Letra pequeña: lo que devuelve como mucho (en una carga) y lo que gana la casa como mínimo. */
138  readonly edge: (max: string, edge: string) => string
139  readonly edgePlacebo: (edge: string) => string
140  readonly edgeRevert: (max: string, edge: string) => string
141  readonly edgeCosmetic: (edge: string) => string
142  readonly cosmetics: string
143  readonly pocket: (items: string) => string
144  readonly pocketEmpty: string
145  readonly pocketArmed: string
146  readonly revertAvailable: (refund: string, price: string, until: string) => string
147  readonly borrow: string
148  readonly footer: string
149  readonly usage: string
150  readonly unknownItem: (raw: string) => string
151  readonly boughtConsumable: (r: {
152    name: string
153    charges: string
154    price: string
155    balance: string
156    alias: string
157  }) => string
158  readonly boughtCosmetic: (name: string, price: string, balance: string) => string
159  readonly boughtRevert: (price: string, refund: string, balance: string) => string
160  readonly noFunds: (name: string) => string
161  readonly soldOutFor: (name: string) => string
162  readonly alreadyOwned: (name: string) => string
163  readonly nothingToRevert: string
164  readonly closed: string
165  readonly cooldown: string
166  readonly useUsage: string
167  readonly notConsumable: (name: string) => string
168  readonly armed: (name: string, charges: string) => string
169  readonly disarmed: (name: string) => string
170  readonly armConflict: (name: string) => string
171  readonly balanceArmed: (items: string) => string
172  readonly balancePocket: (items: string) => string
173  readonly effectPlacebo: string
174  readonly effectPrediction: (n: number, hit: boolean) => string
175  readonly effectRefund: (amount: string) => string
176  readonly effectBoost: (amount: string) => string
177  readonly effectBoostMiss: string
178  readonly effectShield: string
179}
180
181export type CommandId =
182  | 'roulette'
183  | 'bet'
184  | 'allIn'
185  | 'balance'
186  | 'login'
187  | 'logout'
188  | 'hide'
189  | 'show'
190  | 'server'
191  | 'deleteAccount'
192  | 'invite'
193  | 'redeem'
194  | 'shop'
195  | 'use'
196
197const COMMAND_IDS: readonly CommandId[] = [
198  'roulette',
199  'bet',
200  'allIn',
201  'balance',
202  'login',
203  'logout',
204  'hide',
205  'show',
206  'server',
207  'deleteAccount',
208  'invite',
209  'redeem',
210  'shop',
211  'use',
212]
213
214/**
215 * Mensajes de un idioma a partir de sus textos (copia generada de `locales/<idioma>/mod.json`).
216 * `bcp47` decide el singular (`<clave>.one`) con `Intl.PluralRules`, como la web.
217 */
218export function buildMessages(t: ModTexts, bcp47: string): Messages {
219  const text = (key: ModTextKey) => t[key]
220  const rules = new Intl.PluralRules(bcp47)
221  const counted =
222    (key: 'pendingCount' | 'spinSaved' | 'spinDropped' | 'pendingHint' | 'shopCharges') =>
223    (count: number, formatted: string): string =>
224      fillTemplate(t[rules.select(count) === 'one' ? (`${key}.one` as const) : key], {
225        count: formatted,
226      })
227  const hintOf = (id: CommandId): string | undefined =>
228    (t as Readonly<Record<string, string>>)[`command.${id}.argumentHint`]
229  // Día del instante en el formato medio del idioma ("Nov 12, 2026"), en la zona del sistema.
230  const day = new Intl.DateTimeFormat(bcp47, { dateStyle: 'medium' })
231  const date = (iso: string) => day.format(new Date(iso))
232  const integer = new Intl.NumberFormat(bcp47)
233  // Hora de pared del instante ("00:00"), en la zona del sistema: la del que lo lee.
234  const clock = new Intl.DateTimeFormat(bcp47, { hour: '2-digit', minute: '2-digit' })
235  const state = (official: boolean | null) =>
236    official === null
237      ? t['serverState.unreachable']
238      : official
239        ? t['serverState.official']
240        : t['serverState.selfHosted']
241  return {
242    coin: t.coin,
243    colors: { red: t['color.red'], black: t['color.black'], green: t['color.green'] },
244    debtLabel: t.debtLabel,
245    statusOffline: t.statusOffline,
246    statusLogin: t.statusLogin,
247    statusBadConfig: t.statusBadConfig,
248    loginHint: t.loginHint,
249    jackpotTag: t.jackpotTag,
250    noRound: t.noRound,
251    pendingCount: counted('pendingCount'),
252    spinSaved: counted('spinSaved'),
253    spinDropped: counted('spinDropped'),
254    pendingHint: counted('pendingHint'),
255    dailyLimit: reopensAt =>
256      fillTemplate(t.dailyLimit, { time: clock.format(new Date(reopensAt)) }),
257
258    commands: Object.fromEntries(
259      COMMAND_IDS.map(id => {
260        const argumentHint = hintOf(id)
261        const description = text(`command.${id}.description` as ModTextKey)
262        return [id, argumentHint === undefined ? { description } : { description, argumentHint }]
263      }),
264    ) as Messages['commands'],
265
266    notLoggedIn: t.notLoggedIn,
267    offline: t.offline,
268    serverError: detail => fillTemplate(t.serverError, { detail }),
269    badConfig: errors => fillTemplate(t.badConfig, { errors }),
270    sessionExpired: t.sessionExpired,
271
272    openingWindow: t.openingWindow,
273    windowFailed: t.windowFailed,
274
275    betUsage: t.betUsage,
276    allInUsage: t.allInUsage,
277    betBadAmount: raw => fillTemplate(t.betBadAmount, { raw }),
278    betBadTarget: raw => fillTemplate(t.betBadTarget, { raw }),
279    betPlaced: (amount, target, round, balance) =>
280      fillTemplate(t.betPlaced, {
281        amount,
282        target,
283        balance,
284        round: round === 'current' ? t['betRound.current'] : t['betRound.next'],
285      }),
286    insufficientFunds: t.insufficientFunds,
287    tooManyBets: t.tooManyBets,
288    invalidBet: target => fillTemplate(t.invalidBet, { target }),
289
290    balance: (balance, debt, netWorth, rank) =>
291      fillTemplate(t.balance, { balance, debt, netWorth, rank }),
292    rankHidden: t.rankHidden,
293    rankNone: t.rankNone,
294    rankNewAccount: rankedFrom => fillTemplate(t.rankNewAccount, { date: date(rankedFrom) }),
295
296    alreadyLoggedIn: login => fillTemplate(t.alreadyLoggedIn, { login }),
297    loginPrompt: (uri, code, minutes) => fillTemplate(t.loginPrompt, { uri, code, minutes }),
298    loginInProgress: (uri, code) => fillTemplate(t.loginInProgress, { uri, code }),
299    loginWelcome: login => fillTemplate(t.loginWelcome, { login }),
300    loginPublicNotice: t.loginPublicNotice,
301    loginNewAccount: (minAgeDays, rankedFrom) =>
302      fillTemplate(t.loginNewAccount, {
303        days: integer.format(minAgeDays),
304        date: date(rankedFrom),
305      }),
306    loginExpired: t.loginExpired,
307    loginDenied: t.loginDenied,
308    loginFailed: detail => fillTemplate(t.loginFailed, { detail }),
309    loggedOut: t.loggedOut,
310    notLoggedInLogout: t.notLoggedInLogout,
311
312    hidden: t.hidden,
313    shown: t.shown,
314
315    serverCurrent: (url, name, official) =>
316      fillTemplate(t.serverCurrent, {
317        url,
318        name: name === '' ? '' : fillTemplate(t.serverNameSuffix, { name }),
319        state: state(official),
320      }),
321    serverBadUrl: raw => fillTemplate(t.serverBadUrl, { raw }),
322    serverNeedsHttps: raw => fillTemplate(t.serverNeedsHttps, { raw }),
323    serverUnreachable: url => fillTemplate(t.serverUnreachable, { url }),
324    serverIncompatible: (url, protocol, expected) =>
325      fillTemplate(t.serverIncompatible, { url, protocol, expected }),
326    serverChanged: (url, name, official) =>
327      fillTemplate(t.serverChanged, { url, name, state: state(official) }),
328    serverChangeDenied: reason => fillTemplate(t.serverChangeDenied, { reason }),
329    officialTag: t.officialTag,
330    selfHostedTag: t.selfHostedTag,
331
332    deleteUsage: login => fillTemplate(t.deleteUsage, { login }),
333    deleteMismatch: t.deleteMismatch,
334    deleted: t.deleted,
335
336    inviteHeader: t.inviteHeader,
337    inviteLink: (url, code) => fillTemplate(t.inviteLink, { url, code }),
338    inviteStats: stats => fillTemplate(t.inviteStats, stats),
339    inviteActivation: ({ days, ...rest }) =>
340      days > 0
341        ? fillTemplate(t.inviteActivationAge, { ...rest, days: integer.format(days) })
342        : fillTemplate(t.inviteActivation, rest),
343    inviteCanRedeem: (until, bonus) =>
344      fillTemplate(t.inviteCanRedeem, { date: date(until), bonus }),
345    inviteRedeemed: bonus => fillTemplate(t.inviteRedeemed, { bonus }),
346
347    redeemUsage: t.redeemUsage,
348    redeemBadCode: raw => fillTemplate(t.redeemBadCode, { raw }),
349    redeemed: (bonus, balance, login) =>
350      login === null
351        ? fillTemplate(t.redeemedAnon, { bonus, balance })
352        : fillTemplate(t.redeemed, { bonus, balance, login }),
353    redeemRejected: reason => t[`redeemRejected.${reason}`],
354
355    shop: {
356      itemName: id => t[`item.${id}`],
357      itemTag: id => t[`itemTag.${id}`],
358      header: (season, endsAt) => fillTemplate(t.shopHeader, { season, date: date(endsAt) }),
359      closedNote: t.shopClosedNote,
360      charges: counted('shopCharges'),
361      bought: (bought, max) => fillTemplate(t.shopBought, { bought, max }),
362      soldOut: t.shopSoldOut,
363      owned: t.shopOwned,
364      edge: (max, edge) => fillTemplate(t.shopEdge, { max, edge }),
365      edgePlacebo: edge => fillTemplate(t.shopEdgePlacebo, { edge }),
366      edgeRevert: (max, edge) => fillTemplate(t.shopEdgeRevert, { max, edge }),
367      edgeCosmetic: edge => fillTemplate(t.shopEdgeCosmetic, { edge }),
368      cosmetics: t.shopCosmetics,
369      pocket: items => fillTemplate(t.shopPocket, { items }),
370      pocketEmpty: t.shopPocketEmpty,
371      pocketArmed: t.pocketArmed,
372      revertAvailable: (refund, price, until) =>
373        fillTemplate(t.shopRevert, { refund, price, time: clock.format(new Date(until)) }),
374      borrow: t.shopBorrow,
375      footer: t.shopFooter,
376      usage: t.shopUsage,
377      unknownItem: raw => fillTemplate(t.shopUnknownItem, { raw }),
378      boughtConsumable: r => fillTemplate(t.bought, r),
379      boughtCosmetic: (name, price, balance) =>
380        fillTemplate(t.boughtCosmetic, { name, price, balance }),
381      boughtRevert: (price, refund, balance) =>
382        fillTemplate(t.boughtRevert, { price, refund, balance }),
383      noFunds: name => fillTemplate(t.buyNoFunds, { name }),
384      soldOutFor: name => fillTemplate(t.buySoldOut, { name }),
385      alreadyOwned: name => fillTemplate(t.buyOwned, { name }),
386      nothingToRevert: t.buyNothingToRevert,
387      closed: t.shopClosed,
388      cooldown: t.shopCooldown,
389      useUsage: t.useUsage,
390      notConsumable: name => fillTemplate(t.useNotConsumable, { name }),
391      armed: (name, charges) => fillTemplate(t.armed, { name, charges }),
392      disarmed: name => fillTemplate(t.disarmed, { name }),
393      armConflict: name => fillTemplate(t.armConflict, { name }),
394      balanceArmed: items => fillTemplate(t.balanceArmed, { items }),
395      balancePocket: items => fillTemplate(t.balancePocket, { items }),
396      effectPlacebo: t['effect.placebo'],
397      effectPrediction: (n, hit) =>
398        fillTemplate(hit ? t['effect.predictionHit'] : t['effect.predictionMiss'], { n }),
399      effectRefund: amount => fillTemplate(t['effect.refund'], { amount }),
400      effectBoost: amount => fillTemplate(t['effect.boost'], { amount }),
401      effectBoostMiss: t['effect.boostMiss'],
402      effectShield: t['effect.shield'],
403    },
404  }
405}
406
407/** Mensajes de cada idioma registrado (`locales/`, copia generada por `pnpm gen:locales`). */
408export const MESSAGES: Readonly<Record<Locale, Messages>> = Object.fromEntries(
409  LOCALES.map(locale => [locale, buildMessages(MOD_TEXTS[locale], LOCALE_INFO[locale].bcp47)]),
410) as Record<Locale, Messages>
411
412/**
413 * Idioma a partir de las variables de locale del sistema (`LC_ALL`, `LC_MESSAGES`, `LANG`,
414 * `LANGUAGE`, en ese orden): manda la primera con valor (sin contar `C`). Se busca el idioma con
415 * región (`pt_BR` → `pt-BR`) y, si no, el idioma sin región (`fr_CA.UTF-8` → `fr`); si no está
416 * entre los registrados, inglés.
417 */
418export function localeFromEnv(values: readonly (string | undefined)[]): Locale {
419  const first = values.find(value => value !== undefined && value !== '' && value !== 'C')
420  const match =
421    first === undefined ? null : /^([a-z]{2,3})(?:[_-]([a-z]{2}))?(?=[_.@:-]|$)/i.exec(first)
422  if (match) {
423    const base = match[1]!.toLowerCase()
424    const region = match[2]?.toUpperCase()
425    const found =
426      LOCALES.find(code => region !== undefined && code === `${base}-${region}`) ??
427      LOCALES.find(code => code === base)
428    if (found !== undefined) return found
429  }
430  return REFERENCE_LOCALE
431}
432
hooks/lib/runtime.ts 222 lines
1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Estado del mod durante una carga del módulo: configuración, idioma, SO, versión y sesión.
3// Lo que debe sobrevivir entre sesiones (el token) vive en `$.store`; el resto se recalcula.
4import type { Io } from './io.ts'
5
6/** Opciones del `userConfig` tal y como las entrega el engine. */
7export type PluginOptions = Readonly<Record<string, unknown>>
8
9import { PLUGIN_VERSION, USER_CONFIG_DEFAULTS } from '../generated/manifest.ts'
10import type { UserConfigKey } from '../generated/manifest.ts'
11import { OS_VALUES } from '../generated/protocol.ts'
12import type { Locale, Os } from '../generated/protocol.ts'
13import { callApi, debug, isUnauthorized } from './api.ts'
14import { parseConfig } from './config.ts'
15import type { ModConfig } from './config.ts'
16import { statusText } from './format.ts'
17import { armedGlyphs, inventoryOf } from './items.ts'
18import type { InventoryView } from './items.ts'
19import { MESSAGES, localeFromEnv } from './messages.ts'
20import type { Messages } from './messages.ts'
21import { isMe } from './responses.ts'
22import type { MeView, WalletView } from './responses.ts'
23
24/** Sesión del mod en un servidor: el token y el login (para confirmar el borrado de cuenta). */
25export interface StoredSession {
26  readonly token: string
27  readonly login: string
28}
29
30export interface Runtime {
31  /** `null` si las opciones no son válidas (el mod se queda quieto y lo dice). */
32  readonly config: ModConfig | null
33  readonly configErrors: readonly string[]
34  locale: Locale
35  messages: Messages
36  /** Se rellena en `session.start`. */
37  os: Promise<Os>
38  /** La de `plugin.json`, generada en `generated/manifest.ts` (una sola fuente de verdad). */
39  readonly pluginVersion: string
40  isInteractive: boolean
41  /** Para no repetir la última frase del toast. */
42  lastPhrase: string | null
43  /** El aviso de cómo entrar ya se enseñó en esta carga. */
44  loginHintShown: boolean
45  /**
46   * Último bolsillo de la TIENDA que mandó el servidor (null: no lo sabemos o no hay tienda). Sale
47   * en la status line como glifos de lo armado.
48   */
49  inventory: InventoryView | null
50  /** Última cartera enseñada en la status line (para repintarla al armar un objeto). */
51  lastWallet: WalletView | null
52}
53
54export function createRuntime(options: PluginOptions): Runtime {
55  const parsed = parseConfig(options)
56  return {
57    config: parsed.ok ? parsed.config : null,
58    configErrors: parsed.ok ? [] : parsed.errors,
59    locale: 'en',
60    messages: MESSAGES.en,
61    os: Promise.resolve('other'),
62    pluginVersion: PLUGIN_VERSION,
63    isInteractive: false,
64    lastPhrase: null,
65    loginHintShown: false,
66    inventory: null,
67    lastWallet: null,
68  }
69}
70
71/** Idioma: el de la opción `language` o, en `auto`, el del sistema. */
72export async function resolveLocale(io: Io, runtime: Runtime): Promise<Locale> {
73  const choice = runtime.config?.language ?? 'auto'
74  if (choice !== 'auto') return choice
75  return localeFromEnv([
76    await io.env('LC_ALL'),
77    await io.env('LC_MESSAGES'),
78    await io.env('LANG'),
79    await io.env('LANGUAGE'),
80  ])
81}
82
83/**
84 * SO genérico, lo único que el mod cuenta del ordenador: `OS=Windows_NT` en Windows y, si no,
85 * `uname -s` (Darwin / Linux). Cualquier otra cosa es `other`.
86 */
87export async function detectOs(io: Io, timeoutMs: number): Promise<Os> {
88  try {
89    if ((await io.env('OS')) === 'Windows_NT') return 'win32'
90    const { exitCode, stdout } = await io.run(['uname', '-s'], timeoutMs)
91    if (exitCode !== 0) return 'other'
92    const name = stdout.trim().toLowerCase()
93    return OS_VALUES.find(os => os === name) ?? 'other'
94  } catch (error) {
95    debug(io, `no he podido averiguar el SO: ${String(error)}`)
96    return 'other'
97  }
98}
99
100/**
101 * Valor por defecto de una opción, el del `userConfig` del manifiesto. Sale de
102 * `generated/manifest.ts` (generado de `plugin.json`): el mod no lee su manifiesto del disco.
103 */
104export function manifestDefault<K extends UserConfigKey>(key: K): (typeof USER_CONFIG_DEFAULTS)[K] {
105  return USER_CONFIG_DEFAULTS[key]
106}
107
108// ------------------------------------------------------------------ sesión en `$.store`
109
110/** Clave del token: una por servidor, así cambiar de servidor no mezcla sesiones. */
111function sessionKey(serverUrl: string): string {
112  return `session:${serverUrl}`
113}
114
115export async function loadSession(io: Io, serverUrl: string): Promise<StoredSession | null> {
116  try {
117    const value: unknown = await io.storeGet(sessionKey(serverUrl))
118    if (
119      typeof value === 'object' &&
120      value !== null &&
121      'token' in value &&
122      'login' in value &&
123      typeof value.token === 'string' &&
124      typeof value.login === 'string' &&
125      value.token !== ''
126    ) {
127      return { token: value.token, login: value.login }
128    }
129    return null
130  } catch (error) {
131    debug(io, `no he podido leer la sesión guardada: ${String(error)}`)
132    return null
133  }
134}
135
136export async function saveSession(
137  io: Io,
138  serverUrl: string,
139  session: StoredSession,
140): Promise<void> {
141  await io.storeSet(sessionKey(serverUrl), { token: session.token, login: session.login })
142}
143
144export async function clearSession(io: Io, serverUrl: string): Promise<void> {
145  try {
146    await io.storeDelete(sessionKey(serverUrl))
147  } catch (error) {
148    debug(io, `no he podido borrar la sesión guardada: ${String(error)}`)
149  }
150}
151
152// ------------------------------------------------------------------ status line
153
154export function setStatus(io: Io, text: string | undefined): void {
155  try {
156    io.status(text)
157  } catch (error) {
158    debug(io, `no he podido poner la status line: ${String(error)}`)
159  }
160}
161
162export function showWallet(io: Io, runtime: Runtime, wallet: WalletView): void {
163  runtime.lastWallet = wallet
164  setStatus(
165    io,
166    statusText(wallet, runtime.locale, runtime.messages, armedGlyphs(runtime.inventory)),
167  )
168}
169
170/** Repinta la status line con la última cartera (p. ej. tras armar un objeto). */
171export function refreshStatus(io: Io, runtime: Runtime): void {
172  if (runtime.lastWallet !== null) showWallet(io, runtime, runtime.lastWallet)
173}
174
175/** Guarda el bolsillo que manda el servidor (si lo manda y tiene la forma esperada). */
176export function rememberInventory(runtime: Runtime, raw: unknown): InventoryView | null {
177  const inventory = inventoryOf(raw)
178  if (inventory !== null) runtime.inventory = inventory
179  return inventory
180}
181
182export function toast(io: Io, text: string): void {
183  try {
184    io.toast(text)
185  } catch (error) {
186    debug(io, `no he podido enseñar el toast: ${String(error)}`)
187  }
188}
189
190/**
191 * Pide `/api/me` y pone el saldo en la status line. Si la sesión ya no vale, la olvida;
192 * si el servidor no responde, "offline". Devuelve los datos si llegaron.
193 */
194export async function refreshMe(
195  io: Io,
196  runtime: Runtime,
197  config: ModConfig,
198  session: StoredSession,
199  timeoutMs: number,
200): Promise<MeView | null> {
201  const result = await callApi(io, {
202    baseUrl: config.serverUrl,
203    route: 'me',
204    guard: isMe,
205    timeoutMs,
206    token: session.token,
207  })
208  if (result.ok) {
209    // Un servidor sin tienda no manda el bolsillo: entonces no hay nada armado que enseñar.
210    runtime.inventory = inventoryOf(result.data.inventory)
211    showWallet(io, runtime, result.data.wallet)
212    return result.data
213  }
214  if (isUnauthorized(result)) {
215    await clearSession(io, config.serverUrl)
216    setStatus(io, runtime.messages.statusLogin)
217  } else {
218    setStatus(io, runtime.messages.statusOffline)
219  }
220  return null
221}
222
hooks/lib/turns.ts 216 lines
1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Cada prompt es una tirada: `turn/start` al empezar el turno, dos booleanos mientras corre y
3// `turn/end` al acabar. Todo en segundo plano y con timeout: el turno nunca espera al casino.
4
5import type { Io } from './io.ts'
6import { callApi, debug, isUnauthorized } from './api.ts'
7import type { ModConfig } from './config.ts'
8import { dailyLimitToast, queuedToast, resultToast } from './format.ts'
9import { afterRound, effectsOf } from './items.ts'
10import { turnEndBody, turnStartBody } from './payloads.ts'
11import { isTurnEnd, isTurnStart } from './responses.ts'
12import type { TurnEndView } from './responses.ts'
13import { clearSession, loadSession, setStatus, showWallet, toast } from './runtime.ts'
14import type { Runtime, StoredSession } from './runtime.ts'
15import { markConnected, openWindow } from './window.ts'
16import type { WindowState } from './window.ts'
17
18/** Lo que el mod recuerda de un turno en curso: un id propio y dos booleanos. Nada más. */
19export interface TurnRecord {
20  readonly turnId: string
21  gitPushOk: boolean
22  testFailed: boolean
23  /** Resuelve con la sesión con la que se abrió la ronda, o `null` si no se abrió. */
24  readonly started: Promise<StoredSession | null>
25}
26
27export interface TurnState {
28  /** Turnos del bucle principal en curso, por el id del engine. */
29  readonly turns: Map<string, TurnRecord>
30  /** El turno principal más reciente (al que se apuntan las llamadas a Bash). */
31  active: TurnRecord | null
32  readonly window: WindowState
33}
34
35/** Abre la ronda en el servidor y, si no hay ventana conectada, la abre. */
36async function startRound(
37  io: Io,
38  runtime: Runtime,
39  config: ModConfig,
40  state: TurnState,
41  turnId: string,
42): Promise<StoredSession | null> {
43  const session = await loadSession(io, config.serverUrl)
44  if (session === null) {
45    setStatus(io, runtime.messages.statusLogin)
46    if (!runtime.loginHintShown) {
47      runtime.loginHintShown = true
48      toast(io, runtime.messages.loginHint)
49    }
50    return null
51  }
52  const os = await runtime.os
53  const { pluginVersion } = runtime
54  const result = await callApi(io, {
55    baseUrl: config.serverUrl,
56    route: 'turnStart',
57    guard: isTurnStart,
58    timeoutMs: config.turnTimeoutMs,
59    token: session.token,
60    body: turnStartBody({ turnId, pluginVersion, os }),
61  })
62  if (!result.ok) {
63    if (isUnauthorized(result)) {
64      await clearSession(io, config.serverUrl)
65      setStatus(io, runtime.messages.statusLogin)
66    } else {
67      setStatus(io, runtime.messages.statusOffline)
68    }
69    return null
70  }
71  if (result.data.windowConnected) {
72    markConnected(state.window)
73  } else if (config.autoOpen && runtime.isInteractive) {
74    // En segundo plano: el enlace y el lanzador del navegador nunca retrasan `turn/end` (que
75    // espera a esta promesa). En `claude -p` no hay nadie mirando: no se abre ninguna ventana.
76    void openWindow(io, runtime, config, session, state.window, {
77      force: false,
78      timeoutMs: config.turnTimeoutMs,
79    })
80  }
81  return session
82}
83
84/** `turn.start` del bucle principal: apunta el turno y lanza la ronda sin esperarla. */
85export function beginTurn(
86  io: Io,
87  runtime: Runtime,
88  config: ModConfig,
89  state: TurnState,
90  engineTurnId: string,
91): void {
92  const turnId = crypto.randomUUID()
93  const started = startRound(io, runtime, config, state, turnId).catch((error: unknown) => {
94    debug(io, `turn/start: ${String(error)}`)
95    return null
96  })
97  const record: TurnRecord = { turnId, gitPushOk: false, testFailed: false, started }
98  state.turns.set(engineTurnId, record)
99  state.active = record
100}
101
102/** Enseña el resultado cuando la ventana ya lo ha enseñado (`revealAt`), sin destripar nada. */
103function revealResult(io: Io, runtime: Runtime, config: ModConfig, response: TurnEndView): void {
104  if (response.status === 'no_round') {
105    showWallet(io, runtime, response.wallet)
106    return
107  }
108  if (response.status === 'daily_limit') {
109    // Tampoco hay nada que destripar: este prompt no ha tirado.
110    const shown = dailyLimitToast(
111      response.limit,
112      response.reopensAt,
113      runtime.locale,
114      runtime.messages,
115      runtime.lastPhrase,
116    )
117    if (shown.phrase !== null) runtime.lastPhrase = shown.phrase
118    toast(io, shown.text)
119    showWallet(io, runtime, response.wallet)
120    return
121  }
122  if (response.status === 'queued') {
123    // No hay nada que destripar: la tirada se jugará cuando el jugador la gaste en la ventana.
124    const shown = queuedToast(
125      response.dropped,
126      response.wallet,
127      runtime.locale,
128      runtime.messages,
129      runtime.lastPhrase,
130    )
131    if (shown.phrase !== null) runtime.lastPhrase = shown.phrase
132    toast(io, shown.text)
133    showWallet(io, runtime, response.wallet)
134    return
135  }
136  const { round, wallet } = response
137  // Se mide con los relojes del servidor (resolvedAt → revealAt) para no depender del reloj local.
138  const spinLeft = Date.parse(round.revealAt) - Date.parse(round.resolvedAt)
139  const delay = Math.min(config.revealMaxDelayMs, Math.max(0, spinLeft))
140  const show = (): void => {
141    // Las cargas gastadas se descuentan al enseñar el resultado, no antes: que el casco se gaste
142    // (solo al perder) destriparía la tirada.
143    const effects = effectsOf(round)
144    if (runtime.inventory !== null && effects.length > 0) {
145      runtime.inventory = afterRound(runtime.inventory, effects)
146    }
147    const shown = resultToast(round, wallet, runtime.locale, runtime.messages, runtime.lastPhrase)
148    runtime.lastPhrase = shown.phrase
149    toast(io, shown.text)
150    showWallet(io, runtime, wallet)
151  }
152  if (delay === 0) show()
153  else io.after(delay, show)
154}
155
156/** Cierra la ronda en el servidor con la duración y los dos booleanos. */
157async function endRound(
158  io: Io,
159  runtime: Runtime,
160  config: ModConfig,
161  record: TurnRecord,
162  durationMs: number,
163): Promise<void> {
164  const session = await record.started
165  if (session === null) return
166  const os = await runtime.os
167  const { pluginVersion } = runtime
168  const result = await callApi(io, {
169    baseUrl: config.serverUrl,
170    route: 'turnEnd',
171    guard: isTurnEnd,
172    timeoutMs: config.turnTimeoutMs,
173    token: session.token,
174    body: turnEndBody({
175      turnId: record.turnId,
176      durationMs,
177      gitPushOk: record.gitPushOk,
178      testFailed: record.testFailed,
179      pluginVersion,
180      os,
181    }),
182  })
183  if (!result.ok) {
184    if (isUnauthorized(result)) {
185      await clearSession(io, config.serverUrl)
186      setStatus(io, runtime.messages.statusLogin)
187    } else {
188      setStatus(io, runtime.messages.statusOffline)
189    }
190    return
191  }
192  // En `claude -p` no hay nadie mirando: no se programa el toast.
193  if (runtime.isInteractive) revealResult(io, runtime, config, result.data)
194}
195
196/**
197 * `turn.complete` del bucle principal. Devuelve la promesa del envío: la sesión interactiva no la
198 * espera; `claude -p` sí (acotada por los timeouts), para que el proceso no acabe antes de enviarla.
199 */
200export function finishTurn(
201  io: Io,
202  runtime: Runtime,
203  config: ModConfig,
204  state: TurnState,
205  engineTurnId: string,
206  durationMs: number,
207): Promise<void> | null {
208  const record = state.turns.get(engineTurnId)
209  if (record === undefined) return null
210  state.turns.delete(engineTurnId)
211  if (state.active === record) state.active = null
212  return endRound(io, runtime, config, record, durationMs).catch((error: unknown) => {
213    debug(io, `turn/end: ${String(error)}`)
214  })
215}
216
hooks/lib/window.ts 119 lines
1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Abre la ventana del juego: enlace de un solo uso → navegador Chromium en modo app → o, si no
3// hay, el navegador por defecto. Una sola vez: si ya se abrió hace poco y aún no conectó, espera.
4
5import type { Io } from './io.ts'
6import { callApi, debug } from './api.ts'
7import type { ApiFailure } from './api.ts'
8import { findBrowser, isSafeWindowUrl, launchCommand } from './browser.ts'
9import type { BrowserCandidate } from './browser.ts'
10import { originOf } from './config.ts'
11import type { ModConfig } from './config.ts'
12import { emptyBody } from './payloads.ts'
13import { isLink } from './responses.ts'
14import type { Runtime, StoredSession } from './runtime.ts'
15
16/** Cómo acabó la apertura; si falló el enlace, con el motivo (para decir "sesión caducada"). */
17export type OpenResult =
18  | { readonly status: 'opened' | 'skipped' | 'failed' }
19  | { readonly status: 'link-failed'; readonly failure: ApiFailure }
20
21/** Estado de la apertura durante esta carga del módulo. */
22export interface WindowState {
23  /** Cuándo se intentó abrir por última vez sin que la ventana llegara a conectar. */
24  lastOpenAt: number | null
25  /** Hay una apertura en curso (dos turnos a la vez no abren dos ventanas). */
26  opening: boolean
27  /** Navegador encontrado (se busca una vez por carga). `undefined` = aún no se ha buscado. */
28  browser: BrowserCandidate | null | undefined
29}
30
31export function createWindowState(): WindowState {
32  return { lastOpenAt: null, opening: false, browser: undefined }
33}
34
35/** El servidor dice que hay una ventana conectada: la próxima vez que no la haya, se abre ya. */
36export function markConnected(state: WindowState): void {
37  state.lastOpenAt = null
38}
39
40async function locateBrowser(io: Io, state: WindowState, runtime: Runtime) {
41  if (state.browser !== undefined) return state.browser
42  const os = await runtime.os
43  const env = {
44    home: await io.env('HOME'),
45    programFiles: await io.env('ProgramFiles'),
46    programFilesX86: await io.env('ProgramFiles(x86)'),
47    localAppData: await io.env('LOCALAPPDATA'),
48  }
49  state.browser = await findBrowser(os, env, async path => {
50    try {
51      return await io.exists(path)
52    } catch (error) {
53      debug(io, `no he podido comprobar ${path}: ${String(error)}`)
54      return false
55    }
56  })
57  return state.browser
58}
59
60/**
61 * Abre la ventana. Con `force` (el comando `/roulette`) ignora la espera entre aperturas.
62 * Nunca lanza.
63 */
64export async function openWindow(
65  io: Io,
66  runtime: Runtime,
67  config: ModConfig,
68  session: StoredSession,
69  state: WindowState,
70  options: { readonly force: boolean; readonly timeoutMs: number },
71): Promise<OpenResult> {
72  if (state.opening) return { status: 'skipped' }
73  state.opening = true
74  try {
75    const now = await io.now()
76    if (
77      !options.force &&
78      state.lastOpenAt !== null &&
79      now - state.lastOpenAt < config.windowReopenCooldownMs
80    ) {
81      return { status: 'skipped' }
82    }
83    state.lastOpenAt = now
84
85    const link = await callApi(io, {
86      baseUrl: config.serverUrl,
87      route: 'link',
88      guard: isLink,
89      timeoutMs: options.timeoutMs,
90      token: session.token,
91      body: emptyBody(),
92    })
93    if (!link.ok) return { status: 'link-failed', failure: link }
94    if (!isSafeWindowUrl(link.data.url, originOf(config.serverUrl))) {
95      debug(io, 'el servidor ha devuelto un enlace que no es de su origen; no lo abro')
96      return { status: 'failed' }
97    }
98
99    const os = await runtime.os
100    const browser = await locateBrowser(io, state, runtime)
101    const argv = launchCommand(os, browser, link.data.url, config.windowSize)
102    if (argv === null) {
103      debug(io, `no sé abrir un navegador en este SO (${os})`)
104      return { status: 'failed' }
105    }
106    const { exitCode, stderr } = await io.run(argv, config.launchTimeoutMs)
107    if (exitCode !== 0) {
108      debug(io, `el lanzador del navegador ha salido con ${exitCode}: ${stderr.slice(0, 200)}`)
109      return { status: 'failed' }
110    }
111    return { status: 'opened' }
112  } catch (error) {
113    debug(io, `no he podido abrir la ventana: ${String(error)}`)
114    return { status: 'failed' }
115  } finally {
116    state.opening = false
117  }
118}
119
hooks/generated/protocol.ts 113 lines
1// SPDX-License-Identifier: AGPL-3.0-or-later
2// GENERADO por scripts/sync-mod-shared.ts a partir de packages/shared/src/{protocol/common,protocol/routes,game/referral,game/items}.ts. No lo edites a mano.
3// Constantes del protocolo que usa el mod: versión, SO, idiomas, errores, textos, rutas, el
4// formato de los códigos de afiliado y los ids del catálogo de la TIENDA.
5
6export const PROTOCOL_VERSION = 1 as const
7export const OS_VALUES = ['linux', 'darwin', 'win32', 'other'] as const
8export type Os = (typeof OS_VALUES)[number]
9export const LOCALES = ['en', 'es', 'fr', 'de'] as const
10export type Locale = (typeof LOCALES)[number]
11export const ERROR_CODES = [
12  'invalid_request',
13  'unauthorized',
14  'forbidden',
15  'not_found',
16  'conflict',
17  'rate_limited',
18  'payload_too_large',
19  'insufficient_funds',
20  'too_many_bets',
21  'invalid_bet',
22  'loan_not_allowed',
23  'daily_limit',
24  'protocol_mismatch',
25  'github_unavailable',
26  'internal',
27] as const
28export type ErrorCode = (typeof ERROR_CODES)[number]
29export const TEXT_LIMITS = { login: 39, serverName: 80, userCode: 20, errorMessage: 500 } as const
30export const API_ROUTES = {
31  meta: { method: 'GET', path: '/api/meta', auth: 'public' },
32  deviceStart: { method: 'POST', path: '/api/auth/device/start', auth: 'public' },
33  devicePoll: { method: 'POST', path: '/api/auth/device/poll', auth: 'public' },
34  link: { method: 'POST', path: '/api/auth/link', auth: 'mod' },
35  redeem: { method: 'POST', path: '/api/auth/redeem', auth: 'public' },
36  logout: { method: 'POST', path: '/api/auth/logout', auth: 'any' },
37  turnStart: { method: 'POST', path: '/api/turn/start', auth: 'mod' },
38  turnEnd: { method: 'POST', path: '/api/turn/end', auth: 'mod' },
39  bet: { method: 'POST', path: '/api/bet', auth: 'mod' },
40  me: { method: 'GET', path: '/api/me', auth: 'any' },
41  settings: { method: 'POST', path: '/api/me/settings', auth: 'any' },
42  deleteAccount: { method: 'POST', path: '/api/me/delete', auth: 'any' },
43  leaderboard: { method: 'GET', path: '/api/leaderboard', auth: 'public' },
44  hallOfRuin: { method: 'GET', path: '/api/hall-of-ruin', auth: 'public' },
45  referral: { method: 'GET', path: '/api/referral', auth: 'any' },
46  referralRedeem: { method: 'POST', path: '/api/referral/redeem', auth: 'mod' },
47  referralLookup: { method: 'GET', path: '/api/referral/lookup', auth: 'public' },
48  shop: { method: 'GET', path: '/api/shop', auth: 'any' },
49  shopBuy: { method: 'POST', path: '/api/shop/buy', auth: 'any' },
50  itemArm: { method: 'POST', path: '/api/items/arm', auth: 'any' },
51} as const
52export type ApiRouteName = keyof typeof API_ROUTES
53export const PAGE_PATHS = {
54  play: '/play',
55  privacy: '/privacy',
56  invite: '/r',
57  webConfig: '/config.json',
58  ws: '/ws',
59  healthz: '/healthz',
60} as const
61export const REFERRAL_CODE_ALPHABET = '23456789ABCDEFGHJKMNPQRSTVWXYZ' as const
62export const REFERRAL_CODE_LENGTH = 8 as const
63export const REFERRAL_REJECTIONS = [
64  'not_found',
65  'own_code',
66  'already_redeemed',
67  'cycle',
68  'too_late',
69  'too_many_spins',
70] as const
71export type ReferralRejection = (typeof REFERRAL_REJECTIONS)[number]
72export const ITEM_IDS = [
73  'rabbit_foot',
74  'oracle',
75  'insurance',
76  'turbo',
77  'streak_helmet',
78  'revert',
79  'felt_memory_blue',
80  'felt_montecarlo97',
81  'felt_dark_mode',
82  'felt_vaporwave',
83  'chips_floppy',
84  'chips_cinco_duros',
85  'chips_bottle_cap',
86] as const
87export type ItemId = (typeof ITEM_IDS)[number]
88export const CONSUMABLE_IDS = [
89  'rabbit_foot',
90  'oracle',
91  'insurance',
92  'turbo',
93  'streak_helmet',
94] as const
95export type ConsumableId = (typeof CONSUMABLE_IDS)[number]
96export const COSMETIC_IDS = [
97  'felt_memory_blue',
98  'felt_montecarlo97',
99  'felt_dark_mode',
100  'felt_vaporwave',
101  'chips_floppy',
102  'chips_cinco_duros',
103  'chips_bottle_cap',
104] as const
105export const FELT_IDS = [
106  'classic',
107  'memory_blue',
108  'montecarlo97',
109  'dark_mode',
110  'vaporwave',
111] as const
112export const CHIP_SKIN_IDS = ['classic', 'floppy', 'cinco_duros', 'bottle_cap'] as const
113