feat: convert to argument.es — Spanish, vote buttons, Docker

- Translate all ~430 prompts to Spanish with cultural adaptations
- Translate all UI strings (frontend, admin, history, broadcast)
- Translate AI system prompts; models now respond in Spanish
- Replace Twitch/Fossabot viewer voting with in-site vote buttons
- Add POST /api/vote endpoint (IP-based, supports vote switching)
- Vote buttons appear during voting phase with active state highlight
- Rename project to argument.es throughout (package.json, cookie, DB)
- Add docker-compose.yml with SQLite volume mount
- Add .env.sample documenting all required and optional vars

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 13:09:00 +01:00
parent ccaa86b4a6
commit 2abea42c18
16 changed files with 1124 additions and 1150 deletions

212
server.ts
View File

@@ -101,27 +101,11 @@ const MAX_HISTORY_CACHE_KEYS = parsePositiveInt(
process.env.MAX_HISTORY_CACHE_KEYS,
500,
);
const FOSSABOT_CHANNEL_LOGIN = (
process.env.FOSSABOT_CHANNEL_LOGIN ?? "quipslop"
).trim().toLowerCase();
const FOSSABOT_VOTE_SECRET = process.env.FOSSABOT_VOTE_SECRET ?? "";
const FOSSABOT_CHAT_CHANNEL_ID = (
process.env.FOSSABOT_CHAT_CHANNEL_ID ?? "813591620327550976"
).trim();
const FOSSABOT_SESSION_TOKEN = (process.env.FOSSABOT_SESSION_TOKEN ?? "").trim();
const FOSSABOT_VALIDATE_TIMEOUT_MS = parsePositiveInt(
process.env.FOSSABOT_VALIDATE_TIMEOUT_MS,
1_500,
);
const FOSSABOT_SEND_CHAT_TIMEOUT_MS = parsePositiveInt(
process.env.FOSSABOT_SEND_CHAT_TIMEOUT_MS,
3_000,
);
const VIEWER_VOTE_BROADCAST_DEBOUNCE_MS = parsePositiveInt(
process.env.VIEWER_VOTE_BROADCAST_DEBOUNCE_MS,
250,
);
const ADMIN_COOKIE = "quipslop_admin";
const ADMIN_COOKIE = "argumentes_admin";
const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
const requestWindows = new Map<string, number[]>();
@@ -282,109 +266,6 @@ function setHistoryCache(key: string, body: string, expiresAt: number) {
type ViewerVoteSide = "A" | "B";
function isValidFossabotValidateUrl(rawUrl: string): boolean {
try {
const url = new URL(rawUrl);
return (
url.protocol === "https:" &&
url.host === "api.fossabot.com" &&
url.pathname.startsWith("/v2/customapi/validate/")
);
} catch {
return false;
}
}
async function validateFossabotRequest(validateUrl: string): Promise<boolean> {
if (!isValidFossabotValidateUrl(validateUrl)) {
return false;
}
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
FOSSABOT_VALIDATE_TIMEOUT_MS,
);
try {
const res = await fetch(validateUrl, {
method: "GET",
signal: controller.signal,
});
if (!res.ok) return false;
const body = (await res.json().catch(() => null)) as
| { context_url?: unknown }
| null;
return Boolean(body && typeof body.context_url === "string");
} catch {
return false;
} finally {
clearTimeout(timeout);
}
}
async function sendFossabotChatMessage(messageText: string): Promise<void> {
if (!FOSSABOT_SESSION_TOKEN) {
log(
"WARN",
"fossabot:chat",
"Skipped chat message because FOSSABOT_SESSION_TOKEN is not configured",
);
return;
}
if (!FOSSABOT_CHAT_CHANNEL_ID) {
log(
"WARN",
"fossabot:chat",
"Skipped chat message because FOSSABOT_CHAT_CHANNEL_ID is not configured",
);
return;
}
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
FOSSABOT_SEND_CHAT_TIMEOUT_MS,
);
try {
const url = `https://api.fossabot.com/v2/channels/${FOSSABOT_CHAT_CHANNEL_ID}/bot/send_chat_message`;
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${FOSSABOT_SESSION_TOKEN}`,
},
body: JSON.stringify({ messageText }),
signal: controller.signal,
});
if (!res.ok) {
const body = await res.text().catch(() => "");
log("WARN", "fossabot:chat", "Fossabot send_chat_message failed", {
status: res.status,
body: body.slice(0, 250),
});
return;
}
const response = (await res.json().catch(() => null)) as
| { transactionId?: unknown }
| null;
log("INFO", "fossabot:chat", "Sent voting prompt to Twitch chat", {
transactionId:
response && typeof response.transactionId === "string"
? response.transactionId
: undefined,
});
} catch (error) {
log("WARN", "fossabot:chat", "Failed to send chat message", {
error: error instanceof Error ? error.message : String(error),
});
} finally {
clearTimeout(timeout);
}
}
function applyViewerVote(voterId: string, side: ViewerVoteSide): boolean {
const round = gameState.active;
@@ -508,77 +389,34 @@ const server = Bun.serve<WsData>({
return new Response("ok", { status: 200 });
}
if (
url.pathname === "/api/fossabot/vote/1" ||
url.pathname === "/api/fossabot/vote/2"
) {
if (req.method !== "GET") {
return new Response("", {
if (url.pathname === "/api/vote") {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "GET" },
headers: { Allow: "POST" },
});
}
if (!FOSSABOT_VOTE_SECRET) {
log("ERROR", "vote:fossabot", "FOSSABOT_VOTE_SECRET is not configured");
return new Response("", { status: 503 });
let side: string = "";
try {
const body = await req.json();
side = String((body as Record<string, unknown>).side ?? "");
} catch {
return new Response("Invalid JSON body", { status: 400 });
}
const providedSecret = url.searchParams.get("secret") ?? "";
if (!providedSecret || !secureCompare(providedSecret, FOSSABOT_VOTE_SECRET)) {
log("WARN", "vote:fossabot", "Rejected due to missing/invalid secret", {
ip,
});
return new Response("", { status: 401 });
if (side !== "A" && side !== "B") {
return new Response("Invalid side", { status: 400 });
}
const channelProvider = req.headers
.get("x-fossabot-channelprovider")
?.trim()
.toLowerCase();
const channelLogin = req.headers
.get("x-fossabot-channellogin")
?.trim()
.toLowerCase();
if (channelProvider !== "twitch" || channelLogin !== FOSSABOT_CHANNEL_LOGIN) {
log("WARN", "vote:fossabot", "Rejected due to channel/provider mismatch", {
ip,
channelProvider,
channelLogin,
});
return new Response("", { status: 403 });
}
const validateUrl = req.headers.get("x-fossabot-validateurl") ?? "";
const isValid = await validateFossabotRequest(validateUrl);
if (!isValid) {
log("WARN", "vote:fossabot", "Validation check failed", { ip });
return new Response("", { status: 401 });
}
const userProvider = req.headers
.get("x-fossabot-message-userprovider")
?.trim()
.toLowerCase();
if (userProvider && userProvider !== "twitch") {
return new Response("", { status: 403 });
}
const userProviderId = req.headers
.get("x-fossabot-message-userproviderid")
?.trim();
if (!userProviderId) {
log("WARN", "vote:fossabot", "Missing user provider ID", { ip });
return new Response("", { status: 400 });
}
const votedFor: ViewerVoteSide = url.pathname.endsWith("/1") ? "A" : "B";
const applied = applyViewerVote(userProviderId, votedFor);
const applied = applyViewerVote(ip, side as ViewerVoteSide);
if (applied) {
scheduleViewerVoteBroadcast();
}
return new Response("", {
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
},
});
@@ -681,7 +519,7 @@ const server = Bun.serve<WsData>({
headers: {
"Content-Type": "application/json",
"Cache-Control": "no-store",
"Content-Disposition": `attachment; filename="quipslop-export-${Date.now()}.json"`,
"Content-Disposition": `attachment; filename="argumentes-export-${Date.now()}.json"`,
},
});
}
@@ -888,7 +726,7 @@ const server = Bun.serve<WsData>({
broadcastViewerCount();
},
message() {
// Viewer voting moved to Twitch chat via Fossabot.
// Viewer voting handled via /api/vote endpoint.
},
close(ws) {
clients.delete(ws);
@@ -917,9 +755,9 @@ const server = Bun.serve<WsData>({
},
});
console.log(`\n🎮 quipslop Web — http://localhost:${server.port}`);
console.log(`\n🎮 argument.es Web — http://localhost:${server.port}`);
console.log(`📡 WebSocket — ws://localhost:${server.port}/ws`);
console.log(`🎯 ${runs} rounds with ${MODELS.length} models\n`);
console.log(`🎯 ${runs} rondas con ${MODELS.length} modelos\n`);
log("INFO", "server", `Web server started on port ${server.port}`, {
runs,
@@ -928,12 +766,8 @@ log("INFO", "server", `Web server started on port ${server.port}`, {
// ── Start game ──────────────────────────────────────────────────────────────
runGame(runs, gameState, broadcast, (round) => {
runGame(runs, gameState, broadcast, () => {
viewerVoters.clear();
const [modelA, modelB] = round.contestants;
const messageText = `1 in chat for ${modelA.name}, 2 in chat for ${modelB.name}`;
void sendFossabotChatMessage(messageText);
}).then(() => {
console.log(`\n✅ Game complete! Log: ${LOG_FILE}`);
console.log(`\n✅ ¡Juego completado! Registro: ${LOG_FILE}`);
});