🎰 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).

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
| Command | Alias | What it does | |
|---|---|---|---|
/roulette | /ruleta | Open the window | |
/bet <amount> <square> | /apostar | Bet on the current or next spin (/bet 200 red, /bet 100 1st12) | |
/all-in [square] | Your whole balance (red by default) | ||
/balance | /saldo | Balance, debt, net worth, rank and pending spins | |
/vibe-login, /vibe-logout | GitHub device-code login / log out | ||
/vibe-hide, /vibe-show | Hide 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-tienda | The 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-usar | Arm or disarm a pocket item for the next spin; armed items show as glyphs in the status line (🎰 750 🪙 · 🐇🛡) |
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.
A plain list for the plugin directory reviewers and for anyone who wants to know before installing.
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.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.uname -s, once per session, to tell Linux from macOS (the generic OS above).--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.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./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./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.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.
| Option | Default | Meaning |
|---|---|---|
serverUrl | https://vibe.aimar.dev | Server to play on (/vibe-server changes it) |
autoOpen | true | Open the window on each prompt if it is not open |
language | auto | auto (system locale), en, es, fr or de |
turnTimeoutMs | 1500 | Timeout of the requests made during a turn (100–1500) |
commandTimeoutMs | 5000 | Timeout of the slash commands' requests (500–30000) |
windowReopenCooldownMs | 60000 | Wait for an opened window to connect before opening another |
windowSize | 960,720 | App-mode window size |
launchTimeoutMs | 5000 | How long the browser launcher may take (500–30000) |
revealMaxDelayMs | 30000 | Longest 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).
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..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).typecheck, validate and test.hooks/register.ts 484 lines1// 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}
484hooks/lib/api.ts 124 lines1// 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}
124hooks/lib/auth.ts 208 lines1// 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}
208hooks/lib/config.ts 162 lines1// 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}
162hooks/lib/io.ts 57 lines1// 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}
57hooks/lib/bash.ts 351 lines1// 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}
351hooks/lib/commands.ts 629 lines1// 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}
629hooks/lib/messages.ts 432 lines1// 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}
432hooks/lib/runtime.ts 222 lines1// 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}
222hooks/lib/turns.ts 216 lines1// 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}
216hooks/lib/window.ts 119 lines1// 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}
119hooks/generated/protocol.ts 113 lines1// 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