diff --git a/openspec/changes/spanish-copy/apply-progress.md b/openspec/changes/spanish-copy/apply-progress.md index 32cd2ec..8cc36c1 100644 --- a/openspec/changes/spanish-copy/apply-progress.md +++ b/openspec/changes/spanish-copy/apply-progress.md @@ -2,9 +2,8 @@ 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) +## Phase 1 (PR1): Domain Catalog + Hackathon — COMPLETE (1.1-1.10, merged as #39) -Phases 2 and 3 pending. ### Commits @@ -53,6 +52,48 @@ Phases 2 and 3 pending. - 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. +## Phase 2 (PR2): Profile, Team, Membership, Repo Commands + Picker — COMPLETE (2.1-2.7) + +Branch: `feat/spanish-copy-commands` (from `main` c4e7248). Not pushed. + +### Commits + +| Hash | Message | +|------|---------| +| 04d7a0d | feat(copy): extend Telegram adapter catalog with profile, team, repo and picker copy | +| 719a45d | feat(copy): translate profile, team, membership, repo commands and team picker to Spanish | + +### TDD Cycle Evidence + +| Task | Test File | Layer | Safety Net | RED | GREEN | TRIANGULATE | REFACTOR | +|------|-----------|-------|------------|-----|-------|-------------|----------| +| 2.1 | `test/adapters/telegram/commands.test.ts` | Integration | Full suite green before (71 files, 828 tests) | Exact Spanish assertions for /setup, /join, /datachannel, /profile, /promote, /demote, /linkrepo, /unlinkrepo, /repos, team resolution; role labels; `/…y d+ más$/`; 9 new cases | Passed | Many per-command cases | None needed | +| 2.2 | same file (picker tests live in `commands.test.ts`; no separate picker file exists) | Integration | Same | Callback alert text (via new `alerts` capture), reply, "Equipo {id}" buttons; failed | Passed | Forged callback, valid selection, button labels | None needed | +| 2.3 | `test/copy/catalog-language.test.ts` | Unit | Same | Imports missing catalogs; 12 failed | Passed | Denylist + non-empty per catalog, `ROLE_LABELS`, link/unlink map, shared phrases | None needed | +| 2.4 | `src/adapters/telegram/copy.ts` | Unit | Same | Covered by 2.1-2.3 | Passed | Yes | None needed | +| 2.5 | `src/adapters/telegram/commands.ts` | Integration | Same | Covered by 2.1 | Passed | Yes | `reposReply` delegates to `joinLinesWithinLimit`; removed `REPOS_REPLY_MAX` and duplicate loop | +| 2.6 | `src/adapters/telegram/team-picker.ts` | Integration | Same | Covered by 2.2 | Passed | Yes | None needed | +| 2.7 | full suite | - | - | - | 71 files, 857 tests pass; `npm run typecheck` clean | - | - | + +RED run: 51 failed / 76 passed in `test/adapters/telegram`, plus 12 failed in `test/copy`. + +### Work Unit Evidence + +| Evidence | Result | +|---|---| +| Focused test command | `npm test -- test/adapters/telegram test/copy`: RED 51 + 12 failed; GREEN after implementation. Full `npm test`: 857/857 | +| Runtime harness | N/A: copy only | +| Rollback boundary | `commands.ts`, `team-picker.ts`, the PR2 additions in `src/adapters/telegram/copy.ts` | + +### Deviations / notes + +- No dedicated team-picker test file exists; picker cases live in `commands.test.ts`, so they were extended there. +- `test/http/webhook-e2e.test.ts` also asserted `/created/i` on the /setup reply; updated to the Spanish string (out of the listed files, required by the copy change). +- Catalog shape: `ROLE_LABELS`, `teamResolutionCopy`, `setupCopy`, `joinCopy`, `dataChannelCopy`, `profileCopy`, `roleCopy`, `repoCopy` (`topicRequired` is `Record<"link"|"unlink">`), `pickerCopy`; `commonCopy` gained `noTeamForChat` and `membershipCheckFailed(cmd)`. +- "Permanecer anónimo" used as the design assumed. +- Leftover-English grep over Phase 2 reply paths: only domain exception messages (`NotFoundError`/`UnauthorizedError` args, never shown) and log event names remain. +- Size: 6 files, +368 / -128 (src about 129 net lines changed in commands.ts/picker plus 87 catalog; rest tests). + ### Remaining -Phase 2 (2.1-2.7), Phase 3 (3.1-3.5), Phase 4 (4.1-4.2). +Phase 3 (3.1-3.5), Phase 4 (4.1-4.2). diff --git a/openspec/changes/spanish-copy/tasks.md b/openspec/changes/spanish-copy/tasks.md index 618b9ad..586e66d 100644 --- a/openspec/changes/spanish-copy/tasks.md +++ b/openspec/changes/spanish-copy/tasks.md @@ -41,13 +41,13 @@ No `npm run harness` needed: fetch, LLM and validation logic are unchanged; fetc ## 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`. +- [x] 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$/`. +- [x] 2.2 RED: `test/adapters/telegram/team-picker` tests (or nearest existing file) — callback alerts and "Equipo {id}" button. +- [x] 2.3 RED: extend `test/copy/catalog-language.test.ts` to the full adapter catalog incl. `Record`, link/unlink map; run, see fail. +- [x] 2.4 GREEN: extend `src/adapters/telegram/copy.ts` (tú form, "tema", role and link/unlink `Record` maps, shared `noTeamForChat`). +- [x] 2.5 GREEN: `src/adapters/telegram/commands.ts` uses catalog; `reposReply` delegates to `joinLinesWithinLimit(lines, REPLY_MAX, repoCopy.none)`. +- [x] 2.6 GREEN: `src/adapters/telegram/team-picker.ts` uses catalog. +- [x] 2.7 Run `npm test` and `npm run typecheck`. ## Phase 3: GitHub Alerts (PR3) diff --git a/src/adapters/telegram/commands.ts b/src/adapters/telegram/commands.ts index d23e1da..6cc3c34 100644 --- a/src/adapters/telegram/commands.ts +++ b/src/adapters/telegram/commands.ts @@ -34,6 +34,19 @@ import { InlineKeyboard } from "grammy"; import { isPrivateChat, registerTeamPicker } from "./team-picker"; import { registerHackathonCommands } from "./hackathon-commands"; import type { HackathonCommandDeps } from "./hackathon-commands"; +import { + commonCopy, + dataChannelCopy, + ROLE_LABELS, + joinCopy, + profileCopy, + repoCopy, + roleCopy, + setupCopy, + teamResolutionCopy, +} from "./copy"; +import { REPLY_MAX } from "../../domain/hackathon/format"; +import { joinLinesWithinLimit } from "../../domain/text-limit"; // grammY is a thin edge adapter only: every handler below extracts caller // location, calls exactly one domain use case (or port read for a simple @@ -95,21 +108,21 @@ async function resolveCommandTeam(ctx: Parameters[0], dep const team = await deps.teamRepo.findByChatId(loc.chatId); if (!team) { deps.logger.log({ event, outcome: "refused", errorCode: "NoTeamForChat" }); - await ctx.reply("No team is registered for this chat. Ask an admin to run /setup."); + await ctx.reply(commonCopy.noTeamForChat); return null; } return team.id; } const resolution = await resolveDmTeam({ telegramUserId: loc.userId }, deps); if (resolution.kind === "none") { - await ctx.reply("Join a team first by running /join in its group."); + await ctx.reply(teamResolutionCopy.joinFirst); return null; } if (resolution.kind === "needs-selection") { const memberships = await deps.membershipRepo.findByUser(loc.userId); const keyboard = new InlineKeyboard(); - for (const membership of memberships) keyboard.text(`Team ${membership.teamId}`, `sel:${membership.teamId}`).row(); - await ctx.reply("Choose which team this command applies to.", { reply_markup: keyboard }); + for (const membership of memberships) keyboard.text(teamResolutionCopy.teamButton(membership.teamId), `sel:${membership.teamId}`).row(); + await ctx.reply(teamResolutionCopy.choose, { reply_markup: keyboard }); return null; } return resolution.teamId; @@ -137,30 +150,30 @@ async function resolveActorMembership(teamId: TeamId, telegramUserId: number, de // owner (membership id, plus github_username/full_name when set) and role. function formatFieldLine(field: ProfileField): string { - return `${field.field}: ${field.unreadable ? "unreadable" : field.value}`; + return `${field.field}: ${field.unreadable ? profileCopy.unreadable : field.value}`; } function memberIdentity(membershipId: MembershipId, ownFields: ProfileField[]): string { const fullName = ownFields.find((f) => f.field === "full_name"); const github = ownFields.find((f) => f.field === "github_username"); const parts: string[] = []; - if (fullName) parts.push(fullName.unreadable ? "unreadable" : fullName.value); - if (github) parts.push(github.unreadable ? "unreadable" : `@${github.value}`); + if (fullName) parts.push(fullName.unreadable ? profileCopy.unreadable : fullName.value); + if (github) parts.push(github.unreadable ? profileCopy.unreadable : `@${github.value}`); return parts.length ? ` (${parts.join(", ")})` : ""; } function formatMemberBlock(membership: Membership, fields: ProfileField[]): string { const own = fields.filter((f) => f.membershipId === membership.id); - const header = `Member ${membership.id}${memberIdentity(membership.id, own)} — role: ${membership.role}`; + const header = profileCopy.memberHeader(membership.id, memberIdentity(membership.id, own), ROLE_LABELS[membership.role]); const lines = own.map(formatFieldLine); - return [header, ...(lines.length ? lines : ["No profile fields set."])].join("\n"); + return [header, ...(lines.length ? lines : [profileCopy.noFields])].join("\n"); } function profileDirectoryReply(memberships: Membership[], fields: ProfileField[], teamId: TeamId): string { if (memberships.length === 0) { - return `Team ${teamId}\nNo matching member found.`; + return `${profileCopy.teamHeader(teamId)}\n${profileCopy.noMatch}`; } - return `Team ${teamId}\n${memberships.map((m) => formatMemberBlock(m, fields)).join("\n\n")}`; + return `${profileCopy.teamHeader(teamId)}\n${memberships.map((m) => formatMemberBlock(m, fields)).join("\n\n")}`; } // An anonymous group admin's message arrives with `sender_chat` set to the @@ -177,25 +190,11 @@ function isAnonymousGroupAdmin(ctx: Context): boolean { // here would make ctx.reply throw, runCommand would rethrow it as an // unrecognized error (it is not a domain error), and the route would answer // 500, which Telegram retries forever, permanently breaking /repos for any -// team with enough links. Below the limit, whole lines are kept; once a -// line would push the reply over the limit, listing stops and a fixed -// "...and N more" summary line replaces the rest. -const REPOS_REPLY_MAX = 4096; - +// team with enough links. `joinLinesWithinLimit` keeps whole lines and ends +// with a fixed "…y N más" summary line once the limit would be exceeded. function reposReply(links: Array<{ repoFullName: string; threadId: number }>): string { - if (links.length === 0) return "No repos linked yet."; - const allLines = links.map((l) => `${l.repoFullName} -> topic ${l.threadId}`); - const full = allLines.join("\n"); - if (full.length <= REPOS_REPLY_MAX) return full; - for (let kept = allLines.length - 1; kept >= 0; kept--) { - const omitted = allLines.length - kept; - const head = allLines.slice(0, kept).join("\n"); - const candidate = kept > 0 ? `${head}\n...and ${omitted} more` : `...and ${omitted} more`; - if (candidate.length <= REPOS_REPLY_MAX) return candidate; - } - // Pathological case: even the summary line alone (0 links listed) does - // not fit — defensively truncate rather than ever exceed the limit. - return `...and ${allLines.length} more`.slice(0, REPOS_REPLY_MAX); + const lines = links.map((l) => repoCopy.line(l.repoFullName, l.threadId)); + return joinLinesWithinLimit(lines, REPLY_MAX, repoCopy.none); } // READ-001: the genuinely shared part of `/linkrepo` and `/unlinkrepo` — @@ -209,7 +208,7 @@ async function resolveLinkCommandTarget( ctx: Context, deps: CommandDeps, command: string, - action: string, + action: "link" | "unlink", rawRepoArg: string, ): Promise<{ loc: CallerLocation; threadId: number; repo: RepoFullName } | null> { const loc = callerLocation(ctx); @@ -222,13 +221,13 @@ async function resolveLinkCommandTarget( // null, the same refusal as /datachannel"). if (loc.threadId === null) { deps.logger.log({ event: `${action}-repo-to-topic`, outcome: "refused", errorCode: "NoThread" }); - await ctx.reply(`Run /${command} inside the topic you want to ${action}.`); + await ctx.reply(repoCopy.topicRequired[action]); return null; } const threadId = loc.threadId; const repo = parseRepoReference(rawRepoArg); if (!repo) { - await ctx.reply(`Usage: /${command} `); + await ctx.reply(repoCopy.usage(command)); return null; } return { loc, threadId, repo }; @@ -247,14 +246,12 @@ export function registerCommands(bot: Bot, deps: CommandDeps): void { // refusal. if (isPrivateChat(ctx)) { deps.logger.log({ event: "setup-team", outcome: "refused", errorCode: "PrivateChat" }); - await ctx.reply("Run /setup inside the group you want to register as a team, not in a private chat."); + await ctx.reply(setupCopy.privateChat); return; } if (isAnonymousGroupAdmin(ctx)) { deps.logger.log({ event: "setup-team", outcome: "refused", errorCode: "AnonymousAdmin" }); - await ctx.reply( - '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.', - ); + await ctx.reply(setupCopy.anonymousAdmin); return; } await runCommand( @@ -266,14 +263,14 @@ export function registerCommands(bot: Bot, deps: CommandDeps): void { // deliberately left unrecognized, which rethrows to a 500) — see // command-outcome.ts. errorReplies: { - AlreadyExistsError: "A team is already registered for this chat.", - ChatAdminCheckFailedError: "Could not verify your admin status. Please try again.", - UnauthorizedError: "Only a Telegram group admin can run /setup.", + AlreadyExistsError: setupCopy.alreadyExists, + ChatAdminCheckFailedError: setupCopy.adminCheckFailed, + UnauthorizedError: setupCopy.notGroupAdmin, }, }, async () => { await setupTeam({ chatId: loc.chatId, callerTelegramUserId: loc.userId }, deps); - return { okReply: "Team created. You are the first admin." }; + return { okReply: setupCopy.created }; }, ); }); @@ -289,13 +286,13 @@ export function registerCommands(bot: Bot, deps: CommandDeps): void { // Every domain error joinTeam can throw MUST be listed here — see // command-outcome.ts. errorReplies: { - AlreadyExistsError: "You are already a member of this team.", - NotFoundError: "No team is registered for this chat. Ask an admin to run /setup.", + AlreadyExistsError: joinCopy.alreadyMember, + NotFoundError: commonCopy.noTeamForChat, }, }, async () => { await joinTeam({ chatId: loc.chatId, callerTelegramUserId: loc.userId }, deps); - return { okReply: "You joined the team." }; + return { okReply: joinCopy.joined }; }, ); }); @@ -305,9 +302,7 @@ export function registerCommands(bot: Bot, deps: CommandDeps): void { if (!loc) return; if (loc.threadId === null) { deps.logger.log({ event: "bind-data-channel", outcome: "refused", errorCode: "NoThread" }); - await ctx.reply( - "Run /datachannel inside the topic you want to use as the team's data channel.", - ); + await ctx.reply(dataChannelCopy.topicRequired); return; } const threadId = loc.threadId; @@ -324,8 +319,8 @@ export function registerCommands(bot: Bot, deps: CommandDeps): void { // branch and turned a real, recoverable refusal into a permanent // 500 that Telegram would retry forever. errorReplies: { - UnauthorizedError: "Only a team admin may bind the data channel.", - NotFoundError: "Could not verify your team membership. Please try /datachannel again.", + UnauthorizedError: dataChannelCopy.adminOnly, + NotFoundError: commonCopy.membershipCheckFailed("datachannel"), }, }, async () => { @@ -351,7 +346,7 @@ export function registerCommands(bot: Bot, deps: CommandDeps): void { deps, ); return { - okReply: "This topic is now the team's data channel.", + okReply: dataChannelCopy.bound, teamId: resolved.team.id, }; }, @@ -370,8 +365,8 @@ export function registerCommands(bot: Bot, deps: CommandDeps): void { { event: "read-profiles", logger: deps.logger, reply: (text) => ctx.reply(text), errorReplies: { - UnauthorizedError: "Member data is available only in the team's data channel.", - NotFoundError: "You are not a member of this team.", + UnauthorizedError: profileCopy.dataChannelOnly, + NotFoundError: commonCopy.notMember, }, }, async () => { @@ -398,23 +393,23 @@ export function registerCommands(bot: Bot, deps: CommandDeps): void { const field = args.shift(); const value = args.join(" "); if (!field || !profileFields.has(field as ProfileFieldName) || !value) { - await ctx.reply("Usage: /profile set "); + await ctx.reply(profileCopy.setUsage); return; } await runCommand( { event: "update-profile", logger: deps.logger, reply: (text) => ctx.reply(text), - errorReplies: { UnauthorizedError: "You may only edit your own profile.", NotFoundError: "You are not a member of this team." }, + errorReplies: { UnauthorizedError: profileCopy.ownOnly, NotFoundError: commonCopy.notMember }, }, async () => { const actor = await resolveActorMembership(teamId, loc.userId, deps); await updateProfileField({ teamId, actorMembershipId: actor.id, targetMembershipId: actor.id, field: field as ProfileFieldName, value, keyVersion: null }, deps); - return { okReply: `Profile updated for team ${teamId}.`, teamId }; + return { okReply: profileCopy.updated(teamId), teamId }; }, ); return; } - await ctx.reply("Usage: /profile show [membership-id] or /profile set "); + await ctx.reply(profileCopy.usage); }); for (const [command, newRole] of [["promote", "admin"], ["demote", "member"]] as const) { @@ -425,18 +420,18 @@ export function registerCommands(bot: Bot, deps: CommandDeps): void { if (!teamId) return; const targetMembershipId = ctx.match.trim() as MembershipId; if (!targetMembershipId) { - await ctx.reply(`Usage: /${command} `); + await ctx.reply(roleCopy.usage(command)); return; } await runCommand( { event: `${command}-member`, logger: deps.logger, reply: (text) => ctx.reply(text), - errorReplies: { UnauthorizedError: "Only a team admin may change roles.", NotFoundError: "Member not found in this team.", LastAdminError: "The last team admin cannot be demoted." }, + errorReplies: { UnauthorizedError: roleCopy.adminOnly, NotFoundError: roleCopy.notFound, LastAdminError: roleCopy.lastAdmin }, }, async () => { const actor = await resolveActorMembership(teamId, loc.userId, deps); await changeRole({ teamId, actorMembershipId: actor.id, targetMembershipId, newRole }, deps); - return { okReply: `Member role changed to ${newRole} for team ${teamId}.`, teamId }; + return { okReply: roleCopy.changed(newRole, teamId), teamId }; }, ); }); @@ -456,9 +451,9 @@ export function registerCommands(bot: Bot, deps: CommandDeps): void { // Every domain error linkRepoToTopic can throw MUST be listed here // — see command-outcome.ts. errorReplies: { - UnauthorizedError: "Only a team admin may link a repo.", - NotFoundError: "Could not verify your team membership. Please try /linkrepo again.", - OrgNotClaimedError: "This repo's org is not claimed by your team.", + UnauthorizedError: repoCopy.linkAdminOnly, + NotFoundError: commonCopy.membershipCheckFailed("linkrepo"), + OrgNotClaimedError: repoCopy.orgNotClaimed, }, }, async () => { @@ -473,8 +468,8 @@ export function registerCommands(bot: Bot, deps: CommandDeps): void { // spec: repo-topic-links "One Topic Per Repo, Re-Link Moves It" — // the reply MUST name the previous topic, not just confirm success. const okReply = result.previousThreadId === null - ? `Linked ${repo} to this topic.` - : `Moved ${repo} from topic ${result.previousThreadId} to topic ${threadId}. Topic ${result.previousThreadId} will no longer receive alerts for this repo.`; + ? commonCopy.linkedHere(repo) + : repoCopy.moved(repo, result.previousThreadId, threadId); return { okReply, teamId: resolved.team.id }; }, ); @@ -498,8 +493,8 @@ export function registerCommands(bot: Bot, deps: CommandDeps): void { // Every domain error unlinkRepo can throw MUST be listed here — // see command-outcome.ts. errorReplies: { - UnauthorizedError: "Only a team admin may unlink a repo.", - NotFoundError: "Could not verify your team membership. Please try /unlinkrepo again.", + UnauthorizedError: repoCopy.unlinkAdminOnly, + NotFoundError: commonCopy.membershipCheckFailed("unlinkrepo"), }, }, async () => { @@ -512,7 +507,7 @@ export function registerCommands(bot: Bot, deps: CommandDeps): void { deps, ); return { - okReply: removed ? `Unlinked ${repo} from this topic.` : `${repo} was not linked to any topic.`, + okReply: removed ? repoCopy.unlinked(repo) : repoCopy.notLinked(repo), teamId: resolved.team.id, }; }, @@ -535,7 +530,7 @@ export function registerCommands(bot: Bot, deps: CommandDeps): void { logger: deps.logger, reply: (text) => ctx.reply(text), errorReplies: { - NotFoundError: "You are not a member of this team.", + NotFoundError: commonCopy.notMember, }, }, async () => { diff --git a/src/adapters/telegram/copy.ts b/src/adapters/telegram/copy.ts index 40e5c54..84d7a1b 100644 --- a/src/adapters/telegram/copy.ts +++ b/src/adapters/telegram/copy.ts @@ -1,10 +1,15 @@ import { analysisCopy } from "../../domain/copy"; +import type { Role } from "../../domain/entities"; // 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.", + noTeamForChat: + "No hay ningún equipo registrado en este chat. Pide a un administrador que ejecute /setup.", + membershipCheckFailed: (cmd: string) => + `No se pudo verificar tu pertenencia al equipo. Vuelve a intentar /${cmd}.`, linkedHere: (name: string) => `Se vinculó ${name} a este tema.`, }; @@ -23,3 +28,85 @@ export const hackathonCopy = { notConfigured: analysisCopy.notConfigured, publishFailed: "No se pudo publicar en este chat ahora mismo. Inténtalo de nuevo en un minuto.", }; + +// Roles are exposed to users only through this exhaustive map, so a new Role +// fails typecheck until it has a Spanish label. +export const ROLE_LABELS: Readonly> = { + admin: "administrador", + member: "miembro", +}; + +export const teamResolutionCopy = { + joinFirst: "Primero únete a un equipo ejecutando /join en su grupo.", + choose: "Elige a qué equipo se aplica este comando.", + teamButton: (id: string) => `Equipo ${id}`, +}; + +export const setupCopy = { + privateChat: + "Ejecuta /setup dentro del grupo que quieres registrar como equipo, no en un chat privado.", + anonymousAdmin: + '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.', + alreadyExists: "Ya hay un equipo registrado en este chat.", + adminCheckFailed: "No se pudo verificar tu condición de administrador. Inténtalo de nuevo.", + notGroupAdmin: "Solo un administrador del grupo de Telegram puede ejecutar /setup.", + created: "Equipo creado. Eres el primer administrador.", +}; + +export const joinCopy = { + alreadyMember: "Ya eres miembro de este equipo.", + joined: "Te uniste al equipo.", +}; + +export const dataChannelCopy = { + topicRequired: + "Ejecuta /datachannel dentro del tema que quieres usar como canal de datos del equipo.", + adminOnly: "Solo un administrador del equipo puede asignar el canal de datos.", + bound: "Este tema es ahora el canal de datos del equipo.", +}; + +export const profileCopy = { + unreadable: "ilegible", + memberHeader: (id: string, identity: string, role: string) => + `Miembro ${id}${identity} — rol: ${role}`, + noFields: "No hay campos de perfil configurados.", + teamHeader: (id: string) => `Equipo ${id}`, + noMatch: "No se encontró ningún miembro que coincida.", + dataChannelOnly: "Los datos de los miembros solo están disponibles en el canal de datos del equipo.", + setUsage: "Uso: /profile set ", + ownOnly: "Solo puedes editar tu propio perfil.", + updated: (teamId: string) => `Perfil actualizado para el equipo ${teamId}.`, + usage: "Uso: /profile show [membership-id] o /profile set ", +}; + +export const roleCopy = { + usage: (cmd: string) => `Uso: /${cmd} `, + adminOnly: "Solo un administrador del equipo puede cambiar roles.", + notFound: "No se encontró al miembro en este equipo.", + lastAdmin: "No se puede degradar al último administrador del equipo.", + changed: (role: Role, teamId: string) => + `Rol del miembro cambiado a ${ROLE_LABELS[role]} para el equipo ${teamId}.`, +}; + +export const repoCopy = { + topicRequired: { + link: "Ejecuta /linkrepo dentro del tema al que quieres vincular el repositorio.", + unlink: "Ejecuta /unlinkrepo dentro del tema del que quieres desvincular el repositorio.", + } as Readonly>, + usage: (cmd: string) => `Uso: /${cmd} `, + linkAdminOnly: "Solo un administrador del equipo puede vincular un repositorio.", + orgNotClaimed: "La organización de este repositorio no está reclamada por tu equipo.", + moved: (repo: string, from: number | string, to: number | string) => + `Se movió ${repo} del tema ${from} al tema ${to}. El tema ${from} ya no recibirá alertas de este repositorio.`, + unlinkAdminOnly: "Solo un administrador del equipo puede desvincular un repositorio.", + unlinked: (repo: string) => `Se desvinculó ${repo} de este tema.`, + notLinked: (repo: string) => `${repo} no estaba vinculado a ningún tema.`, + none: "Todavía no hay repositorios vinculados.", + line: (repo: string, threadId: number | string) => `${repo} -> tema ${threadId}`, +}; + +export const pickerCopy = { + unavailableAlert: "Ese equipo no está disponible para ti.", + notMember: "No eres miembro de ese equipo.", + selected: "Equipo seleccionado. Vuelve a ejecutar tu comando para continuar.", +}; diff --git a/src/adapters/telegram/team-picker.ts b/src/adapters/telegram/team-picker.ts index 210d876..4610df9 100644 --- a/src/adapters/telegram/team-picker.ts +++ b/src/adapters/telegram/team-picker.ts @@ -3,6 +3,7 @@ import { UnauthorizedError } from "../../domain/errors"; import { parseTeamId, TEAM_ID_PATTERN_SOURCE } from "../../domain/ids"; import { selectDmTeam } from "../../domain/usecases/dm-team-selection"; import type { Clock, DmSelectionRepo, Logger, MembershipRepo } from "../../domain/ports"; +import { pickerCopy } from "./copy"; const SELECTION_PATTERN = new RegExp(`^sel:(${TEAM_ID_PATTERN_SOURCE})$`, "i"); @@ -67,13 +68,13 @@ export function registerTeamPicker(bot: Bot, deps: TeamPickerDeps): void { throw error; } deps.logger.log({ event: EVENT, outcome: "refused", errorCode: error.name }); - await safeAnswerCallbackQuery(ctx, deps, "That team is not available to you."); - await safeReply(ctx, deps, "You are not a member of that team."); + await safeAnswerCallbackQuery(ctx, deps, pickerCopy.unavailableAlert); + await safeReply(ctx, deps, pickerCopy.notMember); return; } deps.logger.log({ event: EVENT, outcome: "ok" }); await safeAnswerCallbackQuery(ctx, deps); - await safeReply(ctx, deps, "Team selected. Run your command again to continue."); + await safeReply(ctx, deps, pickerCopy.selected); }); } diff --git a/test/adapters/telegram/commands.test.ts b/test/adapters/telegram/commands.test.ts index 87fba97..92b0af6 100644 --- a/test/adapters/telegram/commands.test.ts +++ b/test/adapters/telegram/commands.test.ts @@ -59,7 +59,13 @@ function makeBot( // Every raw sendMessage payload, so plain-text (no parse_mode) can be // asserted on the wire. const payloads: Array> = []; + // Text of every answerCallbackQuery alert shown to the user. + const alerts: string[] = []; bot.api.config.use((_prev, method, payload) => { + if (method === "answerCallbackQuery") { + const text = (payload as { text?: string }).text; + if (text !== undefined) alerts.push(text); + } if (method === "sendMessage") { const p = payload as { chat_id: number; text: string }; payloads.push(payload as Record); @@ -104,7 +110,7 @@ function makeBot( logger: createSafeLogger(), }; registerCommands(bot, deps); - return { bot, replies, payloads, deps }; + return { bot, replies, payloads, alerts, deps }; } let nextUpdateId = 1; @@ -175,7 +181,7 @@ describe("registerCommands — /setup (team-registration spec)", () => { expect(deps.teamRepo.rows).toHaveLength(1); expect(deps.membershipRepo.rows[0]?.role).toBe("admin"); - expect(replies[0]?.text).toMatch(/created/i); + expect(replies[0]?.text).toBe("Equipo creado. Eres el primer administrador."); }); it("refuses when a team already exists for the chat", async () => { @@ -183,7 +189,7 @@ describe("registerCommands — /setup (team-registration spec)", () => { await bot.handleUpdate(commandUpdate("setup", 10, 1)); await bot.handleUpdate(commandUpdate("setup", 10, 1)); - expect(replies[1]?.text).toMatch(/already/i); + expect(replies[1]?.text).toBe("Ya hay un equipo registrado en este chat."); }); it("refuses when the caller is not a verified group admin", async () => { @@ -191,7 +197,7 @@ describe("registerCommands — /setup (team-registration spec)", () => { await bot.handleUpdate(commandUpdate("setup", 20, 2)); expect(deps.teamRepo.rows).toHaveLength(0); - expect(replies[0]?.text).toMatch(/admin/i); + expect(replies[0]?.text).toBe("Solo un administrador del grupo de Telegram puede ejecutar /setup."); }); it("tells the caller to run /setup inside the group when run in a private chat (DM)", async () => { @@ -203,8 +209,9 @@ describe("registerCommands — /setup (team-registration spec)", () => { expect(deps.teamRepo.rows).toHaveLength(0); expect(adminCheck).not.toHaveBeenCalled(); - expect(replies[0]?.text).toMatch(/inside the group/i); - expect(replies[0]?.text).not.toMatch(/Only a Telegram group admin/i); + expect(replies[0]?.text).toBe( + "Ejecuta /setup dentro del grupo que quieres registrar como equipo, no en un chat privado.", + ); expect(logSpy).toHaveBeenCalledWith( expect.objectContaining({ event: "setup-team", outcome: "refused", errorCode: "PrivateChat" }), ); @@ -226,8 +233,9 @@ describe("registerCommands — /setup (team-registration spec)", () => { expect(deps.teamRepo.rows).toHaveLength(0); expect(adminCheck).not.toHaveBeenCalled(); - expect(replies[0]?.text).toMatch(/anonymous/i); - expect(replies[0]?.text).not.toMatch(/Only a Telegram group admin/i); + expect(replies[0]?.text).toBe( + '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.', + ); expect(logSpy).toHaveBeenCalledWith( expect.objectContaining({ event: "setup-team", outcome: "refused", errorCode: "AnonymousAdmin" }), ); @@ -241,7 +249,7 @@ describe("registerCommands — /join (team-membership spec)", () => { await bot.handleUpdate(commandUpdate("join", 10, 3)); expect(deps.membershipRepo.rows).toHaveLength(2); - expect(replies[1]?.text).toMatch(/joined/i); + expect(replies[1]?.text).toBe("Te uniste al equipo."); }); it("refuses a duplicate join without creating a second membership", async () => { @@ -250,14 +258,16 @@ describe("registerCommands — /join (team-membership spec)", () => { await bot.handleUpdate(commandUpdate("join", 10, 1)); expect(deps.membershipRepo.rows).toHaveLength(1); - expect(replies[1]?.text).toMatch(/already/i); + expect(replies[1]?.text).toBe("Ya eres miembro de este equipo."); }); it("refuses to join a chat with no registered team", async () => { const { bot, replies } = makeBot(); await bot.handleUpdate(commandUpdate("join", 999, 5)); - expect(replies[0]?.text).toMatch(/\/setup/); + expect(replies[0]?.text).toBe( + "No hay ningún equipo registrado en este chat. Pide a un administrador que ejecute /setup.", + ); }); }); @@ -268,7 +278,7 @@ describe("registerCommands — /datachannel (team-registration spec)", () => { await bot.handleUpdate(commandUpdate("datachannel", 10, 1, { threadId: 77 })); expect(deps.teamRepo.rows[0]?.dataTopicThreadId).toBe(77); - expect(replies[1]?.text).toMatch(/data channel/i); + expect(replies[1]?.text).toBe("Este tema es ahora el canal de datos del equipo."); }); it("refuses when run outside a topic (general chat)", async () => { @@ -277,7 +287,9 @@ describe("registerCommands — /datachannel (team-registration spec)", () => { await bot.handleUpdate(commandUpdate("datachannel", 10, 1)); expect(deps.teamRepo.rows[0]?.dataTopicThreadId).toBeNull(); - expect(replies[1]?.text).toMatch(/topic/i); + expect(replies[1]?.text).toBe( + "Ejecuta /datachannel dentro del tema que quieres usar como canal de datos del equipo.", + ); }); it("refuses a non-admin member", async () => { @@ -287,7 +299,7 @@ describe("registerCommands — /datachannel (team-registration spec)", () => { await bot.handleUpdate(commandUpdate("datachannel", 10, 3, { threadId: 88 })); expect(deps.teamRepo.rows[0]?.dataTopicThreadId).toBeNull(); - expect(replies[2]?.text).toMatch(/admin/i); + expect(replies[2]?.text).toBe("Solo un administrador del equipo puede asignar el canal de datos."); }); // FIX-001: bindDataChannel also throws NotFoundError (actor membership @@ -303,28 +315,31 @@ describe("registerCommands — /datachannel (team-registration spec)", () => { await bot.handleUpdate(commandUpdate("datachannel", 10, 1, { threadId: 77 })); - expect(replies[1]?.text).not.toMatch(/error/i); - expect(replies[1]?.text?.length ?? 0).toBeGreaterThan(0); + expect(replies[1]?.text).toBe( + "No se pudo verificar tu pertenencia al equipo. Vuelve a intentar /datachannel.", + ); }); }); describe("registerCommands — DM team selection (team-membership spec)", () => { it("refuses a forged team callback and does not persist it", async () => { - const { bot, replies, deps } = makeBot(); + const { bot, replies, alerts, deps } = makeBot(); await bot.handleUpdate(callbackUpdate(50, 7, "sel:00000000-0000-0000-0000-000000000099")); expect(deps.dmSelectionRepo.rows).toHaveLength(0); - expect(replies[0]?.text).toMatch(/member/i); + expect(alerts).toEqual(["Ese equipo no está disponible para ti."]); + expect(replies[0]?.text).toBe("No eres miembro de ese equipo."); }); it("stores a valid explicit selection after re-checking membership", async () => { - const { bot, deps } = makeBot([{ chatId: 10, userId: 1 }]); + const { bot, replies, deps } = makeBot([{ chatId: 10, userId: 1 }]); await bot.handleUpdate(commandUpdate("setup", 10, 1)); const teamId = deps.teamRepo.rows[0]!.id; await bot.handleUpdate(callbackUpdate(50, 1, `sel:${teamId}`)); expect(deps.dmSelectionRepo.rows).toMatchObject([{ telegramUserId: 1, teamId }]); + expect(replies.at(-1)?.text).toBe("Equipo seleccionado. Vuelve a ejecutar tu comando para continuar."); }); it("rethrows an unexpected selection persistence failure to the webhook boundary", async () => { @@ -384,7 +399,9 @@ describe("registerCommands — /profile data-channel gating", () => { await bot.handleUpdate(commandUpdate("datachannel", 10, 1, { threadId: 77 })); await bot.handleUpdate(commandUpdate("profile", 10, 1, { args: "show" })); - expect(replies[2]?.text).toMatch(/data channel/i); + expect(replies[2]?.text).toBe( + "Los datos de los miembros solo están disponibles en el canal de datos del equipo.", + ); expect(deps.profileRepo.rows).toHaveLength(0); }); @@ -397,7 +414,7 @@ describe("registerCommands — /profile data-channel gating", () => { await bot.handleUpdate(commandUpdate("profile", 50, 1, { chatType: "private", args: "show" })); expect(replies[3]?.text).toContain("github_username: octocat"); - expect(replies[4]?.text).toContain(`Team ${deps.teamRepo.rows[0]!.id}`); + expect(replies[4]?.text).toContain(`Equipo ${deps.teamRepo.rows[0]!.id}`); }); }); @@ -410,10 +427,11 @@ describe("registerCommands — role commands", () => { await bot.handleUpdate(commandUpdate("promote", 10, 1, { args: targetId })); expect(deps.membershipRepo.rows.find((row) => row.id === targetId)?.role).toBe("admin"); + expect(replies[2]?.text).toBe(`Rol del miembro cambiado a administrador para el equipo ${deps.teamRepo.rows[0]!.id}.`); await bot.handleUpdate(commandUpdate("demote", 10, 1, { args: targetId })); expect(deps.membershipRepo.rows.find((row) => row.id === targetId)?.role).toBe("member"); - expect(replies[3]?.text).toMatch(/member/); + expect(replies[3]?.text).toBe(`Rol del miembro cambiado a miembro para el equipo ${deps.teamRepo.rows[0]!.id}.`); }); }); @@ -433,11 +451,11 @@ describe("registerCommands — /profile show team directory (R1-001)", () => { await bot.handleUpdate(commandUpdate("profile", 10, 1, { threadId: 77, args: "show" })); const text = replies.at(-1)!.text; - expect(text).toContain(`Team ${deps.teamRepo.rows[0]!.id}`); - expect(text).toContain(`Member ${adminMembershipId}`); - expect(text).toContain("role: admin"); - expect(text).toContain(`Member ${memberMembershipId}`); - expect(text).toContain("role: member"); + expect(text).toContain(`Equipo ${deps.teamRepo.rows[0]!.id}`); + expect(text).toContain(`Miembro ${adminMembershipId}`); + expect(text).toContain("rol: administrador"); + expect(text).toContain(`Miembro ${memberMembershipId}`); + expect(text).toContain("rol: miembro"); expect(text).toContain("github_username: octocat"); }); @@ -452,8 +470,8 @@ describe("registerCommands — /profile show team directory (R1-001)", () => { await bot.handleUpdate(commandUpdate("profile", 10, 1, { threadId: 77, args: `show ${memberMembershipId}` })); const text = replies.at(-1)!.text; - expect(text).toContain(`Member ${memberMembershipId}`); - expect(text).not.toContain(`Member ${adminMembershipId}`); + expect(text).toContain(`Miembro ${memberMembershipId}`); + expect(text).not.toContain(`Miembro ${adminMembershipId}`); }); it("renders unreadable for a profile field that failed decryption", async () => { @@ -474,7 +492,7 @@ describe("registerCommands — /profile show team directory (R1-001)", () => { await bot.handleUpdate(commandUpdate("profile", 10, 1, { threadId: 77, args: "show" })); - expect(replies.at(-1)!.text).toContain("full_name: unreadable"); + expect(replies.at(-1)!.text).toContain("full_name: ilegible"); }); }); @@ -488,7 +506,7 @@ describe("registerCommands — no team registered for the chat (R3-002/R4-002)", await bot.handleUpdate(commandUpdate("profile", 999, 5, { args: "show" })); - expect(replies[0]?.text).toBe("No team is registered for this chat. Ask an admin to run /setup."); + expect(replies[0]?.text).toBe("No hay ningún equipo registrado en este chat. Pide a un administrador que ejecute /setup."); expect(logSpy).toHaveBeenCalledWith( expect.objectContaining({ event: "profile-team-resolution", outcome: "refused" }), ); @@ -500,7 +518,7 @@ describe("registerCommands — no team registered for the chat (R3-002/R4-002)", await bot.handleUpdate(commandUpdate("promote", 999, 5, { args: "some-id" })); - expect(replies[0]?.text).toBe("No team is registered for this chat. Ask an admin to run /setup."); + expect(replies[0]?.text).toBe("No hay ningún equipo registrado en este chat. Pide a un administrador que ejecute /setup."); expect(logSpy).toHaveBeenCalledWith( expect.objectContaining({ event: "promote-team-resolution", outcome: "refused" }), ); @@ -512,7 +530,7 @@ describe("registerCommands — no team registered for the chat (R3-002/R4-002)", await bot.handleUpdate(commandUpdate("demote", 999, 5, { args: "some-id" })); - expect(replies[0]?.text).toBe("No team is registered for this chat. Ask an admin to run /setup."); + expect(replies[0]?.text).toBe("No hay ningún equipo registrado en este chat. Pide a un administrador que ejecute /setup."); expect(logSpy).toHaveBeenCalledWith( expect.objectContaining({ event: "demote-team-resolution", outcome: "refused" }), ); @@ -547,7 +565,7 @@ describe("registerCommands — DM team picker for /profile, /promote, /demote (R await bot.handleUpdate(commandUpdate("profile", 50, 1, { chatType: "private", args: "show" })); - expect(replies.at(-1)?.text).toMatch(/choose which team/i); + expect(replies.at(-1)?.text).toBe("Elige a qué equipo se aplica este comando."); }); it("shows the team picker for /promote when the caller has two or more memberships", async () => { @@ -557,7 +575,7 @@ describe("registerCommands — DM team picker for /profile, /promote, /demote (R await bot.handleUpdate(commandUpdate("promote", 50, 1, { chatType: "private", args: "some-id" })); - expect(replies.at(-1)?.text).toMatch(/choose which team/i); + expect(replies.at(-1)?.text).toBe("Elige a qué equipo se aplica este comando."); }); it("shows the team picker for /demote when the caller has two or more memberships", async () => { @@ -567,7 +585,7 @@ describe("registerCommands — DM team picker for /profile, /promote, /demote (R await bot.handleUpdate(commandUpdate("demote", 50, 1, { chatType: "private", args: "some-id" })); - expect(replies.at(-1)?.text).toMatch(/choose which team/i); + expect(replies.at(-1)?.text).toBe("Elige a qué equipo se aplica este comando."); }); it("uses a remembered DM selection within 15 minutes without prompting", async () => { @@ -579,7 +597,7 @@ describe("registerCommands — DM team picker for /profile, /promote, /demote (R await bot.handleUpdate(commandUpdate("profile", 50, 1, { chatType: "private", args: "show" })); - expect(replies.at(-1)?.text).toContain(`Team ${teamId}`); + expect(replies.at(-1)?.text).toContain(`Equipo ${teamId}`); }); it("re-triggers the picker once the remembered selection is older than 15 minutes", async () => { @@ -592,7 +610,7 @@ describe("registerCommands — DM team picker for /profile, /promote, /demote (R await bot.handleUpdate(commandUpdate("profile", 50, 1, { chatType: "private", args: "show" })); - expect(replies.at(-1)?.text).toMatch(/choose which team/i); + expect(replies.at(-1)?.text).toBe("Elige a qué equipo se aplica este comando."); }); it("re-triggers the picker when the remembered team membership was lost (caller still has 2+ other teams)", async () => { @@ -614,7 +632,7 @@ describe("registerCommands — DM team picker for /profile, /promote, /demote (R await bot.handleUpdate(commandUpdate("profile", 50, 1, { chatType: "private", args: "show" })); - expect(replies.at(-1)?.text).toMatch(/choose which team/i); + expect(replies.at(-1)?.text).toBe("Elige a qué equipo se aplica este comando."); }); }); @@ -682,7 +700,7 @@ describe("registerCommands — /linkrepo (repo-topic-links spec)", () => { expect(deps.repoTopicLinkRepo.rows).toMatchObject([ { repoFullName: "owner/repo", threadId: 77 }, ]); - expect(replies[1]?.text).toMatch(/linked/i); + expect(replies[1]?.text).toBe("Se vinculó owner/repo a este tema."); }); it("accepts a pasted GitHub repo URL", async () => { @@ -697,7 +715,7 @@ describe("registerCommands — /linkrepo (repo-topic-links spec)", () => { expect(deps.repoTopicLinkRepo.rows).toMatchObject([ { repoFullName: "owner/repo", threadId: 77 }, ]); - expect(replies[1]?.text).toMatch(/linked/i); + expect(replies[1]?.text).toBe("Se vinculó owner/repo a este tema."); }); it("refuses an unclaimed org and stores no row", async () => { @@ -707,7 +725,7 @@ describe("registerCommands — /linkrepo (repo-topic-links spec)", () => { await bot.handleUpdate(commandUpdate("linkrepo", 10, 1, { threadId: 77, args: "owner/repo" })); expect(deps.repoTopicLinkRepo.rows).toHaveLength(0); - expect(replies[1]?.text).toMatch(/claimed/i); + expect(replies[1]?.text).toBe("La organización de este repositorio no está reclamada por tu equipo."); }); it("refuses a non-admin member", async () => { @@ -719,7 +737,7 @@ describe("registerCommands — /linkrepo (repo-topic-links spec)", () => { await bot.handleUpdate(commandUpdate("linkrepo", 10, 3, { threadId: 77, args: "owner/repo" })); expect(deps.repoTopicLinkRepo.rows).toHaveLength(0); - expect(replies[2]?.text).toMatch(/admin/i); + expect(replies[2]?.text).toBe("Solo un administrador del equipo puede vincular un repositorio."); }); it("refuses when run outside a topic (general chat) and instructs the admin to run it inside the intended topic", async () => { @@ -730,7 +748,9 @@ describe("registerCommands — /linkrepo (repo-topic-links spec)", () => { await bot.handleUpdate(commandUpdate("linkrepo", 10, 1, { args: "owner/repo" })); expect(deps.repoTopicLinkRepo.rows).toHaveLength(0); - expect(replies[1]?.text).toMatch(/topic/i); + expect(replies[1]?.text).toBe( + "Ejecuta /linkrepo dentro del tema al que quieres vincular el repositorio.", + ); }); it("refuses a malformed repo argument with a usage reply", async () => { @@ -739,7 +759,7 @@ describe("registerCommands — /linkrepo (repo-topic-links spec)", () => { await bot.handleUpdate(commandUpdate("linkrepo", 10, 1, { threadId: 77, args: "not-a-repo" })); - expect(replies[1]?.text).toMatch(/usage/i); + expect(replies[1]?.text).toBe("Uso: /linkrepo "); }); it("re-linking an already-linked repo moves it and the reply names the previous topic", async () => { @@ -754,7 +774,7 @@ describe("registerCommands — /linkrepo (repo-topic-links spec)", () => { // REL-001: pin the exact direction (previous topic -> new topic), not // just that both numbers appear somewhere in the reply. expect(replies[2]?.text).toBe( - "Moved owner/repo from topic 77 to topic 88. Topic 77 will no longer receive alerts for this repo.", + "Se movió owner/repo del tema 77 al tema 88. El tema 77 ya no recibirá alertas de este repositorio.", ); }); }); @@ -769,7 +789,7 @@ describe("registerCommands — /unlinkrepo (repo-topic-links spec)", () => { await bot.handleUpdate(commandUpdate("unlinkrepo", 10, 1, { threadId: 77, args: "owner/repo" })); expect(deps.repoTopicLinkRepo.rows).toHaveLength(0); - expect(replies[2]?.text).toMatch(/unlinked/i); + expect(replies[2]?.text).toBe("Se desvinculó owner/repo de este tema."); }); it("accepts a pasted GitHub repo URL", async () => { @@ -783,7 +803,7 @@ describe("registerCommands — /unlinkrepo (repo-topic-links spec)", () => { ); expect(deps.repoTopicLinkRepo.rows).toHaveLength(0); - expect(replies[2]?.text).toMatch(/unlinked/i); + expect(replies[2]?.text).toBe("Se desvinculó owner/repo de este tema."); }); it("refuses a non-admin member", async () => { @@ -796,7 +816,7 @@ describe("registerCommands — /unlinkrepo (repo-topic-links spec)", () => { await bot.handleUpdate(commandUpdate("unlinkrepo", 10, 3, { threadId: 77, args: "owner/repo" })); expect(deps.repoTopicLinkRepo.rows).toHaveLength(1); - expect(replies[3]?.text).toMatch(/admin/i); + expect(replies[3]?.text).toBe("Solo un administrador del equipo puede desvincular un repositorio."); }); it("refuses when run outside a topic (general chat)", async () => { @@ -805,7 +825,9 @@ describe("registerCommands — /unlinkrepo (repo-topic-links spec)", () => { await bot.handleUpdate(commandUpdate("unlinkrepo", 10, 1, { args: "owner/repo" })); - expect(replies[1]?.text).toMatch(/topic/i); + expect(replies[1]?.text).toBe( + "Ejecuta /unlinkrepo dentro del tema del que quieres desvincular el repositorio.", + ); }); }); @@ -831,7 +853,7 @@ describe("registerCommands — /repos (repo-topic-links spec)", () => { await bot.handleUpdate(commandUpdate("repos", 10, 999)); - expect(replies[1]?.text).toMatch(/member/i); + expect(replies[1]?.text).toBe("No eres miembro de este equipo."); }); it("excludes a link whose org claim was later removed", async () => { @@ -853,7 +875,7 @@ describe("registerCommands — /repos (repo-topic-links spec)", () => { await bot.handleUpdate(commandUpdate("repos", 10, 1)); - expect(replies.at(-1)?.text).toBe("No repos linked yet."); + expect(replies.at(-1)?.text).toBe("Todavía no hay repositorios vinculados."); }); // RES-001: past Telegram's 4096-char limit, an unbounded reply would make @@ -861,7 +883,7 @@ describe("registerCommands — /repos (repo-topic-links spec)", () => { // (it is not a domain error), and the route would answer 500 — which // Telegram retries forever, permanently breaking /repos for any team with // enough links. This team has 300 links, comfortably enough to exceed the - // limit with the "owner/repo-N -> topic 1" line format. + // limit with the "owner/repo-N -> tema 1" line format. it("caps the reply below Telegram's 4096-char limit and ends with a fixed summary line", async () => { const { bot, replies, deps } = makeBot([{ chatId: 10, userId: 1 }]); await bot.handleUpdate(commandUpdate("setup", 10, 1)); @@ -884,7 +906,8 @@ describe("registerCommands — /repos (repo-topic-links spec)", () => { const text = replies.at(-1)!.text; expect(text.length).toBeLessThanOrEqual(4096); const includedLines = text.split("\n").filter((line) => line.startsWith("owner/repo-")).length; - expect(text.endsWith(`...and ${total - includedLines} more`)).toBe(true); + expect(text.endsWith(`…y ${total - includedLines} más`)).toBe(true); + expect(text).toMatch(/…y \d+ más$/); expect(includedLines).toBeLessThan(total); expect(includedLines).toBeGreaterThan(0); }); @@ -1315,3 +1338,97 @@ describe("registerCommands — /hackathon and /hackathons replies are plain text } }); }); + +// Spanish copy for the remaining profile, role, unlink and picker replies. +describe("registerCommands — remaining Spanish replies", () => { + it("/profile set replies with the Spanish confirmation and usage", async () => { + const { bot, replies, deps } = makeBot([{ chatId: 10, userId: 1 }]); + await bot.handleUpdate(commandUpdate("setup", 10, 1)); + await bot.handleUpdate(commandUpdate("profile", 10, 1, { args: "set full_name Ada" })); + await bot.handleUpdate(commandUpdate("profile", 10, 1, { args: "set nope x" })); + await bot.handleUpdate(commandUpdate("profile", 10, 1, { args: "wat" })); + + expect(replies[1]?.text).toBe(`Perfil actualizado para el equipo ${deps.teamRepo.rows[0]!.id}.`); + expect(replies[2]?.text).toBe( + "Uso: /profile set ", + ); + expect(replies[3]?.text).toBe("Uso: /profile show [membership-id] o /profile set "); + }); + + it("/profile show renders the empty-member and no-fields copy", async () => { + const { bot, replies, deps } = makeBot([{ chatId: 10, userId: 1 }]); + await bot.handleUpdate(commandUpdate("setup", 10, 1)); + await bot.handleUpdate(commandUpdate("datachannel", 10, 1, { threadId: 77 })); + await bot.handleUpdate(commandUpdate("profile", 10, 1, { threadId: 77, args: "show" })); + expect(replies.at(-1)?.text).toContain("No hay campos de perfil configurados."); + + await bot.handleUpdate(commandUpdate("profile", 10, 1, { threadId: 77, args: "show missing-id" })); + expect(replies.at(-1)?.text).toBe( + `Equipo ${deps.teamRepo.rows[0]!.id}\nNo se encontró ningún miembro que coincida.`, + ); + }); + + it("/profile show refuses a non-member and /profile set refuses a non-registered caller", async () => { + const { bot, replies } = makeBot([{ chatId: 10, userId: 1 }]); + await bot.handleUpdate(commandUpdate("setup", 10, 1)); + await bot.handleUpdate(commandUpdate("datachannel", 10, 1, { threadId: 77 })); + await bot.handleUpdate(commandUpdate("profile", 10, 9, { threadId: 77, args: "show" })); + await bot.handleUpdate(commandUpdate("profile", 10, 9, { args: "set full_name Ada" })); + + expect(replies[2]?.text).toBe("No eres miembro de este equipo."); + expect(replies[3]?.text).toBe("No eres miembro de este equipo."); + }); + + it("/promote and /demote reply with Spanish usage and refusals", async () => { + const { bot, replies, deps } = makeBot([{ chatId: 10, userId: 1 }]); + await bot.handleUpdate(commandUpdate("setup", 10, 1)); + await bot.handleUpdate(commandUpdate("join", 10, 3)); + const adminId = deps.membershipRepo.rows[0]!.id; + await bot.handleUpdate(commandUpdate("promote", 10, 1)); + await bot.handleUpdate(commandUpdate("promote", 10, 1, { args: "missing-id" })); + await bot.handleUpdate(commandUpdate("demote", 10, 3, { args: adminId })); + + expect(replies[2]?.text).toBe("Uso: /promote "); + expect(replies[3]?.text).toBe("No se encontró al miembro en este equipo."); + expect(replies[4]?.text).toBe("Solo un administrador del equipo puede cambiar roles."); + }); + + it("/demote refuses to demote the last admin", async () => { + const { bot, replies, deps } = makeBot([{ chatId: 10, userId: 1 }]); + await bot.handleUpdate(commandUpdate("setup", 10, 1)); + const adminId = deps.membershipRepo.rows[0]!.id; + await bot.handleUpdate(commandUpdate("demote", 10, 1, { args: adminId })); + + expect(replies[1]?.text).toBe("No se puede degradar al último administrador del equipo."); + }); + + it("/unlinkrepo reports a repo that was not linked and usage in Spanish", async () => { + const { bot, replies } = makeBot([{ chatId: 10, userId: 1 }]); + await bot.handleUpdate(commandUpdate("setup", 10, 1)); + await bot.handleUpdate(commandUpdate("unlinkrepo", 10, 1, { threadId: 77, args: "owner/repo" })); + await bot.handleUpdate(commandUpdate("unlinkrepo", 10, 1, { threadId: 77, args: "not-a-repo" })); + + expect(replies[1]?.text).toBe("owner/repo no estaba vinculado a ningún tema."); + expect(replies[2]?.text).toBe("Uso: /unlinkrepo "); + }); + + it("/profile in a DM with no membership tells the caller to join a team first", async () => { + const { bot, replies } = makeBot(); + await bot.handleUpdate(commandUpdate("profile", 50, 5, { chatType: "private", args: "show" })); + + expect(replies[0]?.text).toBe("Primero únete a un equipo ejecutando /join en su grupo."); + }); + + it("the team picker labels each button 'Equipo {id}'", async () => { + const { bot, payloads, deps } = makeBot([{ chatId: 10, userId: 1 }, { chatId: 20, userId: 1 }]); + await bot.handleUpdate(commandUpdate("setup", 10, 1)); + await bot.handleUpdate(commandUpdate("setup", 20, 1)); + await bot.handleUpdate(commandUpdate("profile", 50, 1, { chatType: "private", args: "show" })); + + const markup = payloads.at(-1)?.reply_markup as { + inline_keyboard: Array>; + }; + const buttons = markup.inline_keyboard.flat(); + expect(buttons.map((b) => b.text)).toEqual(deps.teamRepo.rows.map((t) => `Equipo ${t.id}`)); + }); +}); diff --git a/test/copy/catalog-language.test.ts b/test/copy/catalog-language.test.ts index a4d9fda..4c55472 100644 --- a/test/copy/catalog-language.test.ts +++ b/test/copy/catalog-language.test.ts @@ -5,12 +5,24 @@ import { FIELD_LABELS, moreItems, } from "../../src/domain/copy"; -import { commonCopy, hackathonCopy } from "../../src/adapters/telegram/copy"; +import { + commonCopy, + dataChannelCopy, + hackathonCopy, + joinCopy, + pickerCopy, + profileCopy, + repoCopy, + ROLE_LABELS, + roleCopy, + setupCopy, + teamResolutionCopy, +} 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). +// code map must be complete. Scope: the domain catalog and the full Telegram +// adapter catalog (GitHub alert headers join in a later PR). const ENGLISH_DENYLIST = /\b(the|you|your|only|could|please|run|usage|team|member|topic|linked|analysis|page|try)\b/i; @@ -45,6 +57,15 @@ const CATALOGS: Array<[string, Catalog]> = [ ["FETCH_FAILURE_PHRASES", FETCH_FAILURE_PHRASES as unknown as Catalog], ["commonCopy", commonCopy as unknown as Catalog], ["hackathonCopy", hackathonCopy as unknown as Catalog], + ["teamResolutionCopy", teamResolutionCopy as unknown as Catalog], + ["setupCopy", setupCopy as unknown as Catalog], + ["joinCopy", joinCopy as unknown as Catalog], + ["dataChannelCopy", dataChannelCopy as unknown as Catalog], + ["profileCopy", profileCopy as unknown as Catalog], + ["roleCopy", roleCopy as unknown as Catalog], + ["repoCopy", repoCopy as unknown as Catalog], + ["pickerCopy", pickerCopy as unknown as Catalog], + ["ROLE_LABELS", ROLE_LABELS as unknown as Catalog], ]; describe("copy catalogs are Spanish", () => { @@ -92,4 +113,23 @@ describe("copy catalogs are Spanish", () => { "eligibility", ]); }); + + it("the role map covers every role with a distinct Spanish label", () => { + expect(ROLE_LABELS).toEqual({ admin: "administrador", member: "miembro" }); + }); + + it("the link/unlink maps cover both actions with distinct phrases", () => { + expect(Object.keys(repoCopy.topicRequired).sort()).toEqual(["link", "unlink"]); + expect(new Set(Object.values(repoCopy.topicRequired)).size).toBe(2); + expect(repoCopy.usage("linkrepo")).toBe("Uso: /linkrepo "); + }); + + it("the shared no-team and membership-check phrases are Spanish", () => { + expect(commonCopy.noTeamForChat).toBe( + "No hay ningún equipo registrado en este chat. Pide a un administrador que ejecute /setup.", + ); + expect(commonCopy.membershipCheckFailed("datachannel")).toBe( + "No se pudo verificar tu pertenencia al equipo. Vuelve a intentar /datachannel.", + ); + }); }); diff --git a/test/http/webhook-e2e.test.ts b/test/http/webhook-e2e.test.ts index 986475c..25665bf 100644 --- a/test/http/webhook-e2e.test.ts +++ b/test/http/webhook-e2e.test.ts @@ -73,7 +73,7 @@ describe("POST /telegram/webhook — end-to-end through real composition (REL-00 expect(calls.some((c) => c.method === "getChatMember")).toBe(true); const sendMessageCall = calls.find((c) => c.method === "sendMessage"); expect(sendMessageCall).toBeTruthy(); - expect((sendMessageCall?.body as { text: string }).text).toMatch(/created/i); + expect((sendMessageCall?.body as { text: string }).text).toBe("Equipo creado. Eres el primer administrador."); const row = await env.DB.prepare( "SELECT * FROM teams WHERE telegram_chat_id = ?",