diff --git a/openspec/changes/spanish-copy/apply-progress.md b/openspec/changes/spanish-copy/apply-progress.md new file mode 100644 index 0000000..32cd2ec --- /dev/null +++ b/openspec/changes/spanish-copy/apply-progress.md @@ -0,0 +1,58 @@ +# Apply Progress: spanish-copy + +Mode: Strict TDD. Artifact store: hybrid. Branch: `feat/spanish-copy-hackathon` (from `main` 9caa453). Not pushed. + +## Phase 1 (PR1): Domain Catalog + Hackathon — COMPLETE (1.1-1.10) + +Phases 2 and 3 pending. + +### Commits + +| Hash | Message | +|------|---------| +| 5b2794e | feat(copy): add Spanish domain catalog for analysis formatting and truncation | +| 65438c6 | feat(copy): use Spanish catalog in hackathon job, link and request use cases | +| 4e5ff40 | feat(copy): translate /hackathon replies via adapter catalog and add language guard | + +### TDD Cycle Evidence + +| Task | Test File | Layer | Safety Net | RED | GREEN | TRIANGULATE | REFACTOR | +|------|-----------|-------|------------|-----|-------|-------------|----------| +| 1.1 | `test/domain/text-limit.test.ts` | Unit | Full suite green before (baseline 71 files) | Written, failed | Passed | Single (regex only) | None needed | +| 1.2 | `test/domain/hackathon/format.test.ts` | Unit | Same baseline | Written (all 11 labels in order, list markers, empty list), failed | Passed | 3 cases | None needed | +| 1.3 | `test/domain/usecases/{run-hackathon-job,link-analysis-to-topic,list-analyses,request-hackathon-analysis}.test.ts` | Unit | Same baseline | Written (exact outcomes, notes, 6 fetch phrases via `it.each`, too-thin, quota, invalid output, config), failed | Passed | 6 fetch kinds + 4 outcome cases | None needed | +| 1.4 | `test/index.queue.test.ts`, `test/http/hackathon-command-e2e.test.ts`, `test/adapters/telegram/commands.test.ts` (hackathon cases) | Unit / Integration | Same baseline | Written (57 failures across 10 files at first RED run), failed | Passed | Ack, usage, refusals, cap, busy, enqueue failure, link ack, notes | None needed | +| 1.5 | `test/copy/catalog-language.test.ts` | Unit | N/A (new) | Written first (imports missing catalogs), failed | Passed | Denylist + non-empty per catalog, fetch map keys, label order | None needed | +| 1.6 | `src/domain/copy.ts` | Unit | N/A (new) | Covered by 1.1-1.5 | Passed | Via 1.2/1.3/1.5 | None needed | +| 1.7 | `format.ts`, `text-limit.ts` | Unit | Baseline | Covered by 1.1/1.2 | Passed | Yes | Removed local constants | +| 1.8 | three use cases | Unit | Baseline | Covered by 1.3 | Passed | Yes | None needed | +| 1.9 | `src/adapters/telegram/{copy,hackathon-commands}.ts` | Integration | Baseline | Covered by 1.4/1.5 | Passed | Yes | Removed `USAGE`/`GROUP_ONLY` constants | +| 1.10 | full suite | - | - | - | 71 files, 828 tests pass; `npm run typecheck` clean | - | - | + +### Work Unit Evidence + +| Evidence | Result | +|---|---| +| Focused test command | `npm test -- test/domain test/copy test/index.queue.test.ts test/http/hackathon-command-e2e.test.ts test/adapters/telegram/commands.test.ts`. RED: 57 failed / 347 passed. GREEN then: 828/828 after two test-side fixes (see below) | +| Runtime harness | N/A: copy only; fetch, LLM and validation logic unchanged | +| Rollback boundary | `src/domain/copy.ts`, `src/adapters/telegram/copy.ts`, the call sites in `format.ts`, `text-limit.ts`, the three use cases and `hackathon-commands.ts` | + +### Files changed + +- Created: `src/domain/copy.ts`, `src/adapters/telegram/copy.ts`, `test/copy/catalog-language.test.ts` +- Modified src: `src/domain/hackathon/format.ts`, `src/domain/text-limit.ts`, `src/domain/usecases/{run-hackathon-job,link-analysis-to-topic,request-hackathon-analysis}.ts`, `src/adapters/telegram/hackathon-commands.ts` +- Modified tests: `test/domain/{text-limit,hackathon/format}.test.ts`, `test/domain/usecases/{run-hackathon-job,link-analysis-to-topic,list-analyses,request-hackathon-analysis}.test.ts`, `test/index.queue.test.ts`, `test/http/hackathon-command-e2e.test.ts`, `test/adapters/telegram/commands.test.ts` + +### Deviations / notes + +- `http-status` phrase is "el sitio respondió con un error", per the openspec files. +- Domain exception messages (e.g. `UnauthorizedError("Only a team admin may ...")`) stay English: they never reach users (adapters map errors by name) and the spec keeps them unchanged. +- The `/repos` "...and N more" line and its test are unchanged in PR1 (`reposReply` is PR2), as the design allows. +- Post-GREEN fixes on the test side: one e2e assertion (`could not start`) was missed in RED, and the `teamSize` fixture needed a numeric value to typecheck. +- Adapter catalog shape: `commonCopy` (`notMember`, `linkedHere`) and `hackathonCopy` (usage, groupOnly, adminOnly, noAnalysis, unsafeUrl, urlTooLong, dailyCap, busy, queueSendFailed, notConfigured, publishFailed). +- Leftover-English grep over the Phase 1 reply paths: only exception messages and comments remain. +- Size: about 192 src lines changed (131 added, 61 removed) and 334 test lines changed (277 added, 57 removed); above the ~260 forecast because of the added tests. + +### Remaining + +Phase 2 (2.1-2.7), Phase 3 (3.1-3.5), Phase 4 (4.1-4.2). diff --git a/openspec/changes/spanish-copy/design.md b/openspec/changes/spanish-copy/design.md new file mode 100644 index 0000000..d7be784 --- /dev/null +++ b/openspec/changes/spanish-copy/design.md @@ -0,0 +1,160 @@ +# Design: Spanish Bot Copy + +## Technical Approach + +This is a copy-only change. Every bot-authored Telegram string moves into one of two plain TypeScript catalogs, one per hexagonal layer, and is translated to neutral, professional Spanish using the **tú** form (never usted or voseo). Each call site replaces its inline literal with a catalog entry. Interpolated codes (role, GitHub kind/action, fetch kind, link/unlink) go through exhaustive typed maps, so a new code fails `npm run typecheck`. Page values, the LLM prompt, identifiers, log events and codes, and domain exception messages stay unchanged (specs `bot-copy` and the `hackathon-analysis` delta). The only non-copy edit is that `reposReply` now delegates to `joinLinesWithinLimit`, which uses the same algorithm, so only one "…y N más" implementation remains. + +## Architecture Decisions + +| Topic | Choice | Rejected (tradeoff) | +|---|---|---| +| Catalog layout | `src/domain/copy.ts` holds text the domain authors: analysis labels, list markers, job outcomes, link notes, the ack, the truncation line, fetch phrases, GitHub alert header and labels. `src/adapters/telegram/copy.ts` holds command replies, usage, error maps, picker text and role labels. The adapter catalog imports the domain catalog, never the other way round | One catalog in `src/` (the domain would import adapter text, or the adapter would own domain text). Editing strings in place (duplicates drift, and there are ~115 sites to proofread) | +| Shape | Nested `const` objects with string leaves. Interpolated text uses small arrow functions with typed params (`(slug: string) => string`). Code maps are `Readonly>` literals | An i18n library or ICU templates (only one locale is needed; adds a dependency). A string-key lookup like `t("key")` (loses typing) | +| Exhaustiveness | Declared as `Record`, `Record` and `Record<\`${GithubEventKind}:${GithubEventAction}\`,…>`. A missing key or an extra key is a compile error | `switch` with a default branch (a new code silently falls back) | +| GitHub combos | All 8 template-literal keys are present. `issues:merged` and `issues:review_requested` are never produced by the mapper, but the domain type allows them, so they get safe phrases and a comment | Tightening `GithubEvent` into a discriminated union (a behavior-adjacent type change, out of scope) | +| Shared strings | "Only public http(s)…" and "not configured" live in the domain catalog (`analysisCopy.unsafeUrl`, `analysisCopy.notConfigured`); `ERROR_REPLIES` references them. "Not a member" (×4) is `commonCopy.notMember` and "No team is registered…" (×2) is `commonCopy.noTeamForChat`, both in the adapter catalog. "Se vinculó X a este tema." is `commonCopy.linkedHere(name)`, used by `/linkrepo` and `/hackathon ` | Keeping duplicates (they drift) | +| "…y N más" | `domainCopy.moreItems(n)` is the only template. `reposReply` becomes `joinLinesWithinLimit(lines, REPLY_MAX, repoCopy.none)`. The loop is byte-identical, and the existing `/repos` truncation tests guard it | Two loops that each import `moreItems` (the duplicate algorithm remains) | +| Admin refusals | Every one uses the spec pattern "Solo un administrador del equipo puede …". `/setup` says "…del grupo de Telegram…", because it checks the Telegram group admin | Mixed phrasings | +| Tests | Tests assert literal Spanish strings. They never import the catalogs, so a catalog typo fails a test | Asserting against catalog imports (tautological) | + +## Data Flow + +``` +domain use case ── domainCopy ──▶ replyText / publisher.post ─┐ +adapter command ── telegramCopy ─(imports domainCopy)─────────┼─▶ ctx.reply / sendMessage (plain, ≤4096) +github route ── formatGithubAlert ── GITHUB_ALERT_HEADERS ─────┘ +``` + +## Copy Table (verbatim for apply and tests) + +`{x}` marks an interpolated value. Loanwords that stay: slug, PR, Issue, URL, http(s), UTC, and the command and argument keywords. + +**Domain: `src/domain/copy.ts`** + +| Site | English | Spanish | +|---|---|---| +| format labels | Slug / Name / Format / Location / Team size | Slug / Nombre / Formato / Ubicación / Tamaño del equipo | +| | Submission deadline / Start date / End date / Results date | Fecha límite de entrega / Fecha de inicio / Fecha de fin / Fecha de resultados | +| | Prizes / Tracks / Eligibility / Suggested repos | Premios / Categorías / Requisitos / Repositorios sugeridos | +| list markers | (unnamed) / no deadline found / linked / not linked | (sin nombre) / sin fecha límite / vinculado / no vinculado | +| list empty | No hackathons analyzed yet. | Todavía no se ha analizado ningún hackathon. | +| text-limit | ...and {n} more | …y {n} más | +| request ack | Analyzing {host}… the result will be posted here. | Analizando {host}… el resultado se publicará aquí. | +| job | The saved analysis could not be found; run it again. | No se encontró el análisis guardado; ejecútalo de nuevo. | +| | Analysis expired; run it again. | El análisis caducó; ejecútalo de nuevo. | +| | Only public http(s) pages can be analyzed. | Solo se pueden analizar páginas públicas http(s). | +| | Could not read that page ({kind}). Any previous analysis was kept. | No se pudo leer esa página ({frase}). Se conservó el análisis anterior. | +| | The page has too little readable text. Previous analysis kept. | La página tiene muy poco texto legible. Se conservó el análisis anterior. | +| | Today's shared AI quota is used up; try after 00:00 UTC. Previous analysis kept. | Se agotó la cuota compartida de IA de hoy; inténtalo después de las 00:00 UTC. Se conservó el análisis anterior. | +| | The AI could not produce a valid analysis. Previous analysis kept. | La IA no pudo generar un análisis válido. Se conservó el análisis anterior. | +| | Hackathon analysis is not configured. | El análisis de hackathons no está configurado. | +| | The analysis failed due to a temporary error. Try again later. | El análisis falló por un error temporal. Inténtalo de nuevo más tarde. | +| link notes | Replaced the topic's previous link (was {slug}). | Se reemplazó el vínculo anterior del tema (era {slug}). | +| | Moved this analysis's link from another topic. | Se movió el vínculo de este análisis desde otro tema. | +| | Pinning failed; the message was posted unpinned. | No se pudo fijar el mensaje; se publicó sin fijar. | +| fetch kinds | timeout / too-large / http-status | tiempo de espera agotado / la página es demasiado grande / el sitio respondió con un error | +| | content-type / redirects / network | el contenido no es una página web / demasiadas redirecciones / error de red | +| GitHub header `{repo} — {h}` | pull_request: opened / closed / merged / review_requested | PR abierto / PR cerrado / PR fusionado / Revisión solicitada | +| | issues: opened / closed / (merged) / (review_requested) | Issue abierto / Issue cerrado / Issue fusionado / Revisión solicitada | +| GitHub labels | Reviewer: / By: | Revisor: / Por: | + +**Adapter: `src/adapters/telegram/copy.ts`** + +| Site | English | Spanish | +|---|---|---| +| common | You are not a member of this team. | No eres miembro de este equipo. | +| | No team is registered for this chat. Ask an admin to run /setup. | No hay ningún equipo registrado en este chat. Pide a un administrador que ejecute /setup. | +| | Could not verify your team membership. Please try /{cmd} again. | No se pudo verificar tu pertenencia al equipo. Vuelve a intentar /{cmd}. | +| | Linked {x} to this topic. | Se vinculó {x} a este tema. | +| roles | admin / member | administrador / miembro | +| team resolution | Join a team first by running /join in its group. | Primero únete a un equipo ejecutando /join en su grupo. | +| | Choose which team this command applies to. / button Team {id} | Elige a qué equipo se aplica este comando. / Equipo {id} | +| /setup | Run /setup inside the group you want to register as a team, not in a private chat. | Ejecuta /setup dentro del grupo que quieres registrar como equipo, no en un chat privado. | +| | You are posting as an anonymous admin, so your admin status cannot be verified. Turn off "Remain anonymous" in your admin rights and run /setup again. | Estás publicando como administrador anónimo, así que no se puede verificar tu condición de administrador. Desactiva "Permanecer anónimo" en tus permisos de administrador y vuelve a ejecutar /setup. | +| | A team is already registered for this chat. | Ya hay un equipo registrado en este chat. | +| | Could not verify your admin status. Please try again. | No se pudo verificar tu condición de administrador. Inténtalo de nuevo. | +| | Only a Telegram group admin can run /setup. | Solo un administrador del grupo de Telegram puede ejecutar /setup. | +| | Team created. You are the first admin. | Equipo creado. Eres el primer administrador. | +| /join | You are already a member of this team. / You joined the team. | Ya eres miembro de este equipo. / Te uniste al equipo. | +| /datachannel | Run /datachannel inside the topic you want to use as the team's data channel. | Ejecuta /datachannel dentro del tema que quieres usar como canal de datos del equipo. | +| | Only a team admin may bind the data channel. | Solo un administrador del equipo puede asignar el canal de datos. | +| | This topic is now the team's data channel. | Este tema es ahora el canal de datos del equipo. | +| /profile | unreadable | ilegible | +| | Member {id}{identity} — role: {role} | Miembro {id}{identity} — rol: {rol} | +| | No profile fields set. | No hay campos de perfil configurados. | +| | Team {id} / No matching member found. | Equipo {id} / No se encontró ningún miembro que coincida. | +| | Member data is available only in the team's data channel. | Los datos de los miembros solo están disponibles en el canal de datos del equipo. | +| | Usage: /profile set | Uso: /profile set | +| | You may only edit your own profile. | Solo puedes editar tu propio perfil. | +| | Profile updated for team {id}. | Perfil actualizado para el equipo {id}. | +| | Usage: /profile show [membership-id] or /profile set | Uso: /profile show [membership-id] o /profile set | +| /promote /demote | Usage: /{cmd} | Uso: /{cmd} | +| | Only a team admin may change roles. | Solo un administrador del equipo puede cambiar roles. | +| | Member not found in this team. | No se encontró al miembro en este equipo. | +| | The last team admin cannot be demoted. | No se puede degradar al último administrador del equipo. | +| | Member role changed to {role} for team {id}. | Rol del miembro cambiado a {rol} para el equipo {id}. | +| link/unlink (`Record<"link"\|"unlink">`) | Run /linkrepo inside the topic you want to link. | Ejecuta /linkrepo dentro del tema al que quieres vincular el repositorio. | +| | Run /unlinkrepo inside the topic you want to unlink. | Ejecuta /unlinkrepo dentro del tema del que quieres desvincular el repositorio. | +| | Usage: /{cmd} | Uso: /{cmd} | +| /linkrepo | Only a team admin may link a repo. | Solo un administrador del equipo puede vincular un repositorio. | +| | This repo's org is not claimed by your team. | La organización de este repositorio no está reclamada por tu equipo. | +| | Moved {repo} from topic {a} to topic {b}. Topic {a} will no longer receive alerts for this repo. | Se movió {repo} del tema {a} al tema {b}. El tema {a} ya no recibirá alertas de este repositorio. | +| /unlinkrepo | Only a team admin may unlink a repo. | Solo un administrador del equipo puede desvincular un repositorio. | +| | Unlinked {repo} from this topic. / {repo} was not linked to any topic. | Se desvinculó {repo} de este tema. / {repo} no estaba vinculado a ningún tema. | +| /repos | No repos linked yet. / {repo} -> topic {id} | Todavía no hay repositorios vinculados. / {repo} -> tema {id} | +| picker | That team is not available to you. / You are not a member of that team. | Ese equipo no está disponible para ti. / No eres miembro de ese equipo. | +| | Team selected. Run your command again to continue. | Equipo seleccionado. Vuelve a ejecutar tu comando para continuar. | +| /hackathon | Usage: /hackathon | Uso: /hackathon | +| | Run this command inside your team's group chat. | Ejecuta este comando dentro del chat grupal de tu equipo. | +| | Only a team admin may analyze or link a hackathon. | Solo un administrador del equipo puede analizar o vincular un hackathon. | +| | No analysis with that slug. See /hackathons. | No hay ningún análisis con ese slug. Consulta /hackathons. | +| | That URL is too long (max {n} characters). | Esa URL es demasiado larga (máximo {n} caracteres). | +| | Daily limit reached (5 new analyses per UTC day). Re-showing a slug is free. | Límite diario alcanzado (5 análisis nuevos por día UTC). Volver a mostrar un slug no cuenta. | +| | An analysis is already running for this team. Wait for its result. | Ya hay un análisis en curso para este equipo. Espera su resultado. | +| | Could not start the analysis; try again in a minute. This did not count toward the daily limit. | No se pudo iniciar el análisis; inténtalo de nuevo en un minuto. No se contó en el límite diario. | +| | Could not post to this chat right now. Try again in a minute. | No se pudo publicar en este chat ahora mismo. Inténtalo de nuevo en un minuto. | + +## Length (4096 cap) + +Every fixed string is under 300 chars. The dynamic paths keep their existing guards: `formatAnalysis` → `truncate(REPLY_MAX)`, `withNotes` reserves the notes and truncates only the body, `/hackathons` and `/repos` → `joinLinesWithinLimit`, and `formatGithubAlert` → `truncate(4096)`. "…" is one UTF-16 unit (the old "..." was 3), so the summary line is shorter than before and the loop invariant `candidate.length ≤ limit` still holds. All the Spanish characters are in the BMP. `/profile show` has no cap; that issue predates this change and is **out of scope**. + +## File Changes + +| File | Action | PR | +|---|---|---| +| `src/domain/copy.ts` | Create (hackathon sections) → extended with GitHub in PR3 | 1, 3 | +| `src/adapters/telegram/copy.ts` | Create (`commonCopy.notMember`, `linkedHere`, hackathon) → extended in PR2 | 1, 2 | +| `src/domain/hackathon/format.ts`, `src/domain/text-limit.ts` | Modify: labels, markers, `moreItems`. `FIELD_LABELS` moves to the catalog with the **same key order** (it drives line order) | 1 | +| `src/domain/usecases/{run-hackathon-job,link-analysis-to-topic,request-hackathon-analysis}.ts` | Modify | 1 | +| `src/adapters/telegram/hackathon-commands.ts` | Modify | 1 | +| `src/adapters/telegram/commands.ts`, `team-picker.ts` | Modify (including the `reposReply` delegation) | 2 | +| `src/domain/github.ts` | Modify: header map and labels | 3 | +| `test/copy/catalog-language.test.ts` | Create; its scope grows per PR | 1–3 | + +## Testing Strategy (Strict TDD) + +| Layer | What | Approach | +|---|---|---| +| RED first | For each slice, change the exact-string assertions to Spanish before touching `src`: `test/domain/{text-limit,hackathon/format,github}.test.ts`, `test/domain/usecases/{list-analyses,link-analysis-to-topic,run-hackathon-job}.test.ts`, `test/index.queue.test.ts`, `test/http/hackathon-command-e2e.test.ts`, `test/adapters/telegram/commands.test.ts`. The truncation regexes become `/…y \d+ más$/`. New cases: all 6 fetch phrases, the header for each GitHub combo, and role labels in `/profile show` and `/promote` | Vitest, literal strings | +| Guard | `catalog-language.test.ts` walks both catalogs, calls each function with sample args, and asserts no value matches the English denylist `/\b(the\|you\|your\|only\|could\|please\|run\|usage\|team\|member\|topic\|linked\|analysis\|page\|try)\b/i`. Allowed loanwords do not collide (`membership-id` does not match `\bmember\b`). It also checks that every code map has non-empty values | Unit | +| Exhaustiveness | Adding a role, fetch kind or GitHub action without copy fails `npm run typecheck` | `tsc --noEmit` | +| Verify | `rg` over `src/` for the former English fragments in this table (comments excluded) returns nothing. `prompt.test.ts` stays unchanged | verify phase | + +No `npm run harness` run is needed: fetch, LLM and validation are untouched. + +## Threat Matrix + +N/A: no routing, shell, subprocess, VCS/PR automation, executable-file classification or process-integration boundary. Only static strings change. + +## Migration / Rollout + +No migration is required. There are 3 chained PRs (each ≤400 lines), and each leaves its area fully Spanish with the suite green: +1. The domain catalog and hackathon (the use cases, format, text-limit, hackathon-commands, and the adapter catalog seed). +2. The profile, team, membership and repo commands and the team picker (commands.ts, team-picker.ts, and the `reposReply` delegation). +3. GitHub alerts (github.ts). + +Between PR1 and PR2, `/repos` still prints the English "...and N more". That is acceptable, because the area moves together in PR2. Rollback: revert the PRs. + +## Open Questions + +- [ ] Confirm the Telegram Spanish UI label for "Remain anonymous" ("Permanecer anónimo" is assumed). diff --git a/openspec/changes/spanish-copy/proposal.md b/openspec/changes/spanish-copy/proposal.md new file mode 100644 index 0000000..9c3e8db --- /dev/null +++ b/openspec/changes/spanish-copy/proposal.md @@ -0,0 +1,96 @@ +# Proposal: Spanish Bot Copy + +## Intent + +The team speaks Spanish, but every bot reply, refusal, label and alert is English. Translate all bot-authored Telegram text to neutral, professional Spanish with no behavior change, before change 4 (`hackathon-participation`) so it starts in Spanish. + +## Scope + +### In Scope +- Every bot-authored string sent to Telegram (~100 distinct, ~115 sites): + +| Area | Strings | +|------|---------| +| `adapters/telegram/commands.ts` (setup, join, datachannel, profile, promote/demote, linkrepo, unlinkrepo, repos, team resolution) | ~48 | +| `adapters/telegram/hackathon-commands.ts` (usage, group-only, `ERROR_REPLIES`, link ack) | ~13 | +| `adapters/telegram/team-picker.ts` (callback alert, replies) | 3 | +| `domain/hackathon/format.ts` (labels, list markers, empty list) | ~18 | +| `domain/text-limit.ts` ("...and N more") | 1 | +| `domain/usecases/run-hackathon-job.ts` (job outcomes) | 9 | +| `domain/usecases/link-analysis-to-topic.ts` (notes) | 3 | +| `domain/usecases/request-hackathon-analysis.ts` ("Analyzing…") | 1 | +| `domain/github.ts` (alert header and labels) | ~6 | + +- Display labels for interpolated codes: roles, GitHub kind/action, page-fetch failure kind, link/unlink verb. +- Tests that assert exact copy (~11 files, mainly `test/adapters/telegram/commands.test.ts`). +- Cap: every reply stays plain text, 4096 chars or fewer. + +### Out of Scope +- Extracted field values, snippets, LLM prompt, extraction, validation (no `npm run harness`) +- Command names, argument keywords (`show`, `set`, `full_name`), identifiers, logs, reason codes, domain exception messages +- i18n framework or locale switching +- `setMyCommands` (not in code; BotFather descriptions are an operator step) +- Capping `/profile show` length (pre-existing) + +## Capabilities + +### New Capabilities +- `bot-copy`: all bot-authored Telegram text is neutral Spanish; external values stay verbatim; plain text within 4096. + +### Modified Capabilities +- `hackathon-analysis`: scenarios quote English copy ("Analyzing …", "...and N more", "already running", "could not start"). + +Other specs describe replies semantically; unchanged. + +## Approach + +Plain-object catalogs, one per layer (hexagonal; domain never imports adapters): +- `src/domain/copy.ts`: domain-authored text (analysis labels, job outcomes, link notes, alert labels, truncation line) with small builder functions for interpolation. +- `src/adapters/telegram/copy.ts`: command replies, usage, error maps, picker text; reuses domain entries for shared strings ("Only public http(s) pages…", "not configured"). + +Tradeoff vs editing in place: +~120 lines and one indirection, but one proofreading surface, no drift in duplicates, and a home for change 4 copy. Tests keep literal Spanish strings, not catalog imports. + +## Affected Areas + +| Area | Impact | Description | +|------|--------|-------------| +| `src/domain/copy.ts`, `src/adapters/telegram/copy.ts` | New | Catalogs | +| Files in the inventory | Modified | Use catalog entries | +| `test/**` (~11 files) | Modified | Spanish assertions | +| `openspec/specs/hackathon-analysis` | Modified | Delta for quoted copy | + +## Risks + +| Risk | Likelihood | Mitigation | +|------|------------|------------| +| Longer copy exceeds 4096 | Low | Existing truncation; notes reserved in `withNotes` | +| Missed English string | Med | Inventory checklist; grep during verify | +| Accidental behavior change | Low | Copy-only diff; unchanged logic tests | +| Change 4 blocked | Med | Ship slices quickly | + +## Rollback Plan + +Revert the PRs. No data, schema, binding or config changes. + +## Dependencies + +- None. Blocks `hackathon-participation`. + +## Delivery + +~450-550 changed lines. Chained PRs: (1) domain catalog + hackathon; (2) profile/team/membership/repo commands + picker; (3) GitHub alerts. + +## Success Criteria + +- [ ] No English bot-authored text reaches Telegram (inventory complete) +- [ ] Extracted values and snippets unchanged +- [ ] All replies plain text, 4096 chars or fewer +- [ ] Test suite green with Spanish assertions; no logic diffs + +## Proposal question round + +Assumptions needing review (defaults applied): +1. Roles display as "administrador"/"miembro"; stored role values stay `admin`/`member`. +2. GitHub header maps codes to Spanish ("Pull request abierto", "Issue cerrado", "Revisión solicitada"). +3. Fetch failure kind shows a Spanish phrase ("tiempo de espera agotado"), not the raw code. +4. Loanwords "slug", "pull request" and "issue" stay; "topic" becomes "tema". diff --git a/openspec/changes/spanish-copy/specs/bot-copy/spec.md b/openspec/changes/spanish-copy/specs/bot-copy/spec.md new file mode 100644 index 0000000..533c25d --- /dev/null +++ b/openspec/changes/spanish-copy/specs/bot-copy/spec.md @@ -0,0 +1,97 @@ +# Bot Copy Specification + +## Purpose + +Defines the language and format of all bot-authored Telegram text: neutral, professional Spanish, plain text, within the Telegram length cap, with external values left verbatim. + +## Requirements + +### Requirement: Spanish-Only Bot-Authored Text + +The system MUST write every bot-authored user-facing Telegram text (replies, refusals, usage lines, labels, list markers, empty-list text, callback alerts, job outcomes, link notes, alert headers) in neutral, professional Spanish. No English sentence or label authored by the bot MAY reach Telegram. Loanwords "slug", "PR" and "Issue" are allowed. The Telegram concept "topic" MUST be written "tema". Roles MUST display as "administrador" and "miembro"; stored role values MUST NOT change. + +#### Scenario: Refusal is in Spanish + +- GIVEN the caller is not a team admin +- WHEN they run `/hackathon ` +- THEN the reply is "Solo un administrador del equipo puede analizar o vincular un hackathon." + +#### Scenario: Role display + +- GIVEN a member has the stored role `admin` and another has `member` +- WHEN a reply shows their roles +- THEN it shows "administrador" and "miembro" respectively, never `admin` or `member` + +#### Scenario: Topic wording + +- WHEN any reply refers to a Telegram forum topic +- THEN it uses "tema", never "topic" + +### Requirement: Page-Derived Values Stay Verbatim + +The system MUST NOT translate, rewrite or localize values that originate outside the bot: extracted field values, snippets, hackathon names and any text taken from a web page or from GitHub. Only the bot's own labels and framing around them are Spanish. + +#### Scenario: English page keeps its values + +- GIVEN an analysis whose extracted `location` value is "Online" and deadline snippet is "Submissions close June 1" +- WHEN the analysis is formatted for Telegram +- THEN the labels are Spanish +- AND "Online" and "Submissions close June 1" appear unchanged + +### Requirement: Code, Prompt and Logs Stay English + +The system MUST keep the LLM prompt, command names, argument keywords, identifiers, log events, log reason codes and domain exception messages in English. Copy changes MUST NOT alter extraction, validation or any behavior. + +#### Scenario: Prompt unchanged + +- WHEN a fresh analysis calls the LLM +- THEN the prompt is byte-identical to the pre-change prompt + +#### Scenario: Log codes unchanged + +- WHEN a command is refused +- THEN the log reason code (for example `BadArgument`) is the same English code as before + +### Requirement: Plain Text Within the Telegram Cap + +The system MUST send all bot-authored text as plain text (no `parse_mode`) and each message MUST be at most 4096 characters, including the Spanish truncation line. + +#### Scenario: Longer Spanish copy still fits + +- GIVEN a listing whose full Spanish text exceeds 4096 characters +- WHEN the reply is built +- THEN it is truncated with a "…y N más" line +- AND the reply is at most 4096 characters and sent without `parse_mode` + +### Requirement: Technical Codes Are Never Shown Raw + +The system MUST map every code interpolated into user-visible text to a Spanish phrase: page-fetch failure kinds, GitHub kind/action, and link/unlink verbs. Raw codes MUST NOT appear. + +| Code | Displayed phrase | +|------|------------------| +| `timeout` | tiempo de espera agotado | +| `too-large` | la página es demasiado grande | +| `http-status` | el sitio respondió con un error | +| `content-type` | el contenido no es una página web | +| `redirects` | demasiadas redirecciones | +| `network` | error de red | + +#### Scenario: Fetch failure shows a phrase + +- GIVEN a fresh analysis fails with fetch kind `timeout` +- WHEN the failure reply is posted +- THEN it reads "No se pudo leer esa página (tiempo de espera agotado). Se conservó el análisis anterior." +- AND it does not contain "timeout" + +#### Scenario: GitHub alert header + +- GIVEN a pull request `opened` event and an issue `closed` event +- WHEN alerts are posted +- THEN the headers are "PR abierto" and "Issue cerrado" +- AND no raw `pull_request`, `opened` or `closed` code is shown + +#### Scenario: Review requested header + +- GIVEN a pull request `review_requested` event +- WHEN the alert is posted +- THEN the header is "Revisión solicitada" diff --git a/openspec/changes/spanish-copy/specs/hackathon-analysis/spec.md b/openspec/changes/spanish-copy/specs/hackathon-analysis/spec.md new file mode 100644 index 0000000..ff8714b --- /dev/null +++ b/openspec/changes/spanish-copy/specs/hackathon-analysis/spec.md @@ -0,0 +1,172 @@ +# Delta for Hackathon Analysis + +Only requirements whose scenarios quote bot copy are modified. Copy is the exact Spanish text below; see `bot-copy` for cross-cutting rules. + +## MODIFIED Requirements + +### Requirement: Admin-Only Fresh Analysis, Capped + +The system MUST allow only a team admin to run `/hackathon `, in general chat or inside a topic. The command reserves the cap slot and lease, enqueues an analysis job, and immediately acknowledges the request; the page fetch and LLM extraction run asynchronously on a queue consumer. Each such run MUST count against the team's daily cap. +(Previously: acknowledgement quoted as English "Analyzing …"; refusal unquoted) + +#### Scenario: Admin runs a fresh analysis in general chat + +- GIVEN the caller is a team admin and today's run count is below the cap +- WHEN they run `/hackathon ` in the group's general chat +- THEN the system immediately replies "Analizando … el resultado se publicará aquí." and returns +- AND the queue consumer later fetches the page, extracts fields, and stores the analysis under a new slug +- AND posts the analysis and its slug as a separate message, unpinned + +#### Scenario: Non-admin attempts a fresh analysis + +- GIVEN the caller is not a team admin +- WHEN they run `/hackathon ` +- THEN the system MUST refuse with "Solo un administrador del equipo puede analizar o vincular un hackathon." +- AND MUST NOT fetch the page, call the LLM, or count against the cap + +### Requirement: No-Argument Behavior Depends on Topic Linking + +The system MUST show the linked topic's cached analysis when `/hackathon` is run with no argument inside a topic that already has one, and MUST reply with usage instructions otherwise. +(Previously: usage reply was unquoted English) + +#### Scenario: No-argument inside a linked topic + +- GIVEN the current topic already has a linked analysis +- WHEN a member runs `/hackathon` with no argument +- THEN the system replies with the linked analysis + +#### Scenario: No-argument with nothing linked + +- GIVEN the current topic (or general chat) has no linked analysis +- WHEN a member runs `/hackathon` with no argument +- THEN the system replies "Uso: /hackathon " +- AND does not fetch, extract, or count against the cap + +### Requirement: One Analysis Per Topic, Conflicts Move the Link + +The system MUST allow at most one linked analysis per topic (nullable `thread_id`). Linking a second analysis to an already-linked topic, or linking an analysis already linked elsewhere, MUST move the link, unpin the previous pinned message, and state this in the reply. +(Previously: link notes were English) + +#### Scenario: Linking into an empty topic + +- GIVEN the topic has no linked analysis +- WHEN an admin runs `/hackathon ` inside that topic +- THEN the system links and pins the analysis to the topic +- AND a link-only reply reads "Se vinculó a este tema." + +#### Scenario: Topic already holds a different analysis + +- GIVEN topic A is linked to analysis `alpha` +- WHEN an admin runs `/hackathon beta` inside topic A +- THEN the system unpins the old pinned message for `alpha` +- AND links and pins `beta` to topic A +- AND the reply states "Se reemplazó el vínculo anterior del tema (era alpha)." + +#### Scenario: Analysis already linked to another topic + +- GIVEN analysis `alpha` is linked to topic A +- WHEN an admin runs `/hackathon alpha` inside topic B +- THEN the system unpins the old pinned message in topic A +- AND links and pins `alpha` to topic B +- AND the reply states "Se movió el vínculo de este análisis desde otro tema." + +### Requirement: Pin Failure Falls Back to Unpinned Posting + +The system MUST still post the analysis when the bot lacks the "can pin messages" right, and MUST clearly state in the reply that pinning failed. +(Previously: pin-failure note was English) + +#### Scenario: Bot lacks pin rights + +- GIVEN the bot does not have "can pin messages" in the chat +- WHEN an admin runs `/hackathon ` inside a topic +- THEN the system posts the analysis unpinned +- AND the reply states "No se pudo fijar el mensaje; se publicó sin fijar." + +### Requirement: Daily Cap on Fresh Runs + +The system MUST enforce a per-team daily cap of 5 fetch+LLM runs per UTC day, counting only fresh `/hackathon ` runs. The system MUST reserve the cap slot at enqueue time, atomically with the team lease, before the job runs. The system MUST refund the reserved slot only when enqueuing the job fails or the queued job expires unclaimed; a job that starts running MUST NOT be refunded regardless of its outcome. Re-shows by slug MUST NOT count. +(Previously: cap-exceeded refusal was an unquoted English "clear message") + +#### Scenario: Cap reached + +- GIVEN the team has already run 5 fresh analyses in the current UTC day +- WHEN an admin runs `/hackathon ` again +- THEN the system MUST refuse with "Límite diario alcanzado (5 análisis nuevos por día UTC). Volver a mostrar un slug no cuenta." +- AND MUST NOT reserve a slot, fetch the page, or call the LLM + +### Requirement: Fresh Analysis Job Safety Under Concurrency and Delivery Faults + +The system MUST refuse a second fresh analysis request for a team while one is already queued or running, without consuming a cap slot. The system MUST refuse cleanly and consume no cap slot when enqueuing the job itself fails. The system MUST guarantee that a job redelivered after reaching a terminal state produces no second cap count, no second LLM call, and no second posted result. The system MUST post a failure reply and preserve any previously stored analysis when a job exhausts its retries. +(Previously: "already running" and "could not start" replies quoted in English) + +#### Scenario: Analysis already running + +- GIVEN a fresh analysis job for the team is already queued or running +- WHEN the same team runs `/hackathon ` again +- THEN the system MUST refuse with "Ya hay un análisis en curso para este equipo. Espera su resultado." +- AND MUST NOT consume a cap slot + +#### Scenario: Enqueue failure + +- GIVEN the cap slot and lease were reserved but enqueuing the job fails +- WHEN `/hackathon ` is run +- THEN the system MUST reply "No se pudo iniciar el análisis; inténtalo de nuevo en un minuto. No se contó en el límite diario." +- AND MUST refund the reserved slot so it is not counted against the daily cap + +#### Scenario: Duplicate delivery + +- GIVEN a job has already reached a terminal state (succeeded or failed) +- WHEN the queue delivers that same job again +- THEN the system MUST ack it with no second cap count, no LLM call, and no post +- AND a job redelivered while still `persisted` MUST skip the fetch and the LLM call and only post the stored result again +- AND the design accepts that a crash between posting and marking the job succeeded can cause that post to repeat once (design.md "Post then mark") + +#### Scenario: Transient failure exhausts retries + +- GIVEN a job fails with a transient error on every attempt up to the retry limit +- WHEN the final attempt also fails +- THEN the system MUST post "El análisis falló por un error temporal. Inténtalo de nuevo más tarde." to the originating chat or topic +- AND MUST keep any previously stored analysis unchanged + +#### Scenario: Fetch failure keeps the prior result + +- GIVEN a fresh run fails to read the page with fetch kind `timeout` +- WHEN the consumer posts the failure +- THEN the reply is "No se pudo leer esa página (tiempo de espera agotado). Se conservó el análisis anterior." + +### Requirement: Listing Is Read-Only and Truncated + +The system MUST let any registered member run `/hackathons` to list slug, name, key deadline, and linked-topic status for all the team's stored analyses, truncated to at most 4096 characters using the same pattern as `/repos`. +(Previously: truncation note quoted as English "...and N more") + +#### Scenario: Listing within the limit + +- GIVEN the team has several stored analyses +- WHEN a member runs `/hackathons` +- THEN the reply lists each analysis's slug, name, key deadline, and linked status with Spanish labels +- AND the reply is at most 4096 characters + +#### Scenario: Listing exceeds the limit + +- GIVEN the team has enough stored analyses that the full listing would exceed 4096 characters +- WHEN a member runs `/hackathons` +- THEN the system truncates the reply and appends a "…y N más" line +- AND the reply remains at most 4096 characters + +### Requirement: Same-URL Refresh Keeps the Slug + +The system MUST treat a fresh analysis of the same normalized URL, for the same team, as a refresh of the existing row, keeping its slug rather than creating a new one. +(Previously: failure message was an unquoted English "clear failure message") + +#### Scenario: Re-running the same URL refreshes in place + +- GIVEN an analysis for `https://example.com/event` already exists under slug `meridian` +- WHEN an admin runs `/hackathon https://example.com/event` again +- THEN the system updates the existing `meridian` row instead of creating a second one + +#### Scenario: Failed re-analysis keeps the prior result + +- GIVEN an analysis already exists under a slug +- WHEN a fresh run for the same URL fails (fetch or extraction failure) +- THEN the system MUST keep the previously stored analysis unchanged +- AND the queue consumer MUST post a Spanish failure message to the originating chat or topic, for example "La página tiene muy poco texto legible. Se conservó el análisis anterior." diff --git a/openspec/changes/spanish-copy/tasks.md b/openspec/changes/spanish-copy/tasks.md new file mode 100644 index 0000000..618b9ad --- /dev/null +++ b/openspec/changes/spanish-copy/tasks.md @@ -0,0 +1,63 @@ +# Tasks: Spanish Bot Copy + +## Review Workload Forecast + +| Field | Value | +|-------|-------| +| Estimated changed lines | ~450-550 (PR1 ~260, PR2 ~220, PR3 ~60) | +| 400-line budget risk | Medium (aggregate); each PR Low-Medium | +| Chained PRs recommended | Yes | +| Suggested split | PR1 domain catalog + hackathon → PR2 profile/team/repo commands + picker → PR3 GitHub alerts | +| Delivery strategy | 3 chained PRs (decided in proposal/design) | +| Chain strategy | stacked-to-main (each PR targets `main` after the previous merges) | + +Decision needed before apply: No +Chained PRs recommended: Yes +Chain strategy: stacked-to-main +400-line budget risk: Medium + +No `npm run harness` needed: fetch, LLM and validation logic are unchanged; fetch phrases are copy only. + +### Suggested Work Units + +| Unit | Goal | Likely PR | Focused test command | Runtime harness | Rollback boundary | +|------|------|-----------|----------------------|-----------------|-------------------| +| 1 | Domain catalog + hackathon area Spanish | PR1 (~260) | `npm test -- test/domain test/copy test/index.queue.test.ts test/http/hackathon-command-e2e.test.ts` | N/A — copy only | `src/domain/copy.ts`, hackathon call sites, adapter catalog seed | +| 2 | Profile/team/membership/repo commands + picker Spanish | PR2 (~220) | `npm test -- test/adapters/telegram` | N/A — copy only | `commands.ts`, `team-picker.ts`, adapter catalog additions | +| 3 | GitHub alerts Spanish | PR3 (~60) | `npm test -- test/domain/github.test.ts test/copy` | N/A — copy only | `src/domain/github.ts`, its catalog section | + +## Phase 1: Domain Catalog + Hackathon (PR1) + +- [x] 1.1 RED: `test/domain/text-limit.test.ts` — truncation regex `/…y \d+ más$/`; run, see fail. +- [x] 1.2 RED: `test/domain/hackathon/format.test.ts` — Spanish labels, markers, empty list (design table); values stay verbatim. +- [x] 1.3 RED: `test/domain/usecases/{run-hackathon-job,link-analysis-to-topic,list-analyses}.test.ts` — Spanish outcomes/notes; add all 6 fetch phrases (timeout, too-large, `http-status` = "el sitio respondió con un error", content-type, redirects, network), none showing raw codes. +- [x] 1.4 RED: `test/index.queue.test.ts`, `test/http/hackathon-command-e2e.test.ts`, `test/adapters/telegram/commands.test.ts` (hackathon cases only) — ack "Analizando {host}… el resultado se publicará aquí.", usage, refusals, cap, busy, enqueue failure, link ack. +- [x] 1.5 RED: create `test/copy/catalog-language.test.ts` — walk catalogs, call builders with sample args, assert no English denylist match and non-empty code maps (scope: domain catalog + hackathon adapter entries). +- [x] 1.6 GREEN: create `src/domain/copy.ts` — `analysisCopy`, `FIELD_LABELS` (same key order), markers, `moreItems`, job outcomes, link notes, `Record` fetch phrases. +- [x] 1.7 GREEN: `src/domain/hackathon/format.ts`, `src/domain/text-limit.ts` use the catalog. +- [x] 1.8 GREEN: `src/domain/usecases/{run-hackathon-job,link-analysis-to-topic,request-hackathon-analysis}.ts` use the catalog. +- [x] 1.9 GREEN: create `src/adapters/telegram/copy.ts` seed (`commonCopy.notMember`, `linkedHere`, hackathon strings; imports domain catalog); wire `src/adapters/telegram/hackathon-commands.ts` incl. `ERROR_REPLIES`. +- [x] 1.10 Run `npm test` and `npm run typecheck`; area fully Spanish, green. + +## Phase 2: Profile, Team, Membership, Repo Commands + Picker (PR2) + +- [ ] 2.1 RED: `test/adapters/telegram/commands.test.ts` — Spanish assertions for /setup, /join, /datachannel, /profile, /promote, /demote, /linkrepo, /unlinkrepo, /repos, team resolution; add role labels ("administrador"/"miembro") in `/profile show` and `/promote`; `/repos` truncation `/…y \d+ más$/`. +- [ ] 2.2 RED: `test/adapters/telegram/team-picker` tests (or nearest existing file) — callback alerts and "Equipo {id}" button. +- [ ] 2.3 RED: extend `test/copy/catalog-language.test.ts` to the full adapter catalog incl. `Record`, link/unlink map; run, see fail. +- [ ] 2.4 GREEN: extend `src/adapters/telegram/copy.ts` (tú form, "tema", role and link/unlink `Record` maps, shared `noTeamForChat`). +- [ ] 2.5 GREEN: `src/adapters/telegram/commands.ts` uses catalog; `reposReply` delegates to `joinLinesWithinLimit(lines, REPLY_MAX, repoCopy.none)`. +- [ ] 2.6 GREEN: `src/adapters/telegram/team-picker.ts` uses catalog. +- [ ] 2.7 Run `npm test` and `npm run typecheck`. + +## Phase 3: GitHub Alerts (PR3) + +- [ ] 3.1 RED: `test/domain/github.test.ts` — header per combo (`pull_request`: PR abierto/PR cerrado/PR fusionado/Revisión solicitada; `issues`: Issue abierto/Issue cerrado/…), labels "Revisor:"/"Por:", no raw codes, ≤4096. +- [ ] 3.2 RED: extend `test/copy/catalog-language.test.ts` to `GITHUB_ALERT_HEADERS` (all 8 keys non-empty). +- [ ] 3.3 GREEN: `src/domain/copy.ts` — `Record<\`${GithubEventKind}:${GithubEventAction}\`,string>` with comment on unused `issues:merged`/`issues:review_requested`. +- [ ] 3.4 GREEN: `src/domain/github.ts` uses header map and labels. +- [ ] 3.5 Run `npm test` and `npm run typecheck`. + +## Phase 4: Final Verification (after PR3) + +- [ ] 4.1 `rg` over `src/` (comments excluded) for former English fragments (e.g. "Only a team admin", "not a member", "Usage:", "Analyzing", "and \{?n\}? more", "Reviewer:", "topic") on all reply paths; expect none. +- [ ] 4.2 Confirm `prompt.test.ts` unchanged; run `npm test` and `npm run typecheck`, both green. diff --git a/src/adapters/telegram/copy.ts b/src/adapters/telegram/copy.ts new file mode 100644 index 0000000..40e5c54 --- /dev/null +++ b/src/adapters/telegram/copy.ts @@ -0,0 +1,25 @@ +import { analysisCopy } from "../../domain/copy"; + +// Bot-authored Spanish copy for Telegram command replies (neutral "tú" form). +// Imports the domain catalog, never the other way round. PR1 seeds the shared +// and hackathon entries; later PRs extend it. +export const commonCopy = { + notMember: "No eres miembro de este equipo.", + linkedHere: (name: string) => `Se vinculó ${name} a este tema.`, +}; + +export const hackathonCopy = { + usage: "Uso: /hackathon ", + groupOnly: "Ejecuta este comando dentro del chat grupal de tu equipo.", + adminOnly: "Solo un administrador del equipo puede analizar o vincular un hackathon.", + noAnalysis: "No hay ningún análisis con ese slug. Consulta /hackathons.", + unsafeUrl: analysisCopy.unsafeUrl, + urlTooLong: (max: number | string) => `Esa URL es demasiado larga (máximo ${max} caracteres).`, + dailyCap: + "Límite diario alcanzado (5 análisis nuevos por día UTC). Volver a mostrar un slug no cuenta.", + busy: "Ya hay un análisis en curso para este equipo. Espera su resultado.", + queueSendFailed: + "No se pudo iniciar el análisis; inténtalo de nuevo en un minuto. No se contó en el límite diario.", + notConfigured: analysisCopy.notConfigured, + publishFailed: "No se pudo publicar en este chat ahora mismo. Inténtalo de nuevo en un minuto.", +}; diff --git a/src/adapters/telegram/hackathon-commands.ts b/src/adapters/telegram/hackathon-commands.ts index afa9d39..231aa14 100644 --- a/src/adapters/telegram/hackathon-commands.ts +++ b/src/adapters/telegram/hackathon-commands.ts @@ -20,6 +20,7 @@ import { listAnalyses } from "../../domain/usecases/list-analyses"; import { requestHackathonAnalysis } from "../../domain/usecases/request-hackathon-analysis"; import { showAnalysis } from "../../domain/usecases/show-analysis"; import { showTopicAnalysis } from "../../domain/usecases/show-topic-analysis"; +import { commonCopy, hackathonCopy } from "./copy"; import { runCommand } from "./command-outcome"; import type { DomainErrorReasons, DomainErrorReplies } from "./command-outcome"; import { callerLocation, resolveGroupMembership } from "./context"; @@ -46,8 +47,6 @@ export interface HackathonCommandDeps { logger: Logger; } -const USAGE = "Usage: /hackathon "; -const GROUP_ONLY = "Run this command inside your team's group chat."; // hackathon_analysis_jobs.fetch_url is CHECK-limited to 2048 characters // (migrations/0003_hackathon_analysis.sql). const FETCH_URL_MAX = 2048; @@ -62,18 +61,16 @@ class UrlTooLongError extends Error { // (or deliberately left unrecognized, which rethrows to a 500) — see // command-outcome.ts. Texts are the design.md "Error Taxonomy" replies. const ERROR_REPLIES: DomainErrorReplies = { - NotFoundError: "You are not a member of this team.", - UnauthorizedError: "Only a team admin may analyze or link a hackathon.", - AnalysisNotFoundError: "No analysis with that slug. See /hackathons.", - UnsafeUrlError: "Only public http(s) pages can be analyzed.", - UrlTooLongError: `That URL is too long (max ${FETCH_URL_MAX} characters).`, - DailyCapReachedError: - "Daily limit reached (5 new analyses per UTC day). Re-showing a slug is free.", - AnalysisBusyError: "An analysis is already running for this team. Wait for its result.", - QueueSendFailedError: - "Could not start the analysis; try again in a minute. This did not count toward the daily limit.", - ConfigError: "Hackathon analysis is not configured.", - PublishFailedError: "Could not post to this chat right now. Try again in a minute.", + NotFoundError: commonCopy.notMember, + UnauthorizedError: hackathonCopy.adminOnly, + AnalysisNotFoundError: hackathonCopy.noAnalysis, + UnsafeUrlError: hackathonCopy.unsafeUrl, + UrlTooLongError: hackathonCopy.urlTooLong(FETCH_URL_MAX), + DailyCapReachedError: hackathonCopy.dailyCap, + AnalysisBusyError: hackathonCopy.busy, + QueueSendFailedError: hackathonCopy.queueSendFailed, + ConfigError: hackathonCopy.notConfigured, + PublishFailedError: hackathonCopy.publishFailed, }; // Fixed, non-sensitive log reasons (design.md "Error Taxonomy" — Log reason). @@ -91,7 +88,7 @@ async function requireGroupCaller( if (!loc) return null; if (isPrivateChat(ctx)) { deps.logger.log({ event, outcome: "refused", errorCode: "PrivateChat" }); - await ctx.reply(GROUP_ONLY); + await ctx.reply(hackathonCopy.groupOnly); return null; } return loc; @@ -115,7 +112,7 @@ export function registerHackathonCommands(bot: Bot, deps: HackathonCommandDeps): } if (/\s/.test(argument)) { deps.logger.log({ event: "hackathon", outcome: "refused", errorCode: "BadArgument" }); - await ctx.reply(USAGE); + await ctx.reply(hackathonCopy.usage); return; } const classified = classifyHackathonArgument(argument); @@ -164,12 +161,12 @@ async function showLinkedAnalysis( }, async () => { const { team, membership } = await resolveMember(deps, loc); - if (loc.threadId === null) return { okReply: USAGE, teamId: team.id }; + if (loc.threadId === null) return { okReply: hackathonCopy.usage, teamId: team.id }; const result = await showTopicAnalysis( { teamId: team.id, actorMembershipId: membership.id, threadId: loc.threadId }, deps, ); - return { okReply: result?.replyText ?? USAGE, teamId: team.id }; + return { okReply: result?.replyText ?? hackathonCopy.usage, teamId: team.id }; }, ); } @@ -207,7 +204,7 @@ async function showBySlug( ); return { okReply: - linked.notes.length > 0 ? linked.notes.join("\n") : `Linked ${slug} to this topic.`, + linked.notes.length > 0 ? linked.notes.join("\n") : commonCopy.linkedHere(slug), teamId: team.id, }; } diff --git a/src/domain/copy.ts b/src/domain/copy.ts new file mode 100644 index 0000000..710fbca --- /dev/null +++ b/src/domain/copy.ts @@ -0,0 +1,61 @@ +import type { PageFetchFailureKind } from "./errors"; +import type { ExtractedFields } from "./hackathon/extraction"; + +// Bot-authored Spanish copy for the text the domain composes (neutral "tú" +// form, never usted or voseo). Page values, the LLM prompt, log events and +// codes, and domain exception messages are NOT part of this catalog. + +// Same key order as the `ExtractedFields` reply lines: it drives line order in +// `formatAnalysis`. +export const FIELD_LABELS: Readonly> = { + name: "Nombre", + format: "Formato", + location: "Ubicación", + teamSize: "Tamaño del equipo", + submissionDeadline: "Fecha límite de entrega", + startDate: "Fecha de inicio", + endDate: "Fecha de fin", + resultsDate: "Fecha de resultados", + prizes: "Premios", + tracks: "Categorías", + eligibility: "Requisitos", +}; + +// Exhaustive: a new fetch failure kind fails `npm run typecheck` until it has +// a phrase. +export const FETCH_FAILURE_PHRASES: Readonly> = { + timeout: "tiempo de espera agotado", + "too-large": "la página es demasiado grande", + "http-status": "el sitio respondió con un error", + "content-type": "el contenido no es una página web", + redirects: "demasiadas redirecciones", + network: "error de red", +}; + +// The only "…y N más" template (`joinLinesWithinLimit` is its sole caller). +export const moreItems = (n: number | string): string => `…y ${n} más`; + +export const analysisCopy = { + slugLabel: "Slug", + suggestedReposLabel: "Repositorios sugeridos", + unnamed: "(sin nombre)", + noDeadline: "sin fecha límite", + linked: "vinculado", + notLinked: "no vinculado", + noAnalyses: "Todavía no se ha analizado ningún hackathon.", + ack: (host: string) => `Analizando ${host}… el resultado se publicará aquí.`, + missingAnalysis: "No se encontró el análisis guardado; ejecútalo de nuevo.", + expired: "El análisis caducó; ejecútalo de nuevo.", + unsafeUrl: "Solo se pueden analizar páginas públicas http(s).", + fetchFailed: (phrase: string) => + `No se pudo leer esa página (${phrase}). Se conservó el análisis anterior.`, + tooThin: "La página tiene muy poco texto legible. Se conservó el análisis anterior.", + quota: + "Se agotó la cuota compartida de IA de hoy; inténtalo después de las 00:00 UTC. Se conservó el análisis anterior.", + invalidOutput: "La IA no pudo generar un análisis válido. Se conservó el análisis anterior.", + notConfigured: "El análisis de hackathons no está configurado.", + transient: "El análisis falló por un error temporal. Inténtalo de nuevo más tarde.", + replacedLink: (slug: string) => `Se reemplazó el vínculo anterior del tema (era ${slug}).`, + movedLink: "Se movió el vínculo de este análisis desde otro tema.", + pinFailed: "No se pudo fijar el mensaje; se publicó sin fijar.", +}; diff --git a/src/domain/hackathon/format.ts b/src/domain/hackathon/format.ts index 9102530..316f8ff 100644 --- a/src/domain/hackathon/format.ts +++ b/src/domain/hackathon/format.ts @@ -1,3 +1,4 @@ +import { analysisCopy, FIELD_LABELS } from "../copy"; import type { ExtractedFields } from "./extraction"; import { joinLinesWithinLimit } from "../text-limit"; @@ -7,20 +8,6 @@ import { joinLinesWithinLimit } from "../text-limit"; // no `parse_mode` — formatting is plain lines, never markdown/HTML. export const REPLY_MAX = 4096; -const FIELD_LABELS: Record = { - name: "Name", - format: "Format", - location: "Location", - teamSize: "Team size", - submissionDeadline: "Submission deadline", - startDate: "Start date", - endDate: "End date", - resultsDate: "Results date", - prizes: "Prizes", - tracks: "Tracks", - eligibility: "Eligibility", -}; - export interface FormatAnalysisInput { slug: string; fields: ExtractedFields; @@ -28,7 +15,7 @@ export interface FormatAnalysisInput { } export function formatAnalysis(input: FormatAnalysisInput): string { - const lines = [`Slug: ${input.slug}`]; + const lines = [`${analysisCopy.slugLabel}: ${input.slug}`]; for (const [key, label] of Object.entries(FIELD_LABELS) as Array< [keyof ExtractedFields, string] >) { @@ -36,7 +23,7 @@ export function formatAnalysis(input: FormatAnalysisInput): string { if (field !== null) lines.push(`${label}: ${field.value}`); } if (input.suggestions.length > 0) { - lines.push(`Suggested repos: ${input.suggestions.join(", ")}`); + lines.push(`${analysisCopy.suggestedReposLabel}: ${input.suggestions.join(", ")}`); } return truncate(lines.join("\n"), REPLY_MAX); } @@ -48,16 +35,14 @@ export interface HackathonListEntry { linked: boolean; } -const NO_ANALYSES_MESSAGE = "No hackathons analyzed yet."; - export function formatHackathonsList(entries: HackathonListEntry[]): string { const lines = entries.map((entry) => { - const name = entry.name ?? "(unnamed)"; - const deadline = entry.deadline ?? "no deadline found"; - const linked = entry.linked ? "linked" : "not linked"; + const name = entry.name ?? analysisCopy.unnamed; + const deadline = entry.deadline ?? analysisCopy.noDeadline; + const linked = entry.linked ? analysisCopy.linked : analysisCopy.notLinked; return `${entry.slug} — ${name} — ${deadline} — ${linked}`; }); - return joinLinesWithinLimit(lines, REPLY_MAX, NO_ANALYSES_MESSAGE); + return joinLinesWithinLimit(lines, REPLY_MAX, analysisCopy.noAnalyses); } export function truncate(text: string, max: number): string { diff --git a/src/domain/text-limit.ts b/src/domain/text-limit.ts index 56002d7..db105e9 100644 --- a/src/domain/text-limit.ts +++ b/src/domain/text-limit.ts @@ -1,8 +1,10 @@ +import { moreItems } from "./copy"; + // Generic line-list truncation shared by `/repos` and `/hackathons` (spec // hackathon-analysis: "Listing Is Read-Only and Truncated" — "the same // pattern as /repos"). Telegram rejects a message over 4096 chars; below // the limit whole lines are kept, and once a line would push the reply -// over the limit, listing stops and a fixed "...and N more" summary line +// over the limit, listing stops and a fixed "…y N más" summary line // replaces the rest (see adapters/telegram/commands.ts's prior `reposReply`, // which this generalizes). export function joinLinesWithinLimit( @@ -18,10 +20,11 @@ export function joinLinesWithinLimit( for (let kept = lines.length - 1; kept >= 0; kept--) { const omitted = lines.length - kept; const head = lines.slice(0, kept).join("\n"); - const candidate = kept > 0 ? `${head}\n...and ${omitted} more` : `...and ${omitted} more`; + const summary = moreItems(omitted); + const candidate = kept > 0 ? `${head}\n${summary}` : summary; if (candidate.length <= limit) return candidate; } // Pathological case: even the summary line alone does not fit — // defensively truncate rather than ever exceed the limit. - return `...and ${lines.length} more`.slice(0, limit); + return moreItems(lines.length).slice(0, limit); } diff --git a/src/domain/usecases/link-analysis-to-topic.ts b/src/domain/usecases/link-analysis-to-topic.ts index 98d27d7..58f5ae3 100644 --- a/src/domain/usecases/link-analysis-to-topic.ts +++ b/src/domain/usecases/link-analysis-to-topic.ts @@ -1,4 +1,5 @@ import { AnalysisNotFoundError, NotFoundError, UnauthorizedError } from "../errors"; +import { analysisCopy } from "../copy"; import { formatAnalysis, REPLY_MAX, truncate } from "../hackathon/format"; import type { HackathonAnalysis } from "../entities"; import type { MembershipId, TeamId } from "../ids"; @@ -94,7 +95,7 @@ export async function postAnalysisAndLinkTopic( const displaced = await deps.hackathonAnalysisRepo.findByThreadId(teamId, threadId); if (displaced && displaced.id !== analysis.id) { await safeUnpin(chatId, displaced.pinnedMessageId, deps); - notes.push(`Replaced the topic's previous link (was ${displaced.slug}).`); + notes.push(analysisCopy.replacedLink(displaced.slug)); } // spec: "Analysis already linked to another topic" — unpin its old @@ -105,7 +106,7 @@ export async function postAnalysisAndLinkTopic( : null; if (movedFromThreadId !== null) { await safeUnpin(chatId, analysis.pinnedMessageId, deps); - notes.push(`Moved this analysis's link from another topic.`); + notes.push(analysisCopy.movedLink); } const text = formatAnalysis({ @@ -130,7 +131,7 @@ export async function postAnalysisAndLinkTopic( errorCode: err instanceof Error ? err.name : "UnknownError", reason: "pin-failed", }); - notes.push("Pinning failed; the message was posted unpinned."); + notes.push(analysisCopy.pinFailed); } // task 5.3a (RELI-002/RESI-003): one atomic D1 batch replaces the two diff --git a/src/domain/usecases/request-hackathon-analysis.ts b/src/domain/usecases/request-hackathon-analysis.ts index ec83c46..a9d0d02 100644 --- a/src/domain/usecases/request-hackathon-analysis.ts +++ b/src/domain/usecases/request-hackathon-analysis.ts @@ -1,3 +1,4 @@ +import { analysisCopy } from "../copy"; import { AnalysisBusyError, DailyCapReachedError, @@ -128,7 +129,7 @@ export async function requestHackathonAnalysis( } const host = new URL(input.sourceUrl).hostname; - return { jobId, replyText: `Analyzing ${host}… the result will be posted here.` }; + return { jobId, replyText: analysisCopy.ack(host) }; } function utcDayOf(nowMs: number): string { diff --git a/src/domain/usecases/run-hackathon-job.ts b/src/domain/usecases/run-hackathon-job.ts index 1d5b96f..c29a6da 100644 --- a/src/domain/usecases/run-hackathon-job.ts +++ b/src/domain/usecases/run-hackathon-job.ts @@ -7,6 +7,7 @@ import { PublishFailedError, UnsafeUrlError, } from "../errors"; +import { analysisCopy, FETCH_FAILURE_PHRASES } from "../copy"; import { formatAnalysis } from "../hackathon/format"; import { normalizeUrlKey } from "../hackathon/url"; import type { AnalysisJob, AnalysisJobMessage, JobOutcome } from "../entities"; @@ -79,11 +80,7 @@ async function postPersistedResult( // The persisted analysis is missing: this is a permanent failure, not // a silent success — post then mark (design.md "Post then mark ... // never silence"). - await safePost( - job, - "The saved analysis could not be found; run it again.", - deps, - ); + await safePost(job, analysisCopy.missingAnalysis, deps); await deps.analysisJobRepo.markFailed(job.id, "job:missing-analysis"); await deps.analysisQuota.release(job.teamId, job.utcDay, job.id, false); return { kind: "ack" }; @@ -130,7 +127,7 @@ async function runClaimedJob( // Post then mark (design.md "Post then mark ... never silence"): a // crash after this point yields at most a duplicate expiry reply on // redelivery, never silence. - await safePost(job, "Analysis expired; run it again.", deps); + await safePost(job, analysisCopy.expired, deps); await deps.analysisJobRepo.markFailed(job.id, "job:expired"); await deps.analysisQuota.release(job.teamId, job.utcDay, job.id, true); return { kind: "ack" }; @@ -210,7 +207,7 @@ function classifyJobError(err: unknown): JobErrorClassification { transient: false, refund: false, reason: `unsafe-url:${err.reason}`, - reply: "Only public http(s) pages can be analyzed.", + reply: analysisCopy.unsafeUrl, }; } if (err instanceof PageFetchFailedError) { @@ -218,7 +215,7 @@ function classifyJobError(err: unknown): JobErrorClassification { transient: false, refund: false, reason: `fetch:${err.kind}`, - reply: `Could not read that page (${err.kind}). Any previous analysis was kept.`, + reply: analysisCopy.fetchFailed(FETCH_FAILURE_PHRASES[err.kind]), }; } if (err instanceof PageTooThinError) { @@ -226,7 +223,7 @@ function classifyJobError(err: unknown): JobErrorClassification { transient: false, refund: false, reason: `fetch:too-thin${err.browserQuotaDegraded ? "-browser-quota" : ""}`, - reply: "The page has too little readable text. Previous analysis kept.", + reply: analysisCopy.tooThin, }; } if (err instanceof LlmQuotaExceededError) { @@ -234,7 +231,7 @@ function classifyJobError(err: unknown): JobErrorClassification { transient: false, refund: false, reason: "llm:quota", - reply: "Today's shared AI quota is used up; try after 00:00 UTC. Previous analysis kept.", + reply: analysisCopy.quota, }; } if (err instanceof ExtractionFailedError) { @@ -242,7 +239,7 @@ function classifyJobError(err: unknown): JobErrorClassification { transient: false, refund: false, reason: `llm:${err.kind}`, - reply: "The AI could not produce a valid analysis. Previous analysis kept.", + reply: analysisCopy.invalidOutput, }; } if (err instanceof ConfigError) { @@ -250,7 +247,7 @@ function classifyJobError(err: unknown): JobErrorClassification { transient: false, refund: true, reason: "config", - reply: "Hackathon analysis is not configured.", + reply: analysisCopy.notConfigured, }; } if (err instanceof PublishFailedError && err.failureClass === "rejected") { @@ -261,7 +258,7 @@ function classifyJobError(err: unknown): JobErrorClassification { transient: true, refund: false, reason: `job:transient:${name}`, - reply: "The analysis failed due to a temporary error. Try again later.", + reply: analysisCopy.transient, }; } diff --git a/test/adapters/telegram/commands.test.ts b/test/adapters/telegram/commands.test.ts index f5f8cc8..87fba97 100644 --- a/test/adapters/telegram/commands.test.ts +++ b/test/adapters/telegram/commands.test.ts @@ -943,7 +943,7 @@ describe("registerCommands — /hackathon (hackathon-analysis spec: Admin- await bot.handleUpdate(commandUpdate("hackathon", 10, 1, { args: "https://example.com/event" })); expect(replies).toHaveLength(baseReplies + 1); - expect(replies.at(-1)?.text).toMatch(/^Analyzing example\.com/); + expect(replies.at(-1)?.text).toBe("Analizando example.com… el resultado se publicará aquí."); expect(deps.analysisQuota.reserved).toHaveLength(1); expect(deps.analysisJobQueue.sent).toMatchObject([ { v: 1, teamId, chatId: 10, threadId: null, fetchUrl: "https://example.com/event" }, @@ -965,7 +965,9 @@ describe("registerCommands — /hackathon (hackathon-analysis spec: Admin- await bot.handleUpdate(commandUpdate("hackathon", 10, 3, { args: "https://example.com/event" })); - expect(replies.at(-1)?.text).toMatch(/only a team admin/i); + expect(replies.at(-1)?.text).toBe( + "Solo un administrador del equipo puede analizar o vincular un hackathon.", + ); expect(deps.analysisQuota.reserved).toHaveLength(0); expect(deps.analysisJobQueue.sent).toHaveLength(0); }); @@ -975,7 +977,7 @@ describe("registerCommands — /hackathon (hackathon-analysis spec: Admin- await bot.handleUpdate(commandUpdate("hackathon", 10, 999, { args: "https://example.com/event" })); - expect(replies.at(-1)?.text).toMatch(/not a member/i); + expect(replies.at(-1)?.text).toBe("No eres miembro de este equipo."); expect(deps.analysisJobQueue.sent).toHaveLength(0); }); @@ -989,7 +991,7 @@ describe("registerCommands — /hackathon (hackathon-analysis spec: Admin- await bot.handleUpdate(commandUpdate("hackathon", 10, 1, { args: url })); - expect(replies.at(-1)?.text).toBe("Only public http(s) pages can be analyzed."); + expect(replies.at(-1)?.text).toBe("Solo se pueden analizar páginas públicas http(s)."); expect(deps.analysisQuota.reserved).toHaveLength(0); expect(deps.analysisJobQueue.sent).toHaveLength(0); expect(logSpy).toHaveBeenCalledWith( @@ -1005,29 +1007,34 @@ describe("registerCommands — /hackathon (hackathon-analysis spec: Admin- commandUpdate("hackathon", 10, 1, { args: `https://example.com/${"a".repeat(2100)}` }), ); - expect(replies.at(-1)?.text).toMatch(/too long/i); + expect(replies.at(-1)?.text).toBe("Esa URL es demasiado larga (máximo 2048 caracteres)."); expect(deps.analysisQuota.reserved).toHaveLength(0); }); it.each([ - ["busy", /already running/i], - ["cap-reached", /daily limit/i], + ["busy", "Ya hay un análisis en curso para este equipo. Espera su resultado."], + [ + "cap-reached", + "Límite diario alcanzado (5 análisis nuevos por día UTC). Volver a mostrar un slug no cuenta.", + ], ] as const)("replies with a clear refusal when the quota says %s and enqueues nothing", async (quota, expected) => { const { bot, replies, deps } = await hackathonTeam({ quota }); await bot.handleUpdate(commandUpdate("hackathon", 10, 1, { args: "https://example.com/event" })); - expect(replies.at(-1)?.text).toMatch(expected); + expect(replies.at(-1)?.text).toBe(expected); expect(deps.analysisJobQueue.sent).toHaveLength(0); }); - it("replies 'could not start' and refunds the slot when enqueuing fails", async () => { + it("replies 'no se pudo iniciar' and refunds the slot when enqueuing fails", async () => { const { bot, replies, deps } = await hackathonTeam({ queueThrows: true }); const logSpy = vi.spyOn(deps.logger, "log"); await bot.handleUpdate(commandUpdate("hackathon", 10, 1, { args: "https://example.com/event" })); - expect(replies.at(-1)?.text).toMatch(/could not start the analysis/i); + expect(replies.at(-1)?.text).toBe( + "No se pudo iniciar el análisis; inténtalo de nuevo en un minuto. No se contó en el límite diario.", + ); expect(deps.analysisQuota.released).toMatchObject([{ refund: true }]); expect(logSpy).toHaveBeenCalledWith( expect.objectContaining({ errorCode: "QueueSendFailedError", reason: "queue:send-failed" }), @@ -1069,7 +1076,7 @@ describe("registerCommands — /hackathon (hackathon-analysis spec: Re-Sh await bot.handleUpdate(commandUpdate("hackathon", 10, 3, { args: "nope" })); - expect(replies.at(-1)?.text).toBe("No analysis with that slug. See /hackathons."); + expect(replies.at(-1)?.text).toBe("No hay ningún análisis con ese slug. Consulta /hackathons."); }); it("an admin in a topic links and pins the analysis and acknowledges", async () => { @@ -1083,7 +1090,7 @@ describe("registerCommands — /hackathon (hackathon-analysis spec: Re-Sh expect(deps.chatPublisher.pinned).toHaveLength(1); expect(deps.hackathonAnalysisRepo.rows[0]).toMatchObject({ threadId: 77 }); // The pinned post IS the analysis; the ack must not send it a second time. - expect(replies.at(-1)?.text).toBe("Linked meridian to this topic."); + expect(replies.at(-1)?.text).toBe("Se vinculó meridian a este tema."); }); it("states in the ack that the topic's previous link was replaced", async () => { @@ -1096,7 +1103,7 @@ describe("registerCommands — /hackathon (hackathon-analysis spec: Re-Sh await bot.handleUpdate(commandUpdate("hackathon", 10, 1, { threadId: 77, args: "beta" })); expect(deps.chatPublisher.unpinned).toEqual([900]); - expect(replies.at(-1)?.text).toMatch(/replaced the topic's previous link \(was alpha\)/i); + expect(replies.at(-1)?.text).toBe("Se reemplazó el vínculo anterior del tema (era alpha)."); expect(deps.hackathonAnalysisRepo.rows.find((r) => r.slug === "alpha")?.threadId).toBeNull(); }); @@ -1110,7 +1117,7 @@ describe("registerCommands — /hackathon (hackathon-analysis spec: Re-Sh await bot.handleUpdate(commandUpdate("hackathon", 10, 1, { threadId: 77, args: "meridian" })); expect(deps.chatPublisher.posted).toHaveLength(1); - expect(replies.at(-1)?.text).toMatch(/pinning failed/i); + expect(replies.at(-1)?.text).toBe("No se pudo fijar el mensaje; se publicó sin fijar."); }); it("a non-admin member in a topic only sees the analysis, without linking or posting", async () => { @@ -1142,7 +1149,7 @@ describe("registerCommands — /hackathon (hackathon-analysis spec: Re-Sh // "meridian.dev" is classified as a URL, which is not a public http(s) // URL: nothing is looked up, reserved or enqueued. - expect(replies.at(-1)?.text).toBe("Only public http(s) pages can be analyzed."); + expect(replies.at(-1)?.text).toBe("Solo se pueden analizar páginas públicas http(s)."); expect(deps.analysisQuota.reserved).toHaveLength(0); expect(deps.analysisJobQueue.sent).toHaveLength(0); }); @@ -1167,7 +1174,7 @@ describe("registerCommands — /hackathon with no argument (hackathon-analysis s await bot.handleUpdate(commandUpdate("hackathon", 10, 3, { threadId: 77 })); - expect(replies.at(-1)?.text).toMatch(/^Usage: \/hackathon/); + expect(replies.at(-1)?.text).toBe("Uso: /hackathon "); expect(deps.analysisQuota.reserved).toHaveLength(0); expect(deps.analysisJobQueue.sent).toHaveLength(0); }); @@ -1177,7 +1184,7 @@ describe("registerCommands — /hackathon with no argument (hackathon-analysis s await bot.handleUpdate(commandUpdate("hackathon", 10, 3)); - expect(replies.at(-1)?.text).toMatch(/^Usage: \/hackathon/); + expect(replies.at(-1)?.text).toBe("Uso: /hackathon "); expect(deps.analysisQuota.reserved).toHaveLength(0); }); @@ -1186,7 +1193,7 @@ describe("registerCommands — /hackathon with no argument (hackathon-analysis s await bot.handleUpdate(commandUpdate("hackathon", 10, 1, { args: "two words" })); - expect(replies.at(-1)?.text).toMatch(/^Usage: \/hackathon/); + expect(replies.at(-1)?.text).toBe("Uso: /hackathon "); expect(deps.analysisJobQueue.sent).toHaveLength(0); }); }); @@ -1196,7 +1203,7 @@ describe("registerCommands — /hackathon outside the group and on infrastructur const { bot, replies, deps } = makeBot([TEAM_ADMIN]); await bot.handleUpdate(commandUpdate("hackathon", 30, 30, { chatType: "private", args: "meridian" })); - expect(replies[0]?.text).toMatch(/inside your team's group/i); + expect(replies[0]?.text).toBe("Ejecuta este comando dentro del chat grupal de tu equipo."); expect(deps.analysisJobQueue.sent).toHaveLength(0); }); @@ -1223,7 +1230,10 @@ describe("registerCommands — /hackathons (hackathon-analysis spec: Listing Is await bot.handleUpdate(commandUpdate("hackathons", 10, 3)); expect(replies.at(-1)?.text).toBe( - ["meridian — Hack meridian — 2026-11-01 — linked", "orbit — Hack orbit — 2026-11-01 — not linked"].join("\n"), + [ + "meridian — Hack meridian — 2026-11-01 — vinculado", + "orbit — Hack orbit — 2026-11-01 — no vinculado", + ].join("\n"), ); expect(deps.analysisQuota.reserved).toHaveLength(0); }); @@ -1233,10 +1243,10 @@ describe("registerCommands — /hackathons (hackathon-analysis spec: Listing Is await bot.handleUpdate(commandUpdate("hackathons", 10, 3)); - expect(replies.at(-1)?.text).toBe("No hackathons analyzed yet."); + expect(replies.at(-1)?.text).toBe("Todavía no se ha analizado ningún hackathon."); }); - it("truncates within 4096 characters and ends with an '...and N more' note", async () => { + it("truncates within 4096 characters and ends with a '…y N más' note", async () => { const { bot, replies, deps, teamId } = await hackathonTeam(); const total = 200; for (let i = 0; i < total; i++) { @@ -1250,7 +1260,7 @@ describe("registerCommands — /hackathons (hackathon-analysis spec: Listing Is const listed = text.split("\n").filter((line) => line.startsWith("event-")).length; expect(listed).toBeGreaterThan(0); expect(listed).toBeLessThan(total); - expect(text.endsWith(`...and ${total - listed} more`)).toBe(true); + expect(text.endsWith(`…y ${total - listed} más`)).toBe(true); }); it("only lists the caller's own team", async () => { @@ -1259,7 +1269,7 @@ describe("registerCommands — /hackathons (hackathon-analysis spec: Listing Is await bot.handleUpdate(commandUpdate("hackathons", 10, 3)); - expect(replies.at(-1)?.text).toBe("No hackathons analyzed yet."); + expect(replies.at(-1)?.text).toBe("Todavía no se ha analizado ningún hackathon."); }); it("refuses a non-member", async () => { @@ -1267,7 +1277,7 @@ describe("registerCommands — /hackathons (hackathon-analysis spec: Listing Is await bot.handleUpdate(commandUpdate("hackathons", 10, 999)); - expect(replies.at(-1)?.text).toMatch(/not a member/i); + expect(replies.at(-1)?.text).toBe("No eres miembro de este equipo."); }); it("tells a private-chat caller to use the team's group", async () => { @@ -1275,7 +1285,7 @@ describe("registerCommands — /hackathons (hackathon-analysis spec: Listing Is await bot.handleUpdate(commandUpdate("hackathons", 30, 30, { chatType: "private" })); - expect(replies[0]?.text).toMatch(/inside your team's group/i); + expect(replies[0]?.text).toBe("Ejecuta este comando dentro del chat grupal de tu equipo."); }); }); diff --git a/test/copy/catalog-language.test.ts b/test/copy/catalog-language.test.ts new file mode 100644 index 0000000..a4d9fda --- /dev/null +++ b/test/copy/catalog-language.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import { + analysisCopy, + FETCH_FAILURE_PHRASES, + FIELD_LABELS, + moreItems, +} from "../../src/domain/copy"; +import { commonCopy, hackathonCopy } from "../../src/adapters/telegram/copy"; + +// Guard for the Spanish-copy change: every bot-authored string reachable from +// the catalogs must be Spanish (no English words from the denylist) and every +// code map must be complete. Scope grows per PR (domain catalog + hackathon +// adapter entries here). +const ENGLISH_DENYLIST = + /\b(the|you|your|only|could|please|run|usage|team|member|topic|linked|analysis|page|try)\b/i; + +type Catalog = { [key: string]: unknown }; + +// Collects every string leaf; functions are called with sample arguments. +function collectStrings(node: unknown, path: string, out: Array<[string, string]>): void { + if (typeof node === "string") { + out.push([path, node]); + return; + } + if (typeof node === "function") { + out.push([path, String((node as (...args: string[]) => unknown)("sample", "sample", "sample"))]); + return; + } + if (node !== null && typeof node === "object") { + for (const [key, value] of Object.entries(node as Catalog)) { + collectStrings(value, `${path}.${key}`, out); + } + } +} + +function strings(catalog: Catalog, name: string): Array<[string, string]> { + const out: Array<[string, string]> = []; + collectStrings(catalog, name, out); + return out; +} + +const CATALOGS: Array<[string, Catalog]> = [ + ["analysisCopy", analysisCopy as unknown as Catalog], + ["FIELD_LABELS", FIELD_LABELS as unknown as Catalog], + ["FETCH_FAILURE_PHRASES", FETCH_FAILURE_PHRASES as unknown as Catalog], + ["commonCopy", commonCopy as unknown as Catalog], + ["hackathonCopy", hackathonCopy as unknown as Catalog], +]; + +describe("copy catalogs are Spanish", () => { + for (const [name, catalog] of CATALOGS) { + const entries = strings(catalog, name); + + it(`${name} has non-empty entries`, () => { + expect(entries.length).toBeGreaterThan(0); + for (const [path, text] of entries) { + expect(text.trim(), path).not.toBe(""); + } + }); + + it(`${name} contains no English denylist word`, () => { + for (const [path, text] of entries) { + expect(ENGLISH_DENYLIST.test(text), `${path}: ${text}`).toBe(false); + } + }); + } + + it("moreItems renders the Spanish truncation summary", () => { + expect(moreItems(7)).toBe("…y 7 más"); + expect(ENGLISH_DENYLIST.test(moreItems(3))).toBe(false); + }); + + it("the fetch failure map covers every failure kind with a distinct phrase", () => { + expect(Object.keys(FETCH_FAILURE_PHRASES).sort()).toEqual( + ["content-type", "http-status", "network", "redirects", "timeout", "too-large"], + ); + expect(new Set(Object.values(FETCH_FAILURE_PHRASES)).size).toBe(6); + }); + + it("the field label map keeps the reply line order", () => { + expect(Object.keys(FIELD_LABELS)).toEqual([ + "name", + "format", + "location", + "teamSize", + "submissionDeadline", + "startDate", + "endDate", + "resultsDate", + "prizes", + "tracks", + "eligibility", + ]); + }); +}); diff --git a/test/domain/hackathon/format.test.ts b/test/domain/hackathon/format.test.ts index ca01468..d5e124c 100644 --- a/test/domain/hackathon/format.test.ts +++ b/test/domain/hackathon/format.test.ts @@ -38,6 +38,44 @@ describe("formatAnalysis", () => { expect(text).toContain("2026-03-01"); }); + it("labels every field in Spanish, keeping order and page values verbatim", () => { + const field = (value: string) => ({ value, snippet: value, confidence: 0.9 }); + const text = formatAnalysis({ + slug: "meridian", + fields: { + name: field("Meridian 2026"), + format: field("Online"), + location: field("Lima"), + teamSize: { value: 4, snippet: "4", confidence: 0.9 }, + submissionDeadline: field("2026-03-01"), + startDate: field("2026-02-01"), + endDate: field("2026-02-28"), + resultsDate: field("2026-03-15"), + prizes: field("USD 1000"), + tracks: field("AI"), + eligibility: field("Open"), + }, + suggestions: ["octocat/meridian-starter", "acme/kit"], + }); + expect(text).toBe( + [ + "Slug: meridian", + "Nombre: Meridian 2026", + "Formato: Online", + "Ubicación: Lima", + "Tamaño del equipo: 4", + "Fecha límite de entrega: 2026-03-01", + "Fecha de inicio: 2026-02-01", + "Fecha de fin: 2026-02-28", + "Fecha de resultados: 2026-03-15", + "Premios: USD 1000", + "Categorías: AI", + "Requisitos: Open", + "Repositorios sugeridos: octocat/meridian-starter, acme/kit", + ].join("\n"), + ); + }); + it("omits a null field from the reply", () => { const text = formatAnalysis({ slug: "meridian", fields: emptyFields(), suggestions: [] }); expect(text).not.toContain("null"); @@ -78,7 +116,20 @@ describe("formatHackathonsList", () => { expect(text).toContain("orbit"); }); - it("truncates and appends an '...and N more' note past 4096 characters (spec: Listing exceeds the limit)", () => { + it("renders Spanish markers for linked, unlinked and missing values", () => { + const text = formatHackathonsList([ + { slug: "meridian", name: "Meridian 2026", deadline: "2026-03-01", linked: true }, + { slug: "orbit", name: null, deadline: null, linked: false }, + ]); + expect(text).toBe( + [ + "meridian — Meridian 2026 — 2026-03-01 — vinculado", + "orbit — (sin nombre) — sin fecha límite — no vinculado", + ].join("\n"), + ); + }); + + it("truncates and appends a '…y N más' note past 4096 characters (spec: Listing exceeds the limit)", () => { const entries = Array.from({ length: 200 }, (_, i) => ({ slug: `hackathon-${i}`, name: `Hackathon Number ${i}`.repeat(3), @@ -87,10 +138,10 @@ describe("formatHackathonsList", () => { })); const text = formatHackathonsList(entries); expect(text.length).toBeLessThanOrEqual(4096); - expect(text).toMatch(/\.\.\.and \d+ more$/); + expect(text).toMatch(/…y \d+ más$/); }); it("returns a fixed message when there are no analyses", () => { - expect(formatHackathonsList([])).toBe("No hackathons analyzed yet."); + expect(formatHackathonsList([])).toBe("Todavía no se ha analizado ningún hackathon."); }); }); diff --git a/test/domain/text-limit.test.ts b/test/domain/text-limit.test.ts index 3229eed..273b698 100644 --- a/test/domain/text-limit.test.ts +++ b/test/domain/text-limit.test.ts @@ -11,11 +11,11 @@ describe("joinLinesWithinLimit", () => { expect(joinLinesWithinLimit([], 4096, "Nothing yet.")).toBe("Nothing yet."); }); - it("truncates and appends an '...and N more' note when over the limit (spec: Listing exceeds the limit)", () => { + it("truncates and appends a '…y N más' note when over the limit (spec: Listing exceeds the limit)", () => { const lines = Array.from({ length: 50 }, (_, i) => `line-${i}`.repeat(20)); const result = joinLinesWithinLimit(lines, 200); expect(result.length).toBeLessThanOrEqual(200); - expect(result).toMatch(/\.\.\.and \d+ more$/); + expect(result).toMatch(/…y \d+ más$/); }); it("never exceeds the limit even in the pathological case where no line fits", () => { diff --git a/test/domain/usecases/link-analysis-to-topic.test.ts b/test/domain/usecases/link-analysis-to-topic.test.ts index 3e73813..89d933b 100644 --- a/test/domain/usecases/link-analysis-to-topic.test.ts +++ b/test/domain/usecases/link-analysis-to-topic.test.ts @@ -113,8 +113,8 @@ describe("linkAnalysisToTopic", () => { const beta = deps.hackathonAnalysisRepo.rows.find((r) => r.id === "a-beta"); expect(beta?.threadId).toBe(500); expect(beta?.pinnedMessageId).not.toBeNull(); - expect(result.replyText).toContain("Replaced"); - expect(result.notes).toEqual(["Replaced the topic's previous link (was alpha)."]); + expect(result.replyText).toContain("Se reemplazó"); + expect(result.notes).toEqual(["Se reemplazó el vínculo anterior del tema (era alpha)."]); }); // spec hackathon-analysis "Analysis already linked to another topic". @@ -134,8 +134,8 @@ describe("linkAnalysisToTopic", () => { const alpha = deps.hackathonAnalysisRepo.rows.find((r) => r.id === "a-alpha"); expect(alpha?.threadId).toBe(600); expect(alpha?.pinnedMessageId).not.toBeNull(); - expect(result.replyText).toContain("Moved"); - expect(result.notes).toEqual(["Moved this analysis's link from another topic."]); + expect(result.replyText).toContain("Se movió"); + expect(result.notes).toEqual(["Se movió el vínculo de este análisis desde otro tema."]); }); // READ-001: notes appended after a maximal-length analysis body must not @@ -160,7 +160,7 @@ describe("linkAnalysisToTopic", () => { ); expect(result.replyText.length).toBeLessThanOrEqual(REPLY_MAX); - expect(result.replyText).toContain("Replaced"); + expect(result.replyText).toContain("Se reemplazó"); }); // spec hackathon-analysis "Pin Failure Falls Back to Unpinned Posting". @@ -181,8 +181,8 @@ describe("linkAnalysisToTopic", () => { const stored = deps.hackathonAnalysisRepo.rows.find((r) => r.id === "a-1"); expect(stored?.threadId).toBe(500); expect(stored?.pinnedMessageId).toBeNull(); - expect(result.replyText).toContain("pin"); - expect(result.notes).toEqual(["Pinning failed; the message was posted unpinned."]); + expect(result.replyText).toContain("fijar"); + expect(result.notes).toEqual(["No se pudo fijar el mensaje; se publicó sin fijar."]); expect(deps.logger.entries).toHaveLength(1); expect(deps.logger.entries[0]).toMatchObject({ outcome: "error", diff --git a/test/domain/usecases/list-analyses.test.ts b/test/domain/usecases/list-analyses.test.ts index 84ac9b5..23496fc 100644 --- a/test/domain/usecases/list-analyses.test.ts +++ b/test/domain/usecases/list-analyses.test.ts @@ -88,13 +88,13 @@ describe("listAnalyses", () => { const result = await listAnalyses({ teamId, actorMembershipId }, deps); - expect(result.replyText).toContain("meridian — Meridian Hacks — 2025-01-01 — linked"); - expect(result.replyText).toContain("unlinked-hack — (unnamed) — no deadline found — not linked"); + expect(result.replyText).toContain("meridian — Meridian Hacks — 2025-01-01 — vinculado"); + expect(result.replyText).toContain("unlinked-hack — (sin nombre) — sin fecha límite — no vinculado"); expect(result.replyText.length).toBeLessThanOrEqual(4096); }); // spec hackathon-analysis "Listing exceeds the limit". - it("truncates the reply and appends an and-N-more note past 4096 characters", async () => { + it("truncates the reply and appends a …y-N-más note past 4096 characters", async () => { const deps = makeDeps(); const actorMembershipId = pushMember(deps); for (let i = 0; i < 100; i++) { @@ -106,7 +106,7 @@ describe("listAnalyses", () => { const result = await listAnalyses({ teamId, actorMembershipId }, deps); expect(result.replyText.length).toBeLessThanOrEqual(4096); - expect(result.replyText).toMatch(/\.\.\.and \d+ more/); + expect(result.replyText).toMatch(/…y \d+ más/); }); it("throws NotFoundError when the actor is not a registered member", async () => { diff --git a/test/domain/usecases/request-hackathon-analysis.test.ts b/test/domain/usecases/request-hackathon-analysis.test.ts index 0d930e2..d5ac4c4 100644 --- a/test/domain/usecases/request-hackathon-analysis.test.ts +++ b/test/domain/usecases/request-hackathon-analysis.test.ts @@ -70,7 +70,7 @@ describe("requestHackathonAnalysis", () => { deps, ); - expect(result.replyText).toContain("example.com"); + expect(result.replyText).toBe("Analizando example.com… el resultado se publicará aquí."); expect(deps.analysisQuota.reserved).toHaveLength(1); expect(deps.analysisJobQueue.sent).toHaveLength(1); expect(deps.analysisJobQueue.sent[0]!.jobId).toBe(result.jobId); diff --git a/test/domain/usecases/run-hackathon-job.test.ts b/test/domain/usecases/run-hackathon-job.test.ts index 2af780c..8e52c1c 100644 --- a/test/domain/usecases/run-hackathon-job.test.ts +++ b/test/domain/usecases/run-hackathon-job.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vitest"; import { runHackathonJob } from "../../../src/domain/usecases/run-hackathon-job"; -import { PageFetchFailedError, UnsafeUrlError } from "../../../src/domain/errors"; +import { + ConfigError, + ExtractionFailedError, + LlmQuotaExceededError, + PageFetchFailedError, + PageTooThinError, + UnsafeUrlError, + type PageFetchFailureKind, +} from "../../../src/domain/errors"; import { asTeamId } from "../../../src/domain/ids"; import type { AnalysisJob, AnalysisJobMessage } from "../../../src/domain/entities"; import { @@ -324,7 +332,7 @@ describe("runHackathonJob", () => { expect(outcome).toEqual({ kind: "ack" }); expect(deps.chatPublisher.posted).toHaveLength(1); - expect(deps.chatPublisher.posted[0]!.text).toContain("could not be found"); + expect(deps.chatPublisher.posted[0]!.text).toBe("No se encontró el análisis guardado; ejecútalo de nuevo."); expect(deps.analysisJobRepo.succeeded).toEqual([]); expect(deps.analysisJobRepo.failed).toEqual([ { id: "job-1", reason: "job:missing-analysis" }, @@ -347,7 +355,7 @@ describe("runHackathonJob", () => { expect(deps.analysisQuota.released).toEqual([ { team: teamId, day: job.utcDay, jobId: job.id, refund: true }, ]); - expect(deps.chatPublisher.posted[0]!.text).toContain("expired"); + expect(deps.chatPublisher.posted[0]!.text).toBe("El análisis caducó; ejecútalo de nuevo."); }); it("claimed job: runs the pipeline, persists, posts, and succeeds", async () => { @@ -424,7 +432,9 @@ describe("runHackathonJob", () => { expect(depsFinal.analysisQuota.released).toEqual([ { team: teamId, day: job.utcDay, jobId: job.id, refund: false }, ]); - expect(depsFinal.chatPublisher.posted[0]!.text).toContain("temporary error"); + expect(depsFinal.chatPublisher.posted[0]!.text).toBe( + "El análisis falló por un error temporal. Inténtalo de nuevo más tarde.", + ); }); it("stale job: posts the expiry reply before marking failed, never silently (RESI-001, design.md 'Post then mark')", async () => { @@ -440,7 +450,7 @@ describe("runHackathonJob", () => { ); expect(deps.chatPublisher.posted).toHaveLength(1); - expect(deps.chatPublisher.posted[0]!.text).toContain("expired"); + expect(deps.chatPublisher.posted[0]!.text).toBe("El análisis caducó; ejecútalo de nuevo."); }); it("permanent failure: posts the failure reply before marking failed, never silently (RESI-001, design.md 'Post then mark')", async () => { @@ -457,7 +467,7 @@ describe("runHackathonJob", () => { ); expect(deps.chatPublisher.posted).toHaveLength(1); - expect(deps.chatPublisher.posted[0]!.text).toContain("public http(s)"); + expect(deps.chatPublisher.posted[0]!.text).toBe("Solo se pueden analizar páginas públicas http(s)."); }); it("a failure reply that cannot be sent is logged, and the job is still acked and failed (FIXV-001)", async () => { @@ -478,7 +488,7 @@ describe("runHackathonJob", () => { ); }); - it("logs the HTTP status of a failed page fetch and keeps the reply text unchanged", async () => { + it("logs the HTTP status of a failed page fetch and replies with the Spanish http-status phrase", async () => { const deps = makeDeps(); const job = baseJob(); deps.analysisJobRepo = fakeAnalysisJobRepo({ claimResult: { kind: "claimed", job } }); @@ -499,10 +509,62 @@ describe("runHackathonJob", () => { }), ); expect(deps.chatPublisher.posted[0]!.text).toBe( - "Could not read that page (http-status). Any previous analysis was kept.", + "No se pudo leer esa página (el sitio respondió con un error). Se conservó el análisis anterior.", ); }); + it.each<[PageFetchFailureKind, string]>([ + ["timeout", "tiempo de espera agotado"], + ["too-large", "la página es demasiado grande"], + ["http-status", "el sitio respondió con un error"], + ["content-type", "el contenido no es una página web"], + ["redirects", "demasiadas redirecciones"], + ["network", "error de red"], + ])("fetch failure %s replies with its Spanish phrase and never the raw code", async (kind, phrase) => { + const deps = makeDeps(); + const job = baseJob(); + deps.analysisJobRepo = fakeAnalysisJobRepo({ claimResult: { kind: "claimed", job } }); + deps.staticFetcher = fakePageFetcher([{ throws: new PageFetchFailedError("boom", kind) }]); + + await runHackathonJob(baseMsg(), 1, deps); + + const text = deps.chatPublisher.posted[0]!.text; + expect(text).toBe(`No se pudo leer esa página (${phrase}). Se conservó el análisis anterior.`); + if (kind !== "network" && kind !== "timeout") expect(text).not.toContain(kind); + }); + + it.each<[string, () => Error, string]>([ + [ + "too little text", + () => new PageTooThinError("thin"), + "La página tiene muy poco texto legible. Se conservó el análisis anterior.", + ], + [ + "AI quota", + () => new LlmQuotaExceededError("quota"), + "Se agotó la cuota compartida de IA de hoy; inténtalo después de las 00:00 UTC. Se conservó el análisis anterior.", + ], + [ + "invalid AI output", + () => new ExtractionFailedError("bad", "invalid-output"), + "La IA no pudo generar un análisis válido. Se conservó el análisis anterior.", + ], + [ + "not configured", + () => new ConfigError("no models"), + "El análisis de hackathons no está configurado.", + ], + ])("%s replies in Spanish", async (_name, makeError, expected) => { + const deps = makeDeps(); + const job = baseJob(); + deps.analysisJobRepo = fakeAnalysisJobRepo({ claimResult: { kind: "claimed", job } }); + deps.staticFetcher = fakePageFetcher([{ throws: makeError() }]); + + await runHackathonJob(baseMsg(), 1, deps); + + expect(deps.chatPublisher.posted[0]!.text).toBe(expected); + }); + it("logs no httpStatus for a failure that is not an http-status fetch error", async () => { const deps = makeDeps(); const job = baseJob(); diff --git a/test/http/hackathon-command-e2e.test.ts b/test/http/hackathon-command-e2e.test.ts index 12312d5..94eaff3 100644 --- a/test/http/hackathon-command-e2e.test.ts +++ b/test/http/hackathon-command-e2e.test.ts @@ -75,7 +75,7 @@ describe("POST /telegram/webhook — /hackathon through real composition", expect(res.status).toBe(200); const replies = calls.filter((c) => c.method === "sendMessage").map((c) => (c.body as { text: string }).text); - expect(replies.at(-1)).toMatch(/^Analyzing example\.com/); + expect(replies.at(-1)).toBe("Analizando example.com… el resultado se publicará aquí."); expect(queue.sent).toMatchObject([ { v: 1, chatId, threadId: null, fetchUrl: "https://example.com/event" }, ]); @@ -89,7 +89,7 @@ describe("POST /telegram/webhook — /hackathon through real composition", // The lease is real (D1): a second fresh run is refused, nothing enqueued. await post(commandUpdate("hackathon", chatId, userId, "https://example.com/other"), queue.binding); const repliesAfter = calls.filter((c) => c.method === "sendMessage").map((c) => (c.body as { text: string }).text); - expect(repliesAfter.at(-1)).toMatch(/already running/i); + expect(repliesAfter.at(-1)).toBe("Ya hay un análisis en curso para este equipo. Espera su resultado."); expect(queue.sent).toHaveLength(1); }); @@ -107,7 +107,9 @@ describe("POST /telegram/webhook — /hackathon through real composition", expect(res.status).toBe(200); const replies = calls.filter((c) => c.method === "sendMessage").map((c) => (c.body as { text: string }).text); - expect(replies.at(-1)).toMatch(/could not start the analysis/i); + expect(replies.at(-1)).toBe( + "No se pudo iniciar el análisis; inténtalo de nuevo en un minuto. No se contó en el límite diario.", + ); const usage = await env.DB.prepare( `SELECT u.runs AS runs FROM hackathon_analysis_usage u JOIN teams t ON t.id = u.team_id WHERE t.telegram_chat_id = ?`, @@ -128,8 +130,8 @@ describe("POST /telegram/webhook — /hackathon through real composition", await post(commandUpdate("hackathons", chatId, userId), queue.binding); const replies = calls.filter((c) => c.method === "sendMessage").map((c) => (c.body as { text: string }).text); - expect(replies.at(-2)).toBe("No analysis with that slug. See /hackathons."); - expect(replies.at(-1)).toBe("No hackathons analyzed yet."); + expect(replies.at(-2)).toBe("No hay ningún análisis con ese slug. Consulta /hackathons."); + expect(replies.at(-1)).toBe("Todavía no se ha analizado ningún hackathon."); expect(queue.sent).toHaveLength(0); }); }); diff --git a/test/index.queue.test.ts b/test/index.queue.test.ts index b3403d1..a5e2118 100644 --- a/test/index.queue.test.ts +++ b/test/index.queue.test.ts @@ -396,7 +396,7 @@ describe("default export", () => { expect(msg.retries).toEqual([]); expect(msg.acked).toBe(1); - expect(publisher.posted.map((p) => p.text)).toEqual(["Hackathon analysis is not configured."]); + expect(publisher.posted.map((p) => p.text)).toEqual(["El análisis de hackathons no está configurado."]); const row = await env.DB.prepare( "SELECT status, failure_reason FROM hackathon_analysis_jobs WHERE id = ?", )