From 391ee65673e9cb3f29bbc2972e8a381733aef3ff Mon Sep 17 00:00:00 2001 From: Gakuto Furuya Date: Sat, 18 Jul 2026 13:05:35 +0900 Subject: [PATCH 01/15] Scaffold TypeScript bot workspace --- discord/.dockerignore | 2 + discord/.gitignore | 2 + discord/biome.json | 13 + discord/data/.gitignore | 2 + discord/package.json | 34 + discord/pnpm-lock.yaml | 2907 +++++++++++++++++++++++++++++++++++ discord/pnpm-workspace.yaml | 2 + discord/src/index.ts | 41 + discord/tsconfig.json | 15 + discord/vitest.config.ts | 8 + 10 files changed, 3026 insertions(+) create mode 100644 discord/.gitignore create mode 100644 discord/biome.json create mode 100644 discord/data/.gitignore create mode 100644 discord/package.json create mode 100644 discord/pnpm-lock.yaml create mode 100644 discord/pnpm-workspace.yaml create mode 100644 discord/src/index.ts create mode 100644 discord/tsconfig.json create mode 100644 discord/vitest.config.ts diff --git a/discord/.dockerignore b/discord/.dockerignore index da0ab4e..d2db951 100644 --- a/discord/.dockerignore +++ b/discord/.dockerignore @@ -4,3 +4,5 @@ __pycache__/ *.pyc *.pyo *.pyd +node_modules/ +dist/ diff --git a/discord/.gitignore b/discord/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/discord/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/discord/biome.json b/discord/biome.json new file mode 100644 index 0000000..727b94a --- /dev/null +++ b/discord/biome.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.4/schema.json", + "formatter": { + "enabled": true, + "indentStyle": "space" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + } +} diff --git a/discord/data/.gitignore b/discord/data/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/discord/data/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/discord/package.json b/discord/package.json new file mode 100644 index 0000000..ef68155 --- /dev/null +++ b/discord/package.json @@ -0,0 +1,34 @@ +{ + "name": "coderunbot-discord", + "version": "0.1.0", + "private": true, + "type": "module", + "packageManager": "pnpm@11.9.0", + "engines": { + "node": ">=24" + }, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit", + "lint": "biome check src && madge --circular --extensions ts src", + "format": "biome format --write src", + "test": "vitest run" + }, + "dependencies": { + "discord.js": "^14.27.0", + "dotenv": "^17.4.2", + "i18next": "^26.3.6", + "pino": "^10.3.1" + }, + "devDependencies": { + "@biomejs/biome": "^2.5.4", + "@types/node": "^24.13.3", + "dependency-cruiser": "^18.1.0", + "madge": "^8.0.0", + "tsx": "^4.23.1", + "typescript": "^7.0.2", + "vitest": "^4.1.10" + } +} diff --git a/discord/pnpm-lock.yaml b/discord/pnpm-lock.yaml new file mode 100644 index 0000000..2f942f0 --- /dev/null +++ b/discord/pnpm-lock.yaml @@ -0,0 +1,2907 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + discord.js: + specifier: ^14.27.0 + version: 14.27.0 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + i18next: + specifier: ^26.3.6 + version: 26.3.6(typescript@7.0.2) + pino: + specifier: ^10.3.1 + version: 10.3.1 + devDependencies: + '@biomejs/biome': + specifier: ^2.5.4 + version: 2.5.4 + '@types/node': + specifier: ^24.13.3 + version: 24.13.3 + dependency-cruiser: + specifier: ^18.1.0 + version: 18.1.0 + madge: + specifier: ^8.0.0 + version: 8.0.0(typescript@7.0.2) + tsx: + specifier: ^4.23.1 + version: 4.23.1 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)) + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@2.5.4': + resolution: {integrity: sha512-xy5FNE5kQJKyK5MR1gJy6ztXYx4WBAbYGlK04lMEgmyPRWKybY9NFwiG9yo0XdzOU8Xvhj41u034J1ywfoWfMw==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.4': + resolution: {integrity: sha512-4o3NFRobXHynkgcFVrlZsoDAFtF2ldlEGN8sORSws5ZQqyY4PXnPUIylu4ksfyHuwkfvDREuWh3JK+niRwGq3w==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.4': + resolution: {integrity: sha512-D32P5HkU2Y6PySuC/WsVDTOgsDwVFmujzhhhOQjajtATpVWFDXuVd3oRbsWNSEA+aaFzyzZm22szsyydBYlSyQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.4': + resolution: {integrity: sha512-Rpm5/AT1m+DlJmUoYvS4/vXc+0tXJPJ2NQz25TGPyHVF5JrWy75PE0GH6kVxsKtQDuCH4OgzquZq0R4kj/wCVg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.5.4': + resolution: {integrity: sha512-pSEfW7B8kTsXUjUxC1xVVK+y85Ht3C5XxZ9gclmC7/3Ku9Vqz8jmI7k0p/BNIjQ6t4sFERI2sFeH73ybiZl6YQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.5.4': + resolution: {integrity: sha512-aby/PohmmgbShcHqFsZVzG8H6D98+P+A6xRWRrQcLW1pCjabcov5UUlke4UqNQBYTkDQav+jB4zyyDDeKB2GaA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.5.4': + resolution: {integrity: sha512-FNxojWJkL7EajAuzBgoLe0T2G0y112M4lBrDIFl/DomFTx8yqenYOIdsRLNXvOvBBofE8hJi85LjzLmBDpY7/Q==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.5.4': + resolution: {integrity: sha512-emoXexPZIPAZkz2RKmA95WJUqK3I5MJNYtwEbL5ESciRzhmFMMyekDhNG8hpeOaK+ZGRDxAU4wvGuA5IHQ0h0w==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.4': + resolution: {integrity: sha512-U1jaluLw1qQc2Tx7/CeSoL9N5XcqIH+GWjpUAy1ouB5nVjSCMNO+NNHdY3RAs8zxNurLWAdj6pehQdCA2zyU+Q==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@dependents/detective-less@5.0.3': + resolution: {integrity: sha512-v6oD9Ukp+N7V4n6p5I/+mM5fIohSfkrDSGlFm5w/pYmchvbk+sMIHsLxrFJ5Lnujewj1BzWL0K84d88lwZAMQA==} + engines: {node: '>=18'} + + '@discordjs/builders@1.14.1': + resolution: {integrity: sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==} + engines: {node: '>=16.11.0'} + + '@discordjs/collection@1.5.3': + resolution: {integrity: sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==} + engines: {node: '>=16.11.0'} + + '@discordjs/collection@2.1.1': + resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==} + engines: {node: '>=18'} + + '@discordjs/formatters@0.6.2': + resolution: {integrity: sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==} + engines: {node: '>=16.11.0'} + + '@discordjs/rest@2.6.2': + resolution: {integrity: sha512-c5HI3hJuRWWrnpyCZAUYfIUTAEv9weX4tE2UfDqtM3ztizdKE9RFEf3G5IWhPyO+kPldUDpGv02XJAQzdUTC9Q==} + engines: {node: '>=18'} + + '@discordjs/util@1.2.0': + resolution: {integrity: sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==} + engines: {node: '>=18'} + + '@discordjs/ws@1.2.3': + resolution: {integrity: sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==} + engines: {node: '>=16.11.0'} + + '@discoveryjs/json-ext@1.1.0': + resolution: {integrity: sha512-Xc3VhU02wqZ1HvHRJUwL09HkZSTvidqY5Ya0NXBSYOxAp+Ln9dcJr9fySI+CkONzP3PekQo9WdzCv0PGER/mOA==} + engines: {node: '>=14.17.0'} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@sapphire/async-queue@1.5.5': + resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + + '@sapphire/shapeshift@4.0.0': + resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==} + engines: {node: '>=v16'} + + '@sapphire/snowflake@3.5.5': + resolution: {integrity: sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@ts-graphviz/adapter@2.0.6': + resolution: {integrity: sha512-kJ10lIMSWMJkLkkCG5gt927SnGZcBuG0s0HHswGzcHTgvtUe7yk5/3zTEr0bafzsodsOq5Gi6FhQeV775nC35Q==} + engines: {node: '>=18'} + + '@ts-graphviz/ast@2.0.7': + resolution: {integrity: sha512-e6+2qtNV99UT6DJSoLbHfkzfyqY84aIuoV8Xlb9+hZAjgpum8iVHprGeAMQ4rF6sKUAxrmY8rfF/vgAwoPc3gw==} + engines: {node: '>=18'} + + '@ts-graphviz/common@2.1.5': + resolution: {integrity: sha512-S6/9+T6x8j6cr/gNhp+U2olwo1n0jKj/682QVqsh7yXWV6ednHYqxFw0ZsY3LyzT0N8jaZ6jQY9YD99le3cmvg==} + engines: {node: '>=18'} + + '@ts-graphviz/core@2.0.7': + resolution: {integrity: sha512-w071DSzP94YfN6XiWhOxnLpYT3uqtxJBDYdh6Jdjzt+Ce6DNspJsPQgpC7rbts/B8tEkq0LHoYuIF/O5Jh5rPg==} + engines: {node: '>=18'} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@typescript-eslint/project-service@8.64.0': + resolution: {integrity: sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/tsconfig-utils@8.64.0': + resolution: {integrity: sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.64.0': + resolution: {integrity: sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.64.0': + resolution: {integrity: sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.64.0': + resolution: {integrity: sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + '@vladfrangu/async_event_emitter@2.4.7': + resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + + '@vue/compiler-core@3.5.40': + resolution: {integrity: sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==} + + '@vue/compiler-dom@3.5.40': + resolution: {integrity: sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==} + + '@vue/compiler-sfc@3.5.40': + resolution: {integrity: sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==} + + '@vue/compiler-ssr@3.5.40': + resolution: {integrity: sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==} + + '@vue/shared@3.5.40': + resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==} + + acorn-jsx-walk@2.0.0: + resolution: {integrity: sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-loose@8.5.2: + resolution: {integrity: sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==} + engines: {node: '>=0.4.0'} + + acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + app-module-path@2.2.0: + resolution: {integrity: sha512-gkco+qxENJV+8vFcDiiFhuoSvRXb2a/QPqpSoWhVz829VNJfOTnELbBmPmNKFxf3xdNnw4DWCkzkDaavcX/1YQ==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-module-types@6.0.2: + resolution: {integrity: sha512-6KuK/7nZ/2Qh7sGuVEiwxjCxzTY2Pdb5mTo5z1e6/J8BA0tvjR7G8vQJKrQMTqwmnA3UPEyKIFX4YUS1DO1Hvw==} + engines: {node: '>=18'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + dependency-cruiser@18.1.0: + resolution: {integrity: sha512-pbsH0gQ15BxXi97SCuMeVgsh8VzYpziGIVaMdnALbVu+TYjSZrW9/nOvsPU+NjHLmJSF9R1UijjoACq6Ne/ybQ==} + engines: {node: ^22||^24||>=26} + hasBin: true + + dependency-tree@11.5.0: + resolution: {integrity: sha512-K9zBwKDZrot3RkxizugpVSdImxULAg4Ycp3+ydy2r561k96oiiw6nfsOR15fwNDQ5BF2UXe+2JFM/H5Xz4MGQg==} + engines: {node: '>=18'} + hasBin: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detective-amd@6.1.0: + resolution: {integrity: sha512-fmI6LGMvotqd49QaA3ZYw+q0aGp2yXmMjzIuY6fH9j9YFIXY/73yDhMwhX9cPbhWd+AH06NH1Di/LKOuCH0Ubg==} + engines: {node: '>=18'} + hasBin: true + + detective-cjs@6.1.1: + resolution: {integrity: sha512-pSh7mkCKEtLlmANqLu3KDFS3NV8Hx41jy/JF1/gAWOgU+Uo5QTkeI1tWNP4dWGo4L0E9j18Ez9EPsTleautKqA==} + engines: {node: '>=18'} + + detective-es6@5.0.2: + resolution: {integrity: sha512-+qHHGYhjupiVs4rnIpI9nZ5B130A4AmE35ZX1w33hb46vcZ7T3jfDbvmPw0FhWtMHn5BS5HHu7ZtnZ53bMcXZA==} + engines: {node: '>=18'} + + detective-postcss@8.0.4: + resolution: {integrity: sha512-DZ7M/hWPZyr17ZUdoQ+TVXaPj70mYr4XXrAE+GeJbca44haCvZgb191L/jLJmFYewhxRJuBd4lUtNSu986TXag==} + engines: {node: '>=18'} + peerDependencies: + postcss: ^8.4.47 + + detective-sass@6.0.2: + resolution: {integrity: sha512-i3xpXHDKS0qI2aFW4asQ7fqlPK00ndOVZELvQapFJCaF0VxYmsNWtd0AmvXbTLMk7bfO5VdIeorhY9KfmHVoVA==} + engines: {node: '>=18'} + + detective-scss@5.0.2: + resolution: {integrity: sha512-9JOEMZ8pDh3ShXmftq7hoQqqJsClaGgxo1hghfCeFlmKf5TC/Twtwb0PAaK8dXwpg9Z0uCmEYSrCxO+kel2eEg==} + engines: {node: '>=18'} + + detective-stylus@5.0.1: + resolution: {integrity: sha512-Dgn0bUqdGbE3oZJ+WCKf8Dmu7VWLcmRJGc6RCzBgG31DLIyai9WAoEhYRgIHpt/BCRMrnXLbGWGPQuBUrnF0TA==} + engines: {node: '>=18'} + + detective-typescript@14.1.2: + resolution: {integrity: sha512-bIeEn0eVi/JRsE1YizBR2ilnMlWRAIBJJ6kXCKNFxEEWhUcEY3R6I3KYIAy48ieURbD1hcb3Ebvl8AqeoPMSzg==} + engines: {node: '>=18'} + peerDependencies: + typescript: ^5.4.4 || ^6.0.2 + + detective-vue2@2.3.0: + resolution: {integrity: sha512-3gwbZPqVTm9sL9XdZsgEJ7x4x99O853VVZHapQAiEkGuMJMpFPjHDrecSgfqnS5JW3FJfYXesLZGvUOibjn49g==} + engines: {node: '>=18'} + peerDependencies: + typescript: ^5.4.4 || ^6.0.2 + + discord-api-types@0.38.50: + resolution: {integrity: sha512-J2n/bpIETX3DQ6AJ7/0xbsTLmYiJQtO/LKcXKC1YDbB56OUwtDbdXOFE8Q4g8jVGHBR2VAy1+D4ngaIgkMNV9w==} + + discord.js@14.27.0: + resolution: {integrity: sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A==} + engines: {node: '>=18'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + enhanced-resolve@5.24.2: + resolution: {integrity: sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==} + engines: {node: '>=10.13.0'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + filing-cabinet@5.5.1: + resolution: {integrity: sha512-PzLBTChlVPn6LnNxF0KWs+XqPziVh3Sfmz/3TXOymHxu6a9yhrDcQn7YwgpcRM6mqhR2WHVGPR8RU4fmcF1IVA==} + engines: {node: '>=18'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-amd-module-type@6.0.2: + resolution: {integrity: sha512-7zShVYAYtMnj9S65CfN+hvpBCByfuB1OY8xID01nZEzXTZbx4YyysAfi+nMl95JSR6odt4q8TCj2W63KAoyVLQ==} + engines: {node: '>=18'} + + get-own-enumerable-property-symbols@3.0.2: + resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} + + global-directory@4.0.1: + resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} + engines: {node: '>=18'} + + gonzales-pe@4.3.0: + resolution: {integrity: sha512-otgSPpUmdWJ43VXyiNgEYE4luzHCL2pz4wQ0OnDluC6Eg4Ko3Vexy/SrSynglw/eR+OhkzmqFCZa/OFa/RgAOQ==} + engines: {node: '>=0.6.0'} + hasBin: true + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + i18next@26.3.6: + resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==} + peerDependencies: + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + typescript: + optional: true + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + ini@4.1.1: + resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + interpret@3.1.1: + resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} + engines: {node: '>=10.13.0'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-installed-globally@1.0.0: + resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==} + engines: {node: '>=18'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-obj@1.0.1: + resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} + engines: {node: '>=0.10.0'} + + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + + is-regexp@1.0.0: + resolution: {integrity: sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==} + engines: {node: '>=0.10.0'} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + is-url-superb@4.0.0: + resolution: {integrity: sha512-GI+WjezhPPcbM+tqE9LnmsY5qqjwHzTvjJ36wxYX5ujNXefSUJ/T17r5bqDV8yLhcgB59KTPNOc9O9cmHTPWsA==} + engines: {node: '>=10'} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lodash.snakecase@4.1.1: + resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + madge@8.0.0: + resolution: {integrity: sha512-9sSsi3TBPhmkTCIpVQF0SPiChj1L7Rq9kU2KDG1o6v2XH9cCw086MopjVCD+vuoL5v8S77DTbVopTO8OUiQpIw==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: ^5.4.4 + peerDependenciesMeta: + typescript: + optional: true + + magic-bytes.js@1.13.0: + resolution: {integrity: sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + module-definition@6.0.2: + resolution: {integrity: sha512-SvAU3lB0+Yjbq55yHY3wkRZBOh+fhU1SnIF3IFbTewv6mtAh7yUT8ACHAJ2mGIJ7tCes2QuCL/cl6m0JSZ/ArA==} + engines: {node: '>=18'} + hasBin: true + + module-lookup-amd@9.1.3: + resolution: {integrity: sha512-Jc3XmOaR9FdfMJSK8+vyLgsCkzm8z2L0NS6vrlRWi12DjS7MY7TMNE7E1yj8yXx837xtMDbKSSgcdXnFlJ2YLg==} + engines: {node: '>=18'} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-source-walk@7.0.2: + resolution: {integrity: sha512-71kFFjYaSshDTA8/a2HiTYPLdASWjLJxUyJxGE+ffxU+KhxSBtM9kiLUX+R2yooFdSFKMFpi4n3PFtDy6qXv8A==} + engines: {node: '>=18'} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + parse-ms@2.1.0: + resolution: {integrity: sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==} + engines: {node: '>=6'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + + postcss-values-parser@6.0.2: + resolution: {integrity: sha512-YLJpK0N1brcNJrs9WatuJFtHaV9q5aAOj+S4DI5S7jgHlRfm0PIbDCAFRYMQD5SHq7Fy6xsDhyutgS0QOAs0qw==} + engines: {node: '>=10'} + peerDependencies: + postcss: ^8.2.9 + + postcss@8.5.19: + resolution: {integrity: sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==} + engines: {node: ^10 || ^12 || >=14} + + precinct@12.3.2: + resolution: {integrity: sha512-JbJevI1K80z8e/WIyDt/4vUN/4qcfBSKKqOjJA4mosPPPb7zODKRJQV7YN7apVWN3k58nZYm/vEsLgEGYmnxwg==} + engines: {node: '>=18'} + hasBin: true + + pretty-ms@7.0.1: + resolution: {integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==} + engines: {node: '>=10'} + + process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + quote-unquote@1.0.0: + resolution: {integrity: sha512-twwRO/ilhlG/FIgYeKGFqyHhoEhqgnKVkcmqMKi2r524gz3ZbDTcyFt38E9xjJI2vT+KbRNHVbnJ/e0I25Azwg==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + rechoir@0.8.0: + resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} + engines: {node: '>= 10.13.0'} + + regexp-tree@0.1.27: + resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} + hasBin: true + + requirejs-config-file@4.0.0: + resolution: {integrity: sha512-jnIre8cbWOyvr8a5F2KuqBnY+SDA4NXr/hzEZJG79Mxm2WiFQz2dzhC8ibtPJS7zkmBEl1mxSwp5HhC1W4qpxw==} + engines: {node: '>=10.13.0'} + + requirejs@2.3.8: + resolution: {integrity: sha512-7/cTSLOdYkNBNJcDMWf+luFvMriVm7eYxp4BcFCsAX0wF421Vyce5SXP17c+Jd5otXKGNehIonFlyQXSowL6Mw==} + engines: {node: '>=0.4.0'} + hasBin: true + + resolve-dependency-path@4.0.1: + resolution: {integrity: sha512-YQftIIC4vzO9UMhO/sCgXukNyiwVRCVaxiWskCBy7Zpqkplm8kTAISZ8O1MoKW1ca6xzgLUBjZTcDgypXvXxiQ==} + engines: {node: '>=18'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safe-regex@2.1.1: + resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + sass-lookup@6.1.2: + resolution: {integrity: sha512-GjmndmKQBtlPil79RK72L7yc5kDXZPCQeH97bP8R8DcxtXQJO6vECExb3WP/m6+cxaV9h4ZxrSRvCkPG2v/VSw==} + engines: {node: '>=18'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + stream-to-array@2.3.0: + resolution: {integrity: sha512-UsZtOYEn4tWU2RGLOXr/o/xjRBftZRlG3dEWoaHr8j4GuypJ3isitGbVyjQKAuMu+xbiop8q224TjiZWc4XTZA==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + stringify-object@3.3.0: + resolution: {integrity: sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==} + engines: {node: '>=4'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + stylus-lookup@6.1.2: + resolution: {integrity: sha512-O+Q/SJ8s1X2aMLh4213fQ9X/bND9M3dhSsyTRe+O1OXPcewGLiYmAtKCrnP7FDvDBaXB2ZHPkCt3zi4cJXBlCQ==} + engines: {node: '>=18'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-graphviz@2.1.6: + resolution: {integrity: sha512-XyLVuhBVvdJTJr2FJJV2L1pc4MwSjMhcunRVgDE9k4wbb2ee7ORYnPewxMWUav12vxyfUM686MSGsqnVRIInuw==} + engines: {node: '>=18'} + + ts-mixer@6.0.4: + resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} + + tsconfig-paths-webpack-plugin@4.2.0: + resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==} + engines: {node: '>=10.13.0'} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + undici@6.27.0: + resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} + engines: {node: '>=18.17'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + walkdir@0.4.1: + resolution: {integrity: sha512-3eBwRyEln6E1MSzcxcVpQIhRG8Q1jLvEqRmCZqS3dsfXEDR/AhOF4d+jHg1qvDCpYaVRZjENPQyrVxAkQqxPgQ==} + engines: {node: '>=6.0.0'} + + watskeburt@6.0.0: + resolution: {integrity: sha512-jfiuDABaxSkC71T6oZ3vCS99roYkSHm/+As+G0Dz8taAHQb+SJBvLEm5RlsgG71XdfAj3rv7eudUBTgwcQUPlQ==} + engines: {node: ^22.13||^24||>=26} + hasBin: true + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@biomejs/biome@2.5.4': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.4 + '@biomejs/cli-darwin-x64': 2.5.4 + '@biomejs/cli-linux-arm64': 2.5.4 + '@biomejs/cli-linux-arm64-musl': 2.5.4 + '@biomejs/cli-linux-x64': 2.5.4 + '@biomejs/cli-linux-x64-musl': 2.5.4 + '@biomejs/cli-win32-arm64': 2.5.4 + '@biomejs/cli-win32-x64': 2.5.4 + + '@biomejs/cli-darwin-arm64@2.5.4': + optional: true + + '@biomejs/cli-darwin-x64@2.5.4': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.4': + optional: true + + '@biomejs/cli-linux-arm64@2.5.4': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.4': + optional: true + + '@biomejs/cli-linux-x64@2.5.4': + optional: true + + '@biomejs/cli-win32-arm64@2.5.4': + optional: true + + '@biomejs/cli-win32-x64@2.5.4': + optional: true + + '@dependents/detective-less@5.0.3': + dependencies: + gonzales-pe: 4.3.0 + node-source-walk: 7.0.2 + + '@discordjs/builders@1.14.1': + dependencies: + '@discordjs/formatters': 0.6.2 + '@discordjs/util': 1.2.0 + '@sapphire/shapeshift': 4.0.0 + discord-api-types: 0.38.50 + fast-deep-equal: 3.1.3 + ts-mixer: 6.0.4 + tslib: 2.8.1 + + '@discordjs/collection@1.5.3': {} + + '@discordjs/collection@2.1.1': {} + + '@discordjs/formatters@0.6.2': + dependencies: + discord-api-types: 0.38.50 + + '@discordjs/rest@2.6.2': + dependencies: + '@discordjs/collection': 2.1.1 + '@discordjs/util': 1.2.0 + '@sapphire/async-queue': 1.5.5 + '@sapphire/snowflake': 3.5.5 + '@vladfrangu/async_event_emitter': 2.4.7 + discord-api-types: 0.38.50 + magic-bytes.js: 1.13.0 + tslib: 2.8.1 + undici: 6.27.0 + + '@discordjs/util@1.2.0': + dependencies: + discord-api-types: 0.38.50 + + '@discordjs/ws@1.2.3': + dependencies: + '@discordjs/collection': 2.1.1 + '@discordjs/rest': 2.6.2 + '@discordjs/util': 1.2.0 + '@sapphire/async-queue': 1.5.5 + '@types/ws': 8.18.1 + '@vladfrangu/async_event_emitter': 2.4.7 + discord-api-types: 0.38.50 + tslib: 2.8.1 + ws: 8.21.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@discoveryjs/json-ext@1.1.0': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.139.0': {} + + '@pinojs/redact@0.4.0': {} + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@sapphire/async-queue@1.5.5': {} + + '@sapphire/shapeshift@4.0.0': + dependencies: + fast-deep-equal: 3.1.3 + lodash: 4.18.1 + + '@sapphire/snowflake@3.5.5': {} + + '@standard-schema/spec@1.1.0': {} + + '@ts-graphviz/adapter@2.0.6': + dependencies: + '@ts-graphviz/common': 2.1.5 + + '@ts-graphviz/ast@2.0.7': + dependencies: + '@ts-graphviz/common': 2.1.5 + + '@ts-graphviz/common@2.1.5': {} + + '@ts-graphviz/core@2.0.7': + dependencies: + '@ts-graphviz/ast': 2.0.7 + '@ts-graphviz/common': 2.1.5 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@types/node@26.1.1': + dependencies: + undici-types: 8.3.0 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.1.1 + + '@typescript-eslint/project-service@8.64.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.64.0(typescript@5.9.3) + '@typescript-eslint/types': 8.64.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/tsconfig-utils@8.64.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/types@8.64.0': {} + + '@typescript-eslint/typescript-estree@8.64.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.64.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.64.0(typescript@5.9.3) + '@typescript-eslint/types': 8.64.0 + '@typescript-eslint/visitor-keys': 8.64.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.64.0': + dependencies: + '@typescript-eslint/types': 8.64.0 + eslint-visitor-keys: 5.0.1 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@vladfrangu/async_event_emitter@2.4.7': {} + + '@vue/compiler-core@3.5.40': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.40 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.40': + dependencies: + '@vue/compiler-core': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/compiler-sfc@3.5.40': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.40 + '@vue/compiler-dom': 3.5.40 + '@vue/compiler-ssr': 3.5.40 + '@vue/shared': 3.5.40 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.19 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.40': + dependencies: + '@vue/compiler-dom': 3.5.40 + '@vue/shared': 3.5.40 + + '@vue/shared@3.5.40': {} + + acorn-jsx-walk@2.0.0: {} + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn-loose@8.5.2: + dependencies: + acorn: 8.17.0 + + acorn-walk@8.3.5: + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + any-promise@1.3.0: {} + + app-module-path@2.2.0: {} + + assertion-error@2.0.1: {} + + ast-module-types@6.0.2: {} + + atomic-sleep@1.0.0: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.9.2: {} + + clone@1.0.4: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@12.1.0: {} + + commander@15.0.0: {} + + commander@7.2.0: {} + + commondir@1.0.1: {} + + convert-source-map@2.0.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-extend@0.6.0: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + dependency-cruiser@18.1.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + acorn-jsx-walk: 2.0.0 + acorn-loose: 8.5.2 + acorn-walk: 8.3.5 + commander: 15.0.0 + enhanced-resolve: 5.24.2 + ignore: 7.0.6 + interpret: 3.1.1 + is-installed-globally: 1.0.0 + json5: 2.2.3 + picomatch: 4.0.5 + prompts: 2.4.2 + rechoir: 0.8.0 + safe-regex: 2.1.1 + semver: 7.8.5 + tsconfig-paths-webpack-plugin: 4.2.0 + watskeburt: 6.0.0 + + dependency-tree@11.5.0: + dependencies: + '@discoveryjs/json-ext': 1.1.0 + commander: 12.1.0 + filing-cabinet: 5.5.1 + precinct: 12.3.2 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + detect-libc@2.1.2: {} + + detective-amd@6.1.0: + dependencies: + ast-module-types: 6.0.2 + escodegen: 2.1.0 + get-amd-module-type: 6.0.2 + node-source-walk: 7.0.2 + + detective-cjs@6.1.1: + dependencies: + ast-module-types: 6.0.2 + node-source-walk: 7.0.2 + + detective-es6@5.0.2: + dependencies: + node-source-walk: 7.0.2 + + detective-postcss@8.0.4(postcss@8.5.19): + dependencies: + is-url-superb: 4.0.0 + postcss: 8.5.19 + postcss-values-parser: 6.0.2(postcss@8.5.19) + + detective-sass@6.0.2: + dependencies: + gonzales-pe: 4.3.0 + node-source-walk: 7.0.2 + + detective-scss@5.0.2: + dependencies: + gonzales-pe: 4.3.0 + node-source-walk: 7.0.2 + + detective-stylus@5.0.1: {} + + detective-typescript@14.1.2(typescript@5.9.3): + dependencies: + '@typescript-eslint/typescript-estree': 8.64.0(typescript@5.9.3) + ast-module-types: 6.0.2 + node-source-walk: 7.0.2 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + detective-vue2@2.3.0(typescript@5.9.3): + dependencies: + '@dependents/detective-less': 5.0.3 + '@vue/compiler-sfc': 3.5.40 + detective-es6: 5.0.2 + detective-sass: 6.0.2 + detective-scss: 5.0.2 + detective-stylus: 5.0.1 + detective-typescript: 14.1.2(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + discord-api-types@0.38.50: {} + + discord.js@14.27.0: + dependencies: + '@discordjs/builders': 1.14.1 + '@discordjs/collection': 1.5.3 + '@discordjs/formatters': 0.6.2 + '@discordjs/rest': 2.6.2 + '@discordjs/util': 1.2.0 + '@discordjs/ws': 1.2.3 + '@sapphire/snowflake': 3.5.5 + discord-api-types: 0.38.50 + fast-deep-equal: 3.1.3 + lodash.snakecase: 4.1.1 + magic-bytes.js: 1.13.0 + tslib: 2.8.1 + undici: 6.27.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + dotenv@17.4.2: {} + + enhanced-resolve@5.24.2: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@7.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.1: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escodegen@2.1.0: + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + + eslint-visitor-keys@5.0.1: {} + + esprima@4.0.1: {} + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + filing-cabinet@5.5.1: + dependencies: + app-module-path: 2.2.0 + commander: 12.1.0 + enhanced-resolve: 5.24.2 + module-definition: 6.0.2 + module-lookup-amd: 9.1.3 + resolve: 1.22.12 + resolve-dependency-path: 4.0.1 + sass-lookup: 6.1.2 + stylus-lookup: 6.1.2 + tsconfig-paths: 4.2.0 + typescript: 5.9.3 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-amd-module-type@6.0.2: + dependencies: + ast-module-types: 6.0.2 + node-source-walk: 7.0.2 + + get-own-enumerable-property-symbols@3.0.2: {} + + global-directory@4.0.1: + dependencies: + ini: 4.1.1 + + gonzales-pe@4.3.0: + dependencies: + minimist: 1.2.8 + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + i18next@26.3.6(typescript@7.0.2): + optionalDependencies: + typescript: 7.0.2 + + ieee754@1.2.1: {} + + ignore@7.0.6: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + ini@4.1.1: {} + + interpret@3.1.1: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-installed-globally@1.0.0: + dependencies: + global-directory: 4.0.1 + is-path-inside: 4.0.0 + + is-interactive@1.0.0: {} + + is-obj@1.0.1: {} + + is-path-inside@4.0.0: {} + + is-regexp@1.0.0: {} + + is-unicode-supported@0.1.0: {} + + is-url-superb@4.0.0: {} + + json5@2.2.3: {} + + kleur@3.0.3: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lodash.snakecase@4.1.1: {} + + lodash@4.18.1: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + madge@8.0.0(typescript@7.0.2): + dependencies: + chalk: 4.1.2 + commander: 7.2.0 + commondir: 1.0.1 + debug: 4.4.3 + dependency-tree: 11.5.0 + ora: 5.4.1 + pluralize: 8.0.0 + pretty-ms: 7.0.1 + rc: 1.2.8 + stream-to-array: 2.3.0 + ts-graphviz: 2.1.6 + walkdir: 0.4.1 + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - supports-color + + magic-bytes.js@1.13.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mimic-fn@2.1.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + minimist@1.2.8: {} + + module-definition@6.0.2: + dependencies: + ast-module-types: 6.0.2 + node-source-walk: 7.0.2 + + module-lookup-amd@9.1.3: + dependencies: + commander: 12.1.0 + requirejs: 2.3.8 + requirejs-config-file: 4.0.0 + + ms@2.1.3: {} + + nanoid@3.3.16: {} + + node-source-walk@7.0.2: + dependencies: + '@babel/parser': 7.29.7 + + obug@2.1.4: {} + + on-exit-leak-free@2.1.2: {} + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + parse-ms@2.1.0: {} + + path-parse@1.0.7: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.3.1: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + pluralize@8.0.0: {} + + postcss-values-parser@6.0.2(postcss@8.5.19): + dependencies: + color-name: 1.1.4 + is-url-superb: 4.0.0 + postcss: 8.5.19 + quote-unquote: 1.0.0 + + postcss@8.5.19: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + precinct@12.3.2: + dependencies: + '@dependents/detective-less': 5.0.3 + commander: 12.1.0 + detective-amd: 6.1.0 + detective-cjs: 6.1.1 + detective-es6: 5.0.2 + detective-postcss: 8.0.4(postcss@8.5.19) + detective-sass: 6.0.2 + detective-scss: 5.0.2 + detective-stylus: 5.0.1 + detective-typescript: 14.1.2(typescript@5.9.3) + detective-vue2: 2.3.0(typescript@5.9.3) + module-definition: 6.0.2 + node-source-walk: 7.0.2 + postcss: 8.5.19 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + pretty-ms@7.0.1: + dependencies: + parse-ms: 2.1.0 + + process-warning@5.0.0: {} + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + quick-format-unescaped@4.0.4: {} + + quote-unquote@1.0.0: {} + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + real-require@0.2.0: {} + + real-require@1.0.0: {} + + rechoir@0.8.0: + dependencies: + resolve: 1.22.12 + + regexp-tree@0.1.27: {} + + requirejs-config-file@4.0.0: + dependencies: + esprima: 4.0.1 + stringify-object: 3.3.0 + + requirejs@2.3.8: {} + + resolve-dependency-path@4.0.1: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + safe-buffer@5.2.1: {} + + safe-regex@2.1.1: + dependencies: + regexp-tree: 0.1.27 + + safe-stable-stringify@2.5.0: {} + + sass-lookup@6.1.2: + dependencies: + commander: 12.1.0 + enhanced-resolve: 5.24.2 + + semver@7.8.5: {} + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + sisteransi@1.0.5: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + source-map@0.6.1: + optional: true + + split2@4.2.0: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + stream-to-array@2.3.0: + dependencies: + any-promise: 1.3.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + stringify-object@3.3.0: + dependencies: + get-own-enumerable-property-symbols: 3.0.2 + is-obj: 1.0.1 + is-regexp: 1.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom@3.0.0: {} + + strip-json-comments@2.0.1: {} + + stylus-lookup@6.1.2: + dependencies: + commander: 12.1.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tapable@2.3.3: {} + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.0: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-graphviz@2.1.6: + dependencies: + '@ts-graphviz/adapter': 2.0.6 + '@ts-graphviz/ast': 2.0.7 + '@ts-graphviz/common': 2.1.5 + '@ts-graphviz/core': 2.0.7 + + ts-mixer@6.0.4: {} + + tsconfig-paths-webpack-plugin@4.2.0: + dependencies: + chalk: 4.1.2 + enhanced-resolve: 5.24.2 + tapable: 2.3.3 + tsconfig-paths: 4.2.0 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@7.18.2: {} + + undici-types@8.3.0: {} + + undici@6.27.0: {} + + util-deprecate@1.0.2: {} + + vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.19 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + esbuild: 0.28.1 + fsevents: 2.3.3 + tsx: 4.23.1 + + vitest@4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + transitivePeerDependencies: + - msw + + walkdir@0.4.1: {} + + watskeburt@6.0.0: {} + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.21.1: {} diff --git a/discord/pnpm-workspace.yaml b/discord/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/discord/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/discord/src/index.ts b/discord/src/index.ts new file mode 100644 index 0000000..e47dff0 --- /dev/null +++ b/discord/src/index.ts @@ -0,0 +1,41 @@ +import "dotenv/config"; + +import { Client, Events, GatewayIntentBits, Partials } from "discord.js"; +import pino from "pino"; + +const logger = pino(); +const isGaatoBot = process.env.GAATO_BOT !== undefined; +const tokenName = isGaatoBot ? "GAATO_BOT_TOKEN" : "CODERUNBOT_TOKEN"; +const token = process.env[tokenName]; + +if (!token) { + logger.error({ tokenName }, "Discord bot token is not configured"); + process.exit(1); +} + +const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.MessageContent, + ], + partials: [Partials.Channel, Partials.Message], +}); + +client.once(Events.ClientReady, (readyClient) => { + logger.info( + { + tag: readyClient.user.tag, + botName: readyClient.user.username, + }, + "Discord bot logged in", + ); +}); + +try { + await client.login(token); +} catch (error) { + logger.error({ error }, "Discord bot login failed"); + process.exit(1); +} diff --git a/discord/tsconfig.json b/discord/tsconfig.json new file mode 100644 index 0000000..7f268cd --- /dev/null +++ b/discord/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "outDir": "dist", + "rootDir": "src", + "sourceMap": true, + "esModuleInterop": true, + "declaration": false + }, + "include": ["src/**/*.ts"] +} diff --git a/discord/vitest.config.ts b/discord/vitest.config.ts new file mode 100644 index 0000000..5bfa2d5 --- /dev/null +++ b/discord/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + passWithNoTests: true, + }, +}); From 3f98365cb835de66c1b0b8a95d69594803b532d9 Mon Sep 17 00:00:00 2001 From: Gakuto Furuya Date: Sat, 18 Jul 2026 13:26:51 +0900 Subject: [PATCH 02/15] Add platform/shared foundation with router, reply coordinator, and i18n --- discord/.dependency-cruiser.cjs | 32 ++ discord/biome.json | 19 +- discord/package.json | 4 +- discord/pnpm-lock.yaml | 225 +-------- discord/src/config.ts | 28 ++ discord/src/env.ts | 40 ++ discord/src/features/ping/index.ts | 25 + discord/src/index.ts | 115 +++-- discord/src/platform/discord/components.ts | 70 +++ discord/src/platform/discord/context.ts | 48 ++ .../src/platform/discord/errorPresenter.ts | 113 +++++ .../platform/discord/replyCoordinator.test.ts | 96 ++++ .../src/platform/discord/replyCoordinator.ts | 229 +++++++++ discord/src/platform/discord/router.test.ts | 46 ++ discord/src/platform/discord/router.ts | 465 ++++++++++++++++++ discord/src/shared/i18n.ts | 33 ++ discord/src/shared/limitedMap.test.ts | 37 ++ discord/src/shared/limitedMap.ts | 46 ++ discord/src/shared/logger.ts | 7 + discord/src/types.ts | 79 +++ 20 files changed, 1504 insertions(+), 253 deletions(-) create mode 100644 discord/.dependency-cruiser.cjs create mode 100644 discord/src/config.ts create mode 100644 discord/src/env.ts create mode 100644 discord/src/features/ping/index.ts create mode 100644 discord/src/platform/discord/components.ts create mode 100644 discord/src/platform/discord/context.ts create mode 100644 discord/src/platform/discord/errorPresenter.ts create mode 100644 discord/src/platform/discord/replyCoordinator.test.ts create mode 100644 discord/src/platform/discord/replyCoordinator.ts create mode 100644 discord/src/platform/discord/router.test.ts create mode 100644 discord/src/platform/discord/router.ts create mode 100644 discord/src/shared/i18n.ts create mode 100644 discord/src/shared/limitedMap.test.ts create mode 100644 discord/src/shared/limitedMap.ts create mode 100644 discord/src/shared/logger.ts create mode 100644 discord/src/types.ts diff --git a/discord/.dependency-cruiser.cjs b/discord/.dependency-cruiser.cjs new file mode 100644 index 0000000..e38a407 --- /dev/null +++ b/discord/.dependency-cruiser.cjs @@ -0,0 +1,32 @@ +/** @type {import('dependency-cruiser').IConfiguration} */ +module.exports = { + forbidden: [ + { + name: "no-cross-feature-imports", + comment: "Features are isolated and may only depend on platform/shared code.", + severity: "error", + scope: "folder", + from: { path: "^src/features/([^/]+)$" }, + to: { + path: "^src/features/[^/]+$", + pathNot: "^src/features/$1$", + }, + }, + { + name: "platform-shared-do-not-know-features", + severity: "error", + from: { path: "^src/(platform|shared)/" }, + to: { path: "^src/features/" }, + }, + { + name: "config-does-not-know-features", + severity: "error", + from: { path: "^src/config\\.ts$" }, + to: { path: "^src/features/" }, + }, + ], + options: { + doNotFollow: { path: "node_modules" }, + tsConfig: { fileName: "tsconfig.json" }, + }, +}; diff --git a/discord/biome.json b/discord/biome.json index 727b94a..e9ec6b8 100644 --- a/discord/biome.json +++ b/discord/biome.json @@ -7,7 +7,22 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "style": { + "noProcessEnv": "error" + } } - } + }, + "overrides": [ + { + "includes": ["src/env.ts"], + "linter": { + "rules": { + "style": { + "noProcessEnv": "off" + } + } + } + } + ] } diff --git a/discord/package.json b/discord/package.json index ef68155..7df5b1a 100644 --- a/discord/package.json +++ b/discord/package.json @@ -12,7 +12,7 @@ "build": "tsc", "start": "node dist/index.js", "typecheck": "tsc --noEmit", - "lint": "biome check src && madge --circular --extensions ts src", + "lint": "biome check src && dependency-cruiser --config .dependency-cruiser.cjs --include-only '^src' src && madge --circular --extensions ts src", "format": "biome format --write src", "test": "vitest run" }, @@ -28,7 +28,7 @@ "dependency-cruiser": "^18.1.0", "madge": "^8.0.0", "tsx": "^4.23.1", - "typescript": "^7.0.2", + "typescript": "^6.0.3", "vitest": "^4.1.10" } } diff --git a/discord/pnpm-lock.yaml b/discord/pnpm-lock.yaml index 2f942f0..2b73853 100644 --- a/discord/pnpm-lock.yaml +++ b/discord/pnpm-lock.yaml @@ -16,7 +16,7 @@ importers: version: 17.4.2 i18next: specifier: ^26.3.6 - version: 26.3.6(typescript@7.0.2) + version: 26.3.6(typescript@6.0.3) pino: specifier: ^10.3.1 version: 10.3.1 @@ -32,13 +32,13 @@ importers: version: 18.1.0 madge: specifier: ^8.0.0 - version: 8.0.0(typescript@7.0.2) + version: 8.0.0(typescript@6.0.3) tsx: specifier: ^4.23.1 version: 4.23.1 typescript: - specifier: ^7.0.2 - version: 7.0.2 + specifier: ^6.0.3 + version: 6.0.3 vitest: specifier: ^4.1.10 version: 4.1.10(@types/node@24.13.3)(vite@8.1.5(@types/node@24.13.3)(esbuild@0.28.1)(tsx@4.23.1)) @@ -511,126 +511,6 @@ packages: resolution: {integrity: sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript/typescript-aix-ppc64@7.0.2': - resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} - engines: {node: '>=16.20.0'} - cpu: [ppc64] - os: [aix] - - '@typescript/typescript-darwin-arm64@7.0.2': - resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [darwin] - - '@typescript/typescript-darwin-x64@7.0.2': - resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [darwin] - - '@typescript/typescript-freebsd-arm64@7.0.2': - resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [freebsd] - - '@typescript/typescript-freebsd-x64@7.0.2': - resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [freebsd] - - '@typescript/typescript-linux-arm64@7.0.2': - resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [linux] - - '@typescript/typescript-linux-arm@7.0.2': - resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} - engines: {node: '>=16.20.0'} - cpu: [arm] - os: [linux] - - '@typescript/typescript-linux-loong64@7.0.2': - resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} - engines: {node: '>=16.20.0'} - cpu: [loong64] - os: [linux] - - '@typescript/typescript-linux-mips64el@7.0.2': - resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} - engines: {node: '>=16.20.0'} - cpu: [mips64el] - os: [linux] - - '@typescript/typescript-linux-ppc64@7.0.2': - resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} - engines: {node: '>=16.20.0'} - cpu: [ppc64] - os: [linux] - - '@typescript/typescript-linux-riscv64@7.0.2': - resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} - engines: {node: '>=16.20.0'} - cpu: [riscv64] - os: [linux] - - '@typescript/typescript-linux-s390x@7.0.2': - resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} - engines: {node: '>=16.20.0'} - cpu: [s390x] - os: [linux] - - '@typescript/typescript-linux-x64@7.0.2': - resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [linux] - - '@typescript/typescript-netbsd-arm64@7.0.2': - resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [netbsd] - - '@typescript/typescript-netbsd-x64@7.0.2': - resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [netbsd] - - '@typescript/typescript-openbsd-arm64@7.0.2': - resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [openbsd] - - '@typescript/typescript-openbsd-x64@7.0.2': - resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [openbsd] - - '@typescript/typescript-sunos-x64@7.0.2': - resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [sunos] - - '@typescript/typescript-win32-arm64@7.0.2': - resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} - engines: {node: '>=16.20.0'} - cpu: [arm64] - os: [win32] - - '@typescript/typescript-win32-x64@7.0.2': - resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} - engines: {node: '>=16.20.0'} - cpu: [x64] - os: [win32] - '@vitest/expect@4.1.10': resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} @@ -1450,9 +1330,9 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@7.0.2: - resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} - engines: {node: '>=16.20.0'} + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} hasBin: true undici-types@7.18.2: @@ -1932,66 +1812,6 @@ snapshots: '@typescript-eslint/types': 8.64.0 eslint-visitor-keys: 5.0.1 - '@typescript/typescript-aix-ppc64@7.0.2': - optional: true - - '@typescript/typescript-darwin-arm64@7.0.2': - optional: true - - '@typescript/typescript-darwin-x64@7.0.2': - optional: true - - '@typescript/typescript-freebsd-arm64@7.0.2': - optional: true - - '@typescript/typescript-freebsd-x64@7.0.2': - optional: true - - '@typescript/typescript-linux-arm64@7.0.2': - optional: true - - '@typescript/typescript-linux-arm@7.0.2': - optional: true - - '@typescript/typescript-linux-loong64@7.0.2': - optional: true - - '@typescript/typescript-linux-mips64el@7.0.2': - optional: true - - '@typescript/typescript-linux-ppc64@7.0.2': - optional: true - - '@typescript/typescript-linux-riscv64@7.0.2': - optional: true - - '@typescript/typescript-linux-s390x@7.0.2': - optional: true - - '@typescript/typescript-linux-x64@7.0.2': - optional: true - - '@typescript/typescript-netbsd-arm64@7.0.2': - optional: true - - '@typescript/typescript-netbsd-x64@7.0.2': - optional: true - - '@typescript/typescript-openbsd-arm64@7.0.2': - optional: true - - '@typescript/typescript-openbsd-x64@7.0.2': - optional: true - - '@typescript/typescript-sunos-x64@7.0.2': - optional: true - - '@typescript/typescript-win32-arm64@7.0.2': - optional: true - - '@typescript/typescript-win32-x64@7.0.2': - optional: true - '@vitest/expect@4.1.10': dependencies: '@standard-schema/spec': 1.1.0 @@ -2383,9 +2203,9 @@ snapshots: dependencies: function-bind: 1.1.2 - i18next@26.3.6(typescript@7.0.2): + i18next@26.3.6(typescript@6.0.3): optionalDependencies: - typescript: 7.0.2 + typescript: 6.0.3 ieee754@1.2.1: {} @@ -2482,7 +2302,7 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 - madge@8.0.0(typescript@7.0.2): + madge@8.0.0(typescript@6.0.3): dependencies: chalk: 4.1.2 commander: 7.2.0 @@ -2497,7 +2317,7 @@ snapshots: ts-graphviz: 2.1.6 walkdir: 0.4.1 optionalDependencies: - typescript: 7.0.2 + typescript: 6.0.3 transitivePeerDependencies: - supports-color @@ -2820,28 +2640,7 @@ snapshots: typescript@5.9.3: {} - typescript@7.0.2: - optionalDependencies: - '@typescript/typescript-aix-ppc64': 7.0.2 - '@typescript/typescript-darwin-arm64': 7.0.2 - '@typescript/typescript-darwin-x64': 7.0.2 - '@typescript/typescript-freebsd-arm64': 7.0.2 - '@typescript/typescript-freebsd-x64': 7.0.2 - '@typescript/typescript-linux-arm': 7.0.2 - '@typescript/typescript-linux-arm64': 7.0.2 - '@typescript/typescript-linux-loong64': 7.0.2 - '@typescript/typescript-linux-mips64el': 7.0.2 - '@typescript/typescript-linux-ppc64': 7.0.2 - '@typescript/typescript-linux-riscv64': 7.0.2 - '@typescript/typescript-linux-s390x': 7.0.2 - '@typescript/typescript-linux-x64': 7.0.2 - '@typescript/typescript-netbsd-arm64': 7.0.2 - '@typescript/typescript-netbsd-x64': 7.0.2 - '@typescript/typescript-openbsd-arm64': 7.0.2 - '@typescript/typescript-openbsd-x64': 7.0.2 - '@typescript/typescript-sunos-x64': 7.0.2 - '@typescript/typescript-win32-arm64': 7.0.2 - '@typescript/typescript-win32-x64': 7.0.2 + typescript@6.0.3: {} undici-types@7.18.2: {} diff --git a/discord/src/config.ts b/discord/src/config.ts new file mode 100644 index 0000000..6c1b8c6 --- /dev/null +++ b/discord/src/config.ts @@ -0,0 +1,28 @@ +import type { BotName } from "./env.js"; +import type { FeatureId } from "./types.js"; + +export interface BotProfile { + readonly name: BotName; + readonly prefix: string; + readonly defaultLocale: "en" | "ja"; + readonly features: readonly FeatureId[]; +} + +export const BOT_PROFILES = { + coderunbot: { + name: "coderunbot", + prefix: "]", + defaultLocale: "en", + features: ["tex", "code", "privacy"], + }, + "gaato-bot": { + name: "gaato-bot", + prefix: ")", + defaultLocale: "ja", + features: ["tex", "code", "privacy", "wolfram", "misc", "translate"], + }, +} as const satisfies Record; + +export function getBotProfile(name: BotName): BotProfile { + return BOT_PROFILES[name]; +} diff --git a/discord/src/env.ts b/discord/src/env.ts new file mode 100644 index 0000000..fce00c4 --- /dev/null +++ b/discord/src/env.ts @@ -0,0 +1,40 @@ +import "dotenv/config"; + +export type BotName = "coderunbot" | "gaato-bot"; + +export interface Env { + readonly botName: BotName; + readonly token: string; + readonly tokenEnvName: "CODERUNBOT_TOKEN" | "GAATO_BOT_TOKEN"; + readonly logChannelId: string; + readonly developerId: string; + readonly supportServerLink: string; +} + +const DEFAULT_LOG_CHANNEL_ID = "1118867011448078417"; +const DEFAULT_DEVELOPER_ID = "572432137035317249"; +const DEFAULT_SUPPORT_SERVER_LINK = "discord.gg/qRpYRTgvXM"; + +function nonEmpty(value: string | undefined): string | undefined { + return value === undefined || value.length === 0 ? undefined : value; +} + +export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { + const gaatoBot = nonEmpty(source.GAATO_BOT) !== undefined; + const tokenEnvName = gaatoBot ? "GAATO_BOT_TOKEN" : "CODERUNBOT_TOKEN"; + const token = nonEmpty(source[tokenEnvName]); + + if (token === undefined) { + throw new Error(`${tokenEnvName} must be set to a non-empty value`); + } + + return { + botName: gaatoBot ? "gaato-bot" : "coderunbot", + token, + tokenEnvName, + logChannelId: nonEmpty(source.LOG_CHANNEL_ID) ?? DEFAULT_LOG_CHANNEL_ID, + developerId: nonEmpty(source.DEVELOPER_ID) ?? DEFAULT_DEVELOPER_ID, + supportServerLink: + nonEmpty(source.SUPPORT_SERVER_LINK) ?? DEFAULT_SUPPORT_SERVER_LINK, + }; +} diff --git a/discord/src/features/ping/index.ts b/discord/src/features/ping/index.ts new file mode 100644 index 0000000..d812634 --- /dev/null +++ b/discord/src/features/ping/index.ts @@ -0,0 +1,25 @@ +import { SlashCommandBuilder } from "discord.js"; +import { successContainer } from "../../platform/discord/components.js"; +import type { Feature } from "../../types.js"; + +// Temporary smoke-test feature for the TypeScript foundation. Remove it once +// the real feature ports provide end-to-end command coverage. +export function createPingFeature(): Feature { + return { + id: "ping", + slashCommands: [ + { + data: new SlashCommandBuilder() + .setName("ping") + .setDescription("Check whether the bot is responding") + .setDescriptionLocalizations({ ja: "ボットの応答を確認します" }), + async execute() { + return { + kind: "components-v2", + components: [successContainer("pong")], + }; + }, + }, + ], + }; +} diff --git a/discord/src/index.ts b/discord/src/index.ts index e47dff0..28992b3 100644 --- a/discord/src/index.ts +++ b/discord/src/index.ts @@ -1,41 +1,84 @@ -import "dotenv/config"; +import { Client, GatewayIntentBits, Partials, REST } from "discord.js"; +import { getBotProfile } from "./config.js"; +import { loadEnv } from "./env.js"; +import { createPingFeature } from "./features/ping/index.js"; +import { ErrorPresenter } from "./platform/discord/errorPresenter.js"; +import { ReplyCoordinator } from "./platform/discord/replyCoordinator.js"; +import { DiscordRouter } from "./platform/discord/router.js"; +import { getFixedT } from "./shared/i18n.js"; +import { createLogger } from "./shared/logger.js"; +import type { Feature, FeatureId } from "./types.js"; -import { Client, Events, GatewayIntentBits, Partials } from "discord.js"; -import pino from "pino"; +type FeatureFactory = () => Feature; -const logger = pino(); -const isGaatoBot = process.env.GAATO_BOT !== undefined; -const tokenName = isGaatoBot ? "GAATO_BOT_TOKEN" : "CODERUNBOT_TOKEN"; -const token = process.env[tokenName]; - -if (!token) { - logger.error({ tokenName }, "Discord bot token is not configured"); - process.exit(1); +function pendingFeature(id: FeatureId): FeatureFactory { + return () => ({ id }); } -const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.DirectMessages, - GatewayIntentBits.MessageContent, - ], - partials: [Partials.Channel, Partials.Message], -}); - -client.once(Events.ClientReady, (readyClient) => { - logger.info( - { - tag: readyClient.user.tag, - botName: readyClient.user.username, - }, - "Discord bot logged in", - ); -}); - -try { - await client.login(token); -} catch (error) { - logger.error({ error }, "Discord bot login failed"); - process.exit(1); +// This is the only FeatureId -> concrete factory mapping. The no-op entries are +// replaced by real factories in subsequent migration steps. +const featureFactories: Record = { + tex: pendingFeature("tex"), + code: pendingFeature("code"), + privacy: pendingFeature("privacy"), + wolfram: pendingFeature("wolfram"), + misc: pendingFeature("misc"), + translate: pendingFeature("translate"), + ping: createPingFeature, +}; + +export async function bootstrap(): Promise { + const env = loadEnv(); + const profile = getBotProfile(env.botName); + const logger = createLogger(); + getFixedT(profile.defaultLocale); + + const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.DirectMessages, + GatewayIntentBits.MessageContent, + ], + partials: [Partials.Channel, Partials.Message], + }); + const rest = new REST({ version: "10" }).setToken(env.token); + const features = [ + ...profile.features.map((id) => featureFactories[id]()), + featureFactories.ping(), + ]; + + for (const feature of features) { + await feature.init?.(); + } + + const replyCoordinator = new ReplyCoordinator(100); + const errorPresenter = new ErrorPresenter({ + client, + logger, + logChannelId: env.logChannelId, + supportServerLink: env.supportServerLink, + }); + const router = new DiscordRouter({ + client, + rest, + profile, + features, + replyCoordinator, + errorPresenter, + logger, + }); + router.bind(); + + try { + await client.login(env.token); + } catch (error) { + logger.error( + { error, tokenEnvName: env.tokenEnvName }, + "Discord login failed", + ); + throw error; + } } + +await bootstrap(); diff --git a/discord/src/platform/discord/components.ts b/discord/src/platform/discord/components.ts new file mode 100644 index 0000000..b73e7fc --- /dev/null +++ b/discord/src/platform/discord/components.ts @@ -0,0 +1,70 @@ +import { + ActionRowBuilder, + AttachmentBuilder, + ButtonBuilder, + ButtonStyle, + ContainerBuilder, + FileBuilder, + TextDisplayBuilder, +} from "discord.js"; + +export const SUCCESS_ACCENT = 0x2ecc71; +export const FAILURE_ACCENT = 0xe74c3c; +export const TEXT_FILE_CHARACTER_LIMIT = 1_000; +export const TEXT_FILE_LINE_LIMIT = 100; + +export function successContainer(text: string): ContainerBuilder { + return new ContainerBuilder() + .setAccentColor(SUCCESS_ACCENT) + .addTextDisplayComponents(new TextDisplayBuilder().setContent(text)); +} + +export function failureContainer(text: string): ContainerBuilder { + return new ContainerBuilder() + .setAccentColor(FAILURE_ACCENT) + .addTextDisplayComponents(new TextDisplayBuilder().setContent(text)); +} + +export function deleteButton(userId: string): ButtonBuilder { + return new ButtonBuilder() + .setCustomId(`delete:${userId}`) + .setLabel("Delete") + .setStyle(ButtonStyle.Danger); +} + +export function deleteButtonRow( + userId: string, +): ActionRowBuilder { + return new ActionRowBuilder().addComponents( + deleteButton(userId), + ); +} + +export interface TextComponentResult { + readonly component: TextDisplayBuilder | FileBuilder; + readonly files: readonly AttachmentBuilder[]; + readonly isFile: boolean; +} + +export function textComponent( + text: string, + filename = "output.txt", +): TextComponentResult { + const lineCount = text.length === 0 ? 0 : text.split("\n").length; + if ( + text.length > TEXT_FILE_CHARACTER_LIMIT || + lineCount > TEXT_FILE_LINE_LIMIT + ) { + return { + component: new FileBuilder().setURL(`attachment://${filename}`), + files: [new AttachmentBuilder(Buffer.from(text), { name: filename })], + isFile: true, + }; + } + + return { + component: new TextDisplayBuilder().setContent(text), + files: [], + isFile: false, + }; +} diff --git a/discord/src/platform/discord/context.ts b/discord/src/platform/discord/context.ts new file mode 100644 index 0000000..31b9d65 --- /dev/null +++ b/discord/src/platform/discord/context.ts @@ -0,0 +1,48 @@ +import type { + ButtonInteraction, + ChatInputCommandInteraction, + ContextMenuCommandInteraction, + Message, + ModalSubmitInteraction, +} from "discord.js"; + +export type ReplyTarget = + | Message + | ChatInputCommandInteraction + | ContextMenuCommandInteraction + | ButtonInteraction + | ModalSubmitInteraction; + +export interface RequestContext { + readonly userId: string; + readonly locale: string; + readonly inGuild: boolean; + readonly replyTarget: ReplyTarget; +} + +type RequestInteraction = Exclude; + +export function contextForInteraction( + interaction: RequestInteraction, + defaultLocale: string, +): RequestContext { + return { + userId: interaction.user.id, + locale: + interaction.locale || interaction.guild?.preferredLocale || defaultLocale, + inGuild: interaction.inGuild(), + replyTarget: interaction, + }; +} + +export function contextForMessage( + message: Message, + defaultLocale: string, +): RequestContext { + return { + userId: message.author.id, + locale: message.guild?.preferredLocale ?? defaultLocale, + inGuild: message.inGuild(), + replyTarget: message, + }; +} diff --git a/discord/src/platform/discord/errorPresenter.ts b/discord/src/platform/discord/errorPresenter.ts new file mode 100644 index 0000000..7282cc7 --- /dev/null +++ b/discord/src/platform/discord/errorPresenter.ts @@ -0,0 +1,113 @@ +import { + AttachmentBuilder, + type Client, + DiscordAPIError, + RESTJSONErrorCodes, +} from "discord.js"; +import { getFixedT } from "../../shared/i18n.js"; +import type { AppLogger } from "../../shared/logger.js"; +import type { OutgoingReply } from "../../types.js"; +import { failureContainer } from "./components.js"; +import type { RequestContext } from "./context.js"; + +export class InvalidInputError extends Error {} + +export interface ErrorPresenterOptions { + readonly client: Client; + readonly logger: AppLogger; + readonly logChannelId: string; + readonly supportServerLink: string; +} + +export class ErrorPresenter { + readonly #client: Client; + readonly #logger: AppLogger; + readonly #logChannelId: string; + readonly #supportServerLink: string; + + constructor(options: ErrorPresenterOptions) { + this.#client = options.client; + this.#logger = options.logger; + this.#logChannelId = options.logChannelId; + this.#supportServerLink = options.supportServerLink; + } + + async present( + error: unknown, + context: RequestContext, + invocation: string, + ): Promise { + const normalized = + error instanceof Error ? error : new Error(String(error)); + await this.#logToDiscord(invocation, normalized); + + const t = getFixedT(context.locale); + const message = + error instanceof InvalidInputError + ? `## ${t("errors.invalidInput")}\n${error.message}` + : `## ${t("errors.unhandled")}\n${t("errors.report", { + supportLink: this.#supportServerLink, + })}`; + + return { + kind: "components-v2", + components: [failureContainer(message)], + }; + } + + async #logToDiscord(invocation: string, error: Error): Promise { + const stack = error.stack ?? `${error.name}: ${error.message}`; + let channel = this.#client.channels.cache.get(this.#logChannelId); + + if (channel === undefined) { + try { + channel = + (await this.#client.channels.fetch(this.#logChannelId)) ?? undefined; + } catch (fetchError) { + this.#logger.error( + { + error: fetchError, + originalError: stack, + channelId: this.#logChannelId, + }, + "Unable to fetch Discord log channel", + ); + return; + } + } + + if (channel === undefined || !channel.isSendable()) { + this.#logger.error( + { originalError: stack, channelId: this.#logChannelId }, + "Discord log channel is not sendable", + ); + return; + } + + const content = `\`\`\`\n${invocation.slice(0, 1_900)}\n\`\`\``; + try { + await channel.send({ + content, + files: [ + new AttachmentBuilder(Buffer.from(stack), { + name: "error.txt", + }), + ], + }); + } catch (sendError) { + const forbidden = + sendError instanceof DiscordAPIError && + sendError.code === RESTJSONErrorCodes.MissingPermissions; + this.#logger.error( + { + error: sendError, + originalError: stack, + channelId: this.#logChannelId, + }, + forbidden + ? "Bot is missing access to Discord log channel" + : "Failed to send error to Discord log channel", + ); + } + } +} diff --git a/discord/src/platform/discord/replyCoordinator.test.ts b/discord/src/platform/discord/replyCoordinator.test.ts new file mode 100644 index 0000000..10b254b --- /dev/null +++ b/discord/src/platform/discord/replyCoordinator.test.ts @@ -0,0 +1,96 @@ +import type { MessageEditOptions } from "discord.js"; +import { describe, expect, it, vi } from "vitest"; +import type { ReplyTarget } from "./context.js"; +import { + type EditableReply, + ReplyCoordinator, + type ReplyTransport, +} from "./replyCoordinator.js"; + +function editableReply(): EditableReply { + return { + edit: vi.fn(async (_options: MessageEditOptions) => undefined), + delete: vi.fn(async () => undefined), + }; +} + +describe("ReplyCoordinator generations", () => { + it("drops late results from older generations and edits the tracked reply", async () => { + const reply = editableReply(); + const transport: ReplyTransport = { + send: vi.fn(async () => reply), + edit: vi.fn(async (target, payload) => { + await target.edit({ content: payload.content }); + }), + delete: vi.fn(async (target) => { + await target.delete(); + }), + }; + const coordinator = new ReplyCoordinator(100, transport); + const target = {} as ReplyTarget; + const firstGeneration = coordinator.begin("code", "source"); + const latestGeneration = coordinator.begin("code", "source"); + + await expect( + coordinator.deliver({ + featureId: "code", + target, + userId: "user", + outgoing: { kind: "plain", content: "old" }, + sourceMessageId: "source", + generation: firstGeneration, + }), + ).resolves.toBe("stale"); + expect(transport.send).not.toHaveBeenCalled(); + + await expect( + coordinator.deliver({ + featureId: "code", + target, + userId: "user", + outgoing: { kind: "plain", content: "latest" }, + sourceMessageId: "source", + generation: latestGeneration, + }), + ).resolves.toBe("sent"); + + const editedGeneration = coordinator.begin("code", "source"); + await expect( + coordinator.deliver({ + featureId: "code", + target, + userId: "user", + outgoing: { kind: "plain", content: "edited" }, + sourceMessageId: "source", + generation: editedGeneration, + }), + ).resolves.toBe("edited"); + + expect(transport.send).toHaveBeenCalledTimes(1); + expect(transport.edit).toHaveBeenCalledTimes(1); + }); + + it("removes tracked replies when the invocation is deleted", async () => { + const reply = editableReply(); + const transport: ReplyTransport = { + send: vi.fn(async () => reply), + edit: vi.fn(async () => undefined), + delete: vi.fn(async () => undefined), + }; + const coordinator = new ReplyCoordinator(100, transport); + const generation = coordinator.begin("tex", "source"); + await coordinator.deliver({ + featureId: "tex", + target: {} as ReplyTarget, + userId: "user", + outgoing: { kind: "plain", content: "result" }, + sourceMessageId: "source", + generation, + }); + + await coordinator.deleteReplies("source"); + + expect(transport.delete).toHaveBeenCalledWith(reply); + expect(coordinator.isTracked("tex", "source")).toBe(false); + }); +}); diff --git a/discord/src/platform/discord/replyCoordinator.ts b/discord/src/platform/discord/replyCoordinator.ts new file mode 100644 index 0000000..ed7769a --- /dev/null +++ b/discord/src/platform/discord/replyCoordinator.ts @@ -0,0 +1,229 @@ +import { + type BaseMessageOptions, + type InteractionEditReplyOptions, + type InteractionReplyOptions, + Message, + type MessageEditOptions, + MessageFlags, + type MessageReplyOptions, +} from "discord.js"; +import { LimitedSizeMap } from "../../shared/limitedMap.js"; +import type { FeatureId, OutgoingReply } from "../../types.js"; +import { deleteButtonRow } from "./components.js"; +import type { ReplyTarget } from "./context.js"; + +export interface EditableReply { + edit(options: MessageEditOptions): Promise; + delete(): Promise; +} + +export interface ReplyPayload { + readonly kind: OutgoingReply["kind"]; + readonly content?: string; + readonly components: NonNullable; + readonly files?: BaseMessageOptions["files"]; + readonly ephemeral: boolean; + readonly replaceAttachments: boolean; +} + +export interface ReplyTransport { + send(target: ReplyTarget, payload: ReplyPayload): Promise; + edit(reply: EditableReply, payload: ReplyPayload): Promise; + delete(reply: EditableReply): Promise; +} + +function messageFlags(payload: ReplyPayload): number | undefined { + let flags = + payload.kind === "components-v2" ? MessageFlags.IsComponentsV2 : 0; + if (payload.ephemeral) { + flags |= MessageFlags.Ephemeral; + } + return flags === 0 ? undefined : flags; +} + +function editFlags( + payload: ReplyPayload, +): MessageFlags.IsComponentsV2 | undefined { + return payload.kind === "components-v2" + ? MessageFlags.IsComponentsV2 + : undefined; +} + +const discordReplyTransport: ReplyTransport = { + async send(target, payload) { + if (target instanceof Message) { + const options: MessageReplyOptions = { + content: payload.content, + components: payload.components, + files: payload.files, + flags: + payload.kind === "components-v2" + ? MessageFlags.IsComponentsV2 + : undefined, + }; + return target.reply(options); + } + + if (target.deferred || target.replied) { + const options: InteractionEditReplyOptions = { + content: payload.content, + components: payload.components, + files: payload.files, + attachments: payload.replaceAttachments ? [] : undefined, + flags: editFlags(payload), + }; + await target.editReply(options); + return target.fetchReply(); + } + + const options: InteractionReplyOptions = { + content: payload.content, + components: payload.components, + files: payload.files, + flags: messageFlags(payload), + }; + await target.reply(options); + return target.fetchReply(); + }, + + async edit(reply, payload) { + const options: MessageEditOptions = { + content: payload.content ?? null, + components: payload.components, + files: payload.files, + attachments: [], + flags: editFlags(payload), + }; + await reply.edit(options); + }, + + async delete(reply) { + await reply.delete(); + }, +}; + +interface ReplyState { + readonly generation: number; + readonly reply?: EditableReply; + readonly kind?: OutgoingReply["kind"]; +} + +export interface DeliveryRequest { + readonly featureId: FeatureId; + readonly target: ReplyTarget; + readonly userId: string; + readonly outgoing: OutgoingReply; + readonly sourceMessageId?: string; + readonly generation?: number; +} + +export type DeliveryResult = "sent" | "edited" | "stale"; + +export class ReplyCoordinator { + readonly #capacity: number; + readonly #transport: ReplyTransport; + readonly #trackers = new Map>(); + + constructor( + capacity = 100, + transport: ReplyTransport = discordReplyTransport, + ) { + this.#capacity = capacity; + this.#transport = transport; + } + + begin(featureId: FeatureId, sourceMessageId: string): number { + const tracker = this.#tracker(featureId); + const current = tracker.get(sourceMessageId); + const generation = (current?.generation ?? 0) + 1; + tracker.set(sourceMessageId, { ...current, generation }); + return generation; + } + + isTracked(featureId: FeatureId, sourceMessageId: string): boolean { + return this.#trackers.get(featureId)?.has(sourceMessageId) ?? false; + } + + async deliver(request: DeliveryRequest): Promise { + const payload = this.#payload(request.outgoing, request.userId); + if ( + request.sourceMessageId === undefined || + request.generation === undefined + ) { + await this.#transport.send(request.target, payload); + return "sent"; + } + + const sourceMessageId = request.sourceMessageId; + const generation = request.generation; + const tracker = this.#tracker(request.featureId); + const state = tracker.get(sourceMessageId); + if (state?.generation !== generation) { + return "stale"; + } + + if (state.reply !== undefined) { + if (state.kind !== request.outgoing.kind) { + throw new Error( + `tracked reply kind cannot change from ${state.kind} to ${request.outgoing.kind}`, + ); + } + await this.#transport.edit(state.reply, payload); + return "edited"; + } + + const reply = await this.#transport.send(request.target, payload); + const latest = tracker.get(sourceMessageId); + if (latest?.generation !== generation) { + await this.#transport.delete(reply); + return "stale"; + } + + tracker.set(sourceMessageId, { + generation: latest.generation, + reply, + kind: request.outgoing.kind, + }); + return "sent"; + } + + async deleteReplies(sourceMessageId: string): Promise { + const deletions: Promise[] = []; + for (const tracker of this.#trackers.values()) { + const state = tracker.get(sourceMessageId); + tracker.delete(sourceMessageId); + if (state?.reply !== undefined) { + deletions.push(this.#transport.delete(state.reply)); + } + } + await Promise.allSettled(deletions); + } + + #tracker(featureId: FeatureId): LimitedSizeMap { + let tracker = this.#trackers.get(featureId); + if (tracker === undefined) { + tracker = new LimitedSizeMap(this.#capacity); + this.#trackers.set(featureId, tracker); + } + return tracker; + } + + #payload(outgoing: OutgoingReply, userId: string): ReplyPayload { + // Ephemeral replies cannot be deleted via message.delete(); Discord + // provides its own dismiss affordance, so no delete button there. + const deleteRow = outgoing.ephemeral ? [] : [deleteButtonRow(userId)]; + const components = + outgoing.kind === "components-v2" + ? [...outgoing.components, ...deleteRow] + : deleteRow; + + return { + kind: outgoing.kind, + content: outgoing.kind === "plain" ? outgoing.content : undefined, + components, + files: outgoing.files, + ephemeral: outgoing.ephemeral ?? false, + replaceAttachments: true, + }; + } +} diff --git a/discord/src/platform/discord/router.test.ts b/discord/src/platform/discord/router.test.ts new file mode 100644 index 0000000..87181a0 --- /dev/null +++ b/discord/src/platform/discord/router.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import type { Feature } from "../../types.js"; +import { buildRouteRegistry, parsePrefixCommand } from "./router.js"; + +describe("buildRouteRegistry", () => { + it("fails fast when customId namespaces overlap across handler kinds", () => { + const modalFeature: Feature = { + id: "code", + modalHandlers: { + shared: async () => undefined, + }, + }; + const componentFeature: Feature = { + id: "tex", + componentHandlers: { + shared: async () => undefined, + }, + }; + + expect(() => buildRouteRegistry([modalFeature, componentFeature])).toThrow( + "duplicate customId namespace route: shared", + ); + }); + + it("rejects central and collector-owned namespaces", () => { + const feature: Feature = { + id: "code", + componentHandlers: { + pg: async () => undefined, + }, + }; + + expect(() => buildRouteRegistry([feature])).toThrow( + "customId namespace is reserved: pg", + ); + }); +}); + +describe("parsePrefixCommand", () => { + it("preserves the raw rest argument after one separator", () => { + expect(parsePrefixCommand("]run python\nprint(1)", "]")).toEqual({ + name: "run", + args: " python\nprint(1)", + }); + }); +}); diff --git a/discord/src/platform/discord/router.ts b/discord/src/platform/discord/router.ts new file mode 100644 index 0000000..3c249ce --- /dev/null +++ b/discord/src/platform/discord/router.ts @@ -0,0 +1,465 @@ +import { + ApplicationIntegrationType, + type AutocompleteInteraction, + type ButtonInteraction, + type ChatInputCommandInteraction, + type Client, + type ContextMenuCommandInteraction, + Events, + type Interaction, + InteractionContextType, + type Message, + type ModalSubmitInteraction, + type PartialMessage, + type REST, + Routes, +} from "discord.js"; +import type { BotProfile } from "../../config.js"; +import type { AppLogger } from "../../shared/logger.js"; +import type { + ComponentHandler, + Feature, + FeatureId, + ModalHandler, + PrefixCommand, + SlashCommand, +} from "../../types.js"; +import { + contextForInteraction, + contextForMessage, + type RequestContext, +} from "./context.js"; +import type { ErrorPresenter } from "./errorPresenter.js"; +import type { ReplyCoordinator } from "./replyCoordinator.js"; + +interface RegisteredSlash { + readonly featureId: FeatureId; + readonly command: SlashCommand; +} + +interface RegisteredPrefix { + readonly featureId: FeatureId; + readonly command: PrefixCommand; +} + +type CustomRoute = + | { + readonly kind: "modal"; + readonly featureId: FeatureId; + readonly handler: ModalHandler; + } + | { + readonly kind: "component"; + readonly featureId: FeatureId; + readonly handler: ComponentHandler; + }; + +export interface RouteRegistry { + readonly slash: ReadonlyMap; + readonly prefix: ReadonlyMap; + readonly custom: ReadonlyMap; +} + +function addUnique( + map: Map, + route: string, + value: Value, + routeType: string, +): void { + if (map.has(route)) { + throw new Error(`duplicate ${routeType} route: ${route}`); + } + map.set(route, value); +} + +export function buildRouteRegistry( + features: readonly Feature[], +): RouteRegistry { + const slash = new Map(); + const prefix = new Map(); + const custom = new Map(); + const featureIds = new Set(); + + for (const feature of features) { + if (featureIds.has(feature.id)) { + throw new Error(`duplicate feature id: ${feature.id}`); + } + featureIds.add(feature.id); + + for (const command of feature.slashCommands ?? []) { + addUnique( + slash, + command.data.name, + { featureId: feature.id, command }, + "application command", + ); + } + + for (const command of feature.prefixCommands ?? []) { + for (const name of [command.name, ...(command.aliases ?? [])]) { + addUnique( + prefix, + name.toLowerCase(), + { featureId: feature.id, command }, + "prefix command", + ); + } + } + + for (const [namespace, handler] of Object.entries( + feature.modalHandlers ?? {}, + )) { + addUnique( + custom, + namespace, + { kind: "modal", featureId: feature.id, handler }, + "customId namespace", + ); + } + + for (const [namespace, handler] of Object.entries( + feature.componentHandlers ?? {}, + )) { + addUnique( + custom, + namespace, + { kind: "component", featureId: feature.id, handler }, + "customId namespace", + ); + } + } + + for (const reserved of ["delete", "pg"]) { + if (custom.has(reserved)) { + throw new Error(`customId namespace is reserved: ${reserved}`); + } + } + + return { slash, prefix, custom }; +} + +export interface ParsedPrefixCommand { + readonly name: string; + readonly args: string; +} + +export function parsePrefixCommand( + content: string, + prefix: string, +): ParsedPrefixCommand | undefined { + if (!content.startsWith(prefix)) { + return undefined; + } + + const input = content.slice(prefix.length); + const separator = input.search(/\s/u); + const name = ( + separator === -1 ? input : input.slice(0, separator) + ).toLowerCase(); + if (name.length === 0) { + return undefined; + } + + return { + name, + // Remove only the command separator. The rest remains byte-for-byte intact. + args: separator === -1 ? "" : input.slice(separator + 1), + }; +} + +export type MessageGate = (message: Message) => boolean | Promise; + +export interface DiscordRouterOptions { + readonly client: Client; + readonly rest: REST; + readonly profile: BotProfile; + readonly features: readonly Feature[]; + readonly replyCoordinator: ReplyCoordinator; + readonly errorPresenter: ErrorPresenter; + readonly logger: AppLogger; + readonly messageGate?: MessageGate; +} + +export class DiscordRouter { + readonly #client: Client; + readonly #rest: REST; + readonly #profile: BotProfile; + readonly #features: readonly Feature[]; + readonly #routes: RouteRegistry; + readonly #replyCoordinator: ReplyCoordinator; + readonly #errorPresenter: ErrorPresenter; + readonly #logger: AppLogger; + readonly #messageGate: MessageGate; + + constructor(options: DiscordRouterOptions) { + this.#client = options.client; + this.#rest = options.rest; + this.#profile = options.profile; + this.#features = options.features; + this.#routes = buildRouteRegistry(options.features); + this.#replyCoordinator = options.replyCoordinator; + this.#errorPresenter = options.errorPresenter; + this.#logger = options.logger; + this.#messageGate = options.messageGate ?? (() => true); + } + + bind(): void { + this.#client.once(Events.ClientReady, (client) => { + void this.#onReady(client); + }); + this.#client.on(Events.InteractionCreate, (interaction) => { + void this.#onInteraction(interaction); + }); + this.#client.on(Events.MessageCreate, (message) => { + void this.#onMessage(message); + }); + this.#client.on(Events.MessageUpdate, (before, after) => { + void this.#onMessageUpdate(before, after); + }); + this.#client.on(Events.MessageDelete, (message) => { + void this.#replyCoordinator.deleteReplies(message.id); + }); + } + + async #onReady(client: Client): Promise { + const body = [...this.#routes.slash.values()].map(({ command }) => ({ + ...command.data.toJSON(), + integration_types: [ + ApplicationIntegrationType.GuildInstall, + ApplicationIntegrationType.UserInstall, + ], + contexts: [ + InteractionContextType.Guild, + InteractionContextType.BotDM, + InteractionContextType.PrivateChannel, + ], + })); + + try { + await this.#rest.put(Routes.applicationCommands(client.application.id), { + body, + }); + this.#logger.info( + { commandCount: body.length, bot: this.#profile.name }, + "Registered Discord application commands", + ); + } catch (error) { + this.#logger.error({ error }, "Failed to register application commands"); + } + } + + async #onInteraction(interaction: Interaction): Promise { + if (interaction.isAutocomplete()) { + await this.#onAutocomplete(interaction); + return; + } + + if ( + interaction.isChatInputCommand() || + interaction.isContextMenuCommand() + ) { + await this.#onApplicationCommand(interaction); + return; + } + + if (interaction.isButton()) { + await this.#onButton(interaction); + return; + } + + if (interaction.isModalSubmit()) { + await this.#onModal(interaction); + } + } + + async #onApplicationCommand( + interaction: ChatInputCommandInteraction | ContextMenuCommandInteraction, + ): Promise { + const route = this.#routes.slash.get(interaction.commandName); + if (route === undefined) { + return; + } + const context = contextForInteraction( + interaction, + this.#profile.defaultLocale, + ); + await this.#invoke(route.featureId, context, interaction.toString(), () => + route.command.execute(interaction, context), + ); + } + + async #onAutocomplete(interaction: AutocompleteInteraction): Promise { + const route = this.#routes.slash.get(interaction.commandName); + if (route?.command.autocomplete === undefined) { + return; + } + + try { + await route.command.autocomplete(interaction); + } catch (error) { + const context = contextForInteraction( + interaction as unknown as ChatInputCommandInteraction, + this.#profile.defaultLocale, + ); + await this.#errorPresenter.present( + error, + context, + interaction.toString(), + ); + if (!interaction.responded) { + await interaction.respond([]); + } + } + } + + async #onButton(interaction: ButtonInteraction): Promise { + const namespace = interaction.customId.split(":", 1)[0] ?? ""; + if (namespace === "pg") { + return; + } + if (namespace === "delete") { + const ownerId = interaction.customId.slice("delete:".length); + if (ownerId === interaction.user.id) { + await interaction.deferUpdate(); + await interaction.message.delete(); + } else if (!interaction.deferred && !interaction.replied) { + await interaction.deferUpdate(); + } + return; + } + + const route = this.#routes.custom.get(namespace); + if (route?.kind !== "component") { + return; + } + const context = contextForInteraction( + interaction, + this.#profile.defaultLocale, + ); + await this.#invoke(route.featureId, context, interaction.customId, () => + route.handler(interaction, context), + ); + } + + async #onModal(interaction: ModalSubmitInteraction): Promise { + const namespace = interaction.customId.split(":", 1)[0] ?? ""; + const route = this.#routes.custom.get(namespace); + if (route?.kind !== "modal") { + return; + } + const context = contextForInteraction( + interaction, + this.#profile.defaultLocale, + ); + await this.#invoke(route.featureId, context, interaction.customId, () => + route.handler(interaction, context), + ); + } + + async #onMessage(message: Message): Promise { + if (message.author.bot || !(await this.#messageGate(message))) { + return; + } + + const context = contextForMessage(message, this.#profile.defaultLocale); + const handledFeatures = new Set(); + const parsed = parsePrefixCommand(message.content, this.#profile.prefix); + const prefixRoute = parsed && this.#routes.prefix.get(parsed.name); + + if (prefixRoute !== undefined && parsed !== undefined) { + handledFeatures.add(prefixRoute.featureId); + try { + if ("sendTyping" in message.channel) { + await message.channel.sendTyping(); + } + } catch (error) { + this.#logger.debug({ error }, "Unable to send typing indicator"); + } + await this.#invokeMessage( + prefixRoute.featureId, + context, + message.content, + () => prefixRoute.command.execute(message, parsed.args, context), + ); + } + + for (const feature of this.#features) { + if (feature.onMessage === undefined || handledFeatures.has(feature.id)) { + continue; + } + const onMessage = feature.onMessage; + await this.#invokeMessage(feature.id, context, message.content, () => + onMessage(message, context), + ); + } + } + + async #onMessageUpdate( + before: Message | PartialMessage, + after: Message | PartialMessage, + ): Promise { + if (before.partial || after.partial || before.content === after.content) { + return; + } + await this.#onMessage(after); + } + + async #invokeMessage( + featureId: FeatureId, + context: RequestContext, + invocation: string, + handler: () => ReturnType, + ): Promise { + const sourceMessageId = (context.replyTarget as Message).id; + const generation = this.#replyCoordinator.begin(featureId, sourceMessageId); + await this.#invoke(featureId, context, invocation, handler, { + sourceMessageId, + generation, + }); + } + + async #invoke( + featureId: FeatureId, + context: RequestContext, + invocation: string, + handler: () => ReturnType, + tracking?: { + readonly sourceMessageId: string; + readonly generation: number; + }, + ): Promise { + try { + const outgoing = await handler(); + if (outgoing === undefined) { + return; + } + await this.#replyCoordinator.deliver({ + featureId, + target: context.replyTarget, + userId: context.userId, + outgoing, + ...tracking, + }); + } catch (error) { + try { + const outgoing = await this.#errorPresenter.present( + error, + context, + invocation, + ); + await this.#replyCoordinator.deliver({ + featureId, + target: context.replyTarget, + userId: context.userId, + outgoing, + ...tracking, + }); + } catch (presentationError) { + this.#logger.error( + { error: presentationError, originalError: error }, + "Failed to present handler error", + ); + } + } + } +} diff --git a/discord/src/shared/i18n.ts b/discord/src/shared/i18n.ts new file mode 100644 index 0000000..14d511e --- /dev/null +++ b/discord/src/shared/i18n.ts @@ -0,0 +1,33 @@ +import i18next from "i18next"; + +const resources = { + en: { + translation: { + errors: { + unhandled: "An unexpected error occurred.", + invalidInput: "Invalid input", + report: "Please report this problem to us: {{supportLink}}", + }, + }, + }, + ja: { + translation: { + errors: { + unhandled: "予期しないエラーが発生しました。", + invalidInput: "入力が正しくありません", + report: "この問題を報告してください: {{supportLink}}", + }, + }, + }, +} as const; + +const instance = i18next.createInstance(); +await instance.init({ + fallbackLng: "en", + interpolation: { escapeValue: false }, + resources, +}); + +export function getFixedT(locale: string) { + return instance.getFixedT(locale); +} diff --git a/discord/src/shared/limitedMap.test.ts b/discord/src/shared/limitedMap.test.ts new file mode 100644 index 0000000..bd0f4ee --- /dev/null +++ b/discord/src/shared/limitedMap.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { LimitedSizeMap } from "./limitedMap.js"; + +describe("LimitedSizeMap", () => { + it("evicts the oldest insertion when capacity is exceeded", () => { + const map = new LimitedSizeMap(2); + map.set("first", 1).set("second", 2).set("third", 3); + + expect([...map]).toEqual([ + ["second", 2], + ["third", 3], + ]); + }); + + it("keeps the original insertion order when an existing key is updated", () => { + const map = new LimitedSizeMap(2); + map.set("first", 1).set("second", 2).set("first", 10).set("third", 3); + + expect(map.has("first")).toBe(false); + expect([...map]).toEqual([ + ["second", 2], + ["third", 3], + ]); + }); + + it("supports undefined as a key", () => { + const map = new LimitedSizeMap(1); + map.set(undefined, 1).set("next", 2); + + expect([...map]).toEqual([["next", 2]]); + }); + + it("rejects invalid capacities", () => { + expect(() => new LimitedSizeMap(0)).toThrow(RangeError); + expect(() => new LimitedSizeMap(1.5)).toThrow(RangeError); + }); +}); diff --git a/discord/src/shared/limitedMap.ts b/discord/src/shared/limitedMap.ts new file mode 100644 index 0000000..ffae370 --- /dev/null +++ b/discord/src/shared/limitedMap.ts @@ -0,0 +1,46 @@ +export class LimitedSizeMap implements Iterable<[Key, Value]> { + readonly #entries = new Map(); + readonly #capacity: number; + + constructor(capacity: number) { + if (!Number.isInteger(capacity) || capacity < 1) { + throw new RangeError("capacity must be a positive integer"); + } + this.#capacity = capacity; + } + + get size(): number { + return this.#entries.size; + } + + get(key: Key): Value | undefined { + return this.#entries.get(key); + } + + has(key: Key): boolean { + return this.#entries.has(key); + } + + set(key: Key, value: Value): this { + this.#entries.set(key, value); + if (this.#entries.size > this.#capacity) { + const oldest = this.#entries.keys().next(); + if (!oldest.done) { + this.#entries.delete(oldest.value); + } + } + return this; + } + + delete(key: Key): boolean { + return this.#entries.delete(key); + } + + clear(): void { + this.#entries.clear(); + } + + [Symbol.iterator](): MapIterator<[Key, Value]> { + return this.#entries[Symbol.iterator](); + } +} diff --git a/discord/src/shared/logger.ts b/discord/src/shared/logger.ts new file mode 100644 index 0000000..a757d1e --- /dev/null +++ b/discord/src/shared/logger.ts @@ -0,0 +1,7 @@ +import pino, { type Logger, type LoggerOptions } from "pino"; + +export type AppLogger = Logger; + +export function createLogger(options?: LoggerOptions): AppLogger { + return pino(options); +} diff --git a/discord/src/types.ts b/discord/src/types.ts new file mode 100644 index 0000000..6b4441b --- /dev/null +++ b/discord/src/types.ts @@ -0,0 +1,79 @@ +import type { + AutocompleteInteraction, + BaseMessageOptions, + ButtonInteraction, + ChatInputCommandInteraction, + ContextMenuCommandBuilder, + ContextMenuCommandInteraction, + Message, + ModalSubmitInteraction, + SlashCommandBuilder, +} from "discord.js"; +import type { RequestContext } from "./platform/discord/context.js"; + +export type FeatureId = + | "tex" + | "code" + | "privacy" + | "wolfram" + | "misc" + | "translate" + | "ping"; + +type HandlerResult = Promise; + +export interface SlashCommand { + readonly data: SlashCommandBuilder | ContextMenuCommandBuilder; + execute( + interaction: ChatInputCommandInteraction | ContextMenuCommandInteraction, + context: RequestContext, + ): HandlerResult; + autocomplete?(interaction: AutocompleteInteraction): Promise; +} + +export interface PrefixCommand { + readonly name: string; + readonly aliases?: readonly string[]; + execute( + message: Message, + args: string, + context: RequestContext, + ): HandlerResult; +} + +export type ModalHandler = ( + interaction: ModalSubmitInteraction, + context: RequestContext, +) => HandlerResult; + +export type ComponentHandler = ( + interaction: ButtonInteraction, + context: RequestContext, +) => HandlerResult; + +export interface Feature { + readonly id: FeatureId; + readonly slashCommands?: readonly SlashCommand[]; + readonly prefixCommands?: readonly PrefixCommand[]; + readonly modalHandlers?: Readonly>; + readonly componentHandlers?: Readonly>; + onMessage?(message: Message, context: RequestContext): HandlerResult; + init?(): Promise; +} + +interface ReplyBase { + readonly files?: BaseMessageOptions["files"]; + readonly ephemeral?: boolean; +} + +export interface ComponentsV2Reply extends ReplyBase { + readonly kind: "components-v2"; + readonly components: NonNullable; +} + +export interface PlainContentReply extends ReplyBase { + readonly kind: "plain"; + readonly content: string; +} + +export type OutgoingReply = ComponentsV2Reply | PlainContentReply; From 26015f072fa7f7bccfaeae431d009d10922f1636 Mon Sep 17 00:00:00 2001 From: Gakuto Furuya Date: Sat, 18 Jul 2026 13:32:14 +0900 Subject: [PATCH 03/15] Add state backends and opt-out gating --- discord/package.json | 1 + discord/pnpm-lock.yaml | 302 +++++++++++++++++++++++++++++ discord/src/env.test.ts | 48 +++++ discord/src/env.ts | 62 +++++- discord/src/features/ping/index.ts | 4 +- discord/src/index.ts | 36 +++- discord/src/shared/state.test.ts | 99 ++++++++++ discord/src/shared/state.ts | 152 +++++++++++++++ discord/src/types.ts | 7 + 9 files changed, 702 insertions(+), 9 deletions(-) create mode 100644 discord/src/env.test.ts create mode 100644 discord/src/shared/state.test.ts create mode 100644 discord/src/shared/state.ts diff --git a/discord/package.json b/discord/package.json index 7df5b1a..d0a910d 100644 --- a/discord/package.json +++ b/discord/package.json @@ -17,6 +17,7 @@ "test": "vitest run" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1089.0", "discord.js": "^14.27.0", "dotenv": "^17.4.2", "i18next": "^26.3.6", diff --git a/discord/pnpm-lock.yaml b/discord/pnpm-lock.yaml index 2b73853..d7da0dd 100644 --- a/discord/pnpm-lock.yaml +++ b/discord/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@aws-sdk/client-s3': + specifier: ^3.1089.0 + version: 3.1089.0 discord.js: specifier: ^14.27.0 version: 14.27.0 @@ -45,6 +48,78 @@ importers: packages: + '@aws-sdk/checksums@3.1000.18': + resolution: {integrity: sha512-IImkbEyXdV6/uaF5r6Wkk+8718mQw1ll83j0a4a30R3JM/rHVFdWAiT4jtJpFjJiIwM/oJ6SxIxr0z2TaQUGqw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-s3@3.1089.0': + resolution: {integrity: sha512-Sc+JOtNeP+v+XdP9Rb4Hh+uhiNO2hh/CZQbKYooNakhOIgK6/K0cjoDCEGeFmkG0vf2DopTmSctzKlcKwy1BPw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.975.3': + resolution: {integrity: sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.59': + resolution: {integrity: sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.61': + resolution: {integrity: sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.4': + resolution: {integrity: sha512-e6ZvVsj90aRALf1kHP+J4iqC1496ZpVgqI/+u0LJ5HL7q7ATauGy4gdDvRCP13L1pN/fMiZLah162PGIYkbUVQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.66': + resolution: {integrity: sha512-g2fsqm87r/nKthLZ0VkkDBElkGg0PvSa8d97HQ6EilMbJTZ6hxa8FxkSZyJfgPfFdZn0TTmkOffQmTSUcAHIng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.70': + resolution: {integrity: sha512-3xzvkGdykBunxqh8WudmUpSyLWvIhfI6aBQo1b5rb3mDO5mNLadK+0hiI0qBQBMVynJbfLO+Ajy9dztMwy9O8w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.59': + resolution: {integrity: sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.3': + resolution: {integrity: sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.65': + resolution: {integrity: sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.64': + resolution: {integrity: sha512-RBi43anhDBUv+HCfxCOXwGOE7GmT4n7ChV04Mwr22RhXTNcamW/iWnJlOotDPCZSrJ4dEvhZSiWWQMwLX+ZhFA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.33': + resolution: {integrity: sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.41': + resolution: {integrity: sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1088.0': + resolution: {integrity: sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.36': + resolution: {integrity: sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -445,6 +520,30 @@ packages: resolution: {integrity: sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + '@smithy/core@3.29.5': + resolution: {integrity: sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.4.10': + resolution: {integrity: sha512-MJenAe4OKRZUo1LdYYFDCsSHxaHvInIU/z52GsheO9vl1/VSySVCr0zkyKD6TFiGkSUaWGxvKZ/70OvgUZR5HQ==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.6.7': + resolution: {integrity: sha512-3zpg8yqqyXzoK2TsRDdkqVOj2RDBFfLXwCczOZ5c7TWB4eiaebfSCsbMjDPYB3PJ9ihV62QaeadZ+wLadZtNGA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.9.7': + resolution: {integrity: sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.6.6': + resolution: {integrity: sha512-efP6DN3UTFrzIsGO42/xcabv8jU7+9nwEdphFUH7yL0k010ERyAWaO41KFQIDLcFZLZ8xzIQr4wplFxNzslSGQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} + engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -616,6 +715,9 @@ packages: bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@5.0.7: resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} engines: {node: 18 || 20 || >=22} @@ -1463,6 +1565,171 @@ packages: snapshots: + '@aws-sdk/checksums@3.1000.18': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1089.0': + dependencies: + '@aws-sdk/checksums': 3.1000.18 + '@aws-sdk/core': 3.975.3 + '@aws-sdk/credential-provider-node': 3.972.70 + '@aws-sdk/middleware-sdk-s3': 3.972.64 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/fetch-http-handler': 5.6.7 + '@smithy/node-http-handler': 4.9.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/core@3.975.3': + dependencies: + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.36 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.29.5 + '@smithy/signature-v4': 5.6.6 + '@smithy/types': 4.16.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.59': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.61': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/fetch-http-handler': 5.6.7 + '@smithy/node-http-handler': 4.9.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.4': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/credential-provider-env': 3.972.59 + '@aws-sdk/credential-provider-http': 3.972.61 + '@aws-sdk/credential-provider-login': 3.972.66 + '@aws-sdk/credential-provider-process': 3.972.59 + '@aws-sdk/credential-provider-sso': 3.973.3 + '@aws-sdk/credential-provider-web-identity': 3.972.65 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/credential-provider-imds': 4.4.10 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.66': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.70': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.59 + '@aws-sdk/credential-provider-http': 3.972.61 + '@aws-sdk/credential-provider-ini': 3.973.4 + '@aws-sdk/credential-provider-process': 3.972.59 + '@aws-sdk/credential-provider-sso': 3.973.3 + '@aws-sdk/credential-provider-web-identity': 3.972.65 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/credential-provider-imds': 4.4.10 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.59': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.3': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/token-providers': 3.1088.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.65': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.64': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.33': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/fetch-http-handler': 5.6.7 + '@smithy/node-http-handler': 4.9.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.41': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.6 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1088.0': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.2': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.36': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -1734,6 +2001,39 @@ snapshots: '@sapphire/snowflake@3.5.5': {} + '@smithy/core@3.29.5': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.4.10': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.6.7': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.9.7': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.6.6': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/types@4.16.1': + dependencies: + tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} '@ts-graphviz/adapter@2.0.6': @@ -1929,6 +2229,8 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 + bowser@2.14.1: {} + brace-expansion@5.0.7: dependencies: balanced-match: 4.0.4 diff --git a/discord/src/env.test.ts b/discord/src/env.test.ts new file mode 100644 index 0000000..1f4335b --- /dev/null +++ b/discord/src/env.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { loadEnv } from "./env.js"; + +const baseEnv = { CODERUNBOT_TOKEN: "token" }; + +describe("loadEnv state configuration", () => { + it("uses the local state file by default", () => { + expect(loadEnv(baseEnv).state).toEqual({ + backend: "local", + filePath: "data/opt-out-users.txt", + }); + }); + + it("builds a normalized S3 object key", () => { + expect( + loadEnv({ + ...baseEnv, + BOT_STATE_BACKEND: "s3", + BOT_STATE_PREFIX: "/bots/coderunbot/", + S3_ENDPOINT: "https://example.invalid", + S3_REGION: "test-region-1", + S3_BUCKET: "state", + S3_ACCESS_KEY_ID: "access-key", + S3_SECRET_ACCESS_KEY: "secret-key", + }).state, + ).toEqual({ + backend: "s3", + endpoint: "https://example.invalid", + region: "test-region-1", + bucket: "state", + key: "bots/coderunbot/opt-out-users.txt", + accessKeyId: "access-key", + secretAccessKey: "secret-key", + }); + }); + + it("fails fast when an S3 setting is missing", () => { + expect(() => + loadEnv({ + ...baseEnv, + BOT_STATE_BACKEND: "s3", + S3_ENDPOINT: "https://example.invalid", + }), + ).toThrow( + "S3_REGION, S3_BUCKET, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY must be set when BOT_STATE_BACKEND=s3", + ); + }); +}); diff --git a/discord/src/env.ts b/discord/src/env.ts index fce00c4..20efd4e 100644 --- a/discord/src/env.ts +++ b/discord/src/env.ts @@ -2,6 +2,23 @@ import "dotenv/config"; export type BotName = "coderunbot" | "gaato-bot"; +export interface LocalStateEnv { + readonly backend: "local"; + readonly filePath: "data/opt-out-users.txt"; +} + +export interface S3StateEnv { + readonly backend: "s3"; + readonly endpoint: string; + readonly region: string; + readonly bucket: string; + readonly key: string; + readonly accessKeyId: string; + readonly secretAccessKey: string; +} + +export type StateEnv = LocalStateEnv | S3StateEnv; + export interface Env { readonly botName: BotName; readonly token: string; @@ -9,6 +26,7 @@ export interface Env { readonly logChannelId: string; readonly developerId: string; readonly supportServerLink: string; + readonly state: StateEnv; } const DEFAULT_LOG_CHANNEL_ID = "1118867011448078417"; @@ -16,7 +34,48 @@ const DEFAULT_DEVELOPER_ID = "572432137035317249"; const DEFAULT_SUPPORT_SERVER_LINK = "discord.gg/qRpYRTgvXM"; function nonEmpty(value: string | undefined): string | undefined { - return value === undefined || value.length === 0 ? undefined : value; + return value === undefined || value.trim().length === 0 ? undefined : value; +} + +function loadStateEnv(source: NodeJS.ProcessEnv): StateEnv { + const backend = nonEmpty(source.BOT_STATE_BACKEND) ?? "local"; + if (backend === "local") { + return { backend, filePath: "data/opt-out-users.txt" }; + } + if (backend !== "s3") { + throw new Error('BOT_STATE_BACKEND must be either "local" or "s3"'); + } + + const required = { + S3_ENDPOINT: nonEmpty(source.S3_ENDPOINT), + S3_REGION: nonEmpty(source.S3_REGION), + S3_BUCKET: nonEmpty(source.S3_BUCKET), + S3_ACCESS_KEY_ID: nonEmpty(source.S3_ACCESS_KEY_ID), + S3_SECRET_ACCESS_KEY: nonEmpty(source.S3_SECRET_ACCESS_KEY), + }; + const missing = Object.entries(required) + .filter(([, value]) => value === undefined) + .map(([name]) => name); + if (missing.length > 0) { + throw new Error( + `${missing.join(", ")} must be set when BOT_STATE_BACKEND=s3`, + ); + } + + const prefix = (nonEmpty(source.BOT_STATE_PREFIX) ?? "").replace( + /^\/+|\/+$/gu, + "", + ); + return { + backend, + endpoint: required.S3_ENDPOINT as string, + region: required.S3_REGION as string, + bucket: required.S3_BUCKET as string, + key: + prefix.length > 0 ? `${prefix}/opt-out-users.txt` : "opt-out-users.txt", + accessKeyId: required.S3_ACCESS_KEY_ID as string, + secretAccessKey: required.S3_SECRET_ACCESS_KEY as string, + }; } export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { @@ -36,5 +95,6 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { developerId: nonEmpty(source.DEVELOPER_ID) ?? DEFAULT_DEVELOPER_ID, supportServerLink: nonEmpty(source.SUPPORT_SERVER_LINK) ?? DEFAULT_SUPPORT_SERVER_LINK, + state: loadStateEnv(source), }; } diff --git a/discord/src/features/ping/index.ts b/discord/src/features/ping/index.ts index d812634..e80da58 100644 --- a/discord/src/features/ping/index.ts +++ b/discord/src/features/ping/index.ts @@ -1,10 +1,10 @@ import { SlashCommandBuilder } from "discord.js"; import { successContainer } from "../../platform/discord/components.js"; -import type { Feature } from "../../types.js"; +import type { Feature, FeatureDependencies } from "../../types.js"; // Temporary smoke-test feature for the TypeScript foundation. Remove it once // the real feature ports provide end-to-end command coverage. -export function createPingFeature(): Feature { +export function createPingFeature(_dependencies: FeatureDependencies): Feature { return { id: "ping", slashCommands: [ diff --git a/discord/src/index.ts b/discord/src/index.ts index 28992b3..17b403b 100644 --- a/discord/src/index.ts +++ b/discord/src/index.ts @@ -7,12 +7,16 @@ import { ReplyCoordinator } from "./platform/discord/replyCoordinator.js"; import { DiscordRouter } from "./platform/discord/router.js"; import { getFixedT } from "./shared/i18n.js"; import { createLogger } from "./shared/logger.js"; -import type { Feature, FeatureId } from "./types.js"; - -type FeatureFactory = () => Feature; +import { + LocalFileState, + OptOutUsers, + S3ObjectState, + type StateBackend, +} from "./shared/state.js"; +import type { FeatureFactory, FeatureId } from "./types.js"; function pendingFeature(id: FeatureId): FeatureFactory { - return () => ({ id }); + return (_dependencies) => ({ id }); } // This is the only FeatureId -> concrete factory mapping. The no-op entries are @@ -33,6 +37,25 @@ export async function bootstrap(): Promise { const logger = createLogger(); getFixedT(profile.defaultLocale); + let stateBackend: StateBackend; + if (env.state.backend === "local") { + stateBackend = new LocalFileState(env.state.filePath); + } else { + stateBackend = new S3ObjectState({ + endpoint: env.state.endpoint, + region: env.state.region, + bucket: env.state.bucket, + key: env.state.key, + credentials: { + accessKeyId: env.state.accessKeyId, + secretAccessKey: env.state.secretAccessKey, + }, + }); + } + const optOutUsers = new OptOutUsers(stateBackend); + await optOutUsers.init(); + const featureDependencies = { optOutUsers }; + const client = new Client({ intents: [ GatewayIntentBits.Guilds, @@ -44,8 +67,8 @@ export async function bootstrap(): Promise { }); const rest = new REST({ version: "10" }).setToken(env.token); const features = [ - ...profile.features.map((id) => featureFactories[id]()), - featureFactories.ping(), + ...profile.features.map((id) => featureFactories[id](featureDependencies)), + featureFactories.ping(featureDependencies), ]; for (const feature of features) { @@ -67,6 +90,7 @@ export async function bootstrap(): Promise { replyCoordinator, errorPresenter, logger, + messageGate: (message) => !optOutUsers.has(message.author.id), }); router.bind(); diff --git a/discord/src/shared/state.test.ts b/discord/src/shared/state.test.ts new file mode 100644 index 0000000..573aca2 --- /dev/null +++ b/discord/src/shared/state.test.ts @@ -0,0 +1,99 @@ +import { mkdtemp, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { LocalFileState, OptOutUsers, type StateBackend } from "./state.js"; + +class MemoryState implements StateBackend { + readonly writes: string[] = []; + + constructor(readonly value = "") {} + + async read(): Promise { + return this.value; + } + + async write(value: string): Promise { + this.writes.push(value); + } +} + +describe("OptOutUsers", () => { + it("parses newline-delimited string snowflakes and ignores blank lines", async () => { + const state = new MemoryState( + " 123456789012345678\r\n\n987654321098765432\n", + ); + const users = new OptOutUsers(state); + + await users.init(); + + expect(users.has("123456789012345678")).toBe(true); + expect(users.has("987654321098765432")).toBe(true); + expect(users.has("123456789012345679")).toBe(false); + }); + + it("serializes sorted IDs with a trailing newline", async () => { + const state = new MemoryState(); + const users = new OptOutUsers(state); + await users.init(); + + await users.add("9"); + await users.add("10"); + await users.remove("9"); + + expect(state.writes).toEqual(["9\n", "10\n9\n", "10\n"]); + }); + + it("serializes concurrent writes in call order", async () => { + const writes: string[] = []; + let releaseFirstWrite: (() => void) | undefined; + const firstWriteBlocked = new Promise((resolve) => { + releaseFirstWrite = resolve; + }); + let writeCount = 0; + const state: StateBackend = { + async read() { + return ""; + }, + async write(value) { + writeCount += 1; + if (writeCount === 1) { + await firstWriteBlocked; + } + writes.push(value); + }, + }; + const users = new OptOutUsers(state); + await users.init(); + + const first = users.add("2"); + const second = users.add("1"); + await Promise.resolve(); + expect(writeCount).toBe(1); + + releaseFirstWrite?.(); + await Promise.all([first, second]); + + expect(writes).toEqual(["2\n", "1\n2\n"]); + }); +}); + +describe("LocalFileState", () => { + it("returns an empty string for a missing file", async () => { + const directory = await mkdtemp(join(tmpdir(), "coderunbot-state-")); + const state = new LocalFileState(join(directory, "missing", "state.txt")); + + await expect(state.read()).resolves.toBe(""); + }); + + it("creates parent directories and writes UTF-8 text", async () => { + const directory = await mkdtemp(join(tmpdir(), "coderunbot-state-")); + const filePath = join(directory, "nested", "state.txt"); + const state = new LocalFileState(filePath); + + await state.write("123\n"); + + await expect(readFile(filePath, "utf8")).resolves.toBe("123\n"); + await expect(state.read()).resolves.toBe("123\n"); + }); +}); diff --git a/discord/src/shared/state.ts b/discord/src/shared/state.ts new file mode 100644 index 0000000..1fc4ad5 --- /dev/null +++ b/discord/src/shared/state.ts @@ -0,0 +1,152 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname } from "node:path"; +import { + GetObjectCommand, + NoSuchKey, + PutObjectCommand, + S3Client, + type S3ClientConfig, +} from "@aws-sdk/client-s3"; + +export interface StateBackend { + read(): Promise; + write(value: string): Promise; +} + +export class LocalFileState implements StateBackend { + readonly #filePath: string; + + constructor(filePath: string) { + this.#filePath = filePath; + } + + async read(): Promise { + try { + return await readFile(this.#filePath, "utf8"); + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + return ""; + } + throw error; + } + } + + async write(value: string): Promise { + await mkdir(dirname(this.#filePath), { recursive: true }); + await writeFile(this.#filePath, value, "utf8"); + } +} + +export interface S3ObjectStateOptions { + readonly endpoint: string; + readonly region: string; + readonly bucket: string; + readonly key: string; + readonly credentials: S3ClientConfig["credentials"]; +} + +export class S3ObjectState implements StateBackend { + readonly #client: S3Client; + readonly #bucket: string; + readonly #key: string; + + constructor(options: S3ObjectStateOptions) { + this.#client = new S3Client({ + endpoint: options.endpoint, + region: options.region, + credentials: options.credentials, + forcePathStyle: true, + }); + this.#bucket = options.bucket; + this.#key = options.key; + } + + async read(): Promise { + try { + const response = await this.#client.send( + new GetObjectCommand({ Bucket: this.#bucket, Key: this.#key }), + ); + return (await response.Body?.transformToString("utf-8")) ?? ""; + } catch (error) { + if (!isMissingS3Object(error)) { + throw error; + } + await this.write(""); + return ""; + } + } + + async write(value: string): Promise { + await this.#client.send( + new PutObjectCommand({ + Bucket: this.#bucket, + Key: this.#key, + Body: value, + ContentType: "text/plain; charset=utf-8", + }), + ); + } +} + +export class OptOutUsers { + readonly #backend: StateBackend; + #users = new Set(); + #writeChain: Promise = Promise.resolve(); + + constructor(backend: StateBackend) { + this.#backend = backend; + } + + async init(): Promise { + this.#users = parseUsers(await this.#backend.read()); + } + + has(userId: string): boolean { + return this.#users.has(userId); + } + + add(userId: string): Promise { + this.#users.add(userId); + return this.#enqueueWrite(serializeUsers(this.#users)); + } + + remove(userId: string): Promise { + this.#users.delete(userId); + return this.#enqueueWrite(serializeUsers(this.#users)); + } + + #enqueueWrite(value: string): Promise { + const write = this.#writeChain.then(() => this.#backend.write(value)); + this.#writeChain = write.catch(() => undefined); + return write; + } +} + +function parseUsers(value: string): Set { + return new Set( + value + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0), + ); +} + +function serializeUsers(users: ReadonlySet): string { + const sorted = [...users].sort(); + return sorted.length === 0 ? "" : `${sorted.join("\n")}\n`; +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} + +function isMissingS3Object(error: unknown): boolean { + if (error instanceof NoSuchKey) { + return true; + } + if (!(error instanceof Error) || !("$metadata" in error)) { + return false; + } + const metadata = error.$metadata as { readonly httpStatusCode?: number }; + return error.name === "NoSuchKey" && metadata.httpStatusCode === 404; +} diff --git a/discord/src/types.ts b/discord/src/types.ts index 6b4441b..a648f39 100644 --- a/discord/src/types.ts +++ b/discord/src/types.ts @@ -10,6 +10,7 @@ import type { SlashCommandBuilder, } from "discord.js"; import type { RequestContext } from "./platform/discord/context.js"; +import type { OptOutUsers } from "./shared/state.js"; export type FeatureId = | "tex" @@ -61,6 +62,12 @@ export interface Feature { init?(): Promise; } +export interface FeatureDependencies { + readonly optOutUsers: OptOutUsers; +} + +export type FeatureFactory = (dependencies: FeatureDependencies) => Feature; + interface ReplyBase { readonly files?: BaseMessageOptions["files"]; readonly ephemeral?: boolean; From 55cb3a70b8b481ad9e3179a447533a081b9b7e5e Mon Sep 17 00:00:00 2001 From: Gakuto Furuya Date: Sat, 18 Jul 2026 13:42:05 +0900 Subject: [PATCH 04/15] Add privacy and in-process TeX features --- discord/config/privacy-policy.md | 35 ++ discord/package.json | 4 +- discord/pnpm-lock.yaml | 370 ++++++++++++++++++ discord/pnpm-workspace.yaml | 1 + discord/src/features/privacy/index.ts | 67 ++++ discord/src/features/tex/index.ts | 131 +++++++ discord/src/features/tex/presentation.test.ts | 87 ++++ discord/src/features/tex/presentation.ts | 85 ++++ discord/src/features/tex/renderer.test.ts | 28 ++ discord/src/features/tex/renderer.ts | 86 ++++ discord/src/index.ts | 6 +- discord/src/shared/i18n.ts | 48 +++ discord/tsconfig.json | 1 + 13 files changed, 946 insertions(+), 3 deletions(-) create mode 100644 discord/config/privacy-policy.md create mode 100644 discord/src/features/privacy/index.ts create mode 100644 discord/src/features/tex/index.ts create mode 100644 discord/src/features/tex/presentation.test.ts create mode 100644 discord/src/features/tex/presentation.ts create mode 100644 discord/src/features/tex/renderer.test.ts create mode 100644 discord/src/features/tex/renderer.ts diff --git a/discord/config/privacy-policy.md b/discord/config/privacy-policy.md new file mode 100644 index 0000000..1724d17 --- /dev/null +++ b/discord/config/privacy-policy.md @@ -0,0 +1,35 @@ +# Privacy Policy + +## Owner + +Gakuto Furuya (gaato.) + +## Bot + +- **CodeRunBot**#4348 + +## What data we collect + +- Message content of the channel where the bot can be seen +- Internal error logs (not related to the user) + +## How we use it + +### Message content + +- The bot uses to determine if a command is being called. +- If so, the bot use it to create a response. + +It is not used for any other purpose and is discarded immediately. + +### Internal error logs + +- For debugging purposes. + +## How to opt-out of providing us that data + +The /opt-out command will prevent the bot from tracking the content of your messages. Note that this command will disable most of the features of the bot. + +## An accessible way for users to request to and deletion of their data + +Originally, users data is not saved because it is immediately discarded. diff --git a/discord/package.json b/discord/package.json index d0a910d..2f36d10 100644 --- a/discord/package.json +++ b/discord/package.json @@ -21,7 +21,9 @@ "discord.js": "^14.27.0", "dotenv": "^17.4.2", "i18next": "^26.3.6", - "pino": "^10.3.1" + "mathjax-full": "^3.2.2", + "pino": "^10.3.1", + "sharp": "^0.35.3" }, "devDependencies": { "@biomejs/biome": "^2.5.4", diff --git a/discord/pnpm-lock.yaml b/discord/pnpm-lock.yaml index d7da0dd..9ea7459 100644 --- a/discord/pnpm-lock.yaml +++ b/discord/pnpm-lock.yaml @@ -20,9 +20,15 @@ importers: i18next: specifier: ^26.3.6 version: 26.3.6(typescript@6.0.3) + mathjax-full: + specifier: ^3.2.2 + version: 3.2.2 pino: specifier: ^10.3.1 version: 10.3.1 + sharp: + specifier: ^0.35.3 + version: 0.35.3(@types/node@24.13.3) devDependencies: '@biomejs/biome': specifier: ^2.5.4 @@ -395,6 +401,168 @@ packages: cpu: [x64] os: [win32] + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -658,6 +826,10 @@ packages: '@vue/shared@3.5.40': resolution: {integrity: sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==} + '@xmldom/xmldom@0.9.10': + resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + engines: {node: '>=14.6'} + acorn-jsx-walk@2.0.0: resolution: {integrity: sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==} @@ -756,6 +928,10 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@15.0.0: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} engines: {node: '>=22.12.0'} @@ -883,6 +1059,10 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + esm@3.2.25: + resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} + engines: {node: '>=6'} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -1128,6 +1308,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mathjax-full@3.2.2: + resolution: {integrity: sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w==} + deprecated: Version 4 replaces this package with the scoped package @mathjax/src + + mhchemparser@4.2.1: + resolution: {integrity: sha512-kYmyrCirqJf3zZ9t/0wGgRZ4/ZJw//VwaRVGA75C4nhE60vtnIzhl9J9ndkX/h6hxSN7pjg/cE0VxbnNM+bnDQ==} + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -1139,6 +1326,9 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mj-context-menu@0.6.1: + resolution: {integrity: sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA==} + module-definition@6.0.2: resolution: {integrity: sha512-SvAU3lB0+Yjbq55yHY3wkRZBOh+fhU1SnIF3IFbTewv6mtAh7yUT8ACHAJ2mGIJ7tCes2QuCL/cl6m0JSZ/ArA==} engines: {node: '>=18'} @@ -1310,6 +1500,15 @@ packages: engines: {node: '>=10'} hasBin: true + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1330,6 +1529,10 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + speech-rule-engine@4.1.4: + resolution: {integrity: sha512-i/VCLG1fvRc95pMHRqG4aQNscv+9aIsqA2oI7ZQS51sTdUcDHYX6cpT8/tqZ+enjs1tKVwbRBWgxut9SWn+f9g==} + hasBin: true + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -1551,6 +1754,9 @@ packages: engines: {node: '>=8'} hasBin: true + wicked-good-xpath@1.3.0: + resolution: {integrity: sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw==} + ws@8.21.1: resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} engines: {node: '>=10.0.0'} @@ -1928,6 +2134,112 @@ snapshots: '@esbuild/win32-x64@0.28.1': optional: true + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.1 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-win32-arm64@0.35.3': + optional: true + + '@img/sharp-win32-ia32@0.35.3': + optional: true + + '@img/sharp-win32-x64@0.35.3': + optional: true + '@jridgewell/sourcemap-codec@1.5.5': {} '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': @@ -2187,6 +2499,8 @@ snapshots: '@vue/shared@3.5.40': {} + '@xmldom/xmldom@0.9.10': {} + acorn-jsx-walk@2.0.0: {} acorn-jsx@5.3.2(acorn@8.17.0): @@ -2263,6 +2577,8 @@ snapshots: commander@12.1.0: {} + commander@13.1.0: {} + commander@15.0.0: {} commander@7.2.0: {} @@ -2443,6 +2759,8 @@ snapshots: eslint-visitor-keys@5.0.1: {} + esm@3.2.25: {} + esprima@4.0.1: {} estraverse@5.3.0: {} @@ -2629,6 +2947,15 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mathjax-full@3.2.2: + dependencies: + esm: 3.2.25 + mhchemparser: 4.2.1 + mj-context-menu: 0.6.1 + speech-rule-engine: 4.1.4 + + mhchemparser@4.2.1: {} + mimic-fn@2.1.0: {} minimatch@10.2.5: @@ -2637,6 +2964,8 @@ snapshots: minimist@1.2.8: {} + mj-context-menu@0.6.1: {} + module-definition@6.0.2: dependencies: ast-module-types: 6.0.2 @@ -2836,6 +3165,39 @@ snapshots: semver@7.8.5: {} + sharp@0.35.3(@types/node@24.13.3): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 24.13.3 + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -2851,6 +3213,12 @@ snapshots: source-map@0.6.1: optional: true + speech-rule-engine@4.1.4: + dependencies: + '@xmldom/xmldom': 0.9.10 + commander: 13.1.0 + wicked-good-xpath: 1.3.0 + split2@4.2.0: {} stackback@0.0.2: {} @@ -3005,4 +3373,6 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wicked-good-xpath@1.3.0: {} + ws@8.21.1: {} diff --git a/discord/pnpm-workspace.yaml b/discord/pnpm-workspace.yaml index 5ed0b5a..dbb26c8 100644 --- a/discord/pnpm-workspace.yaml +++ b/discord/pnpm-workspace.yaml @@ -1,2 +1,3 @@ allowBuilds: esbuild: true + sharp: true diff --git a/discord/src/features/privacy/index.ts b/discord/src/features/privacy/index.ts new file mode 100644 index 0000000..0cb0a65 --- /dev/null +++ b/discord/src/features/privacy/index.ts @@ -0,0 +1,67 @@ +import { readFile } from "node:fs/promises"; +import { SlashCommandBuilder } from "discord.js"; +import { getFixedT } from "../../shared/i18n.js"; +import type { Feature, FeatureDependencies } from "../../types.js"; + +const privacyPolicyUrl = new URL( + "../../../config/privacy-policy.md", + import.meta.url, +); + +export function createPrivacyFeature({ + optOutUsers, +}: FeatureDependencies): Feature { + return { + id: "privacy", + slashCommands: [ + { + data: new SlashCommandBuilder() + .setName("privacy-policy") + .setDescription("Show the privacy policy") + .setDescriptionLocalizations({ + ja: "プライバシーポリシーを表示します", + }), + async execute() { + return { + kind: "plain", + content: await readFile(privacyPolicyUrl, "utf8"), + }; + }, + }, + { + data: new SlashCommandBuilder() + .setName("opt-out") + .setDescription("Stop the bot from processing your message content") + .setDescriptionLocalizations({ + ja: "ボットによるメッセージ内容の処理を停止します", + }), + async execute(_interaction, context) { + const t = getFixedT(context.locale); + if (optOutUsers.has(context.userId)) { + return { kind: "plain", content: t("privacy.optOut.already") }; + } + + await optOutUsers.add(context.userId); + return { kind: "plain", content: t("privacy.optOut.success") }; + }, + }, + { + data: new SlashCommandBuilder() + .setName("opt-in") + .setDescription("Allow the bot to process your message content again") + .setDescriptionLocalizations({ + ja: "ボットによるメッセージ内容の処理を再開します", + }), + async execute(_interaction, context) { + const t = getFixedT(context.locale); + if (!optOutUsers.has(context.userId)) { + return { kind: "plain", content: t("privacy.optIn.already") }; + } + + await optOutUsers.remove(context.userId); + return { kind: "plain", content: t("privacy.optIn.success") }; + }, + }, + ], + }; +} diff --git a/discord/src/features/tex/index.ts b/discord/src/features/tex/index.ts new file mode 100644 index 0000000..a7a9d47 --- /dev/null +++ b/discord/src/features/tex/index.ts @@ -0,0 +1,131 @@ +import { + ActionRowBuilder, + ModalBuilder, + SlashCommandBuilder, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; +import { getFixedT } from "../../shared/i18n.js"; +import type { + Feature, + FeatureDependencies, + OutgoingReply, +} from "../../types.js"; +import { + stripTexFences, + texFailureReply, + texSuccessReply, +} from "./presentation.js"; +import { renderTexToPng, TexRenderError } from "./renderer.js"; + +type TexEnvironment = "align" | "gather"; + +export function createTexFeature(_dependencies: FeatureDependencies): Feature { + const slashCommand = new SlashCommandBuilder() + .setName("tex") + .setDescription("Render TeX as an image") + .setDescriptionLocalizations({ + ja: "TeX を画像としてレンダリングします", + }); + slashCommand.addStringOption((option) => + option + .setName("env") + .setDescription("The environment to use") + .setDescriptionLocalizations({ ja: "使用する環境" }) + .addChoices( + { name: "align", value: "align" }, + { name: "gather", value: "gather" }, + ), + ); + slashCommand.addBooleanOption((option) => + option + .setName("spoiler") + .setDescription("Mark the rendered image as a spoiler") + .setDescriptionLocalizations({ + ja: "レンダリング画像をスポイラーとして隠します", + }), + ); + + return { + id: "tex", + prefixCommands: [ + { + name: "tex", + async execute(_message, args, context) { + return renderReply(stripTexFences(args), false, context.locale); + }, + }, + { + name: "stex", + async execute(_message, args, context) { + return renderReply(stripTexFences(args), true, context.locale); + }, + }, + ], + slashCommands: [ + { + data: slashCommand, + async execute(interaction, context) { + if (!interaction.isChatInputCommand()) { + return undefined; + } + const environment = parseEnvironment( + interaction.options.getString("env"), + ); + const spoiler = interaction.options.getBoolean("spoiler") ?? false; + const t = getFixedT(context.locale); + const input = new TextInputBuilder() + .setCustomId("latex") + .setLabel(t("tex.modal.codeLabel")) + .setPlaceholder(t("tex.modal.codePlaceholder")) + .setStyle(TextInputStyle.Paragraph) + .setRequired(true); + if (environment !== undefined) { + input.setValue(`\\begin{${environment}}\n\n\\end{${environment}}`); + } + + const modal = new ModalBuilder() + .setCustomId( + `texModal:${spoiler}:${environment === undefined ? "" : environment}`, + ) + .setTitle(t("tex.modal.title")) + .addComponents( + new ActionRowBuilder().addComponents(input), + ); + await interaction.showModal(modal); + return undefined; + }, + }, + ], + modalHandlers: { + texModal: async (interaction, context) => { + const [, spoilerValue] = interaction.customId.split(":"); + const spoiler = spoilerValue === "true"; + const latex = interaction.fields.getTextInputValue("latex"); + await interaction.deferReply(); + return renderReply(latex, spoiler, context.locale); + }, + }, + }; +} + +async function renderReply( + latex: string, + spoiler: boolean, + locale: string, +): Promise { + const t = getFixedT(locale); + try { + const png = await renderTexToPng(latex); + return texSuccessReply(latex, png, spoiler, t); + } catch (error) { + if (error instanceof TexRenderError) { + return texFailureReply(error.message, t); + } + throw error; + } +} + +function parseEnvironment(value: string | null): TexEnvironment | undefined { + return value === "align" || value === "gather" ? value : undefined; +} diff --git a/discord/src/features/tex/presentation.test.ts b/discord/src/features/tex/presentation.test.ts new file mode 100644 index 0000000..ae2ba4f --- /dev/null +++ b/discord/src/features/tex/presentation.test.ts @@ -0,0 +1,87 @@ +import { ContainerBuilder } from "discord.js"; +import { describe, expect, it } from "vitest"; +import { getFixedT } from "../../shared/i18n.js"; +import type { OutgoingReply } from "../../types.js"; +import { + shouldShowMultilineHint, + stripTexFences, + texSuccessReply, +} from "./presentation.js"; + +function containerJson(reply: OutgoingReply) { + if (reply.kind !== "components-v2") { + throw new Error("expected a Components V2 reply"); + } + const container = reply.components[0]; + if (!(container instanceof ContainerBuilder)) { + throw new Error("expected a ContainerBuilder"); + } + return container.toJSON(); +} + +describe("stripTexFences", () => { + it.each([ + ["```tex\nx^2\n```", "x^2"], + ["```\nx^2\n```", "x^2"], + [" x^2 ", "x^2"], + ])("removes an optional TeX or plain code fence", (input, expected) => { + expect(stripTexFences(input)).toBe(expected); + }); +}); + +describe("shouldShowMultilineHint", () => { + it("suggests an environment for bare line breaks", () => { + expect(shouldShowMultilineHint("x \\\\ y")).toBe(true); + }); + + it.each([ + "x + y", + "\\begin{align}x \\\\ y\\end{align}", + "\\begin{gather}x \\\\ y", + "x \\\\ y\\end{gather}", + ])("does not suggest one when inappropriate", (latex) => { + expect(shouldShowMultilineHint(latex)).toBe(false); + }); +}); + +describe("texSuccessReply", () => { + it("marks the media-gallery item itself as a spoiler", () => { + const reply = texSuccessReply( + "x^2", + Buffer.from("png"), + true, + getFixedT("en"), + ); + + expect(containerJson(reply)).toMatchObject({ + type: 17, + components: expect.arrayContaining([ + { + type: 12, + items: [ + { + media: { url: "attachment://tex.png" }, + spoiler: true, + }, + ], + }, + ]), + }); + }); + + it("uses a text attachment when the source is over the display limit", () => { + const reply = texSuccessReply( + "x".repeat(1_001), + Buffer.from("png"), + false, + getFixedT("en"), + ); + + expect(reply.files).toHaveLength(2); + expect(containerJson(reply)).toMatchObject({ + components: expect.arrayContaining([ + { file: { url: "attachment://tex-source.txt" }, type: 13 }, + ]), + }); + }); +}); diff --git a/discord/src/features/tex/presentation.ts b/discord/src/features/tex/presentation.ts new file mode 100644 index 0000000..f693469 --- /dev/null +++ b/discord/src/features/tex/presentation.ts @@ -0,0 +1,85 @@ +import { + AttachmentBuilder, + ContainerBuilder, + FileBuilder, + MediaGalleryBuilder, + MediaGalleryItemBuilder, + TextDisplayBuilder, +} from "discord.js"; +import type { TFunction } from "i18next"; +import { + FAILURE_ACCENT, + SUCCESS_ACCENT, + textComponent, +} from "../../platform/discord/components.js"; +import type { OutgoingReply } from "../../types.js"; + +export function stripTexFences(input: string): string { + return input + .replace(/```tex/giu, "") + .replace(/```/gu, "") + .trim(); +} + +export function shouldShowMultilineHint(latex: string): boolean { + return ( + latex.includes("\\\\") && + !latex.includes("\\begin") && + !latex.includes("\\end") + ); +} + +export function texSuccessReply( + latex: string, + png: Buffer, + spoiler: boolean, + t: TFunction, +): OutgoingReply { + const container = new ContainerBuilder() + .setAccentColor(SUCCESS_ACCENT) + .addMediaGalleryComponents( + new MediaGalleryBuilder().addItems( + new MediaGalleryItemBuilder() + .setURL("attachment://tex.png") + .setSpoiler(spoiler), + ), + ); + + if (shouldShowMultilineHint(latex)) { + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `### ${t("tex.hintHeading")}\n${t("tex.multilineHint")}`, + ), + ); + } + + const source = textComponent(latex, "tex-source.txt"); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(`### ${t("tex.codeHeading")}`), + ); + if (source.component instanceof FileBuilder) { + container.addFileComponents(source.component); + } else { + const safeLatex = latex.replaceAll("```", "`\u200b``"); + source.component.setContent(`\`\`\`tex\n${safeLatex}\n\`\`\``); + container.addTextDisplayComponents(source.component); + } + + return { + kind: "components-v2", + components: [container], + files: [new AttachmentBuilder(png, { name: "tex.png" }), ...source.files], + }; +} + +export function texFailureReply(message: string, t: TFunction): OutgoingReply { + const safeMessage = message.replaceAll("```", "`\u200b``"); + const container = new ContainerBuilder() + .setAccentColor(FAILURE_ACCENT) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `### ${t("tex.errorHeading")}\n\`\`\`\n${safeMessage}\n\`\`\``, + ), + ); + return { kind: "components-v2", components: [container] }; +} diff --git a/discord/src/features/tex/renderer.test.ts b/discord/src/features/tex/renderer.test.ts new file mode 100644 index 0000000..71a4e25 --- /dev/null +++ b/discord/src/features/tex/renderer.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { renderTexToPng, renderTexToSvg, TexRenderError } from "./renderer.js"; + +describe("TeX renderer", () => { + it("renders TeX to a PNG buffer", async () => { + const png = await renderTexToPng("x^2"); + + expect(png.subarray(0, 8)).toEqual( + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + ); + }); + + it("throws a typed error for invalid TeX", () => { + expect(() => renderTexToSvg("\\invalid")).toThrow(TexRenderError); + }); + + it("sets the Japanese serif fallback on SVG text nodes", () => { + const svg = renderTexToSvg("\\text{日本語}"); + + const textTags = svg.match(/]*>/gu) ?? []; + expect(textTags.length).toBeGreaterThan(0); + expect( + textTags.every((tag) => + tag.includes('font-family="Noto Serif CJK JP, serif"'), + ), + ).toBe(true); + }); +}); diff --git a/discord/src/features/tex/renderer.ts b/discord/src/features/tex/renderer.ts new file mode 100644 index 0000000..a392748 --- /dev/null +++ b/discord/src/features/tex/renderer.ts @@ -0,0 +1,86 @@ +import * as liteAdaptorModule from "mathjax-full/js/adaptors/liteAdaptor.js"; +import * as htmlHandlerModule from "mathjax-full/js/handlers/html.js"; +import * as allPackagesModule from "mathjax-full/js/input/tex/AllPackages.js"; +import * as texModule from "mathjax-full/js/input/tex.js"; +import * as mathjaxModule from "mathjax-full/js/mathjax.js"; +import * as svgModule from "mathjax-full/js/output/svg.js"; +import sharp from "sharp"; + +const TEXT_FONT_FAMILY = "Noto Serif CJK JP, serif"; +const PADDING = 20; + +const adaptor = liteAdaptorModule.liteAdaptor(); +htmlHandlerModule.RegisterHTMLHandler(adaptor); + +// `noundefined` turns unknown commands into red text and prevents MathJax from +// producing the data-mjx-error/title pair needed for typed render failures. +const renderPackages = allPackagesModule.AllPackages.filter( + (packageName) => packageName !== "noundefined", +); + +export class TexRenderError extends Error { + constructor(message: string) { + super(message); + this.name = "TexRenderError"; + } +} + +export function renderTexToSvg(latex: string): string { + const tex = new texModule.TeX({ packages: renderPackages }); + const svg = new svgModule.SVG(); + const document = mathjaxModule.mathjax.document("", { + InputJax: tex, + OutputJax: svg, + }); + const converted = document.convert(latex, { + display: true, + em: 16, + ex: 8, + containerWidth: 80, + }); + const svgElement = adaptor.tags(converted, "svg")[0]; + if (svgElement === undefined) { + throw new Error("MathJax did not produce an SVG element"); + } + + const rendered = adaptor.outerHTML(svgElement); + if (rendered.includes("data-mjx-error")) { + const title = rendered.match(/\btitle=(['"])(.*?)\1/u)?.[2]; + throw new TexRenderError( + title === undefined + ? "Unknown LaTeX rendering error" + : decodeXmlEntities(title), + ); + } + + for (const textElement of adaptor.tags(svgElement, "text")) { + adaptor.setAttribute(textElement, "font-family", TEXT_FONT_FAMILY); + } + return adaptor.outerHTML(svgElement); +} + +export async function renderTexToPng(latex: string): Promise { + const svg = renderTexToSvg(latex); + const white = { r: 255, g: 255, b: 255 }; + return sharp(Buffer.from(svg)) + .resize({ height: 500 }) + .flatten({ background: white }) + .extend({ + top: PADDING, + bottom: PADDING, + left: PADDING, + right: PADDING, + background: { ...white, alpha: 1 }, + }) + .png() + .toBuffer(); +} + +function decodeXmlEntities(value: string): string { + return value + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&"); +} diff --git a/discord/src/index.ts b/discord/src/index.ts index 17b403b..e5335f3 100644 --- a/discord/src/index.ts +++ b/discord/src/index.ts @@ -2,6 +2,8 @@ import { Client, GatewayIntentBits, Partials, REST } from "discord.js"; import { getBotProfile } from "./config.js"; import { loadEnv } from "./env.js"; import { createPingFeature } from "./features/ping/index.js"; +import { createPrivacyFeature } from "./features/privacy/index.js"; +import { createTexFeature } from "./features/tex/index.js"; import { ErrorPresenter } from "./platform/discord/errorPresenter.js"; import { ReplyCoordinator } from "./platform/discord/replyCoordinator.js"; import { DiscordRouter } from "./platform/discord/router.js"; @@ -22,9 +24,9 @@ function pendingFeature(id: FeatureId): FeatureFactory { // This is the only FeatureId -> concrete factory mapping. The no-op entries are // replaced by real factories in subsequent migration steps. const featureFactories: Record = { - tex: pendingFeature("tex"), + tex: createTexFeature, code: pendingFeature("code"), - privacy: pendingFeature("privacy"), + privacy: createPrivacyFeature, wolfram: pendingFeature("wolfram"), misc: pendingFeature("misc"), translate: pendingFeature("translate"), diff --git a/discord/src/shared/i18n.ts b/discord/src/shared/i18n.ts index 14d511e..cebc844 100644 --- a/discord/src/shared/i18n.ts +++ b/discord/src/shared/i18n.ts @@ -8,6 +8,30 @@ const resources = { invalidInput: "Invalid input", report: "Please report this problem to us: {{supportLink}}", }, + privacy: { + optOut: { + already: + "Your message content is already off-track. To use other commands, please use the /opt-in command.", + success: + "This bot will not track your message content from now on. Most commands will no longer respond.", + }, + optIn: { + already: "This bot is already tracking your message content.", + success: + "This bot will now track the content of your messages. It will only be used to provide commands. Use the /privacy-policy command to view the privacy policy.", + }, + }, + tex: { + hintHeading: "Hint", + multilineHint: "You can use the gather or align environment.", + codeHeading: "Code", + errorHeading: "Rendering Error", + modal: { + title: "LaTeX to Image", + codeLabel: "Code", + codePlaceholder: "Enter TeX code here", + }, + }, }, }, ja: { @@ -17,6 +41,30 @@ const resources = { invalidInput: "入力が正しくありません", report: "この問題を報告してください: {{supportLink}}", }, + privacy: { + optOut: { + already: + "メッセージ内容はすでに処理対象外です。他のコマンドを使用するには /opt-in コマンドを使用してください。", + success: + "今後、このボットはあなたのメッセージ内容を処理しません。ほとんどのコマンドは応答しなくなります。", + }, + optIn: { + already: "このボットはすでにあなたのメッセージ内容を処理しています。", + success: + "このボットは今後、あなたのメッセージ内容をコマンド提供のためだけに処理します。プライバシーポリシーは /privacy-policy コマンドで確認できます。", + }, + }, + tex: { + hintHeading: "ヒント", + multilineHint: "gather または align 環境を利用できます。", + codeHeading: "コード", + errorHeading: "レンダリングエラー", + modal: { + title: "LaTeX を画像に変換", + codeLabel: "コード", + codePlaceholder: "TeX コードを入力してください", + }, + }, }, }, } as const; diff --git a/discord/tsconfig.json b/discord/tsconfig.json index 7f268cd..f9b1acf 100644 --- a/discord/tsconfig.json +++ b/discord/tsconfig.json @@ -4,6 +4,7 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, + "skipLibCheck": true, "noUncheckedIndexedAccess": true, "outDir": "dist", "rootDir": "src", From 52c62577aa33bdc98c94521707e4eac1a80c7703 Mon Sep 17 00:00:00 2001 From: Gakuto Furuya Date: Sat, 18 Jul 2026 13:52:49 +0900 Subject: [PATCH 05/15] Add Wandbox code execution feature --- discord/src/features/code/index.ts | 172 +++++++++ .../src/features/code/presentation.test.ts | 90 +++++ discord/src/features/code/presentation.ts | 172 +++++++++ discord/src/features/code/wandbox.test.ts | 194 ++++++++++ discord/src/features/code/wandbox.ts | 358 ++++++++++++++++++ discord/src/index.ts | 5 +- discord/src/shared/i18n.ts | 43 +++ discord/src/types.ts | 2 + 8 files changed, 1034 insertions(+), 2 deletions(-) create mode 100644 discord/src/features/code/index.ts create mode 100644 discord/src/features/code/presentation.test.ts create mode 100644 discord/src/features/code/presentation.ts create mode 100644 discord/src/features/code/wandbox.test.ts create mode 100644 discord/src/features/code/wandbox.ts diff --git a/discord/src/features/code/index.ts b/discord/src/features/code/index.ts new file mode 100644 index 0000000..1e2d33c --- /dev/null +++ b/discord/src/features/code/index.ts @@ -0,0 +1,172 @@ +import { + ActionRowBuilder, + ApplicationCommandType, + ContextMenuCommandBuilder, + escapeMarkdown, + ModalBuilder, + SlashCommandBuilder, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; +import { getFixedT } from "../../shared/i18n.js"; +import type { + Feature, + FeatureDependencies, + OutgoingReply, +} from "../../types.js"; +import { + parsePrefixRunInput, + runResultReply, + supportedLanguagesReply, + wandboxErrorReply, +} from "./presentation.js"; +import { + normalizeLanguageKey, + WandboxClient, + WandboxError, +} from "./wandbox.js"; + +export function escapeMentions(content: string): string { + return content.replaceAll("@", "@\u200b"); +} + +export function createCodeFeature(dependencies: FeatureDependencies): Feature { + const wandbox = new WandboxClient({ logger: dependencies.logger }); + const runCommand = new SlashCommandBuilder() + .setName("run") + .setDescription("Run code on Wandbox") + .setDescriptionLocalizations({ ja: "Wandbox でコードを実行します" }); + runCommand.addStringOption((option) => + option + .setName("language") + .setNameLocalizations({ ja: "言語" }) + .setDescription("Programming language") + .setDescriptionLocalizations({ ja: "プログラミング言語" }) + .setRequired(true) + .setAutocomplete(true), + ); + const escapeCommand = new ContextMenuCommandBuilder() + .setName("escape") + .setNameLocalizations({ ja: "エスケープ" }) + .setType(ApplicationCommandType.Message); + + return { + id: "code", + init: () => wandbox.init(), + prefixCommands: [ + { + name: "run", + async execute(_message, args, context) { + const input = parsePrefixRunInput(args); + return runCode( + wandbox, + input.languageKey, + input.code, + "", + context.locale, + ); + }, + }, + ], + slashCommands: [ + { + data: runCommand, + async execute(interaction, context) { + if (!interaction.isChatInputCommand()) { + return undefined; + } + const languageKey = normalizeLanguageKey( + interaction.options.getString("language", true), + ); + const t = getFixedT(context.locale); + const code = new TextInputBuilder() + .setCustomId("code") + .setLabel(t("code.modal.codeLabel")) + .setPlaceholder(t("code.modal.codePlaceholder")) + .setStyle(TextInputStyle.Paragraph) + .setRequired(true); + const stdin = new TextInputBuilder() + .setCustomId("stdin") + .setLabel(t("code.modal.stdinLabel")) + .setStyle(TextInputStyle.Paragraph) + .setRequired(false); + const modal = new ModalBuilder() + .setCustomId(`runModal:${languageKey}`) + .setTitle(t("code.modal.title")) + .addComponents( + new ActionRowBuilder().addComponents(code), + new ActionRowBuilder().addComponents(stdin), + ); + await interaction.showModal(modal); + return undefined; + }, + async autocomplete(interaction) { + const focused = normalizeLanguageKey( + String(interaction.options.getFocused()), + ); + await interaction.respond( + wandbox + .getLanguageChoices() + .filter(({ value }) => value.startsWith(focused)) + .slice(0, 25), + ); + }, + }, + { + data: escapeCommand, + async execute(interaction) { + if (!interaction.isMessageContextMenuCommand()) { + return undefined; + } + return { + kind: "plain", + content: escapeMentions( + escapeMarkdown(interaction.targetMessage.content), + ), + ephemeral: true, + }; + }, + }, + ], + modalHandlers: { + runModal: async (interaction, context) => { + const languageKey = normalizeLanguageKey( + interaction.customId.slice("runModal:".length), + ); + const code = interaction.fields.getTextInputValue("code"); + const stdin = interaction.fields.getTextInputValue("stdin"); + await interaction.deferReply(); + return runCode(wandbox, languageKey, code, stdin, context.locale); + }, + }, + }; +} + +async function runCode( + wandbox: WandboxClient, + languageKey: string, + code: string, + stdin: string, + locale: string, +): Promise { + const t = getFixedT(locale); + const resolution = await wandbox.resolveCompiler(languageKey); + if (resolution === undefined) { + return supportedLanguagesReply(wandbox.getLanguageChoices(), t); + } + + try { + const result = await wandbox.compile(resolution.compiler, code, stdin); + return runResultReply(resolution.compiler, languageKey, code, result, t); + } catch (error) { + if ( + error instanceof WandboxError && + (error.code === "connection" || + error.code === "http" || + error.code === "non_json") + ) { + return wandboxErrorReply(error, t); + } + throw error; + } +} diff --git a/discord/src/features/code/presentation.test.ts b/discord/src/features/code/presentation.test.ts new file mode 100644 index 0000000..e848a48 --- /dev/null +++ b/discord/src/features/code/presentation.test.ts @@ -0,0 +1,90 @@ +import { ContainerBuilder } from "discord.js"; +import { describe, expect, it } from "vitest"; +import { getFixedT } from "../../shared/i18n.js"; +import type { OutgoingReply } from "../../types.js"; +import { + parsePrefixRunInput, + runResultReply, + supportedLanguagesReply, +} from "./presentation.js"; + +function containerJson(reply: OutgoingReply) { + if (reply.kind !== "components-v2") { + throw new Error("expected a Components V2 reply"); + } + const container = reply.components[0]; + if (!(container instanceof ContainerBuilder)) { + throw new Error("expected a ContainerBuilder"); + } + return container.toJSON(); +} + +describe("parsePrefixRunInput", () => { + it("extracts the language key and removes complete fence lines", () => { + expect( + parsePrefixRunInput(" Python ```python\nprint('hello')\n```"), + ).toEqual({ languageKey: "python", code: "print('hello')" }); + }); + + it("does not rewrite language aliases", () => { + expect(parsePrefixRunInput("cpp code").languageKey).toBe("cpp"); + }); +}); + +describe("runResultReply", () => { + it("uses the status color and omits message fields", () => { + const reply = runResultReply( + "compiler", + "python", + "print(1)", + { + status: "0", + program_output: "1\n", + program_message: "duplicate output", + compiler_message: "ignored", + }, + getFixedT("en"), + ); + const json = containerJson(reply); + + expect(json.accent_color).toBe(0x2ecc71); + expect(JSON.stringify(json)).not.toContain("duplicate output"); + expect(JSON.stringify(json)).not.toContain("ignored"); + expect(JSON.stringify(json)).toContain("print(1)"); + }); + + it("uses a File component for output over 1000 characters", () => { + const reply = runResultReply( + "compiler", + "python", + "print(1)", + { status: "1", compiler_error: "x".repeat(1_001) }, + getFixedT("en"), + ); + + expect(reply.files).toHaveLength(1); + expect(containerJson(reply)).toMatchObject({ + accent_color: 0xe74c3c, + components: expect.arrayContaining([ + { file: { url: "attachment://compiler_error.txt" }, type: 13 }, + ]), + }); + }); +}); + +describe("supportedLanguagesReply", () => { + it("moves a long language list into a text attachment", () => { + const reply = supportedLanguagesReply( + Array.from({ length: 200 }, (_, index) => ({ + name: `Language ${index}`, + value: `language-${index}`, + })), + getFixedT("en"), + ); + + expect(reply.files).toHaveLength(1); + expect(JSON.stringify(containerJson(reply))).toContain( + "supported-languages.txt", + ); + }); +}); diff --git a/discord/src/features/code/presentation.ts b/discord/src/features/code/presentation.ts new file mode 100644 index 0000000..4a785ee --- /dev/null +++ b/discord/src/features/code/presentation.ts @@ -0,0 +1,172 @@ +import { + type AttachmentBuilder, + ContainerBuilder, + FileBuilder, + TextDisplayBuilder, +} from "discord.js"; +import type { TFunction } from "i18next"; +import { + FAILURE_ACCENT, + SUCCESS_ACCENT, + textComponent, +} from "../../platform/discord/components.js"; +import type { OutgoingReply } from "../../types.js"; +import type { LanguageChoice, WandboxCompileResult } from "./wandbox.js"; +import { normalizeLanguageKey, type WandboxError } from "./wandbox.js"; + +export interface PrefixRunInput { + readonly languageKey: string; + readonly code: string; +} + +export function parsePrefixRunInput(args: string): PrefixRunInput { + const input = args.trimStart(); + const separator = input.search(/\s/u); + if (separator === -1) { + return { languageKey: normalizeLanguageKey(input), code: "" }; + } + return { + languageKey: normalizeLanguageKey(input.slice(0, separator)), + code: stripRunFences(input.slice(separator).trimStart()), + }; +} + +export function stripRunFences(code: string): string { + return code.replace(/^```.*$/gmu, "").trim(); +} + +function safeCodeBlock(text: string): string { + return text.replaceAll("```", "`\u200b``"); +} + +function safeFilename(field: string): string { + const safe = field.replace(/[^a-z0-9_-]/giu, "-"); + return `${safe.length === 0 ? "output" : safe}.txt`; +} + +function addTextSection( + container: ContainerBuilder, + files: AttachmentBuilder[], + heading: string, + text: string, + filename: string, + language = "", +): void { + const rendered = textComponent(text, filename); + if (rendered.component instanceof FileBuilder) { + container + .addTextDisplayComponents( + new TextDisplayBuilder().setContent(`### ${heading}`), + ) + .addFileComponents(rendered.component); + } else { + rendered.component.setContent( + `### ${heading}\n\`\`\`${language}\n${safeCodeBlock(text)}\n\`\`\``, + ); + container.addTextDisplayComponents(rendered.component); + } + files.push(...rendered.files); +} + +export function supportedLanguagesReply( + choices: readonly LanguageChoice[], + t: TFunction, +): OutgoingReply { + const container = new ContainerBuilder().setAccentColor(FAILURE_ACCENT); + const languages = choices.map(({ value }) => value).join(", "); + const rendered = textComponent(languages, "supported-languages.txt"); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `### ${t("code.supportedLanguagesHeading")}`, + ), + ); + if (rendered.component instanceof FileBuilder) { + container.addFileComponents(rendered.component); + } else if (languages.length > 0) { + container.addTextDisplayComponents(rendered.component); + } else { + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(t("code.noLanguagesAvailable")), + ); + } + return { + kind: "components-v2", + components: [container], + files: rendered.files, + }; +} + +export function runResultReply( + compiler: string, + languageKey: string, + code: string, + result: WandboxCompileResult, + t: TFunction, +): OutgoingReply { + const succeeded = result.status === "0"; + const container = new ContainerBuilder() + .setAccentColor(succeeded ? SUCCESS_ACCENT : FAILURE_ACCENT) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `## ${t("code.resultHeading", { compiler })}`, + ), + ); + const files: AttachmentBuilder[] = []; + + for (const [field, value] of Object.entries(result)) { + if ( + value === undefined || + value === "" || + field === "program_message" || + field === "compiler_message" + ) { + continue; + } + addTextSection(container, files, field, value, safeFilename(field)); + } + addTextSection( + container, + files, + t("code.codeHeading"), + code, + "code.txt", + languageKey, + ); + + return { + kind: "components-v2", + components: [container], + files, + }; +} + +export function wandboxErrorReply( + error: WandboxError, + t: TFunction, +): OutgoingReply { + const description = + error.code === "non_json" + ? t("code.errors.nonJson") + : error.code === "http" && "status" in error + ? t("code.errors.http", { status: error.status }) + : t("code.errors.connection"); + const container = new ContainerBuilder() + .setAccentColor(FAILURE_ACCENT) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `### ${t("code.errors.heading")}\n${description}`, + ), + ); + + if (error.code === "non_json" && "responseText" in error) { + const preview = String(error.responseText).slice(0, 750); + if (preview.length > 0) { + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `### ${t("code.errors.responsePreview")}\n\`\`\`\n${safeCodeBlock(preview)}\n\`\`\``, + ), + ); + } + } + return { kind: "components-v2", components: [container] }; +} diff --git a/discord/src/features/code/wandbox.test.ts b/discord/src/features/code/wandbox.test.ts new file mode 100644 index 0000000..eed4628 --- /dev/null +++ b/discord/src/features/code/wandbox.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it, vi } from "vitest"; +import { + languageChoices, + parseCompilerList, + resolveCompilerFromList, + WandboxClient, + WandboxNonJsonError, + WandboxSchemaError, +} from "./wandbox.js"; + +const silentLogger = { + error: vi.fn(), + warn: vi.fn(), +}; + +describe("resolveCompilerFromList", () => { + it("uses a pinned compiler when it is present", () => { + expect( + resolveCompilerFromList("python", [ + { name: "cpython-head", language: "Python" }, + { name: "cpython-3.14.0", language: "Python" }, + ]), + ).toEqual({ compiler: "cpython-3.14.0", source: "pinned" }); + }); + + it("uses the first stable compiler for an unpinned language", () => { + expect( + resolveCompilerFromList( + "brainfuck", + [ + { name: "brainfuck-git", language: "Brainfuck" }, + { name: "bfc-stable", language: "Brainfuck" }, + ], + {}, + ), + ).toEqual({ compiler: "bfc-stable", source: "heuristic" }); + }); + + it("falls back to the first compiler if no stable compiler exists", () => { + expect( + resolveCompilerFromList( + "future", + [ + { name: "future-head", language: "Future" }, + { name: "future-git", language: "Future" }, + ], + {}, + ), + ).toEqual({ compiler: "future-head", source: "heuristic" }); + }); + + it("falls back heuristically when a pinned compiler disappears", () => { + expect( + resolveCompilerFromList("python", [ + { name: "cpython-head", language: "Python" }, + { name: "cpython-3.15.0", language: "Python" }, + ]), + ).toEqual({ + compiler: "cpython-3.15.0", + source: "heuristic", + missingPinnedCompiler: "cpython-3.14.0", + }); + }); +}); + +describe("parseCompilerList", () => { + it("accepts entries containing the required fields", () => { + expect( + parseCompilerList([ + { name: "compiler", language: "Language", extra: true }, + ]), + ).toEqual([{ name: "compiler", language: "Language" }]); + }); + + it.each([ + {}, + [null], + [{ name: "compiler" }], + [{ name: 1, language: "Language" }], + ])("rejects a malformed response", (value) => { + expect(() => parseCompilerList(value)).toThrow(WandboxSchemaError); + }); +}); + +describe("languageChoices", () => { + it("preserves display names and uses normalized keys without alias hacks", () => { + expect( + languageChoices([ + { name: "gcc", language: "C++" }, + { name: "clang", language: "C++" }, + { name: "bfc", language: "Brain Fuck" }, + ]), + ).toEqual([ + { name: "C++", value: "c++" }, + { name: "Brain Fuck", value: "brainfuck" }, + ]); + }); +}); + +describe("WandboxClient.compile", () => { + it("filters Nim compiler CC lines and sends the hint-suppression flags", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + status: "0", + compiler_error: "CC: stdlib_system.nim\nwarning\nCC: main.nim\n", + }), + { status: 200 }, + ), + ); + const client = new WandboxClient({ + logger: silentLogger, + fetch: fetchMock, + }); + + await expect(client.compile("nim-2.2.10", "echo 1", "")).resolves.toEqual({ + status: "0", + compiler_error: "warning\n", + }); + const body = JSON.parse( + String(fetchMock.mock.calls[0]?.[1]?.body), + ) as Record; + expect(body["compiler-option-raw"]?.split("\n")).toHaveLength(4); + }); + + it("retains the response text when JSON parsing fails", async () => { + const client = new WandboxClient({ + logger: silentLogger, + fetch: vi + .fn() + .mockResolvedValue( + new Response("service unavailable", { status: 200 }), + ), + }); + + const error = await client + .compile("compiler", "code", "") + .catch((value: unknown) => value); + expect(error).toBeInstanceOf(WandboxNonJsonError); + expect((error as WandboxNonJsonError).responseText).toBe( + "service unavailable", + ); + }); +}); + +describe("WandboxClient compiler-list cache", () => { + it("keeps stale compilers when a TTL refresh fails", async () => { + let now = 0; + const logger = { error: vi.fn(), warn: vi.fn() }; + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify([{ name: "cpython-3.14.0", language: "Python" }]), + ), + ) + .mockRejectedValueOnce(new TypeError("offline")); + const client = new WandboxClient({ + logger, + fetch: fetchMock, + now: () => now, + cacheTtlMs: 100, + }); + + await client.init(); + now = 101; + + await expect(client.resolveCompiler("python")).resolves.toMatchObject({ + compiler: "cpython-3.14.0", + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ staleCompilerCount: 1 }), + expect.stringContaining("retaining stale cache"), + ); + }); + + it("logs a schema error and starts with an empty list", async () => { + const logger = { error: vi.fn(), warn: vi.fn() }; + const client = new WandboxClient({ + logger, + fetch: vi + .fn() + .mockResolvedValue(new Response(JSON.stringify({ changed: true }))), + }); + + await expect(client.init()).resolves.toBeUndefined(); + expect(client.getLanguageChoices()).toEqual([]); + expect(logger.error).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.any(WandboxSchemaError) }), + expect.stringContaining("expected schema"), + ); + }); +}); diff --git a/discord/src/features/code/wandbox.ts b/discord/src/features/code/wandbox.ts new file mode 100644 index 0000000..87d9c28 --- /dev/null +++ b/discord/src/features/code/wandbox.ts @@ -0,0 +1,358 @@ +import type { AppLogger } from "../../shared/logger.js"; + +const LIST_URL = "https://wandbox.org/api/list.json"; +const COMPILE_URL = "https://wandbox.org/api/compile.json"; +export const WANDBOX_CACHE_TTL_MS = 60 * 60 * 1_000; + +const NIM_COMPILER_OPTIONS = [ + "--hint[Processing]:off", + "--hint[Conf]:off", + "--hint[Link]:off", + "--hint[SuccessX]:off", +].join("\n"); + +export const PINNED_COMPILERS: Readonly> = { + python: "cpython-3.14.0", + "c++": "gcc-13.2.0", + c: "gcc-13.2.0-c", + "c#": "dotnetcore-8.0.402", + nim: "nim-2.2.10", + haskell: "ghc-9.10.1", + rust: "rust-1.82.0", + go: "go-1.23.2", + typescript: "typescript-5.6.2", + javascript: "nodejs-20.17.0", + java: "openjdk-jdk-21+35", + ruby: "ruby-3.4.9", + php: "php-8.3.12", + swift: "swift-6.0.1", + d: "dmd-2.109.1", + lua: "lua-5.4.7", + perl: "perl-5.40.0", + r: "r-4.4.1", + ocaml: "ocaml-5.2.0", +}; + +export interface WandboxCompiler { + readonly name: string; + readonly language: string; +} + +export interface LanguageChoice { + readonly name: string; + readonly value: string; +} + +export interface CompilerResolution { + readonly compiler: string; + readonly source: "pinned" | "heuristic"; + readonly missingPinnedCompiler?: string; +} + +export interface WandboxCompileResult { + readonly status?: string; + readonly signal?: string; + readonly compiler_output?: string; + readonly compiler_error?: string; + readonly compiler_message?: string; + readonly program_output?: string; + readonly program_error?: string; + readonly program_message?: string; + readonly permlink?: string; + readonly url?: string; + readonly [field: string]: string | undefined; +} + +export type WandboxErrorCode = "connection" | "http" | "non_json" | "schema"; + +export class WandboxError extends Error { + constructor( + readonly code: WandboxErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = "WandboxError"; + } +} + +export class WandboxHttpError extends WandboxError { + constructor( + readonly status: number, + readonly responseText: string, + ) { + super("http", `Wandbox returned HTTP ${status}`); + this.name = "WandboxHttpError"; + } +} + +export class WandboxNonJsonError extends WandboxError { + constructor( + readonly status: number, + readonly responseText: string, + options?: ErrorOptions, + ) { + super("non_json", "Wandbox returned a non-JSON response", options); + this.name = "WandboxNonJsonError"; + } +} + +export class WandboxSchemaError extends WandboxError { + constructor(message: string) { + super("schema", message); + this.name = "WandboxSchemaError"; + } +} + +export function normalizeLanguageKey(language: string): string { + return language.toLowerCase().replaceAll(" ", ""); +} + +export function parseCompilerList(value: unknown): WandboxCompiler[] { + if (!Array.isArray(value)) { + throw new WandboxSchemaError("Wandbox compiler list is not an array"); + } + + return value.map((entry, index) => { + if ( + typeof entry !== "object" || + entry === null || + !("name" in entry) || + !("language" in entry) || + typeof entry.name !== "string" || + typeof entry.language !== "string" || + entry.name.length === 0 || + entry.language.length === 0 + ) { + throw new WandboxSchemaError( + `Wandbox compiler list entry ${index} is missing a valid name or language`, + ); + } + return { name: entry.name, language: entry.language }; + }); +} + +export function languageChoices( + compilers: readonly WandboxCompiler[], +): LanguageChoice[] { + const choices = new Map(); + for (const compiler of compilers) { + const value = normalizeLanguageKey(compiler.language); + if (!choices.has(value)) { + choices.set(value, { name: compiler.language, value }); + } + } + return [...choices.values()]; +} + +export function resolveCompilerFromList( + languageKey: string, + compilers: readonly WandboxCompiler[], + pinnedCompilers: Readonly> = PINNED_COMPILERS, +): CompilerResolution | undefined { + const key = normalizeLanguageKey(languageKey); + const candidates = compilers.filter( + (compiler) => normalizeLanguageKey(compiler.language) === key, + ); + if (candidates.length === 0) { + return undefined; + } + + const pinned = pinnedCompilers[key]; + if (pinned !== undefined && candidates.some(({ name }) => name === pinned)) { + return { compiler: pinned, source: "pinned" }; + } + + const stable = candidates.find( + ({ name }) => !/(?:head|devel|git)/iu.test(name), + ); + return { + compiler: (stable ?? candidates[0])?.name ?? "", + source: "heuristic", + ...(pinned === undefined ? {} : { missingPinnedCompiler: pinned }), + }; +} + +function parseCompileResult(value: unknown): WandboxCompileResult { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new WandboxSchemaError("Wandbox compile result is not an object"); + } + + const result: Record = {}; + for (const [field, fieldValue] of Object.entries(value)) { + if (typeof fieldValue !== "string") { + throw new WandboxSchemaError( + `Wandbox compile result field ${field} is not a string`, + ); + } + result[field] = fieldValue; + } + return result; +} + +export interface WandboxClientOptions { + readonly logger: Pick; + readonly fetch?: typeof fetch; + readonly now?: () => number; + readonly cacheTtlMs?: number; +} + +export class WandboxClient { + readonly #logger: Pick; + readonly #fetch: typeof fetch; + readonly #now: () => number; + readonly #cacheTtlMs: number; + #compilers: WandboxCompiler[] = []; + #lastRefreshAttempt = Number.NEGATIVE_INFINITY; + #refreshing?: Promise; + + constructor(options: WandboxClientOptions) { + this.#logger = options.logger; + this.#fetch = options.fetch ?? globalThis.fetch; + this.#now = options.now ?? Date.now; + this.#cacheTtlMs = options.cacheTtlMs ?? WANDBOX_CACHE_TTL_MS; + } + + async init(): Promise { + await this.#refresh(); + } + + getLanguageChoices(): readonly LanguageChoice[] { + return languageChoices(this.#compilers); + } + + async resolveCompiler( + languageKey: string, + ): Promise { + if (this.#now() - this.#lastRefreshAttempt >= this.#cacheTtlMs) { + await this.#refresh(); + } + const resolution = resolveCompilerFromList(languageKey, this.#compilers); + return resolution; + } + + async compile( + compiler: string, + code: string, + stdin: string, + ): Promise { + let response: Response; + try { + response = await this.#fetch(COMPILE_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + compiler, + code, + stdin, + "compiler-option-raw": compiler.startsWith("nim-") + ? NIM_COMPILER_OPTIONS + : "", + }), + }); + } catch (error) { + throw new WandboxError("connection", "Could not connect to Wandbox", { + cause: error, + }); + } + + if (!response.ok) { + throw new WandboxHttpError(response.status, await response.text()); + } + + const fallbackResponse = response.clone(); + let value: unknown; + try { + value = await response.json(); + } catch (error) { + throw new WandboxNonJsonError( + response.status, + await fallbackResponse.text(), + { cause: error }, + ); + } + + const result = parseCompileResult(value); + if (compiler.startsWith("nim-") && result.compiler_error !== undefined) { + return { + ...result, + compiler_error: result.compiler_error.replace(/^CC: \S+\r?\n/gmu, ""), + }; + } + return result; + } + + async #refresh(): Promise { + if (this.#refreshing !== undefined) { + return this.#refreshing; + } + + this.#refreshing = this.#refreshCompilerList(); + try { + await this.#refreshing; + } finally { + this.#refreshing = undefined; + } + } + + async #refreshCompilerList(): Promise { + this.#lastRefreshAttempt = this.#now(); + try { + let response: Response; + try { + response = await this.#fetch(LIST_URL); + } catch (error) { + throw new WandboxError( + "connection", + "Could not fetch the Wandbox compiler list", + { cause: error }, + ); + } + if (!response.ok) { + throw new WandboxHttpError(response.status, await response.text()); + } + + const fallbackResponse = response.clone(); + let value: unknown; + try { + value = await response.json(); + } catch (error) { + throw new WandboxNonJsonError( + response.status, + await fallbackResponse.text(), + { cause: error }, + ); + } + this.#compilers = parseCompilerList(value); + for (const [languageKey, pinnedCompiler] of Object.entries( + PINNED_COMPILERS, + )) { + const resolution = resolveCompilerFromList( + languageKey, + this.#compilers, + ); + if (resolution?.source !== "pinned") { + this.#logger.warn( + { + languageKey, + pinnedCompiler, + fallbackCompiler: resolution?.compiler, + }, + "Pinned Wandbox compiler is unavailable; using heuristic fallback when possible", + ); + } + } + } catch (error) { + if (error instanceof WandboxSchemaError) { + this.#logger.error( + { error }, + "Wandbox compiler list response did not match the expected schema", + ); + } + this.#logger.warn( + { error, staleCompilerCount: this.#compilers.length }, + "Failed to refresh Wandbox compiler list; retaining stale cache", + ); + } + } +} diff --git a/discord/src/index.ts b/discord/src/index.ts index e5335f3..03ab96e 100644 --- a/discord/src/index.ts +++ b/discord/src/index.ts @@ -1,6 +1,7 @@ import { Client, GatewayIntentBits, Partials, REST } from "discord.js"; import { getBotProfile } from "./config.js"; import { loadEnv } from "./env.js"; +import { createCodeFeature } from "./features/code/index.js"; import { createPingFeature } from "./features/ping/index.js"; import { createPrivacyFeature } from "./features/privacy/index.js"; import { createTexFeature } from "./features/tex/index.js"; @@ -25,7 +26,7 @@ function pendingFeature(id: FeatureId): FeatureFactory { // replaced by real factories in subsequent migration steps. const featureFactories: Record = { tex: createTexFeature, - code: pendingFeature("code"), + code: createCodeFeature, privacy: createPrivacyFeature, wolfram: pendingFeature("wolfram"), misc: pendingFeature("misc"), @@ -56,7 +57,7 @@ export async function bootstrap(): Promise { } const optOutUsers = new OptOutUsers(stateBackend); await optOutUsers.init(); - const featureDependencies = { optOutUsers }; + const featureDependencies = { optOutUsers, logger }; const client = new Client({ intents: [ diff --git a/discord/src/shared/i18n.ts b/discord/src/shared/i18n.ts index cebc844..a30e32d 100644 --- a/discord/src/shared/i18n.ts +++ b/discord/src/shared/i18n.ts @@ -32,6 +32,27 @@ const resources = { codePlaceholder: "Enter TeX code here", }, }, + code: { + supportedLanguagesHeading: "The following languages are supported", + noLanguagesAvailable: + "The language list is temporarily unavailable. Please try again later.", + resultHeading: "Result ({{compiler}})", + codeHeading: "Code", + modal: { + title: "Run code", + codeLabel: "Code", + codePlaceholder: "Write code here", + stdinLabel: "Standard Input", + }, + errors: { + heading: "Wandbox Error", + connection: "Could not connect to Wandbox. Please try again later.", + http: "Wandbox returned HTTP status {{status}}.", + nonJson: + "Wandbox returned a non-JSON response. Please check the service status and try again later.", + responsePreview: "Response Preview", + }, + }, }, }, ja: { @@ -65,6 +86,28 @@ const resources = { codePlaceholder: "TeX コードを入力してください", }, }, + code: { + supportedLanguagesHeading: "対応している言語", + noLanguagesAvailable: + "言語一覧を一時的に取得できません。しばらくしてからもう一度お試しください。", + resultHeading: "実行結果 ({{compiler}})", + codeHeading: "コード", + modal: { + title: "コードを実行", + codeLabel: "コード", + codePlaceholder: "コードを入力してください", + stdinLabel: "標準入力", + }, + errors: { + heading: "Wandbox エラー", + connection: + "Wandbox に接続できませんでした。しばらくしてからもう一度お試しください。", + http: "Wandbox が HTTP ステータス {{status}} を返しました。", + nonJson: + "Wandbox から JSON ではない応答が返されました。サービスの状態を確認して、しばらくしてからもう一度お試しください。", + responsePreview: "応答のプレビュー", + }, + }, }, }, } as const; diff --git a/discord/src/types.ts b/discord/src/types.ts index a648f39..4e0db2d 100644 --- a/discord/src/types.ts +++ b/discord/src/types.ts @@ -10,6 +10,7 @@ import type { SlashCommandBuilder, } from "discord.js"; import type { RequestContext } from "./platform/discord/context.js"; +import type { AppLogger } from "./shared/logger.js"; import type { OptOutUsers } from "./shared/state.js"; export type FeatureId = @@ -64,6 +65,7 @@ export interface Feature { export interface FeatureDependencies { readonly optOutUsers: OptOutUsers; + readonly logger: AppLogger; } export type FeatureFactory = (dependencies: FeatureDependencies) => Feature; From 9c3d8b218c26635ee2c7210484f7d36a3f83b369 Mon Sep 17 00:00:00 2001 From: Gakuto Furuya Date: Sat, 18 Jul 2026 14:05:46 +0900 Subject: [PATCH 06/15] Add wolfram, misc, and translate features --- discord/package.json | 2 + discord/pnpm-lock.yaml | 38 ++++ discord/src/env.test.ts | 23 ++ discord/src/env.ts | 16 ++ discord/src/features/misc/index.test.ts | 72 +++++++ discord/src/features/misc/index.ts | 119 ++++++++++ discord/src/features/translate/index.test.ts | 32 +++ discord/src/features/translate/index.ts | 203 ++++++++++++++++++ discord/src/features/wolfram/index.ts | 143 ++++++++++++ .../features/wolfram/wolframClient.test.ts | 56 +++++ discord/src/features/wolfram/wolframClient.ts | 103 +++++++++ discord/src/index.ts | 39 ++-- discord/src/platform/discord/paginator.ts | 127 +++++++++++ .../src/platform/discord/replyCoordinator.ts | 16 +- discord/src/shared/i18n.ts | 22 ++ discord/src/types.ts | 8 +- 16 files changed, 1004 insertions(+), 15 deletions(-) create mode 100644 discord/src/features/misc/index.test.ts create mode 100644 discord/src/features/misc/index.ts create mode 100644 discord/src/features/translate/index.test.ts create mode 100644 discord/src/features/translate/index.ts create mode 100644 discord/src/features/wolfram/index.ts create mode 100644 discord/src/features/wolfram/wolframClient.test.ts create mode 100644 discord/src/features/wolfram/wolframClient.ts create mode 100644 discord/src/platform/discord/paginator.ts diff --git a/discord/package.json b/discord/package.json index 2f36d10..689d25c 100644 --- a/discord/package.json +++ b/discord/package.json @@ -21,7 +21,9 @@ "discord.js": "^14.27.0", "dotenv": "^17.4.2", "i18next": "^26.3.6", + "iso-639-1": "^3.1.6", "mathjax-full": "^3.2.2", + "openai": "^6.48.0", "pino": "^10.3.1", "sharp": "^0.35.3" }, diff --git a/discord/pnpm-lock.yaml b/discord/pnpm-lock.yaml index 9ea7459..10ad49b 100644 --- a/discord/pnpm-lock.yaml +++ b/discord/pnpm-lock.yaml @@ -20,9 +20,15 @@ importers: i18next: specifier: ^26.3.6 version: 26.3.6(typescript@6.0.3) + iso-639-1: + specifier: ^3.1.6 + version: 3.1.6 mathjax-full: specifier: ^3.2.2 version: 3.2.2 + openai: + specifier: ^6.48.0 + version: 6.48.0(@aws-sdk/credential-provider-node@3.972.70)(@smithy/signature-v4@5.6.6)(ws@8.21.1) pino: specifier: ^10.3.1 version: 10.3.1 @@ -1199,6 +1205,10 @@ packages: resolution: {integrity: sha512-GI+WjezhPPcbM+tqE9LnmsY5qqjwHzTvjJ36wxYX5ujNXefSUJ/T17r5bqDV8yLhcgB59KTPNOc9O9cmHTPWsA==} engines: {node: '>=10'} + iso-639-1@3.1.6: + resolution: {integrity: sha512-ZFar/L4ngX7wZh2QX+Fiftmuf0igWJsrJtfizrovWifF1gAWkfmRa5Z1m0LQZbm0hKCHRDYhLRSLFrSqNe4EJA==} + engines: {node: '>=6.0'} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -1363,6 +1373,26 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + openai@6.48.0: + resolution: {integrity: sha512-KhVp+FyV50QrXNextvL9hIU5l6ox5HYuKQjGVk7lIqprgJol90+dQXWONV6S1lRWsKA1bXjrow8RsUT14M1hNA==} + peerDependencies: + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' + '@smithy/hash-node': '>=4.3.0 <5' + '@smithy/signature-v4': '>=5.4.0 <6' + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@aws-sdk/credential-provider-node': + optional: true + '@smithy/hash-node': + optional: true + '@smithy/signature-v4': + optional: true + ws: + optional: true + zod: + optional: true + ora@5.4.1: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} @@ -2860,6 +2890,8 @@ snapshots: is-url-superb@4.0.0: {} + iso-639-1@3.1.6: {} + json5@2.2.3: {} kleur@3.0.3: {} @@ -2993,6 +3025,12 @@ snapshots: dependencies: mimic-fn: 2.1.0 + openai@6.48.0(@aws-sdk/credential-provider-node@3.972.70)(@smithy/signature-v4@5.6.6)(ws@8.21.1): + optionalDependencies: + '@aws-sdk/credential-provider-node': 3.972.70 + '@smithy/signature-v4': 5.6.6 + ws: 8.21.1 + ora@5.4.1: dependencies: bl: 4.1.0 diff --git a/discord/src/env.test.ts b/discord/src/env.test.ts index 1f4335b..265beb9 100644 --- a/discord/src/env.test.ts +++ b/discord/src/env.test.ts @@ -4,6 +4,29 @@ import { loadEnv } from "./env.js"; const baseEnv = { CODERUNBOT_TOKEN: "token" }; describe("loadEnv state configuration", () => { + it("uses current default OpenAI models", () => { + expect(loadEnv(baseEnv)).toMatchObject({ + openAIChatModel: "gpt-5.2", + openAIChatModelLite: "gpt-5-mini", + openAITranslateModel: "gpt-5-mini", + }); + }); + + it("loads optional gaato feature credentials", () => { + expect( + loadEnv({ + GAATO_BOT: "1", + GAATO_BOT_TOKEN: "token", + WOLFRAM_APPID: "wolfram", + OPENAI_API_KEY: "openai", + }), + ).toMatchObject({ + botName: "gaato-bot", + wolframAppId: "wolfram", + openAIApiKey: "openai", + }); + }); + it("uses the local state file by default", () => { expect(loadEnv(baseEnv).state).toEqual({ backend: "local", diff --git a/discord/src/env.ts b/discord/src/env.ts index 20efd4e..ff8deae 100644 --- a/discord/src/env.ts +++ b/discord/src/env.ts @@ -26,12 +26,20 @@ export interface Env { readonly logChannelId: string; readonly developerId: string; readonly supportServerLink: string; + readonly wolframAppId?: string; + readonly openAIApiKey?: string; + readonly openAIChatModel: string; + readonly openAIChatModelLite: string; + readonly openAITranslateModel: string; readonly state: StateEnv; } const DEFAULT_LOG_CHANNEL_ID = "1118867011448078417"; const DEFAULT_DEVELOPER_ID = "572432137035317249"; const DEFAULT_SUPPORT_SERVER_LINK = "discord.gg/qRpYRTgvXM"; +const DEFAULT_OPENAI_CHAT_MODEL = "gpt-5.2"; +const DEFAULT_OPENAI_CHAT_MODEL_LITE = "gpt-5-mini"; +const DEFAULT_OPENAI_TRANSLATE_MODEL = "gpt-5-mini"; function nonEmpty(value: string | undefined): string | undefined { return value === undefined || value.trim().length === 0 ? undefined : value; @@ -95,6 +103,14 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { developerId: nonEmpty(source.DEVELOPER_ID) ?? DEFAULT_DEVELOPER_ID, supportServerLink: nonEmpty(source.SUPPORT_SERVER_LINK) ?? DEFAULT_SUPPORT_SERVER_LINK, + wolframAppId: nonEmpty(source.WOLFRAM_APPID), + openAIApiKey: nonEmpty(source.OPENAI_API_KEY), + openAIChatModel: + nonEmpty(source.OPENAI_CHAT_MODEL) ?? DEFAULT_OPENAI_CHAT_MODEL, + openAIChatModelLite: + nonEmpty(source.OPENAI_CHAT_MODEL_LITE) ?? DEFAULT_OPENAI_CHAT_MODEL_LITE, + openAITranslateModel: + nonEmpty(source.OPENAI_TRANSLATE_MODEL) ?? DEFAULT_OPENAI_TRANSLATE_MODEL, state: loadStateEnv(source), }; } diff --git a/discord/src/features/misc/index.test.ts b/discord/src/features/misc/index.test.ts new file mode 100644 index 0000000..ec57937 --- /dev/null +++ b/discord/src/features/misc/index.test.ts @@ -0,0 +1,72 @@ +import type { Message } from "discord.js"; +import { describe, expect, it, vi } from "vitest"; +import type { FeatureDependencies } from "../../types.js"; +import { + type ChatCompletionClient, + createMiscFeature, + MentionRateLimiter, + SHORT_REPLY_SYSTEM_PROMPT, +} from "./index.js"; + +describe("MentionRateLimiter", () => { + it("allows three messages per user in sixty seconds", () => { + const limiter = new MentionRateLimiter(); + expect(limiter.allow("user", 0)).toBe(true); + expect(limiter.allow("user", 1)).toBe(true); + expect(limiter.allow("user", 2)).toBe(true); + expect(limiter.allow("user", 3)).toBe(false); + expect(limiter.allow("user", 60_000)).toBe(true); + }); +}); + +describe("misc mention response", () => { + it("filters opted-out history and uses the mocked completion", async () => { + const fetched = new Map([ + ["3", { author: { id: "caller" }, content: "<@bot> hello" }], + ["2", { author: { id: "opted-out" }, content: "private" }], + ["1", { author: { id: "bot" }, content: "earlier reply" }], + ]); + const fetch = vi.fn().mockResolvedValue(fetched); + const sendTyping = vi.fn().mockResolvedValue(undefined); + const reply = vi.fn().mockResolvedValue(undefined); + const hasMention = vi.fn().mockReturnValue(true); + const message = { + author: { id: "caller", bot: false }, + mentions: { has: hasMention }, + channel: { messages: { fetch }, sendTyping }, + reply, + } as unknown as Message; + const create = vi.fn().mockResolvedValue({ + choices: [{ message: { content: "short answer" } }], + }); + const completion = { create } as ChatCompletionClient; + const dependencies = { + client: { user: { id: "bot" } }, + optOutUsers: { has: (id: string) => id === "opted-out" }, + env: { + openAIApiKey: "key", + developerId: "developer", + openAIChatModel: "full-model", + openAIChatModelLite: "lite-model", + }, + } as unknown as FeatureDependencies; + + const feature = createMiscFeature(dependencies, completion); + await feature?.onMessage?.(message, {} as never); + + expect(hasMention).toHaveBeenCalledWith(dependencies.client.user); + expect(fetch).toHaveBeenCalledWith({ limit: 10 }); + expect(create).toHaveBeenCalledWith({ + model: "lite-model", + messages: [ + { role: "system", content: SHORT_REPLY_SYSTEM_PROMPT }, + { role: "assistant", content: "earlier reply" }, + { role: "user", content: "<@bot> hello" }, + ], + }); + expect(reply).toHaveBeenCalledWith({ + content: "short answer", + allowedMentions: { parse: [], repliedUser: true }, + }); + }); +}); diff --git a/discord/src/features/misc/index.ts b/discord/src/features/misc/index.ts new file mode 100644 index 0000000..0d4b5f9 --- /dev/null +++ b/discord/src/features/misc/index.ts @@ -0,0 +1,119 @@ +import type { Message } from "discord.js"; +import OpenAI from "openai"; +import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; +import type { OptOutUsers } from "../../shared/state.js"; +import type { Feature, FeatureDependencies } from "../../types.js"; + +export const DEVELOPER_SYSTEM_PROMPT = + "これはDiscordでのチャットです。" + + "以下の様々なユーザーによる直近のメッセージ履歴を参考に、" + + "あなたがメンションされている最後のメッセージに返信してください。"; + +export const SHORT_REPLY_SYSTEM_PROMPT = + "これはDiscordのチャットです。" + + "以下は直近のメッセージ履歴です。" + + "一言で返信してください。"; + +const RATE_LIMIT_WINDOW_MS = 60_000; +const RATE_LIMIT_COUNT = 3; + +export class MentionRateLimiter { + readonly #mentions = new Map(); + + allow(userId: string, now = Date.now()): boolean { + const recent = (this.#mentions.get(userId) ?? []).filter( + (timestamp) => now - timestamp < RATE_LIMIT_WINDOW_MS, + ); + if (recent.length >= RATE_LIMIT_COUNT) { + this.#mentions.set(userId, recent); + return false; + } + recent.push(now); + this.#mentions.set(userId, recent); + return true; + } +} + +export interface ChatCompletionClient { + create(options: { + readonly model: string; + readonly messages: readonly ChatCompletionMessageParam[]; + }): Promise<{ + readonly choices: readonly { + readonly message: { readonly content: string | null }; + }[]; + }>; +} + +export async function fetchRecentHistory( + message: Message, + botUserId: string, + optOutUsers: Pick, + limit = 10, +): Promise { + const fetched = await message.channel.messages.fetch({ limit }); + return [...fetched.values()] + .reverse() + .filter((entry) => !optOutUsers.has(entry.author.id)) + .map((entry) => ({ + role: entry.author.id === botUserId ? "assistant" : "user", + content: entry.content, + })); +} + +export function createMiscFeature( + dependencies: FeatureDependencies, + completionClient?: ChatCompletionClient, +): Feature | undefined { + if (dependencies.env.openAIApiKey === undefined) { + return undefined; + } + const completion = + completionClient ?? + new OpenAI({ apiKey: dependencies.env.openAIApiKey }).chat.completions; + const limiter = new MentionRateLimiter(); + + return { + id: "misc", + async onMessage(message) { + const botUser = dependencies.client.user; + if ( + message.author.bot || + botUser === null || + !message.mentions.has(botUser) || + !limiter.allow(message.author.id) + ) { + return undefined; + } + + if ("sendTyping" in message.channel) { + await message.channel.sendTyping(); + } + const history = await fetchRecentHistory( + message, + botUser.id, + dependencies.optOutUsers, + ); + const isDeveloper = message.author.id === dependencies.env.developerId; + const response = await completion.create({ + model: isDeveloper + ? dependencies.env.openAIChatModel + : dependencies.env.openAIChatModelLite, + messages: [ + { + role: "system", + content: isDeveloper + ? DEVELOPER_SYSTEM_PROMPT + : SHORT_REPLY_SYSTEM_PROMPT, + }, + ...history, + ], + }); + await message.reply({ + content: response.choices[0]?.message.content ?? "", + allowedMentions: { parse: [], repliedUser: true }, + }); + return undefined; + }, + }; +} diff --git a/discord/src/features/translate/index.test.ts b/discord/src/features/translate/index.test.ts new file mode 100644 index 0000000..c6e405b --- /dev/null +++ b/discord/src/features/translate/index.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { autocompleteLanguages, resolveLanguage } from "./index.js"; + +describe("autocompleteLanguages", () => { + it("returns no choices for one character", () => { + expect(autocompleteLanguages("j")).toEqual([]); + }); + + it("resolves a two-letter code to its language name", () => { + expect(autocompleteLanguages("ja")).toEqual([ + { name: "Japanese", value: "ja" }, + ]); + }); + + it("never exceeds Discord's 25-choice limit", () => { + const names = Array.from( + { length: 30 }, + (_, index) => `Test language ${index}`, + ); + expect(autocompleteLanguages("test", names)).toHaveLength(25); + }); +}); + +describe("resolveLanguage", () => { + it("accepts both codes and language names", () => { + expect(resolveLanguage("ja")).toEqual({ code: "ja", name: "Japanese" }); + expect(resolveLanguage("Japanese")).toEqual({ + code: "ja", + name: "Japanese", + }); + }); +}); diff --git a/discord/src/features/translate/index.ts b/discord/src/features/translate/index.ts new file mode 100644 index 0000000..29e5209 --- /dev/null +++ b/discord/src/features/translate/index.ts @@ -0,0 +1,203 @@ +import { + type AttachmentBuilder, + ContainerBuilder, + FileBuilder, + SlashCommandBuilder, + TextDisplayBuilder, +} from "discord.js"; +import ISO6391 from "iso-639-1"; +import OpenAI from "openai"; +import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; +import { + SUCCESS_ACCENT, + textComponent, +} from "../../platform/discord/components.js"; +import { getFixedT } from "../../shared/i18n.js"; +import type { + Feature, + FeatureDependencies, + OutgoingReply, +} from "../../types.js"; + +export interface LanguageChoice { + readonly name: string; + readonly value: string; +} + +export interface ResolvedLanguage { + readonly code: string; + readonly name: string; +} + +export function autocompleteLanguages( + input: string, + languageNames: readonly string[] = ISO6391.getAllNames(), +): LanguageChoice[] { + const normalized = input.trim().toLowerCase(); + if (normalized.length <= 1) { + return []; + } + if (normalized.length === 2) { + const name = ISO6391.getName(normalized); + return name.length === 0 ? [] : [{ name, value: normalized }]; + } + + return languageNames + .filter((name) => name.toLowerCase().startsWith(normalized)) + .sort( + (left, right) => left.length - right.length || left.localeCompare(right), + ) + .slice(0, 25) + .map((name) => ({ name, value: ISO6391.getCode(name) })); +} + +export function resolveLanguage(input: string): ResolvedLanguage | undefined { + const normalized = input.trim().toLowerCase(); + const code = ISO6391.validate(normalized) + ? normalized + : ISO6391.getCode(input.trim()); + if (code.length === 0) { + return undefined; + } + return { code, name: ISO6391.getName(code) }; +} + +interface TranslationCompletionClient { + create(options: { + readonly model: string; + readonly messages: readonly ChatCompletionMessageParam[]; + }): Promise<{ + readonly choices: readonly { + readonly message: { readonly content: string | null }; + }[]; + }>; +} + +export function createTranslateFeature( + dependencies: FeatureDependencies, + completionClient?: TranslationCompletionClient, +): Feature | undefined { + if (dependencies.env.openAIApiKey === undefined) { + return undefined; + } + const completion = + completionClient ?? + new OpenAI({ apiKey: dependencies.env.openAIApiKey }).chat.completions; + const command = new SlashCommandBuilder() + .setName("translate") + .setDescription("Translate text") + .setDescriptionLocalizations({ ja: "テキストを翻訳します" }); + command.addStringOption((option) => + option + .setName("text") + .setDescription("Text to translate") + .setDescriptionLocalizations({ ja: "翻訳するテキスト" }) + .setRequired(true), + ); + command.addStringOption((option) => + option + .setName("to") + .setDescription("Language to translate to") + .setDescriptionLocalizations({ ja: "翻訳先の言語" }) + .setRequired(true) + .setAutocomplete(true), + ); + + return { + id: "translate", + slashCommands: [ + { + data: command, + async autocomplete(interaction) { + await interaction.respond( + autocompleteLanguages(String(interaction.options.getFocused())), + ); + }, + async execute(interaction, context) { + if (!interaction.isChatInputCommand()) { + return undefined; + } + const t = getFixedT(context.locale); + const language = resolveLanguage( + interaction.options.getString("to", true), + ); + if (language === undefined) { + return { + kind: "plain", + content: t("translate.invalidLanguage"), + ephemeral: true, + }; + } + + const original = interaction.options.getString("text", true); + await interaction.deferReply(); + const response = await completion.create({ + model: dependencies.env.openAITranslateModel, + messages: [ + { + role: "system", + content: + "This is a direct translation task. " + + `Translate the following text to ${language.name}. ` + + "Do not add any additional comments or language indicators.", + }, + { role: "user", content: original }, + ], + }); + return translationReply( + original, + response.choices[0]?.message.content ?? "", + language.name, + context.locale, + ); + }, + }, + ], + }; +} + +function addSection( + container: ContainerBuilder, + files: AttachmentBuilder[], + heading: string, + content: string, + filename: string, +): void { + const rendered = textComponent(content, filename); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(`### ${heading}`), + ); + if (rendered.component instanceof FileBuilder) { + container.addFileComponents(rendered.component); + } else { + rendered.component.setContent(content.length === 0 ? "\u200b" : content); + container.addTextDisplayComponents(rendered.component); + } + files.push(...rendered.files); +} + +function translationReply( + original: string, + translated: string, + languageName: string, + locale: string, +): OutgoingReply { + const t = getFixedT(locale); + const container = new ContainerBuilder().setAccentColor(SUCCESS_ACCENT); + const files: AttachmentBuilder[] = []; + addSection( + container, + files, + t("translate.originalHeading"), + original, + "original.txt", + ); + addSection( + container, + files, + t("translate.translatedHeading", { language: languageName }), + translated, + "translated.txt", + ); + return { kind: "components-v2", components: [container], files }; +} diff --git a/discord/src/features/wolfram/index.ts b/discord/src/features/wolfram/index.ts new file mode 100644 index 0000000..d901520 --- /dev/null +++ b/discord/src/features/wolfram/index.ts @@ -0,0 +1,143 @@ +import { + ContainerBuilder, + MediaGalleryBuilder, + MediaGalleryItemBuilder, + SlashCommandBuilder, + TextDisplayBuilder, +} from "discord.js"; +import { + FAILURE_ACCENT, + SUCCESS_ACCENT, +} from "../../platform/discord/components.js"; +import { paginatedReply } from "../../platform/discord/paginator.js"; +import { getFixedT } from "../../shared/i18n.js"; +import type { + Feature, + FeatureDependencies, + OutgoingReply, +} from "../../types.js"; +import { WolframClient, type WolframQueryResult } from "./wolframClient.js"; + +export function createWolframFeature( + dependencies: FeatureDependencies, +): Feature | undefined { + if (dependencies.env.wolframAppId === undefined) { + return undefined; + } + const client = new WolframClient(dependencies.env.wolframAppId); + const slashCommand = new SlashCommandBuilder() + .setName("wolf") + .setDescription("Query Wolfram|Alpha") + .setDescriptionLocalizations({ ja: "Wolfram|Alpha に問い合わせます" }); + slashCommand.addStringOption((option) => + option + .setName("query") + .setDescription("Query to send to Wolfram|Alpha") + .setDescriptionLocalizations({ + ja: "Wolfram|Alpha に送信するクエリ", + }) + .setRequired(true), + ); + + const execute = async ( + query: string, + userId: string, + locale: string, + ): Promise => { + const result = await client.query(query); + return wolframReply(result, userId, locale, dependencies); + }; + + return { + id: "wolfram", + prefixCommands: [ + { + name: "wolf", + aliases: ["wolfram"], + execute(_message, args, context) { + return execute(args, context.userId, context.locale); + }, + }, + ], + slashCommands: [ + { + data: slashCommand, + async execute(interaction, context) { + if (!interaction.isChatInputCommand()) { + return undefined; + } + await interaction.deferReply(); + return execute( + interaction.options.getString("query", true), + context.userId, + context.locale, + ); + }, + }, + ], + }; +} + +function wolframReply( + result: WolframQueryResult, + userId: string, + locale: string, + dependencies: Pick, +): OutgoingReply { + const t = getFixedT(locale); + if (!result.success) { + return { + kind: "components-v2", + components: [ + new ContainerBuilder() + .setAccentColor(FAILURE_ACCENT) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `### ${t("wolfram.errorHeading")}\n${t("wolfram.notUnderstood")}`, + ), + ), + ], + }; + } + + const pages = result.pods.flatMap((pod) => + pod.subpods.map((subpod) => { + const container = new ContainerBuilder() + .setAccentColor(SUCCESS_ACCENT) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent(`## ${pod.title}`), + ); + if (subpod.plaintext.length > 0) { + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(subpod.plaintext), + ); + } + if (subpod.img !== undefined) { + container.addMediaGalleryComponents( + new MediaGalleryBuilder().addItems( + new MediaGalleryItemBuilder().setURL(subpod.img.src), + ), + ); + } + return container; + }), + ); + + if (pages.length === 0) { + return { + kind: "components-v2", + components: [ + new ContainerBuilder() + .setAccentColor(FAILURE_ACCENT) + .addTextDisplayComponents( + new TextDisplayBuilder().setContent(t("wolfram.noResults")), + ), + ], + }; + } + return paginatedReply({ + pages, + userId, + logger: dependencies.logger, + }); +} diff --git a/discord/src/features/wolfram/wolframClient.test.ts b/discord/src/features/wolfram/wolframClient.test.ts new file mode 100644 index 0000000..5403061 --- /dev/null +++ b/discord/src/features/wolfram/wolframClient.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { parseWolframResponse, WolframResponseError } from "./wolframClient.js"; + +describe("parseWolframResponse", () => { + it("returns typed pods and subpods", () => { + expect( + parseWolframResponse({ + queryresult: { + success: true, + pods: [ + { + title: "Result", + subpods: [ + { + plaintext: "42", + img: { src: "https://example.invalid/result.png" }, + }, + ], + }, + ], + }, + }), + ).toEqual({ + success: true, + pods: [ + { + title: "Result", + subpods: [ + { + plaintext: "42", + img: { src: "https://example.invalid/result.png" }, + }, + ], + }, + ], + }); + }); + + it("accepts an unsuccessful query without pods", () => { + expect(parseWolframResponse({ queryresult: { success: false } })).toEqual({ + success: false, + pods: [], + }); + }); + + it("rejects malformed nested data", () => { + expect(() => + parseWolframResponse({ + queryresult: { + success: true, + pods: [{ title: "Result", subpods: [{ plaintext: 42 }] }], + }, + }), + ).toThrow(WolframResponseError); + }); +}); diff --git a/discord/src/features/wolfram/wolframClient.ts b/discord/src/features/wolfram/wolframClient.ts new file mode 100644 index 0000000..47aebec --- /dev/null +++ b/discord/src/features/wolfram/wolframClient.ts @@ -0,0 +1,103 @@ +const WOLFRAM_QUERY_URL = "http://api.wolframalpha.com/v2/query"; + +export interface WolframImage { + readonly src: string; +} + +export interface WolframSubpod { + readonly plaintext: string; + readonly img?: WolframImage; +} + +export interface WolframPod { + readonly title: string; + readonly subpods: readonly WolframSubpod[]; +} + +export interface WolframQueryResult { + readonly success: boolean; + readonly pods: readonly WolframPod[]; +} + +export class WolframResponseError extends Error { + constructor(message: string) { + super(message); + this.name = "WolframResponseError"; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseSubpod(value: unknown): WolframSubpod { + if (!isRecord(value) || typeof value.plaintext !== "string") { + throw new WolframResponseError("invalid Wolfram|Alpha subpod"); + } + if (value.img === undefined) { + return { plaintext: value.plaintext }; + } + if (!isRecord(value.img) || typeof value.img.src !== "string") { + throw new WolframResponseError("invalid Wolfram|Alpha subpod image"); + } + return { plaintext: value.plaintext, img: { src: value.img.src } }; +} + +function parsePod(value: unknown): WolframPod { + if ( + !isRecord(value) || + typeof value.title !== "string" || + !Array.isArray(value.subpods) + ) { + throw new WolframResponseError("invalid Wolfram|Alpha pod"); + } + return { + title: value.title, + subpods: value.subpods.map(parseSubpod), + }; +} + +export function parseWolframResponse(value: unknown): WolframQueryResult { + if (!isRecord(value) || !isRecord(value.queryresult)) { + throw new WolframResponseError("missing Wolfram|Alpha queryresult"); + } + const queryResult = value.queryresult; + if (typeof queryResult.success !== "boolean") { + throw new WolframResponseError("invalid Wolfram|Alpha success flag"); + } + if (queryResult.pods === undefined && !queryResult.success) { + return { success: false, pods: [] }; + } + if (!Array.isArray(queryResult.pods)) { + throw new WolframResponseError("invalid Wolfram|Alpha pods"); + } + return { + success: queryResult.success, + pods: queryResult.pods.map(parsePod), + }; +} + +export class WolframClient { + readonly #appId: string; + readonly #fetch: typeof fetch; + + constructor(appId: string, fetchImplementation: typeof fetch = fetch) { + this.#appId = appId; + this.#fetch = fetchImplementation; + } + + async query(input: string): Promise { + const url = new URL(WOLFRAM_QUERY_URL); + url.search = new URLSearchParams({ + input, + format: "image,plaintext", + output: "JSON", + appid: this.#appId, + }).toString(); + const response = await this.#fetch(url); + if (!response.ok) { + throw new Error(`Wolfram|Alpha returned HTTP status ${response.status}`); + } + return parseWolframResponse(await response.json()); + } +} diff --git a/discord/src/index.ts b/discord/src/index.ts index 03ab96e..7bdb5c7 100644 --- a/discord/src/index.ts +++ b/discord/src/index.ts @@ -2,9 +2,12 @@ import { Client, GatewayIntentBits, Partials, REST } from "discord.js"; import { getBotProfile } from "./config.js"; import { loadEnv } from "./env.js"; import { createCodeFeature } from "./features/code/index.js"; +import { createMiscFeature } from "./features/misc/index.js"; import { createPingFeature } from "./features/ping/index.js"; import { createPrivacyFeature } from "./features/privacy/index.js"; import { createTexFeature } from "./features/tex/index.js"; +import { createTranslateFeature } from "./features/translate/index.js"; +import { createWolframFeature } from "./features/wolfram/index.js"; import { ErrorPresenter } from "./platform/discord/errorPresenter.js"; import { ReplyCoordinator } from "./platform/discord/replyCoordinator.js"; import { DiscordRouter } from "./platform/discord/router.js"; @@ -16,21 +19,15 @@ import { S3ObjectState, type StateBackend, } from "./shared/state.js"; -import type { FeatureFactory, FeatureId } from "./types.js"; +import type { Feature, FeatureFactory, FeatureId } from "./types.js"; -function pendingFeature(id: FeatureId): FeatureFactory { - return (_dependencies) => ({ id }); -} - -// This is the only FeatureId -> concrete factory mapping. The no-op entries are -// replaced by real factories in subsequent migration steps. const featureFactories: Record = { tex: createTexFeature, code: createCodeFeature, privacy: createPrivacyFeature, - wolfram: pendingFeature("wolfram"), - misc: pendingFeature("misc"), - translate: pendingFeature("translate"), + wolfram: createWolframFeature, + misc: createMiscFeature, + translate: createTranslateFeature, ping: createPingFeature, }; @@ -57,8 +54,6 @@ export async function bootstrap(): Promise { } const optOutUsers = new OptOutUsers(stateBackend); await optOutUsers.init(); - const featureDependencies = { optOutUsers, logger }; - const client = new Client({ intents: [ GatewayIntentBits.Guilds, @@ -68,11 +63,29 @@ export async function bootstrap(): Promise { ], partials: [Partials.Channel, Partials.Message], }); + const featureDependencies = { optOutUsers, logger, env, client }; + if (env.botName === "gaato-bot") { + if (env.wolframAppId === undefined) { + logger.warn( + { envName: "WOLFRAM_APPID", feature: "wolfram" }, + "gaato-bot feature disabled because an environment variable is missing", + ); + } + if (env.openAIApiKey === undefined) { + logger.warn( + { + envName: "OPENAI_API_KEY", + features: ["misc", "translate"], + }, + "gaato-bot features disabled because an environment variable is missing", + ); + } + } const rest = new REST({ version: "10" }).setToken(env.token); const features = [ ...profile.features.map((id) => featureFactories[id](featureDependencies)), featureFactories.ping(featureDependencies), - ]; + ].filter((feature): feature is Feature => feature !== undefined); for (const feature of features) { await feature.init?.(); diff --git a/discord/src/platform/discord/paginator.ts b/discord/src/platform/discord/paginator.ts new file mode 100644 index 0000000..ef75920 --- /dev/null +++ b/discord/src/platform/discord/paginator.ts @@ -0,0 +1,127 @@ +import { randomUUID } from "node:crypto"; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ComponentType, + type ContainerBuilder, + type Message, + MessageFlags, +} from "discord.js"; +import type { AppLogger } from "../../shared/logger.js"; +import type { OutgoingReply } from "../../types.js"; +import { deleteButtonRow } from "./components.js"; + +export const PAGINATOR_TIMEOUT_MS = 10 * 60 * 1_000; + +interface StoppableCollector { + stop(reason?: string): void; +} + +const activeCollectors = new WeakMap(); + +export function cancelPaginator(message: Message): void { + activeCollectors.get(message)?.stop("replaced"); + activeCollectors.delete(message); +} + +export interface PaginatorOptions { + readonly pages: readonly ContainerBuilder[]; + readonly userId: string; + readonly logger: AppLogger; + readonly timeoutMs?: number; +} + +function navigationRow( + token: string, + pageIndex: number, + pageCount: number, + disabled = false, +): ActionRowBuilder { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`pg:${token}:previous`) + .setLabel("◀") + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled || pageIndex === 0), + new ButtonBuilder() + .setCustomId(`pg:${token}:page`) + .setLabel(`${pageIndex + 1} / ${pageCount}`) + .setStyle(ButtonStyle.Secondary) + .setDisabled(true), + new ButtonBuilder() + .setCustomId(`pg:${token}:next`) + .setLabel("▶") + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled || pageIndex === pageCount - 1), + ); +} + +export function paginatedReply(options: PaginatorOptions): OutgoingReply { + if (options.pages.length === 0) { + throw new Error("paginator requires at least one page"); + } + + const token = randomUUID(); + let pageIndex = 0; + const messageComponents = (disabled = false) => [ + options.pages[pageIndex] as ContainerBuilder, + navigationRow(token, pageIndex, options.pages.length, disabled), + deleteButtonRow(options.userId), + ]; + + return { + kind: "components-v2", + components: [ + options.pages[0] as ContainerBuilder, + navigationRow(token, 0, options.pages.length), + ], + async onDelivered(message) { + const collector = message.createMessageComponentCollector({ + componentType: ComponentType.Button, + filter: (interaction) => + interaction.customId.startsWith(`pg:${token}:`), + time: options.timeoutMs ?? PAGINATOR_TIMEOUT_MS, + }); + activeCollectors.set(message, collector); + + collector.on("collect", (interaction) => { + void (async () => { + if (interaction.user.id !== options.userId) { + await interaction.deferUpdate(); + return; + } + + if (interaction.customId.endsWith(":previous")) { + pageIndex = Math.max(0, pageIndex - 1); + } else if (interaction.customId.endsWith(":next")) { + pageIndex = Math.min(options.pages.length - 1, pageIndex + 1); + } + await interaction.update({ components: messageComponents() }); + })().catch((error: unknown) => { + options.logger.warn({ error }, "Paginator interaction failed"); + }); + }); + + collector.on("end", (_collected, reason) => { + if (reason === "replaced") { + return; + } + if (activeCollectors.get(message) === collector) { + activeCollectors.delete(message); + } + void message + .edit({ + components: messageComponents(true), + flags: MessageFlags.IsComponentsV2, + }) + .catch((error: unknown) => { + options.logger.debug( + { error }, + "Unable to disable expired paginator", + ); + }); + }); + }, + }; +} diff --git a/discord/src/platform/discord/replyCoordinator.ts b/discord/src/platform/discord/replyCoordinator.ts index ed7769a..b00b661 100644 --- a/discord/src/platform/discord/replyCoordinator.ts +++ b/discord/src/platform/discord/replyCoordinator.ts @@ -11,6 +11,7 @@ import { LimitedSizeMap } from "../../shared/limitedMap.js"; import type { FeatureId, OutgoingReply } from "../../types.js"; import { deleteButtonRow } from "./components.js"; import type { ReplyTarget } from "./context.js"; +import { cancelPaginator } from "./paginator.js"; export interface EditableReply { edit(options: MessageEditOptions): Promise; @@ -150,7 +151,8 @@ export class ReplyCoordinator { request.sourceMessageId === undefined || request.generation === undefined ) { - await this.#transport.send(request.target, payload); + const reply = await this.#transport.send(request.target, payload); + await this.#afterDelivery(request.outgoing, reply); return "sent"; } @@ -169,6 +171,7 @@ export class ReplyCoordinator { ); } await this.#transport.edit(state.reply, payload); + await this.#afterDelivery(request.outgoing, state.reply); return "edited"; } @@ -184,6 +187,7 @@ export class ReplyCoordinator { reply, kind: request.outgoing.kind, }); + await this.#afterDelivery(request.outgoing, reply); return "sent"; } @@ -226,4 +230,14 @@ export class ReplyCoordinator { replaceAttachments: true, }; } + + async #afterDelivery( + outgoing: OutgoingReply, + reply: EditableReply, + ): Promise { + if (reply instanceof Message) { + cancelPaginator(reply); + await outgoing.onDelivered?.(reply); + } + } } diff --git a/discord/src/shared/i18n.ts b/discord/src/shared/i18n.ts index a30e32d..dda51bd 100644 --- a/discord/src/shared/i18n.ts +++ b/discord/src/shared/i18n.ts @@ -53,6 +53,17 @@ const resources = { responsePreview: "Response Preview", }, }, + wolfram: { + errorHeading: "Wolfram|Alpha Error", + notUnderstood: "Wolfram|Alpha could not understand that input.", + noResults: "Wolfram|Alpha returned no result pages.", + }, + translate: { + invalidLanguage: + "Invalid language. Enter an ISO 639-1 code or language name.", + originalHeading: "Original", + translatedHeading: "Translated to {{language}}", + }, }, }, ja: { @@ -108,6 +119,17 @@ const resources = { responsePreview: "応答のプレビュー", }, }, + wolfram: { + errorHeading: "Wolfram|Alpha エラー", + notUnderstood: "Wolfram|Alpha は、その入力を理解できませんでした。", + noResults: "Wolfram|Alpha から結果ページが返されませんでした。", + }, + translate: { + invalidLanguage: + "無効な言語です。ISO 639-1 の2文字コードまたは言語名を入力してください。", + originalHeading: "原文", + translatedHeading: "翻訳結果({{language}})", + }, }, }, } as const; diff --git a/discord/src/types.ts b/discord/src/types.ts index 4e0db2d..dd47d06 100644 --- a/discord/src/types.ts +++ b/discord/src/types.ts @@ -9,6 +9,7 @@ import type { ModalSubmitInteraction, SlashCommandBuilder, } from "discord.js"; +import type { Env } from "./env.js"; import type { RequestContext } from "./platform/discord/context.js"; import type { AppLogger } from "./shared/logger.js"; import type { OptOutUsers } from "./shared/state.js"; @@ -66,13 +67,18 @@ export interface Feature { export interface FeatureDependencies { readonly optOutUsers: OptOutUsers; readonly logger: AppLogger; + readonly env: Env; + readonly client: import("discord.js").Client; } -export type FeatureFactory = (dependencies: FeatureDependencies) => Feature; +export type FeatureFactory = ( + dependencies: FeatureDependencies, +) => Feature | undefined; interface ReplyBase { readonly files?: BaseMessageOptions["files"]; readonly ephemeral?: boolean; + readonly onDelivered?: (message: Message) => void | Promise; } export interface ComponentsV2Reply extends ReplyBase { From 4b2162f001ea8fc44d5076d2853f0efa839f94e6 Mon Sep 17 00:00:00 2001 From: Gakuto Furuya Date: Sat, 18 Jul 2026 14:14:25 +0900 Subject: [PATCH 07/15] Containerize Node bot, update CI, and remove Python and tex assets --- .github/workflows/ci.yml | 48 + .github/workflows/codeql-analysis.yml | 62 +- .github/workflows/container-images.yml | 18 +- README.md | 35 +- coderunbot.env.example | 5 - compose.yaml | 5 - discord/.dockerignore | 5 - discord/Containerfile | 41 +- discord/Dockerfile | 41 +- discord/bots/__init__.py | 44 - discord/bots/__main__.py | 31 - discord/bots/cogs/Code.py | 333 ----- discord/bots/cogs/Misc.py | 138 -- discord/bots/cogs/Privacy.py | 43 - discord/bots/cogs/TeX.py | 161 --- discord/bots/cogs/Translate.py | 121 -- discord/bots/cogs/Wolfram.py | 105 -- discord/bots/cogs/__init__.py | 0 discord/bots/config/privacy-policy.md | 35 - discord/bots/core/__init__.py | 0 discord/bots/core/bot.py | 167 --- discord/bots/core/state.py | 126 -- discord/bots/data/.gitignore | 2 - discord/pyproject.toml | 18 - discord/uv.lock | 1015 --------------- tex/.gitignore | 130 -- tex/Dockerfile | 14 - tex/README-ja.md | 43 - tex/README.md | 43 - tex/package-lock.json | 1628 ------------------------ tex/package.json | 9 - tex/server.js | 99 -- 32 files changed, 140 insertions(+), 4425 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 discord/bots/__init__.py delete mode 100644 discord/bots/__main__.py delete mode 100644 discord/bots/cogs/Code.py delete mode 100644 discord/bots/cogs/Misc.py delete mode 100644 discord/bots/cogs/Privacy.py delete mode 100644 discord/bots/cogs/TeX.py delete mode 100644 discord/bots/cogs/Translate.py delete mode 100644 discord/bots/cogs/Wolfram.py delete mode 100644 discord/bots/cogs/__init__.py delete mode 100644 discord/bots/config/privacy-policy.md delete mode 100644 discord/bots/core/__init__.py delete mode 100644 discord/bots/core/bot.py delete mode 100644 discord/bots/core/state.py delete mode 100644 discord/bots/data/.gitignore delete mode 100644 discord/pyproject.toml delete mode 100644 discord/uv.lock delete mode 100644 tex/.gitignore delete mode 100644 tex/Dockerfile delete mode 100644 tex/README-ja.md delete mode 100644 tex/README.md delete mode 100644 tex/package-lock.json delete mode 100644 tex/package.json delete mode 100644 tex/server.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7791ee9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + push: + branches: [main] + paths: + - "discord/**" + pull_request: + branches: [main] + paths: + - "discord/**" + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + working-directory: discord + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: 24 + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm typecheck + + - name: Lint + run: pnpm lint + + - name: Test + run: pnpm test diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 0df1655..e41918c 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,24 +1,12 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" +name: CodeQL on: push: - branches: [ main ] + branches: [main] pull_request: - # The branches below must be a subset of the branches above - branches: [ main ] + branches: [main] schedule: - - cron: '28 0 * * 0' + - cron: "28 0 * * 0" jobs: analyze: @@ -32,39 +20,19 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'python' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] - # Learn more about CodeQL language support at https://git.io/codeql-language-support + language: ['javascript-typescript'] steps: - - name: Checkout repository - uses: actions/checkout@v3 + - name: Checkout repository + uses: actions/checkout@v7 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 + - name: Autobuild + uses: github/codeql-action/autobuild@v4 - # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 diff --git a/.github/workflows/container-images.yml b/.github/workflows/container-images.yml index 0d8827c..aff6be3 100644 --- a/.github/workflows/container-images.yml +++ b/.github/workflows/container-images.yml @@ -7,7 +7,6 @@ on: paths: - ".github/workflows/container-images.yml" - "discord/**" - - "tex/**" - "README.md" - "coderunbot.env.example" pull_request: @@ -16,7 +15,6 @@ on: paths: - ".github/workflows/container-images.yml" - "discord/**" - - "tex/**" - "README.md" - "coderunbot.env.example" workflow_dispatch: @@ -39,23 +37,19 @@ jobs: - image_name: coderunbot-discord context: ./discord file: ./discord/Containerfile - - image_name: coderunbot-tex - context: ./tex - file: ./tex/Dockerfile - steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to GitHub Container Registry if: github.event_name != 'pull_request' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: gaato @@ -63,7 +57,7 @@ jobs: - name: Extract image metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: ghcr.io/gaato/${{ matrix.image_name }} tags: | @@ -73,7 +67,7 @@ jobs: type=sha,prefix=sha- - name: Build and push - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ${{ matrix.context }} file: ${{ matrix.file }} diff --git a/README.md b/README.md index 656024c..1771004 100644 --- a/README.md +++ b/README.md @@ -12,54 +12,53 @@ This is a bot for internal use that adds other functions to CodeRunBot. Also, th ## Links -- [Privacy Policy](bots/config/privacy-policy.md) +- [Privacy Policy](discord/config/privacy-policy.md) - [Invite Link](https://discord.com/api/oauth2/authorize?client_id=761428259241328680&permissions=0&scope=bot) - [Discord Server](https://discord.gg/qRpYRTgvXM) ## Requirements -- Python >= 3.14 -- [tex.gaato.net](https://github.com/gaato/tex.gaato.net) +- Node.js >= 24 +- [pnpm](https://pnpm.io/) ## How to run (CodeRunBot) Set `CODERUNBOT_TOKEN` in the environment file. -### with uv (recommended) +### with pnpm ```bash -$ cd discord -$ uv sync --locked -$ uv run python -m bots +cd discord +pnpm install +pnpm build +pnpm start ``` -### without uv +### with Docker Compose ```bash -$ python -m venv .venv -$ . .venv/bin/activate -$ pip install aiohttp google-api-python-client google-auth-oauthlib iso639-lang openai "py-cord[voice]>=2.6" python-dotenv -$ python -m bots +docker compose up --build coderunbot ``` ## How to run (gaato bot) -Basically the same as CodeRunBot, but set the environment file to include `GAATO_BOT_TOKEN`, `GOOGLE_API_KEY` and `WOLFRAM_APPID` and the execution command is as follows. You also need ffmpeg. +Basically the same as CodeRunBot, but set the environment file to include `GAATO_BOT_TOKEN` and `WOLFRAM_APPID`, then run: ```bash -$ GAATO_BOT=1 uv run python -m bots +cd discord +GAATO_BOT=1 pnpm start ``` +With Docker Compose, run `docker compose up --build gaato-bot` instead. + ## For developer Pull requests are welcome. -Please use [uv](https://docs.astral.sh/uv/) to manage dependencies. -Do not rewrite `uv.lock` by hand. +Please use [pnpm](https://pnpm.io/) to manage dependencies. +Do not rewrite `pnpm-lock.yaml` by hand. ## Environment For containerized or service-manager based deployment, keep secrets outside the repo and point the process at an environment file. - Example file: [`coderunbot.env.example`](coderunbot.env.example) -- `TEX_BASE_URL` defaults to `http://127.0.0.1:8080` -- The TeX renderer can be started with `HOST` and `PORT` overrides diff --git a/coderunbot.env.example b/coderunbot.env.example index c21b9f3..4c33527 100644 --- a/coderunbot.env.example +++ b/coderunbot.env.example @@ -9,8 +9,3 @@ GAATO_BOT_TOKEN= # ---- Optional features ---- OPENAI_API_KEY= WOLFRAM_APPID= -# GOOGLE_API_KEY= # mentioned in README; may be unused depending on enabled cogs - -# ---- TeX renderer endpoint ---- -# TEX_BASE_URL defaults to http://127.0.0.1:8080 when unset -TEX_BASE_URL=http://127.0.0.1:8080 diff --git a/compose.yaml b/compose.yaml index 10b44f6..8f9b258 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,5 +1,3 @@ -version: '3.8' - services: coderunbot: build: discord @@ -11,6 +9,3 @@ services: environment: - GAATO_BOT=1 restart: unless-stopped - tex: - build: tex - restart: unless-stopped diff --git a/discord/.dockerignore b/discord/.dockerignore index d2db951..ddc3c2a 100644 --- a/discord/.dockerignore +++ b/discord/.dockerignore @@ -1,8 +1,3 @@ .env -.venv -__pycache__/ -*.pyc -*.pyo -*.pyd node_modules/ dist/ diff --git a/discord/Containerfile b/discord/Containerfile index 76ef6ac..0b3de2a 100644 --- a/discord/Containerfile +++ b/discord/Containerfile @@ -1,23 +1,36 @@ -FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim +FROM node:24-slim AS build WORKDIR /app -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PYTHONPATH=/app \ - UV_LINK_MODE=copy \ - UV_NO_DEV=1 \ - UV_PROJECT_ENVIRONMENT=/app/.venv +RUN corepack enable -RUN groupadd --gid 10001 app \ - && useradd --uid 10001 --gid app --home-dir /app --shell /usr/sbin/nologin --no-create-home app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile -COPY pyproject.toml uv.lock /app/ -RUN uv sync --locked --no-install-project \ - && chown -R app:app /app +COPY tsconfig.json ./ +COPY src ./src +RUN pnpm build \ + && pnpm prune --prod -COPY --chown=app:app bots /app/bots +FROM node:24-slim AS runtime + +WORKDIR /app + +ENV NODE_ENV=production + +RUN apt-get update \ + && apt-get install -y --no-install-recommends fonts-noto-cjk fonts-noto-cjk-extra \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid 10001 app \ + && useradd --uid 10001 --gid app --home-dir /app --shell /usr/sbin/nologin --no-create-home app \ + && mkdir -p /app/data \ + && chown app:app /app/data + +COPY --from=build --chown=app:app /app/node_modules ./node_modules +COPY --from=build --chown=app:app /app/dist ./dist +COPY --chown=app:app package.json ./package.json +COPY --chown=app:app config ./config USER app -CMD ["/app/.venv/bin/python", "-m", "bots"] +CMD ["node", "dist/index.js"] diff --git a/discord/Dockerfile b/discord/Dockerfile index 76ef6ac..0b3de2a 100644 --- a/discord/Dockerfile +++ b/discord/Dockerfile @@ -1,23 +1,36 @@ -FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim +FROM node:24-slim AS build WORKDIR /app -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PYTHONPATH=/app \ - UV_LINK_MODE=copy \ - UV_NO_DEV=1 \ - UV_PROJECT_ENVIRONMENT=/app/.venv +RUN corepack enable -RUN groupadd --gid 10001 app \ - && useradd --uid 10001 --gid app --home-dir /app --shell /usr/sbin/nologin --no-create-home app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile -COPY pyproject.toml uv.lock /app/ -RUN uv sync --locked --no-install-project \ - && chown -R app:app /app +COPY tsconfig.json ./ +COPY src ./src +RUN pnpm build \ + && pnpm prune --prod -COPY --chown=app:app bots /app/bots +FROM node:24-slim AS runtime + +WORKDIR /app + +ENV NODE_ENV=production + +RUN apt-get update \ + && apt-get install -y --no-install-recommends fonts-noto-cjk fonts-noto-cjk-extra \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid 10001 app \ + && useradd --uid 10001 --gid app --home-dir /app --shell /usr/sbin/nologin --no-create-home app \ + && mkdir -p /app/data \ + && chown app:app /app/data + +COPY --from=build --chown=app:app /app/node_modules ./node_modules +COPY --from=build --chown=app:app /app/dist ./dist +COPY --chown=app:app package.json ./package.json +COPY --chown=app:app config ./config USER app -CMD ["/app/.venv/bin/python", "-m", "bots"] +CMD ["node", "dist/index.js"] diff --git a/discord/bots/__init__.py b/discord/bots/__init__.py deleted file mode 100644 index 1d33c33..0000000 --- a/discord/bots/__init__.py +++ /dev/null @@ -1,44 +0,0 @@ -from collections import OrderedDict -from typing import Any - -import discord - -SUPPORT_SERVER_LINK = 'discord.gg/qRpYRTgvXM' -LOG_CHANNEL_ID = 1118867011448078417 -DEVELOPER_ID = 572432137035317249 - - -class DeleteButton(discord.ui.Button): - def __init__( - self, - user: discord.abc.User, - label='Delete', - style=discord.ButtonStyle.danger, - *args: Any, - **kwargs: Any, - ): - self.user_id = user.id - super().__init__(label=label, style=style, *args, **kwargs) - - async def callback(self, interaction: discord.Interaction) -> None: - if interaction.user is None or interaction.message is None: - return - if self.user_id == interaction.user.id: - await interaction.message.delete() - - -class LimitedSizeDict(OrderedDict): - - def __init__(self, size_limit=None, *args: Any, **kwds: Any): - self.size_limit = size_limit - super().__init__(*args, **kwds) - self._check_size_limit() - - def __setitem__(self, key: Any, value: Any) -> None: - super().__setitem__(key, value) - self._check_size_limit() - - def _check_size_limit(self) -> None: - if self.size_limit is not None: - while len(self) > self.size_limit: - self.popitem(last=False) diff --git a/discord/bots/__main__.py b/discord/bots/__main__.py deleted file mode 100644 index 342be40..0000000 --- a/discord/bots/__main__.py +++ /dev/null @@ -1,31 +0,0 @@ -import os - -from bots.core.bot import Bot -from dotenv import load_dotenv - -CODERUNBOT_COGS = ["bots.cogs.TeX", "bots.cogs.Code", "bots.cogs.Privacy"] -GAATO_BOT_COGS = [ - "bots.cogs.TeX", - "bots.cogs.Code", - "bots.cogs.Privacy", - "bots.cogs.Wolfram", - "bots.cogs.Misc", - "bots.cogs.Translate", -] - - -def main() -> None: - load_dotenv(verbose=True) - - coderunbot_token = os.environ.get("CODERUNBOT_TOKEN") - gaato_bot_token = os.environ.get("GAATO_BOT_TOKEN") - - if os.environ.get("GAATO_BOT"): - Bot(gaato_bot_token, GAATO_BOT_COGS, ")").run() - return - - Bot(coderunbot_token, CODERUNBOT_COGS, "]").run() - - -if __name__ == "__main__": - main() diff --git a/discord/bots/cogs/Code.py b/discord/bots/cogs/Code.py deleted file mode 100644 index 210761e..0000000 --- a/discord/bots/cogs/Code.py +++ /dev/null @@ -1,333 +0,0 @@ -import io -import pathlib -import re -from typing import cast - -import aiohttp -import discord -import requests -from discord.ext import commands -from discord.interactions import Interaction - -from .. import DeleteButton, LimitedSizeDict - -URL = "https://wandbox.org/api/" -BASE_DIR = pathlib.Path(__file__).parent.parent - - -# dbname = BASE_DIR.parent / "db.sqlite3" -# conn = sqlite3.connect(dbname, check_same_thread=False) -# c = conn.cursor() -# c.execute( -# "CREATE TABLE IF NOT EXISTS code (message_id INTEGER, author_id INTEGER, language TEXT, code TEXT, stdin TEXT)" -# ) - - -def get_autocomplete_languages() -> list[str]: - with requests.get(URL + "list.json") as r: - r.raise_for_status() - result = r.json() - language_names = set( - map(lambda data: data["language"].lower().replace(" ", ""), result) - ) - print(list(language_names)) - return list(language_names) - - -autocomplete_languages = get_autocomplete_languages() - - -def auto_complete_language(ctx: discord.AutocompleteContext) -> list[str]: - return list( - filter( - lambda language_code: language_code.startswith(ctx.value.lower()), - autocomplete_languages, - ) - ) - - -async def get_languages() -> dict[str, str]: - async with aiohttp.ClientSession() as session: - async with session.get(URL + "list.json") as r: - if r.status == 200: - result = await r.json() - language_names = set(map(lambda data: data["language"], result)) - languages_dict = {} - for language_name in language_names: - # Find all compilers for this language - compilers = [ - item["name"] - for item in result - if item["language"] == language_name - ] - # Prefer stable versions: filter out HEAD, devel, or git versions - stable_compilers = [ - c for c in compilers - if "head" not in c.lower() and "devel" not in c.lower() and "git" not in c.lower() - ] - # Use stable version if available, otherwise use the first available - selected = stable_compilers[0] if stable_compilers else compilers[0] - languages_dict[language_name.lower().replace(" ", "")] = selected - return languages_dict - - -async def run_core( - author: discord.abc.User, - language: str, - code: str, - stdin: str = "", -) -> tuple[discord.Embed, list[discord.File]]: - language_dict = await get_languages() - if language not in language_dict.keys(): - embed = discord.Embed( - title="The following languages are supported", - description=", ".join(language_dict.keys()), - color=0xFF0000, - ) - embed.set_author(name=author.name) - return embed, [] - if language == "nim": - compiler_option = ( - "--hint[Processing]:off\n" - "--hint[Conf]:off\n" - "--hint[Link]:off\n" - "--hint[SuccessX]:off" - ) - else: - compiler_option = "" - url = URL + "compile.json" - params = { - "compiler": language_dict[language], - "code": code, - "stdin": stdin, - "compiler-option-raw": compiler_option, - } - async with aiohttp.ClientSession() as session: - try: - async with session.post(url, json=params) as r: - if r.status == 200: - try: - # Some responses from Wandbox may omit the Content-Type header. - # Allow parsing JSON even when content-type is missing by setting content_type=None. - result = await r.json(content_type=None) - except ValueError: - # Not JSON — read text for debugging and return error embed. - text = await r.text() - embed = discord.Embed( - title="Wandbox Error", - description=( - "Wandbox returned a non-JSON response. " - "Check the service status or your request." - ), - color=0xFF0000, - ) - max_preview = 750 - preview = text[:max_preview] + ("..." if len(text) > max_preview else "") - embed.add_field(name="Response Preview", value=f"``\n{preview}\n```") - embed.set_author(name=author.name) - return embed, [] - else: - embed = discord.Embed( - title="Connection Error", description=f"{r.status}", color=0xFF0000 - ) - embed.set_author(name=author.name) - return embed, [] - except aiohttp.ClientError as e: - embed = discord.Embed( - title="Connection Error", - description=str(e), - color=0xFF0000, - ) - embed.set_author(name=author.name) - return embed, [] - embed = discord.Embed(title=f"Result ({language_dict[language]}):") - embed_color = 0xFF0000 - files = [] - for k, v in result.items(): - if k in ("program_message", "compiler_message"): - continue - if v == "": - continue - if k == "status" and v == "0": - embed_color = 0x007000 - if language == "nim" and k == "compiler_error": - v = re.sub(r"CC: \S+\n", "", v) - if v == "": - continue - if len(v) > 1000 or len(v.split("\n")) > 100: - files.append(discord.File(io.BytesIO(v.encode()), k + ".txt")) - else: - embed.add_field( - name=k, - value="```\n" + v + "\n```", - ) - embed.color = embed_color - embed.set_author(name=author.name) - return embed, files - - -# class EditButton(discord.ui.Button): -# def __init__(self, label="Edit", style=discord.ButtonStyle.primary, **kwargs): -# super().__init__(label=label, style=style, **kwargs) - -# async def callback(self, interaction: discord.Interaction): -# c = conn.cursor() -# c.execute("SELECT * FROM code WHERE message_id = ?", (interaction.message.id,)) -# result = c.fetchone() -# if result is None: -# embed = discord.Embed( -# title="Error", description="Not found.", color=0xFF0000 -# ) -# await interaction.response.send_message(embed=embed, ephemeral=True) -# return -# await interaction.response.send_modal( -# RunModal(result[2], code=result[3], stdin=result[4]) -# ) - - -# class ViewCodeButton(discord.ui.Button): -# def __init__( -# self, label="View Code", style=discord.ButtonStyle.secondary, **kwargs -# ): -# super().__init__(label=label, style=style, **kwargs) - -# async def callback(self, interaction: discord.Interaction): -# c = conn.cursor() -# c.execute("SELECT * FROM code WHERE message_id = ?", (interaction.message.id,)) -# result = c.fetchone() -# if result is None: -# embed = discord.Embed( -# title="Error", description="Not found.", color=0xFF0000 -# ) -# await interaction.response.send_message(embed=embed, ephemeral=True) -# return -# embed = discord.Embed( -# title=result[2], -# description=f"```{result[2]}\n{result[3]}```", -# color=0x007000, -# ) -# if result[4] != "": -# embed.add_field( -# name="Standard Input", -# value=f"```\n{result[4]}\n```", -# ) -# embed.set_author( -# name=interaction.guild.get_member(result[1]).name, -# icon_url=interaction.guild.get_member(result[1]).display_avatar.url, -# ) -# embed.set_footer( -# text=f"Requested by {interaction.user.name}", -# icon_url=interaction.user.display_avatar.url, -# ) -# view = discord.ui.View(DeleteButton(interaction.user), timeout=None) -# await interaction.response.send_message(embed=embed, view=view) - - -class RunModal(discord.ui.Modal): - def __init__( - self, language: str, title="Run code", code="", stdin="", *args, **kwargs - ): - super().__init__(title=title, *args, **kwargs) - self.language = ( - language.lower() - .replace("pp", "++") - .replace("sharp", "#") - .replace("clisp", "lisp") - ) - self.add_item( - discord.ui.InputText( - label="Code", - placeholder="Write code here", - style=discord.InputTextStyle.long, - value=code, - ) - ) - self.add_item( - discord.ui.InputText( - label="Standard Input", - required=False, - style=discord.InputTextStyle.long, - value=stdin, - ) - ) - - async def callback(self, interaction: Interaction) -> None: - author = interaction.user - if author is None: - return - await interaction.response.defer(invisible=False) - code_value = cast(str, self.children[0].value or "") - stdin_value = cast(str, self.children[1].value or "") - embed, files = await run_core( - author, - self.language, - code_value, - stdin_value, - ) - embed.add_field( - name="Code", - value=f"```{self.language}\n{code_value}\n```", - ) - view = discord.ui.View(DeleteButton(author), timeout=None) - await interaction.followup.send( - embed=embed, files=files or None, view=view, wait=True - ) - # c.execute( - # "INSERT INTO code VALUES (?, ?, ?, ?, ?)", - # ( - # m.id, - # interaction.user.id, - # self.language, - # self.children[0].value, - # self.children[1].value, - # ), - # ) - - -class Code(commands.Cog): - def __init__(self, bot: commands.Bot): - self.bot = bot - self.user_message_id_to_bot_message = LimitedSizeDict(size_limit=100) - - @commands.Cog.listener() - async def on_message_edit(self, before: discord.Message, after: discord.Message): - if before.content != after.content: - if before.id in self.user_message_id_to_bot_message: - await self.user_message_id_to_bot_message[before.id].delete() - - @commands.command() - async def run(self, ctx: commands.Context, language: str, *, code: str): - """Run code""" - code = re.sub(r"^```.*$", "", code, flags=re.MULTILINE) - view = discord.ui.View(DeleteButton(ctx.author), timeout=None) - embed, files = await run_core(ctx.author, language, code) - m = await ctx.reply(embed=embed, files=files or None, view=view) - self.user_message_id_to_bot_message[ctx.message.id] = m - - @discord.message_command() - async def escape(self, ctx: discord.ApplicationContext, message: discord.Message): - await ctx.respond( - discord.utils.escape_markdown( - discord.utils.escape_mentions(message.content) - ), - ephemeral=True, - ) - - @discord.slash_command( - name="run", - description="Run code", - options=[ - discord.Option( - name="language", - description="Language", - required=True, - autocomplete=auto_complete_language, - ) - ], - ) - async def run_slash(self, ctx: discord.ApplicationContext, language: str): - await ctx.send_modal(RunModal(language, title="Run code")) - - -def setup(bot): - return bot.add_cog(Code(bot)) diff --git a/discord/bots/cogs/Misc.py b/discord/bots/cogs/Misc.py deleted file mode 100644 index 1e7e60c..0000000 --- a/discord/bots/cogs/Misc.py +++ /dev/null @@ -1,138 +0,0 @@ -import os -import pathlib -from collections import defaultdict -from datetime import datetime, timedelta -from typing import DefaultDict, cast - -import discord -from discord.ext import commands -from openai import AsyncOpenAI -from openai.types.chat import ChatCompletionMessageParam - - -# from sudachipy import tokenizer, dictionary - - - -BASE_DIR = pathlib.Path(__file__).parent.parent - -client = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY")) - - -class Misc(commands.Cog): - def __init__(self, bot: commands.Bot): - self.bot = bot - self.mention_times: DefaultDict[int, list[datetime]] = defaultdict(list) - # self.tokenizer_obj = dictionary.Dictionary().create() - - async def fetch_message_history( - self, - channel: discord.TextChannel | discord.Thread | discord.DMChannel, - limit: int = 10, - ) -> list[ChatCompletionMessageParam]: - history = await channel.history(limit=limit).flatten() - messages = [] - bot_user = self.bot.user - for message in history: - messages.append( - cast(ChatCompletionMessageParam, { - "role": "user" if message.author != bot_user else "assistant", - "content": message.content, - }) - ) - messages.reverse() - return messages - - def is_mention_limit_exceeded( - self, user_id: int, time_limit: int = 60, max_mentions: int = 3 - ) -> bool: - now = datetime.now() - mention_times = self.mention_times[user_id] - # Remove mentions older than time_limit seconds - self.mention_times[user_id] = [time for time in mention_times if now - time < timedelta(seconds=time_limit)] - # Check if mention limit is exceeded - print(self.mention_times) - return len(self.mention_times[user_id]) >= max_mentions - - @commands.Cog.listener("on_message") - async def on_mentioned(self, message: discord.Message) -> None: - if message.author.bot: - return - bot_user = self.bot.user - if bot_user is None: - return - if str(bot_user.id) in message.content: - if self.is_mention_limit_exceeded(message.author.id): - return - self.mention_times[message.author.id].append(datetime.now()) - if not isinstance( - message.channel, (discord.TextChannel, discord.Thread, discord.DMChannel) - ): - return - async with message.channel.typing(): - history = await self.fetch_message_history(message.channel, limit=10) - history.append( - cast(ChatCompletionMessageParam, { - "role": "assistant" if message.author.id == bot_user.id else "user", - "content": message.content, - }) - ) - gaato_messages: list[ChatCompletionMessageParam] = [ - cast( - ChatCompletionMessageParam, - { - "role": "system", - "content": "これはDiscordでのチャットです。" - "以下の様々なユーザーによる直近のメッセージ履歴を参考に、" - "あなたがメンションされている最後のメッセージに返信してください。", - }, - ), - *history, - ] - short_reply_messages: list[ChatCompletionMessageParam] = [ - cast( - ChatCompletionMessageParam, - { - "role": "system", - "content": "これはDiscordのチャットです。" - "以下は直近のメッセージ履歴です。" - "一言で返信してください。", - }, - ), - *history, - ] - if message.author.id == 572432137035317249: # gaato. - response = await client.chat.completions.create( - model= "gpt-4-turbo", - messages=gaato_messages, - ) - else: - response = await client.chat.completions.create( - model= "gpt-3.5-turbo", - messages=short_reply_messages, - ) - allowed_mentions = discord.AllowedMentions.none() - allowed_mentions.replied_user = True - await message.reply( - response.choices[0].message.content or "", - allowed_mentions=allowed_mentions, - ) - await self.bot.process_commands(message) - - # @discord.slash_command( - # name='sudachi', - # description='形態素解析', - # ) - # async def sudachi(self, ctx: discord.ApplicationContext, text: str): - # """形態素解析""" - # mode = tokenizer.Tokenizer.SplitMode.C - # tokens = self.tokenizer_obj.tokenize(text, mode) - # outputs = [f'{text}'] - # for t in tokens: - # outputs.append(f'{t.surface()}\t{",".join(t.part_of_speech())}\t{t.reading_form()}\t{t.normalized_form()}') - # view = discord.ui.View(DeleteButton(ctx.author), timeout=None) - # await ctx.respond(f'```\n' + '\n'.join(outputs) + '\n```', view=view) - - -def setup(bot: commands.Bot): - return bot.add_cog(Misc(bot)) diff --git a/discord/bots/cogs/Privacy.py b/discord/bots/cogs/Privacy.py deleted file mode 100644 index ff24090..0000000 --- a/discord/bots/cogs/Privacy.py +++ /dev/null @@ -1,43 +0,0 @@ -import pathlib - -import discord -from discord.commands import slash_command -from discord.ext import commands - -from bots.core.bot import OPT_OUT_USERS - -BASE_DIR = pathlib.Path(__file__).parent.parent - - -class Privacy(commands.Cog): - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @slash_command(name='privacy-policy') - async def privacy_policy(self, ctx: discord.ApplicationContext): - """Show the privacy policy""" - with open(BASE_DIR / 'config' / 'privacy-policy.md', 'r') as f: - await ctx.respond(f.read()) - - @slash_command(name='opt-out') - async def opt_out(self, ctx: discord.ApplicationContext): - """Opt out of your message content data to be tracked""" - if ctx.author.id in OPT_OUT_USERS: - await ctx.respond('Your message content is already off-track. To use other commands, please use the /opt-in command.') - else: - OPT_OUT_USERS.add(ctx.author.id) - await ctx.respond('This bot will not track your message content from now on. Most commands will no longer respond.') - - @slash_command(name='opt-in') - async def opt_in(self, ctx: discord.ApplicationContext): - """Opt out of your message content data to be tracked""" - if ctx.author.id in OPT_OUT_USERS: - OPT_OUT_USERS.remove(ctx.author.id) - await ctx.respond('This bot will now track the content of your messages. It will only be used to provide commands. Use the /privacy-policy command to view the privacy policy.') - else: - await ctx.respond('This bot is already tracking your message content.') - - -def setup(bot): - return bot.add_cog(Privacy(bot)) diff --git a/discord/bots/cogs/TeX.py b/discord/bots/cogs/TeX.py deleted file mode 100644 index 0da5346..0000000 --- a/discord/bots/cogs/TeX.py +++ /dev/null @@ -1,161 +0,0 @@ -import io -import os -import pathlib -from typing import Optional, cast - -import aiohttp -import discord -from discord.ext import commands - -from .. import DeleteButton, LimitedSizeDict - -BASE_DIR = pathlib.Path(__file__).parent.parent - - -async def respond_core( - author: discord.abc.User, code: str, spoiler: bool -) -> tuple[str, discord.Embed, Optional[discord.File]]: - base_url = os.environ.get("TEX_BASE_URL", "http://127.0.0.1:8080").rstrip("/") - url = f"{base_url}/render/png" - params = {"latex": code} - headers = {"Content-Type": "application/json"} - async with aiohttp.ClientSession() as session: - async with session.post(url, json=params, headers=headers) as r: - if r.status != 200: - error_message = await r.text() - embed = discord.Embed( - title="Rendering Error", - description=f"```\n{error_message}\n```", - color=0xFF0000, - ) - embed.set_author( - name=author.name, - ) - return "", embed, None - - result = await r.read() - file = discord.File(io.BytesIO(result), filename="tex.png", spoiler=spoiler) - embed = discord.Embed(color=0x008000) - embed.set_author(name=author.name) - if not spoiler: - embed.set_image(url="attachment://tex.png") - if "\\\\" in code and "\\begin" not in code and "\\end" not in code: - embed.add_field( - name="Hint", value="You can use gather or align environment." - ) - return "", embed, file - - -class TeXModal(discord.ui.Modal): - def __init__( - self, spoiler, env=None, value="", title="LaTeX to Image", *arg, **kwargs - ): - self.spoiler = spoiler - if env: - value = f"\\begin{{{env}}}\n{value}\n\\end{{{env}}}" - super().__init__(title=title, *arg, **kwargs) - self.add_item( - discord.ui.InputText( - label="Code", - placeholder="Input TeX code here", - style=discord.InputTextStyle.long, - value=value, - ) - ) - - async def callback(self, interaction: discord.Interaction) -> None: - author = interaction.user - if author is None: - return - await interaction.response.defer(invisible=False) - code_value = cast(str, self.children[0].value or "") - content, embed, file = await respond_core( - author, - code_value, - self.spoiler, - ) - embed.add_field( - name="Code", - value=f"```tex\n{code_value}\n```", - ) - view = discord.ui.View(DeleteButton(author), timeout=None) - if file is None: - await interaction.followup.send( - content=content, embed=embed, view=view, wait=True - ) - else: - await interaction.followup.send( - content=content, embed=embed, file=file, view=view, wait=True - ) - - -class TeX(commands.Cog): - def __init__(self, bot: commands.Bot): - self.bot = bot - self.user_message_id_to_bot_message = LimitedSizeDict(size_limit=100) - - @commands.Cog.listener() - async def on_message_edit(self, before: discord.Message, after: discord.Message): - if before.content != after.content: - if before.id in self.user_message_id_to_bot_message: - await self.user_message_id_to_bot_message[before.id].delete() - - async def respond(self, ctx: commands.Context, code: str, spoiler: bool): - async with ctx.channel.typing(): - view = discord.ui.View(DeleteButton(ctx.author), timeout=None) - code = code.replace("```tex", "").replace("```", "").strip() - content, embed, file = await respond_core(ctx.author, code, spoiler) - if file is None: - m = await ctx.reply(content=content, embed=embed, view=view) - else: - m = await ctx.reply(content=content, embed=embed, file=file, view=view) - return m - - @commands.command() - async def tex(self, ctx: commands.Context, *, code: str): - """LaTeX to image (in math mode)""" - m = await self.respond(ctx, code, False) - self.user_message_id_to_bot_message[ctx.message.id] = m - - @commands.command() - async def stex(self, ctx: commands.Context, *, code: str): - """LaTeX to spoiler image (in math mode)""" - m = await self.respond(ctx, code, True) - self.user_message_id_to_bot_message[ctx.message.id] = m - - @discord.slash_command( - name="tex", - description="TeX to image", - options=[ - discord.Option( - type=str, - name="env", - description="The environment to use", - required=False, - choices=[ - discord.OptionChoice(name="align", value="align"), - discord.OptionChoice(name="gather", value="gather"), - ], - ), - discord.Option( - type=bool, - name="spoiler", - description="Whether to mark the image as a spoiler", - required=False, - default=False, - ), - ], - ) - async def tex_slash( - self, - ctx: discord.ApplicationContext, - env: Optional[str] = None, - spoiler: bool = False, - ): - # modal = TeXModal(plain, spoiler) - modal = TeXModal(spoiler, env) - await ctx.send_modal(modal) - - -def setup(bot): - return bot.add_cog(TeX(bot)) diff --git a/discord/bots/cogs/Translate.py b/discord/bots/cogs/Translate.py deleted file mode 100644 index 1d88c2d..0000000 --- a/discord/bots/cogs/Translate.py +++ /dev/null @@ -1,121 +0,0 @@ -import os - -import discord -import dotenv -import iso639 -from discord.ext import commands -from iso639.exceptions import InvalidLanguageValue -from openai import AsyncOpenAI - -from .. import DeleteButton - -dotenv.load_dotenv(verbose=True) - - -client = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY")) - -two_letter_codes: dict[str, list[str]] = {} -three_letter_codes: dict[str, list[str]] = {} -language_names: list[str] = [] -for lang in iso639.iter_langs(): - if not lang.pt1: - continue - if lang.pt1: - if lang.pt1 in two_letter_codes: - two_letter_codes[lang.pt1].append(lang.name) - else: - two_letter_codes[lang.pt1] = [lang.name] - language_names.append(f"{lang.name}") - - -def autocomplete_language(ctx: discord.AutocompleteContext) -> list[str]: - input_value = ctx.value.lower() - if len(input_value) <= 1: - return [] - elif len(input_value) == 2: - return two_letter_codes.get(input_value, []) - elif len(input_value) == 3: - return three_letter_codes.get(input_value, []) - else: - matching_languages = [ - name for name in language_names if name.lower().startswith(input_value) - ] - matching_languages.sort(key=lambda name: len(name)) - return matching_languages[:30] - - -class Translate(commands.Cog): - def __init__(self, bot: commands.Bot): - self.bot = bot - - @discord.slash_command( - name="translate", - description="Translate text", - description_localizaitons={ - "ja_JP": "テキストを翻訳します", - }, - options=[ - discord.Option( - name="text", - description="Text to translate", - description_localizaitons={ - "ja_JP": "翻訳するテキスト", - }, - required=True, - ), - discord.Option( - name="to", - description="Language to translate to", - description_localizaitons={ - "ja_JP": "翻訳先の言語", - }, - autocomplete=autocomplete_language, - required=True, - ), - ], - ) - async def translate(self, ctx: discord.ApplicationContext, text: str, to: str): - """Translate text""" - await ctx.defer() - try: - lang = iso639.Lang(to) - except InvalidLanguageValue: - return await ctx.followup.send("Invalid language", ephemeral=True) - if not lang.pt1: - return await ctx.followup.send("Invalid language", ephemeral=True) - response = await client.chat.completions.create( - model="gpt-4", - messages=[ - { - "role": "system", - "content": "This is a direct translation task. " - f"Translate the following text to {lang.name}. " - "Do not add any additional comments or language indicators.", - }, - { - "role": "user", - "content": text, - }, - ], - max_tokens=2000, - ) - embed = discord.Embed( - title="Translate", - color=discord.Color.blurple(), - ) - embed.add_field( - name="Original text", - value=text, - inline=False, - ) - embed.add_field( - name=f"Translated to {to}", - value=response.choices[0].message.content or "", - inline=False, - ) - view = discord.ui.View(DeleteButton(ctx.user)) - await ctx.followup.send(embed=embed, view=view) - - -def setup(bot): - return bot.add_cog(Translate(bot)) diff --git a/discord/bots/cogs/Wolfram.py b/discord/bots/cogs/Wolfram.py deleted file mode 100644 index 3a18082..0000000 --- a/discord/bots/cogs/Wolfram.py +++ /dev/null @@ -1,105 +0,0 @@ -import os -import pathlib -from collections import OrderedDict - -import aiohttp -import discord -import dotenv -from discord.ext import commands, pages - -from .. import SUPPORT_SERVER_LINK, DeleteButton - -dotenv.load_dotenv(verbose=True) -URL = 'http://api.wolframalpha.com/v2/query' -BASE_DIR = pathlib.Path(__file__).parent.parent - - -class LimitedSizeDict(OrderedDict): - - def __init__(self, size_limit=None, *args, **kwds): - self.size_limit = size_limit - super().__init__(*args, **kwds) - self._check_size_limit() - - def __setitem__(self, key, value): - super().__setitem__(key, value) - self._check_size_limit() - - def _check_size_limit(self): - if self.size_limit is not None: - while len(self) > self.size_limit: - self.popitem(last=False) - - -class Wolfram(commands.Cog): - - def __init__(self, bot: commands.Bot): - self.bot = bot - self.user_message_id_to_bot_message = LimitedSizeDict(size_limit=100) - - @commands.Cog.listener() - async def on_message_edit(self, before: discord.Message, after: discord.Message): - if before.content != after.content: - if before.id in self.user_message_id_to_bot_message: - await self.user_message_id_to_bot_message[before.id].delete() - - @commands.Cog.listener() - async def on_message_delete(self, message: discord.Message): - if message.id in self.user_message_id_to_bot_message: - await self.user_message_id_to_bot_message[message.id].delete() - - - @commands.command(aliases=['wolfram']) - async def wolf(self, ctx: commands.Context, *, query: str): - - async with ctx.channel.typing(): - view = discord.ui.View(DeleteButton(ctx.author), timeout=None) - - async with aiohttp.ClientSession() as session: - async with session.get(URL, params={'input': query, 'format': 'image,plaintext', 'output': 'JSON', 'appid': os.environ.get('WOLFRAM_APPID')}) as resp: - if resp.status == 200: - data = await resp.json() - else: - embed = discord.Embed( - title='Connection Error', - description=f'{resp.status}', - color=0xff0000 - ) - embed.set_author( - name=ctx.author.name, - icon_url=ctx.author.display_avatar.url - ) - self.user_message_id_to_bot_message[ctx.message.id] = await ctx.reply(content=f'Please Report us!\n{SUPPORT_SERVER_LINK}', embed=embed, view=view) - return - - page_list = [] - if data['queryresult']['success']: - for pod in data['queryresult']['pods']: - for subpod in pod['subpods']: - embed = discord.Embed( - title=pod['title'], - description=subpod['plaintext'], - color=0x00ff00, - ) - if 'img' in subpod: - embed.set_image(url=subpod['img']['src']) - embed.set_author(name=ctx.author.name, icon_url=ctx.author.display_avatar.url) - page_list.append(embed) - paginator = pages.Paginator(pages=page_list) - # paginator.add_button(DeleteButton(self.bot)) - m = await paginator.send(ctx) - else: - embed = discord.Embed( - title='Error', - description='Wolfram|Alpha は、その入力を理解できませんでした。', - color=0xff0000, - ) - embed.set_author( - name=ctx.author.name, - icon_url=ctx.author.display_avatar.url - ) - m = await ctx.reply(embed=embed, view=view) - self.user_message_id_to_bot_message[ctx.message.id] = m - -def setup(bot): - return bot.add_cog(Wolfram(bot)) diff --git a/discord/bots/cogs/__init__.py b/discord/bots/cogs/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/discord/bots/config/privacy-policy.md b/discord/bots/config/privacy-policy.md deleted file mode 100644 index 1724d17..0000000 --- a/discord/bots/config/privacy-policy.md +++ /dev/null @@ -1,35 +0,0 @@ -# Privacy Policy - -## Owner - -Gakuto Furuya (gaato.) - -## Bot - -- **CodeRunBot**#4348 - -## What data we collect - -- Message content of the channel where the bot can be seen -- Internal error logs (not related to the user) - -## How we use it - -### Message content - -- The bot uses to determine if a command is being called. -- If so, the bot use it to create a response. - -It is not used for any other purpose and is discarded immediately. - -### Internal error logs - -- For debugging purposes. - -## How to opt-out of providing us that data - -The /opt-out command will prevent the bot from tracking the content of your messages. Note that this command will disable most of the features of the bot. - -## An accessible way for users to request to and deletion of their data - -Originally, users data is not saved because it is immediately discarded. diff --git a/discord/bots/core/__init__.py b/discord/bots/core/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/discord/bots/core/bot.py b/discord/bots/core/bot.py deleted file mode 100644 index bda8709..0000000 --- a/discord/bots/core/bot.py +++ /dev/null @@ -1,167 +0,0 @@ -import io -import asyncio -import pathlib -import traceback -from typing import TypeAlias, TypeGuard - -import discord -from discord.ext import commands - -from .. import DEVELOPER_ID, LOG_CHANNEL_ID, SUPPORT_SERVER_LINK, DeleteButton -from .state import OptOutUsers, TextState - -BASE_DIR = pathlib.Path(__file__).parent.parent -OPT_OUT_USERS = OptOutUsers( - TextState(BASE_DIR / "data" / "opt-out-users.txt", "opt-out-users.txt") -) -SendableChannel: TypeAlias = ( - discord.TextChannel | discord.Thread | discord.DMChannel | discord.GroupChannel -) - - -def is_sendable_channel(channel: object) -> TypeGuard[SendableChannel]: - return isinstance( - channel, - (discord.TextChannel, discord.Thread, discord.DMChannel, discord.GroupChannel), - ) - - -def ensure_event_loop() -> asyncio.AbstractEventLoop: - try: - return asyncio.get_event_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - return loop - - -class Bot(commands.Bot): - def __init__(self, token: str | None, cogs: list[str], prefix: str): - self.token = token - self.logging_channel: SendableChannel | None = None - self.developer: discord.User | None = None - intents = discord.Intents.default() - intents.message_content = True - ensure_event_loop() - super().__init__(command_prefix=prefix, intents=intents) - self.load_cogs(cogs) - - def load_cogs(self, cogs: list[str]) -> None: - for cog in cogs: - self.load_extension(cog) - print('Loaded ' + cog) - - async def on_ready(self) -> None: - user = self.user - if user is None: - return - print(f'Logged in as {user} (ID: {user.id})') - print(f'Pycord Version: {discord.__version__}') - channel = self.get_channel(LOG_CHANNEL_ID) - self.logging_channel = channel if is_sendable_channel(channel) else None - self.developer = self.get_user(DEVELOPER_ID) - - - async def on_message(self, message: discord.Message) -> None: - if message.author.id in OPT_OUT_USERS: - return - await super().on_message(message) - - async def on_message_edit( - self, before: discord.Message, after: discord.Message - ) -> None: - if before.content == after.content: - return - await self.on_message(after) - - async def on_command_error( - self, context: commands.Context, exception: commands.CommandError - ) -> None: - if isinstance(exception, commands.CommandNotFound): - return - if isinstance(exception, commands.UserInputError): - view = discord.ui.View(DeleteButton(context.author), timeout=None) - embed = discord.Embed( - title='Invalid Input', - description=f'```\n{exception}\n```', - color=0xff0000, - ) - embed.set_author( - name=context.author.name, - icon_url=context.author.display_avatar.url, - ) - await context.reply(embed=embed, view=view) - return - view = discord.ui.View(DeleteButton(context.author), timeout=None) - embed = discord.Embed( - title='Unhandled Error', - color=0xff0000, - ) - embed.set_author( - name=context.author.name, - icon_url=context.author.display_avatar.url, - ) - await context.reply( - content=f'Please Report us!\n{SUPPORT_SERVER_LINK}', - embed=embed, - view=view, - ) - await self.log_error(context, exception) - await super().on_command_error(context, exception) - - async def on_slash_command_error( - self, ctx: discord.ApplicationContext, exception: Exception - ) -> None: - await self.log_error(ctx, exception) - - async def log_error( - self, ctx: commands.Context | discord.ApplicationContext, exception: Exception - ) -> None: - if isinstance(ctx, commands.Context): - content = ctx.message.content - else: - command_name = ctx.command.qualified_name if ctx.command else "unknown" - option_values = " ".join(str(arg) for arg in (ctx.options or {}).values()) - content = f'/{command_name} {option_values}'.strip() - exception_text = ''.join(traceback.format_exception(type(exception), exception, exception.__traceback__)) - content_formatted = "```\n" + content + "\n```" - # attempt to resolve the channel if not cached - if self.logging_channel is None: - try: - channel = await self.fetch_channel(LOG_CHANNEL_ID) - except Exception: - print("Unable to fetch log channel", LOG_CHANNEL_ID) - print(exception_text) - return - self.logging_channel = channel if is_sendable_channel(channel) else None - if self.logging_channel is None: - print("Log channel is not sendable", LOG_CHANNEL_ID) - print(exception_text) - return - error_file = discord.File( - io.BytesIO(exception_text.encode()), - filename='error.txt', - ) - # Try to send to Discord log channel, but don't raise if it fails; fallback to stdout - try: - await self.logging_channel.send(content=content_formatted, file=error_file) - except discord.Forbidden: - print('Bot missing access to log channel:', LOG_CHANNEL_ID) - print(exception_text) - except Exception: - print('Failed to send log to channel:', LOG_CHANNEL_ID) - traceback.print_exc() - - def run(self) -> None: - if self.token is None: - print('Missing Discord token') - return - try: - self.loop.run_until_complete(self.start(self.token)) - except discord.LoginFailure: - print('Invalid Discord Token') - except KeyboardInterrupt: - print('Shutdown') - self.loop.run_until_complete(self.close()) - except Exception: - traceback.print_exc() diff --git a/discord/bots/core/state.py b/discord/bots/core/state.py deleted file mode 100644 index 10b29e1..0000000 --- a/discord/bots/core/state.py +++ /dev/null @@ -1,126 +0,0 @@ -import os -import pathlib - -import oci -from oci.exceptions import ServiceError - - -class TextState: - def __init__(self, local_path: pathlib.Path, object_name: str) -> None: - self.local_path = local_path - self.object_name = object_name - self.backend = os.environ.get("BOT_STATE_BACKEND", "local") - self.bucket = os.environ.get("BOT_STATE_BUCKET") - self.namespace = os.environ.get("BOT_STATE_NAMESPACE") - self.prefix = os.environ.get("BOT_STATE_PREFIX", "").strip("/") - self._client = None - - @property - def key(self) -> str: - if self.prefix: - return f"{self.prefix}/{self.object_name}" - return self.object_name - - @property - def client(self): - if self._client is None: - self._client = oci.object_storage.ObjectStorageClient(self._oci_config()) - return self._client - - def _oci_config(self) -> dict[str, str]: - key_file = os.environ.get("OCI_PRIVATE_KEY_FILE") - if key_file: - return { - "user": os.environ["OCI_USER_OCID"], - "fingerprint": os.environ["OCI_FINGERPRINT"], - "tenancy": os.environ["OCI_TENANCY_OCID"], - "region": os.environ["OCI_REGION"], - "key_file": key_file, - **( - {"pass_phrase": os.environ["OCI_PRIVATE_KEY_PASSPHRASE"]} - if os.environ.get("OCI_PRIVATE_KEY_PASSPHRASE") - else {} - ), - } - - key_content = os.environ.get("OCI_PRIVATE_KEY") - if key_content: - return { - "user": os.environ["OCI_USER_OCID"], - "fingerprint": os.environ["OCI_FINGERPRINT"], - "tenancy": os.environ["OCI_TENANCY_OCID"], - "region": os.environ["OCI_REGION"], - "key_content": key_content, - **( - {"pass_phrase": os.environ["OCI_PRIVATE_KEY_PASSPHRASE"]} - if os.environ.get("OCI_PRIVATE_KEY_PASSPHRASE") - else {} - ), - } - return oci.config.from_file() - - def read(self) -> str: - if self.backend != "oci": - return self._read_local() - if not self.bucket or not self.namespace: - raise RuntimeError( - "BOT_STATE_BUCKET and BOT_STATE_NAMESPACE are required when BOT_STATE_BACKEND=oci" - ) - try: - response = self.client.get_object(self.namespace, self.bucket, self.key) - except ServiceError as error: - if error.status != 404: - raise - self.write("") - return "" - return response.data.content.decode("utf-8") - - def write(self, value: str) -> None: - if self.backend != "oci": - self._write_local(value) - return - if not self.bucket or not self.namespace: - raise RuntimeError( - "BOT_STATE_BUCKET and BOT_STATE_NAMESPACE are required when BOT_STATE_BACKEND=oci" - ) - self.client.put_object( - self.namespace, - self.bucket, - self.key, - value.encode("utf-8"), - content_type="text/plain; charset=utf-8", - ) - - def _read_local(self) -> str: - if not self.local_path.exists(): - return "" - return self.local_path.read_text() - - def _write_local(self, value: str) -> None: - self.local_path.parent.mkdir(parents=True, exist_ok=True) - self.local_path.write_text(value) - - -class OptOutUsers: - def __init__(self, state: TextState) -> None: - self.state = state - self.users = self._parse(state.read()) - - def __contains__(self, user_id: int) -> bool: - return user_id in self.users - - def add(self, user_id: int) -> None: - self.users.add(user_id) - self.save() - - def remove(self, user_id: int) -> None: - self.users.remove(user_id) - self.save() - - def save(self) -> None: - value = "".join(f"{user_id}\n" for user_id in sorted(self.users)) - self.state.write(value) - - @staticmethod - def _parse(value: str) -> set[int]: - return {int(line) for line in value.splitlines() if line.strip()} diff --git a/discord/bots/data/.gitignore b/discord/bots/data/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/discord/bots/data/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/discord/pyproject.toml b/discord/pyproject.toml deleted file mode 100644 index 477d7c9..0000000 --- a/discord/pyproject.toml +++ /dev/null @@ -1,18 +0,0 @@ -[project] -name = "coderunbot-discord" -version = "0.1.0" -description = "Discord bots for coderunbot and gaato-bot" -requires-python = ">=3.14" -dependencies = [ - "aiohttp", - "google-api-python-client", - "google-auth-oauthlib", - "iso639-lang", - "oci", - "openai", - "py-cord[voice]>=2.6", - "python-dotenv", -] - -[tool.uv] -package = false diff --git a/discord/uv.lock b/discord/uv.lock deleted file mode 100644 index 3b19a93..0000000 --- a/discord/uv.lock +++ /dev/null @@ -1,1015 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.14" - -[[package]] -name = "aiohappyeyeballs" -version = "2.6.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, -] - -[[package]] -name = "aiohttp" -version = "3.13.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, - { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, - { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, - { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, - { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, - { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, - { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, - { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, - { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, - { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, - { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, - { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, - { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, - { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, - { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, - { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, - { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, - { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, - { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, - { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, - { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, - { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, - { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, - { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, - { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, - { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, - { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, - { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, - { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "frozenlist" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "anyio" -version = "4.13.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, -] - -[[package]] -name = "attrs" -version = "26.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, -] - -[[package]] -name = "certifi" -version = "2026.4.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, -] - -[[package]] -name = "cffi" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, -] - -[[package]] -name = "circuitbreaker" -version = "2.1.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/ac/de7a92c4ed39cba31fe5ad9203b76a25ca67c530797f6bb420fff5f65ccb/circuitbreaker-2.1.3.tar.gz", hash = "sha256:1a4baee510f7bea3c91b194dcce7c07805fe96c4423ed5594b75af438531d084", size = 10787, upload-time = "2025-03-31T08:12:08.963Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/34/15f08edd4628f65217de1fc3c1a27c82e46fe357d60c217fc9881e12ebcc/circuitbreaker-2.1.3-py3-none-any.whl", hash = "sha256:87ba6a3ed03fdc7032bc175561c2b04d52ade9d5faf94ca2b035fbdc5e6b1dd1", size = 7737, upload-time = "2025-03-31T08:12:07.802Z" }, -] - -[[package]] -name = "coderunbot-discord" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "aiohttp" }, - { name = "google-api-python-client" }, - { name = "google-auth-oauthlib" }, - { name = "iso639-lang" }, - { name = "oci" }, - { name = "openai" }, - { name = "py-cord", extra = ["voice"] }, - { name = "python-dotenv" }, -] - -[package.metadata] -requires-dist = [ - { name = "aiohttp" }, - { name = "google-api-python-client" }, - { name = "google-auth-oauthlib" }, - { name = "iso639-lang" }, - { name = "oci" }, - { name = "openai" }, - { name = "py-cord", extras = ["voice"], specifier = ">=2.6" }, - { name = "python-dotenv" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, -] - -[[package]] -name = "cryptography" -version = "46.0.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, - { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, - { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, - { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, - { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, - { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, - { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, - { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, - { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, - { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, - { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, - { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, - { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, - { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, - { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, - { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, - { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, - { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, - { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, - { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, - { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, - { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, - { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, - { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, - { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, - { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, - { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, - { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, - { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, - { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, - { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, - { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, -] - -[[package]] -name = "distro" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, -] - -[[package]] -name = "frozenlist" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, - { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, - { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, - { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, - { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, - { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, - { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, - { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, - { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, - { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, - { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, - { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, - { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, - { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, - { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, - { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, - { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, - { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, - { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, - { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, - { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, - { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, - { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, -] - -[[package]] -name = "google-api-core" -version = "2.30.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-auth" }, - { name = "googleapis-common-protos" }, - { name = "proto-plus" }, - { name = "protobuf" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/16/ce/502a57fb0ec752026d24df1280b162294b22a0afb98a326084f9a979138b/google_api_core-2.30.3.tar.gz", hash = "sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b", size = 177001, upload-time = "2026-04-10T00:41:28.035Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/15/e56f351cf6ef1cfea58e6ac226a7318ed1deb2218c4b3cc9bd9e4b786c5a/google_api_core-2.30.3-py3-none-any.whl", hash = "sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8", size = 173274, upload-time = "2026-04-09T22:57:16.198Z" }, -] - -[[package]] -name = "google-api-python-client" -version = "2.194.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core" }, - { name = "google-auth" }, - { name = "google-auth-httplib2" }, - { name = "httplib2" }, - { name = "uritemplate" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/60/ab/e83af0eb043e4ccc49571ca7a6a49984e9d00f4e9e6e6f1238d60bc84dce/google_api_python_client-2.194.0.tar.gz", hash = "sha256:db92647bd1a90f40b79c9618461553c2b20b6a43ce7395fa6de07132dc14f023", size = 14443469, upload-time = "2026-04-08T23:07:35.757Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/34/5a624e49f179aa5b0cb87b2ce8093960299030ff40423bfbde09360eb908/google_api_python_client-2.194.0-py3-none-any.whl", hash = "sha256:61eaaac3b8fc8fdf11c08af87abc3d1342d1b37319cc1b57405f86ef7697e717", size = 15016514, upload-time = "2026-04-08T23:07:33.093Z" }, -] - -[[package]] -name = "google-auth" -version = "2.49.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "pyasn1-modules" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c6/fc/e925290a1ad95c975c459e2df070fac2b90954e13a0370ac505dff78cb99/google_auth-2.49.2.tar.gz", hash = "sha256:c1ae38500e73065dcae57355adb6278cf8b5c8e391994ae9cbadbcb9631ab409", size = 333958, upload-time = "2026-04-10T00:41:21.888Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/76/d241a5c927433420507215df6cac1b1fa4ac0ba7a794df42a84326c68da8/google_auth-2.49.2-py3-none-any.whl", hash = "sha256:c2720924dfc82dedb962c9f52cabb2ab16714fd0a6a707e40561d217574ed6d5", size = 240638, upload-time = "2026-04-10T00:41:14.501Z" }, -] - -[[package]] -name = "google-auth-httplib2" -version = "0.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-auth" }, - { name = "httplib2" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ed/99/107612bef8d24b298bb5a7c8466f908ecda791d43f9466f5c3978f5b24c1/google_auth_httplib2-0.3.1.tar.gz", hash = "sha256:0af542e815784cb64159b4469aa5d71dd41069ba93effa006e1916b1dcd88e55", size = 11152, upload-time = "2026-03-30T22:50:26.766Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/e9/93afb14d23a949acaa3f4e7cc51a0024671174e116e35f42850764b99634/google_auth_httplib2-0.3.1-py3-none-any.whl", hash = "sha256:682356a90ef4ba3d06548c37e9112eea6fc00395a11b0303a644c1a86abc275c", size = 9534, upload-time = "2026-03-30T22:49:03.384Z" }, -] - -[[package]] -name = "google-auth-oauthlib" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-auth" }, - { name = "requests-oauthlib" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a6/82/62482931dcbe5266a2680d0da17096f2aab983ecb320277d9556700ce00e/google_auth_oauthlib-1.3.1.tar.gz", hash = "sha256:14c22c7b3dd3d06dbe44264144409039465effdd1eef94f7ce3710e486cc4bfa", size = 21663, upload-time = "2026-03-30T22:49:56.408Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/e0/cb454a95f460903e39f101e950038ec24a072ca69d0a294a6df625cc1627/google_auth_oauthlib-1.3.1-py3-none-any.whl", hash = "sha256:1a139ef23f1318756805b0e95f655c238bffd29655329a2978218248da4ee7f8", size = 19247, upload-time = "2026-03-30T20:02:23.894Z" }, -] - -[[package]] -name = "googleapis-common-protos" -version = "1.74.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/20/18/a746c8344152d368a5aac738d4c857012f2c5d1fd2eac7e17b647a7861bd/googleapis_common_protos-1.74.0.tar.gz", hash = "sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1", size = 151254, upload-time = "2026-04-02T21:23:26.679Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/b0/be5d3329badb9230b765de6eea66b73abd5944bdeb5afb3562ddcd80ae84/googleapis_common_protos-1.74.0-py3-none-any.whl", hash = "sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5", size = 300743, upload-time = "2026-04-02T21:22:49.108Z" }, -] - -[[package]] -name = "h11" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, -] - -[[package]] -name = "httpcore" -version = "1.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "h11" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, -] - -[[package]] -name = "httplib2" -version = "0.31.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyparsing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c1/1f/e86365613582c027dda5ddb64e1010e57a3d53e99ab8a72093fa13d565ec/httplib2-0.31.2.tar.gz", hash = "sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24", size = 250800, upload-time = "2026-01-23T11:04:44.165Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/90/fd509079dfcab01102c0fdd87f3a9506894bc70afcf9e9785ef6b2b3aff6/httplib2-0.31.2-py3-none-any.whl", hash = "sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349", size = 91099, upload-time = "2026-01-23T11:04:42.78Z" }, -] - -[[package]] -name = "httpx" -version = "0.28.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, - { name = "idna" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, -] - -[[package]] -name = "idna" -version = "3.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, -] - -[[package]] -name = "iso639-lang" -version = "2.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9b/5a/49bbf16d155192255e7bb37e403b2ac360144992d0d112a865afc62e457f/iso639_lang-2.6.3.tar.gz", hash = "sha256:078ddb7cd0182dcc04367691acc8022ddf7158b6cb09f08f798af823fa864265", size = 319391, upload-time = "2025-07-23T09:04:53.568Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/c7/f6fd3db6c33a164631c39dce2ca26a3794e3abf91b875cc99a43a5565d88/iso639_lang-2.6.3-py3-none-any.whl", hash = "sha256:a6c2fb9f739dca180dc7f48b098880f303bcce2cdf93a4ca3152ed8bbbb94fbb", size = 324990, upload-time = "2025-07-23T09:04:52.221Z" }, -] - -[[package]] -name = "jiter" -version = "0.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/1e/354ed92461b165bd581f9ef5150971a572c873ec3b68a916d5aa91da3cc2/jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140", size = 315277, upload-time = "2026-04-10T14:27:18.109Z" }, - { url = "https://files.pythonhosted.org/packages/a6/95/8c7c7028aa8636ac21b7a55faef3e34215e6ed0cbf5ae58258427f621aa3/jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9", size = 315923, upload-time = "2026-04-10T14:27:19.603Z" }, - { url = "https://files.pythonhosted.org/packages/47/40/e2a852a44c4a089f2681a16611b7ce113224a80fd8504c46d78491b47220/jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615", size = 344943, upload-time = "2026-04-10T14:27:21.262Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1f/670f92adee1e9895eac41e8a4d623b6da68c4d46249d8b556b60b63f949e/jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850", size = 369725, upload-time = "2026-04-10T14:27:22.766Z" }, - { url = "https://files.pythonhosted.org/packages/01/2f/541c9ba567d05de1c4874a0f8f8c5e3fd78e2b874266623da9a775cf46e0/jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9", size = 461210, upload-time = "2026-04-10T14:27:24.315Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a9/c31cbec09627e0d5de7aeaec7690dba03e090caa808fefd8133137cf45bc/jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994", size = 380002, upload-time = "2026-04-10T14:27:26.155Z" }, - { url = "https://files.pythonhosted.org/packages/50/02/3c05c1666c41904a2f607475a73e7a4763d1cbde2d18229c4f85b22dc253/jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa", size = 354678, upload-time = "2026-04-10T14:27:27.701Z" }, - { url = "https://files.pythonhosted.org/packages/7d/97/e15b33545c2b13518f560d695f974b9891b311641bdcf178d63177e8801e/jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5", size = 358920, upload-time = "2026-04-10T14:27:29.256Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d2/8b1461def6b96ba44530df20d07ef7a1c7da22f3f9bf1727e2d611077bf1/jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928", size = 394512, upload-time = "2026-04-10T14:27:31.344Z" }, - { url = "https://files.pythonhosted.org/packages/e3/88/837566dd6ed6e452e8d3205355afd484ce44b2533edfa4ed73a298ea893e/jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28", size = 521120, upload-time = "2026-04-10T14:27:33.299Z" }, - { url = "https://files.pythonhosted.org/packages/89/6b/b00b45c4d1b4c031777fe161d620b755b5b02cdade1e316dcb46e4471d63/jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de", size = 553668, upload-time = "2026-04-10T14:27:34.868Z" }, - { url = "https://files.pythonhosted.org/packages/ad/d8/6fe5b42011d19397433d345716eac16728ac241862a2aac9c91923c7509a/jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc", size = 207001, upload-time = "2026-04-10T14:27:36.455Z" }, - { url = "https://files.pythonhosted.org/packages/e5/43/5c2e08da1efad5e410f0eaaabeadd954812612c33fbbd8fd5328b489139d/jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02", size = 202187, upload-time = "2026-04-10T14:27:38Z" }, - { url = "https://files.pythonhosted.org/packages/aa/1f/6e39ac0b4cdfa23e606af5b245df5f9adaa76f35e0c5096790da430ca506/jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611", size = 192257, upload-time = "2026-04-10T14:27:39.504Z" }, - { url = "https://files.pythonhosted.org/packages/05/57/7dbc0ffbbb5176a27e3518716608aa464aee2e2887dc938f0b900a120449/jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b", size = 323441, upload-time = "2026-04-10T14:27:41.039Z" }, - { url = "https://files.pythonhosted.org/packages/83/6e/7b3314398d8983f06b557aa21b670511ec72d3b79a68ee5e4d9bff972286/jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a", size = 348109, upload-time = "2026-04-10T14:27:42.552Z" }, - { url = "https://files.pythonhosted.org/packages/ae/4f/8dc674bcd7db6dba566de73c08c763c337058baff1dbeb34567045b27cdc/jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a", size = 368328, upload-time = "2026-04-10T14:27:44.574Z" }, - { url = "https://files.pythonhosted.org/packages/3b/5f/188e09a1f20906f98bbdec44ed820e19f4e8eb8aff88b9d1a5a497587ff3/jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b", size = 463301, upload-time = "2026-04-10T14:27:46.717Z" }, - { url = "https://files.pythonhosted.org/packages/ac/f0/19046ef965ed8f349e8554775bb12ff4352f443fbe12b95d31f575891256/jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746", size = 378891, upload-time = "2026-04-10T14:27:48.32Z" }, - { url = "https://files.pythonhosted.org/packages/c4/c3/da43bd8431ee175695777ee78cf0e93eacbb47393ff493f18c45231b427d/jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310", size = 360749, upload-time = "2026-04-10T14:27:49.88Z" }, - { url = "https://files.pythonhosted.org/packages/72/26/e054771be889707c6161dbdec9c23d33a9ec70945395d70f07cfea1e9a6f/jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4", size = 358526, upload-time = "2026-04-10T14:27:51.504Z" }, - { url = "https://files.pythonhosted.org/packages/c3/0f/7bea65ea2a6d91f2bf989ff11a18136644392bf2b0497a1fa50934c30a9c/jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2", size = 393926, upload-time = "2026-04-10T14:27:53.368Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/b1ff7d70deef61ac0b7c6c2f12d2ace950cdeecb4fdc94500a0926802857/jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560", size = 521052, upload-time = "2026-04-10T14:27:55.058Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7b/3b0649983cbaf15eda26a414b5b1982e910c67bd6f7b1b490f3cfc76896a/jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06", size = 553716, upload-time = "2026-04-10T14:27:57.269Z" }, - { url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" }, - { url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" }, -] - -[[package]] -name = "multidict" -version = "6.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, - { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, - { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, - { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, - { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, - { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, - { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, - { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, - { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, - { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, - { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, - { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, - { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, - { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, - { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, - { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, - { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, -] - -[[package]] -name = "oauthlib" -version = "3.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, -] - -[[package]] -name = "oci" -version = "2.172.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "circuitbreaker" }, - { name = "cryptography" }, - { name = "pyopenssl" }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/97/ed/e9c0fa954f0fa77946993a46de68862c984f7ae8e9e651406a776214d1c9/oci-2.172.0.tar.gz", hash = "sha256:4453ab363e4f9d4b9890a3030f082f1841398f2cea66b63e44dd51cf6be0d347", size = 17086041, upload-time = "2026-04-21T04:41:35.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/e5/193bca3a95b1fb272a0f0fb02d966a9ba1d10dd264d21143d85625c55de8/oci-2.172.0-py3-none-any.whl", hash = "sha256:61f07c086b0140033ccd57a0632bdd747ce2dbc3cedc198639116dd78510e3ca", size = 34825199, upload-time = "2026-04-21T04:41:26.297Z" }, -] - -[[package]] -name = "openai" -version = "2.32.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "anyio" }, - { name = "distro" }, - { name = "httpx" }, - { name = "jiter" }, - { name = "pydantic" }, - { name = "sniffio" }, - { name = "tqdm" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ed/59/bdcc6b759b8c42dd73afaf5bf8f902c04b37987a5514dbc1c64dba390fef/openai-2.32.0.tar.gz", hash = "sha256:c54b27a9e4cb8d51f0dd94972ffd1a04437efeb259a9e60d8922b8bd26fe55e0", size = 693286, upload-time = "2026-04-15T22:28:19.434Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/c1/d6e64ccd0536bf616556f0cad2b6d94a8125f508d25cfd814b1d2db4e2f1/openai-2.32.0-py3-none-any.whl", hash = "sha256:4dcc9badeb4bf54ad0d187453742f290226d30150890b7890711bda4f32f192f", size = 1162570, upload-time = "2026-04-15T22:28:17.714Z" }, -] - -[[package]] -name = "propcache" -version = "0.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, - { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, - { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, - { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, - { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, - { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, - { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, - { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, - { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, - { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, - { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, - { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, - { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, - { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, - { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, - { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, - { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, - { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, - { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, - { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, - { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, -] - -[[package]] -name = "proto-plus" -version = "1.27.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/81/0d/94dfe80193e79d55258345901acd2917523d56e8381bc4dee7fd38e3868a/proto_plus-1.27.2.tar.gz", hash = "sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24", size = 57204, upload-time = "2026-03-26T22:18:57.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/f3/1fba73eeffafc998a25d59703b63f8be4fe8a5cb12eaff7386a0ba0f7125/proto_plus-1.27.2-py3-none-any.whl", hash = "sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718", size = 50450, upload-time = "2026-03-26T22:13:42.927Z" }, -] - -[[package]] -name = "protobuf" -version = "7.34.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" }, - { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" }, - { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" }, - { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" }, - { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" }, - { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" }, - { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" }, -] - -[[package]] -name = "py-cord" -version = "2.7.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/75/5b510b4f30c0fe9ae0c440d968e32df1d8857562e49cc652e4f491871384/py_cord-2.7.2.tar.gz", hash = "sha256:86d2172c5b0de33d3aa5611d940dd98acfb1b95adfa10ae7b749e6aaf923ffff", size = 1132471, upload-time = "2026-04-14T11:21:20.481Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/4c/1d623000e5a91236895d89bfc2a2947db328182715ac2d7e7ef727938e77/py_cord-2.7.2-py3-none-any.whl", hash = "sha256:e25e5e9392f297b3d8fb8f9f31e57989276c1e2b12ebed5a7b1040dd5891d972", size = 1208198, upload-time = "2026-04-14T11:21:18.741Z" }, -] - -[package.optional-dependencies] -voice = [ - { name = "pynacl" }, -] - -[[package]] -name = "pyasn1" -version = "0.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, -] - -[[package]] -name = "pyasn1-modules" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyasn1" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, -] - -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - -[[package]] -name = "pydantic" -version = "2.13.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.46.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, - { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, - { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, - { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, - { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, - { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, - { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, - { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, - { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, - { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, - { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, - { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, - { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, - { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, - { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, - { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, - { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, - { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, - { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, - { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, - { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, - { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, - { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, - { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, - { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, - { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, - { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, - { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, - { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, -] - -[[package]] -name = "pynacl" -version = "1.6.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, - { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, - { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, - { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, - { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, - { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, - { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, - { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, - { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, - { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, - { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, - { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, - { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, - { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, - { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, - { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, - { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, - { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, - { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, -] - -[[package]] -name = "pyopenssl" -version = "26.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8e/11/a62e1d33b373da2b2c2cd9eb508147871c80f12b1cacde3c5d314922afdd/pyopenssl-26.0.0.tar.gz", hash = "sha256:f293934e52936f2e3413b89c6ce36df66a0b34ae1ea3a053b8c5020ff2f513fc", size = 185534, upload-time = "2026-03-15T14:28:26.353Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/7d/d4f7d908fa8415571771b30669251d57c3cf313b36a856e6d7548ae01619/pyopenssl-26.0.0-py3-none-any.whl", hash = "sha256:df94d28498848b98cc1c0ffb8ef1e71e40210d3b0a8064c9d29571ed2904bf81", size = 57969, upload-time = "2026-03-15T14:28:24.864Z" }, -] - -[[package]] -name = "pyparsing" -version = "3.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, -] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, -] - -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - -[[package]] -name = "pytz" -version = "2026.1.post1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" }, -] - -[[package]] -name = "requests" -version = "2.33.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, -] - -[[package]] -name = "requests-oauthlib" -version = "2.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "oauthlib" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, -] - -[[package]] -name = "six" -version = "1.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, -] - -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - -[[package]] -name = "tqdm" -version = "4.67.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -] - -[[package]] -name = "uritemplate" -version = "4.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, -] - -[[package]] -name = "urllib3" -version = "2.6.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, -] - -[[package]] -name = "yarl" -version = "1.23.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, - { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, - { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, - { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, - { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, - { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, - { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, - { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, - { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, - { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, - { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, - { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, - { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, - { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, - { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, - { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, - { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, - { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, - { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, - { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, - { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, - { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, - { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, - { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, - { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, - { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, - { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, - { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, - { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, - { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, - { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, - { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, - { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, -] diff --git a/tex/.gitignore b/tex/.gitignore deleted file mode 100644 index c6bba59..0000000 --- a/tex/.gitignore +++ /dev/null @@ -1,130 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -lerna-debug.log* -.pnpm-debug.log* - -# Diagnostic reports (https://nodejs.org/api/report.html) -report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json - -# Runtime data -pids -*.pid -*.seed -*.pid.lock - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage -*.lcov - -# nyc test coverage -.nyc_output - -# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) -.grunt - -# Bower dependency directory (https://bower.io/) -bower_components - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (https://nodejs.org/api/addons.html) -build/Release - -# Dependency directories -node_modules/ -jspm_packages/ - -# Snowpack dependency directory (https://snowpack.dev/) -web_modules/ - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Yarn Integrity file -.yarn-integrity - -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local - -# parcel-bundler cache (https://parceljs.org/) -.cache -.parcel-cache - -# Next.js build output -.next -out - -# Nuxt.js build / generate output -.nuxt -dist - -# Gatsby files -.cache/ -# Comment in the public line in if your project uses Gatsby and not Next.js -# https://nextjs.org/blog/next-9-1#public-directory-support -# public - -# vuepress build output -.vuepress/dist - -# vuepress v2.x temp and cache directory -.temp -.cache - -# Docusaurus cache and generated files -.docusaurus - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* diff --git a/tex/Dockerfile b/tex/Dockerfile deleted file mode 100644 index 37d467b..0000000 --- a/tex/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM node:18-alpine - -WORKDIR /usr/src/app - -RUN apk --no-cache add font-noto-cjk-extra - -COPY package*.json ./ -RUN npm install - -COPY . . - -EXPOSE 80 - -CMD [ "node", "server.js" ] diff --git a/tex/README-ja.md b/tex/README-ja.md deleted file mode 100644 index 41bb944..0000000 --- a/tex/README-ja.md +++ /dev/null @@ -1,43 +0,0 @@ -# tex.gaato.net - -tex.gaato.net は、LaTeX 文字列を SVG や PNG 画像としてレンダリングするためのエンドポイントを提供します。 - -## エンドポイント - -### POST `/render/png` - -LaTeX 文字列を PNG 画像としてレンダリングします。 - -#### リクエストパラメータ - -| パラメータ | 型 | 説明 | -|------------|--------|------------------------| -| latex | string | レンダリングする LaTeX 文字列 | - -#### レスポンス - -- **200 OK** - - `image/png`: PNG 画像 -- **400 Bad Request** - - `text/plain`: LaTeX 文字列が不足している場合、または LaTeX の構文エラーが発生した場合のエラーメッセージ -- **500 Internal Server Error** - - `text/plain`: その他のエラーが発生した場合のエラーメッセージ - -### POST `/render/svg` - -LaTeX 文字列を SVG 画像としてレンダリングします。 - -#### リクエストパラメータ - -| パラメータ | 型 | 説明 | -|------------|--------|------------------------| -| latex | string | レンダリングする LaTeX 文字列 | - -#### レスポンス - -- **200 OK** - - `image/svg+xml`: SVG 画像 -- **400 Bad Request** - - `text/plain`: LaTeX 文字列が不足している場合、または LaTeX の構文エラーが発生した場合のエラーメッセージ -- **500 Internal Server Error** - - `text/plain`: その他のエラーが発生した場合のエラーメッセージ diff --git a/tex/README.md b/tex/README.md deleted file mode 100644 index e52d811..0000000 --- a/tex/README.md +++ /dev/null @@ -1,43 +0,0 @@ -# tex.gaato.net - -tex.gaato.net provides endpoints to render LaTeX strings as SVG or PNG images. - -## Endpoints - -### POST `/render/png` - -Renders a LaTeX string as a PNG image. - -#### Request Parameters - -| Parameter | Type | Description | -|-----------|--------|-------------------------------| -| latex | string | LaTeX string to be rendered | - -#### Response - -- **200 OK** - - `image/png`: PNG image -- **400 Bad Request** - - `text/plain`: Error message if the LaTeX string is missing or if there's a LaTeX syntax error -- **500 Internal Server Error** - - `text/plain`: Error message if any other error occurs - -### POST `/render/svg` - -Renders a LaTeX string as an SVG image. - -#### Request Parameters - -| Parameter | Type | Description | -|-----------|--------|-------------------------------| -| latex | string | LaTeX string to be rendered | - -#### Response - -- **200 OK** - - `image/svg+xml`: SVG image -- **400 Bad Request** - - `text/plain`: Error message if the LaTeX string is missing or if there's a LaTeX syntax error -- **500 Internal Server Error** - - `text/plain`: Error message if any other error occurs diff --git a/tex/package-lock.json b/tex/package-lock.json deleted file mode 100644 index 5fdb00f..0000000 --- a/tex/package-lock.json +++ /dev/null @@ -1,1628 +0,0 @@ -{ - "name": "tex.gaato.net", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "express": "^4.18.2", - "jsdom": "^22.1.0", - "mathjax": "^3.2.2", - "mathjax-full": "^3.2.2", - "sharp": "^0.32.5" - } - }, - "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "engines": { - "node": ">= 10" - } - }, - "node_modules/abab": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", - "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==" - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/agent-base/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/agent-base/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/b4a": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", - "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/body-parser": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", - "integrity": "sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==", - "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.4", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.11.0", - "raw-body": "2.5.1", - "type-is": "~1.6.18", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" - }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-9.2.0.tgz", - "integrity": "sha512-e2i4wANQiSXgnrBlIatyHtP1odfUp0BbV5Y5nEGbxtIrStkEOAAzCUirvLBNXHLr7kwLvJl6V+4V3XV9x7Wd9w==", - "engines": { - "node": "^12.20.0 || >=14" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" - }, - "node_modules/cssstyle": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", - "integrity": "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==", - "dependencies": { - "rrweb-cssom": "^0.6.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/data-urls": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz", - "integrity": "sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==", - "dependencies": { - "abab": "^2.0.6", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^12.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/decimal.js": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", - "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-libc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", - "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", - "engines": { - "node": ">=8" - } - }, - "node_modules/domexception": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", - "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", - "dependencies": { - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" - }, - "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" - }, - "node_modules/esm": { - "version": "3.2.25", - "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", - "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/express": { - "version": "4.18.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", - "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.1", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.5.0", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.2.0", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", - "proxy-addr": "~2.0.7", - "qs": "6.11.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" - }, - "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" - }, - "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" - }, - "node_modules/get-intrinsic": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", - "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", - "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", - "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", - "dependencies": { - "whatwg-encoding": "^2.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", - "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/http-proxy-agent/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/http-proxy-agent/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/https-proxy-agent/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/https-proxy-agent/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==" - }, - "node_modules/jsdom": { - "version": "22.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", - "integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==", - "dependencies": { - "abab": "^2.0.6", - "cssstyle": "^3.0.0", - "data-urls": "^4.0.0", - "decimal.js": "^10.4.3", - "domexception": "^4.0.0", - "form-data": "^4.0.0", - "html-encoding-sniffer": "^3.0.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.1", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.4", - "parse5": "^7.1.2", - "rrweb-cssom": "^0.6.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^4.1.2", - "w3c-xmlserializer": "^4.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^2.0.0", - "whatwg-mimetype": "^3.0.0", - "whatwg-url": "^12.0.1", - "ws": "^8.13.0", - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "canvas": "^2.5.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/mathjax": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/mathjax/-/mathjax-3.2.2.tgz", - "integrity": "sha512-Bt+SSVU8eBG27zChVewOicYs7Xsdt40qm4+UpHyX7k0/O9NliPc+x77k1/FEsPsjKPZGJvtRZM1vO+geW0OhGw==" - }, - "node_modules/mathjax-full": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/mathjax-full/-/mathjax-full-3.2.2.tgz", - "integrity": "sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w==", - "dependencies": { - "esm": "^3.2.25", - "mhchemparser": "^4.1.0", - "mj-context-menu": "^0.6.1", - "speech-rule-engine": "^4.0.6" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mhchemparser": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/mhchemparser/-/mhchemparser-4.2.1.tgz", - "integrity": "sha512-kYmyrCirqJf3zZ9t/0wGgRZ4/ZJw//VwaRVGA75C4nhE60vtnIzhl9J9ndkX/h6hxSN7pjg/cE0VxbnNM+bnDQ==" - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mj-context-menu": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/mj-context-menu/-/mj-context-menu-0.6.1.tgz", - "integrity": "sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA==" - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, - "node_modules/napi-build-utils": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", - "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-abi": { - "version": "3.47.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.47.0.tgz", - "integrity": "sha512-2s6B2CWZM//kPgwnuI0KrYwNjfdByE25zvAaEpq9IH4zcNsarH8Ihu/UuX6XMPEogDAxkuUFeZn60pXNHAqn3A==", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==" - }, - "node_modules/nwsapi": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", - "integrity": "sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==" - }, - "node_modules/object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/parse5": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", - "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "dependencies": { - "entities": "^4.4.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" - }, - "node_modules/prebuild-install": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", - "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^1.0.1", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/prebuild-install/node_modules/tar-fs": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", - "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/prebuild-install/node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" - }, - "node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", - "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "dependencies": { - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" - }, - "node_modules/queue-tick": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", - "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==" - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.1.tgz", - "integrity": "sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" - }, - "node_modules/rrweb-cssom": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", - "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==" - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", - "dependencies": { - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.18.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" - }, - "node_modules/sharp": { - "version": "0.32.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.5.tgz", - "integrity": "sha512-0dap3iysgDkNaPOaOL4X/0akdu0ma62GcdC2NBQ+93eqpePdDdr2/LM0sFdDSMmN7yS+odyZtPsb7tx/cYBKnQ==", - "hasInstallScript": true, - "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.2", - "node-addon-api": "^6.1.0", - "prebuild-install": "^7.1.1", - "semver": "^7.5.4", - "simple-get": "^4.0.1", - "tar-fs": "^3.0.4", - "tunnel-agent": "^0.6.0" - }, - "engines": { - "node": ">=14.15.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, - "node_modules/speech-rule-engine": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/speech-rule-engine/-/speech-rule-engine-4.0.7.tgz", - "integrity": "sha512-sJrL3/wHzNwJRLBdf6CjJWIlxC04iYKkyXvYSVsWVOiC2DSkHmxsqOhEeMsBA9XK+CHuNcsdkbFDnoUfAsmp9g==", - "dependencies": { - "commander": "9.2.0", - "wicked-good-xpath": "1.3.0", - "xmldom-sre": "0.1.31" - }, - "bin": { - "sre": "bin/sre" - } - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/streamx": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.1.tgz", - "integrity": "sha512-fQMzy2O/Q47rgwErk/eGeLu/roaFWV0jVsogDmrszM9uIw8L5OA+t+V93MgYlufNptfjmYR1tOMWhei/Eh7TQA==", - "dependencies": { - "fast-fifo": "^1.1.0", - "queue-tick": "^1.0.1" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" - }, - "node_modules/tar-fs": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz", - "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==", - "dependencies": { - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - } - }, - "node_modules/tar-stream": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz", - "integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tough-cookie": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", - "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", - "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tr46": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", - "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", - "dependencies": { - "punycode": "^2.3.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/w3c-xmlserializer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", - "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", - "dependencies": { - "xml-name-validator": "^4.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-encoding": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", - "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/whatwg-mimetype": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", - "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-url": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz", - "integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==", - "dependencies": { - "tr46": "^4.1.1", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/wicked-good-xpath": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/wicked-good-xpath/-/wicked-good-xpath-1.3.0.tgz", - "integrity": "sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw==" - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "node_modules/ws": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", - "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml-name-validator": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", - "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", - "engines": { - "node": ">=12" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" - }, - "node_modules/xmldom-sre": { - "version": "0.1.31", - "resolved": "https://registry.npmjs.org/xmldom-sre/-/xmldom-sre-0.1.31.tgz", - "integrity": "sha512-f9s+fUkX04BxQf+7mMWAp5zk61pciie+fFLC9hX9UVvCeJQfNHRHXpeo5MPcR0EUf57PYLdt+ZO4f3Ipk2oZUw==", - "engines": { - "node": ">=0.1" - } - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - } - } -} diff --git a/tex/package.json b/tex/package.json deleted file mode 100644 index 45f87df..0000000 --- a/tex/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "dependencies": { - "express": "^4.18.2", - "jsdom": "^22.1.0", - "mathjax": "^3.2.2", - "mathjax-full": "^3.2.2", - "sharp": "^0.32.5" - } -} diff --git a/tex/server.js b/tex/server.js deleted file mode 100644 index 1abfd38..0000000 --- a/tex/server.js +++ /dev/null @@ -1,99 +0,0 @@ -const { mathjax } = require('mathjax-full/js/mathjax.js'); -const { TeX } = require('mathjax-full/js/input/tex.js'); -const { SVG } = require('mathjax-full/js/output/svg.js'); -const { liteAdaptor } = require('mathjax-full/js/adaptors/liteAdaptor.js'); -const { RegisterHTMLHandler } = require('mathjax-full/js/handlers/html.js'); -const { AllPackages } = require('mathjax-full/js/input/tex/AllPackages.js'); -const { JSDOM } = require('jsdom'); -const express = require('express'); -const bodyParser = require('body-parser'); -const sharp = require('sharp'); - - -const app = express(); -const host = process.env.HOST || '127.0.0.1'; -const port = Number.parseInt(process.env.PORT || '8080', 10); - -app.use(bodyParser.json()); - -const adaptor = liteAdaptor(); -RegisterHTMLHandler(adaptor); - -async function convertToSvg(latex) { - try { - const tex = new TeX({ packages: AllPackages }); - const svg = new SVG(); - const doc = mathjax.document('', { InputJax: tex, OutputJax: svg }); - const node = doc.convert(latex, { display: true, em: 16, ex: 8, containerWidth: 80 }); - - const svgString = adaptor.outerHTML(node); - const svgTag = svgString.match(/]*>[\s\S]*<\/svg>/g)[0]; - - if (svgTag.includes('data-mjx-error')) { - const errorTitle = svgTag.match(/title="([^"]+)"/)[1]; - throw new Error(errorTitle); - } - const dom = new JSDOM(svgTag); - const textElements = dom.window.document.querySelectorAll('text'); - textElements.forEach(text => { - text.setAttribute('font-family', 'Noto Serif CJK JP, serif'); - }); - const updatedSvgTag = dom.window.document.querySelector('svg').outerHTML; - - return updatedSvgTag; - - } catch (error) { - throw new Error(`LaTeX error: ${error.message}`); - } -} - -app.post('/render/png', async (req, res) => { - try { - const latex = req.body.latex; - if (!latex) { - return res.status(400).send('LaTeX string is required.'); - } - - const svgString = await convertToSvg(latex); - const padding = 20; // 余白のサイズ - - const image = await sharp(Buffer.from(svgString)) - .resize({ height: 500 }) - .flatten({ background: { r: 255, g: 255, b: 255 } }) - .extend({ top: padding, bottom: padding, left: padding, right: padding, background: { r: 255, g: 255, b: 255, alpha: 1 } }) // 余白を追加 - .png() - .toBuffer(); - - res.contentType('image/png'); - res.send(image); - } catch (error) { - handleRenderingError(error, res); - } -}); - -app.post('/render/svg', async (req, res) => { - try { - const latex = req.body.latex; - if (!latex) { - return res.status(400).send('LaTeX string is required.'); - } - - const svgString = await convertToSvg(latex); - res.contentType('image/svg+xml'); - res.send(svgString); - } catch (error) { - handleRenderingError(error, res); - } -}); - -function handleRenderingError(error, res) { - if (error.message.startsWith('LaTeX error:')) { - return res.status(400).send(error.message); - } - console.error(error); - res.status(500).send('An error occurred while rendering the LaTeX string.'); -} - -app.listen(port, host, () => { - console.log(`Server is running at http://${host}:${port}`); -}); From f75962025c44cc1e10b4806cf7e5ccd2bba5fa5e Mon Sep 17 00:00:00 2001 From: Gakuto Furuya Date: Sat, 18 Jul 2026 14:15:46 +0900 Subject: [PATCH 08/15] Rewrite docs and privacy policy for the TypeScript bot --- discord/config/privacy-policy.md | 74 ++++++++----- docs/en/index.md | 153 +++++++++---------------- docs/ja/index.md | 184 ++++++------------------------- 3 files changed, 133 insertions(+), 278 deletions(-) diff --git a/discord/config/privacy-policy.md b/discord/config/privacy-policy.md index 1724d17..516b581 100644 --- a/discord/config/privacy-policy.md +++ b/discord/config/privacy-policy.md @@ -4,32 +4,52 @@ Gakuto Furuya (gaato.) -## Bot +## Bots - **CodeRunBot**#4348 - -## What data we collect - -- Message content of the channel where the bot can be seen -- Internal error logs (not related to the user) - -## How we use it - -### Message content - -- The bot uses to determine if a command is being called. -- If so, the bot use it to create a response. - -It is not used for any other purpose and is discarded immediately. - -### Internal error logs - -- For debugging purposes. - -## How to opt-out of providing us that data - -The /opt-out command will prevent the bot from tracking the content of your messages. Note that this command will disable most of the features of the bot. - -## An accessible way for users to request to and deletion of their data - -Originally, users data is not saved because it is immediately discarded. +- **gaato bot** (private) + +## What data we process + +- **Message content** in channels the bot can read, used to detect and execute + commands (e.g. `]run`, `]tex`) and, for gaato bot, to reply when mentioned. + Message content is processed transiently and is not stored by us, except as + described under "Error logs" below. +- **Opt-out list**: if you use `/opt-out`, your Discord user ID is stored + persistently in cloud object storage so that the setting survives restarts. + `/opt-in` removes it. +- **Error logs**: when a command fails unexpectedly, the invoking command text + and the internal error trace are posted to a private developer-only Discord + channel for debugging. + +## Third-party services + +Some commands send your input to external services to do their work: + +- `/run`, `]run`: your code and standard input are sent to + [Wandbox](https://wandbox.org) for execution. +- `]wolf`, `/wolf` (gaato bot): your query is sent to Wolfram|Alpha. +- `/translate` and mention replies (gaato bot): the text to translate, or the + mentioning message together with up to 10 recent messages in the channel, is + sent to OpenAI. Messages authored by opted-out users are excluded from that + context. +- `/tex`, `]tex`: LaTeX is rendered inside the bot itself; nothing is sent to + a third party. + +These services receive only what is needed to fulfil the command and are +governed by their own privacy policies. + +## Opting out + +`/opt-out` stops all passive processing of your message content: prefix +commands, mention replies, edit tracking, and inclusion of your messages in +OpenAI context. Explicitly invoked slash commands and context menu commands +keep working, since using them is itself consent to process that input. + +## Data deletion + +We do not build up a store of user data: message content is discarded after a +command completes. To remove your stored user ID from the opt-out list, use +`/opt-in`. For anything else (e.g. an error log that captured your command), +ask on the [support server](https://discord.gg/qRpYRTgvXM) and it will be +deleted. diff --git a/docs/en/index.md b/docs/en/index.md index b7dc52f..5b9019d 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -1,118 +1,69 @@ -# Bot help -## Index -- [Introduction](#Intoroduction) -- [Commands](#Commands) - - [run](#run) - - [Example](#e1) - - [Languages](#Languages) - - [save](#save) - - [tex](#tex) - - [Example](#e2) - - [texp](#texp) - - [Example](#e3) - -## Intoroduction -This Bot is made by Gart ([@gaato__](https://twitter.com/gaato__)). - -[Here](https://discord.gg/qRpYRTgvXM) is the discord server. +# How to use CodeRunBot + +## Introduction + +This bot is made by gaato ([@gaato__](https://twitter.com/gaato__)). + +- Official Discord server: [here](https://discord.gg/qRpYRTgvXM) +- [Invite link](https://discord.com/api/oauth2/authorize?client_id=761428259241328680&permissions=0&scope=bot) + +## Installation + +There are two ways to use the bot. + +- **Add to a server**: adds slash commands plus `]`-prefixed text commands. +- **User install**: add the app to your own account and use the slash commands + (`/run`, `/tex`, ...) in DMs and in servers that don't have the bot. + ## Commands -### run + +### /run + +Pick a language (with autocomplete) and a form opens for your code and +standard input. The code runs on [Wandbox](https://wandbox.org). + +Supported languages are fetched dynamically from Wandbox — whatever appears in +the autocomplete is what is available. + +### ]run (server only) + ``` ]run language code ``` -runs your code. -And the bot ignores "```" for a code block. -#### Example +Code fences (```) are ignored. + ``` ]run python print('hello') ``` -#### Languages -- Bash (bash) -- C (c) -- C# (c#) -- CLISP (clisp) -- CoffeeScript (coffeescript) -- Crystal (crystal) -- D (d) -- Elixir (elixir) -- Erlang (erlang) -- F# (f#) -- Free Pascal (fpc) -- Go (go) -- Groovy (groovy) -- Haskell (haskell) -- Java (java) -- JavaScript (javascript) -- Lazy K (lazyk) -- Lua (lua) -- Nim (nim) -- OCaml (ocaml) -- OpenSSL (openssl) -- Perl (perl) -- PHP (php) -- Pony (pony) -- PyPy (pypy) -- Python (python) -- R (r) -- Rill (rill) -- Ruby (ruby) -- Rust (rust) -- Scala (scala) -- SQL (sql) -- Swift (swift) -- TypeScript (typescript) -- VimScript (vimscript) - -### save -``` -]save language -code -``` -saves your code. -You can call it by sending as follows. -``` -]run saved -standard input -``` -### tex -``` -]tex -math commands -``` -images your math commands. +### /tex -And the bot ignores "```" for a code block. +Opens a form for LaTeX input. The `env` option wraps your input in an +align / gather environment, and `spoiler` posts the image as a spoiler. + +### ]tex / ]stex (server only) -It uses LaTeX gather* environment. -#### Example -``` -]tex -\int_0^1 f(x)\,dx -``` -### texp ``` -]texp -LaTeX text +]tex x^2 + y^2 = r^2 ``` -images your LaTeX text. -And the bot ignores "```" for a code block. +Renders LaTeX (math mode) as an image. `]stex` posts it as a spoiler. -It uses LaTeX text mode. -#### Example -``` -]texp -For real constants $a,b,c$ the solution of the quadratic equation -\[ - ax^2+bx+c=0 -\] -for complex number $x$ is -\[ - x=\frac{-b\pm\sqrt{b^2-4ac}}{2a} -\] -. -``` +### Right-click a message → Apps → escape + +Shows the message content with Markdown and mentions escaped (visible only to you). + +### /privacy-policy, /opt-out, /opt-in + +Shows the privacy policy and lets you opt out of / back into message content +processing. See the [privacy policy](../../discord/config/privacy-policy.md) +for details. + +## Handy behaviors + +- Every bot reply has a **Delete button** that only the invoking user can press. +- If you **edit** the message that invoked `]run` or `]tex`, the bot's reply is + updated automatically. diff --git a/docs/ja/index.md b/docs/ja/index.md index 014595b..0e1dbfc 100644 --- a/docs/ja/index.md +++ b/docs/ja/index.md @@ -1,183 +1,67 @@ -# Bot の使い方 - -## 目次 - -- [はじめに](#はじめに) -- [コマンド](#コマンド) - - [run](#run) - - [例](#e1) - - [言語一覧](#言語一覧) - - [save](#save) - - [tex](#tex) - - [例](#e2) - - [texp](#texp) - - [例](#e3) - - [stex](#stex) - - [stexp](#stexp) - - [texpdf](#texpdf) - - [例](#e4) +# CodeRunBot の使い方 ## はじめに この Bot はがーと([@gaato__](https://twitter.com/gaato__))によって作られました. -公式 Discord サーバーは[こちら](https://discord.gg/qRpYRTgvXM) +- 公式 Discord サーバーは[こちら](https://discord.gg/qRpYRTgvXM) +- [招待リンク](https://discord.com/api/oauth2/authorize?client_id=761428259241328680&permissions=0&scope=bot) -## コマンド +## 導入方法 -### run +2 つの使い方があります. -``` -]run 言語 -コード -``` -であなたのコードを実行します. - -なおコードブロックのための "```" は無視されます. - -#### 例 - -``` -]run python -print('hello') -``` - -#### 言語一覧 - -- Bash (bash) -- C (c) -- C# (c#) -- CLISP (clisp) -- CoffeeScript (coffeescript) -- Crystal (crystal) -- D (d) -- Elixir (elixir) -- Erlang (erlang) -- F# (f#) -- Free Pascal (fpc) -- Go (go) -- Groovy (groovy) -- Haskell (haskell) -- Java (java) -- JavaScript (javascript) -- Lazy K (lazyk) -- Lua (lua) -- Nim (nim) -- OCaml (ocaml) -- OpenSSL (openssl) -- Perl (perl) -- PHP (php) -- Pony (pony) -- PyPy (pypy) -- Python (python) -- R (r) -- Rill (rill) -- Ruby (ruby) -- Rust (rust) -- Scala (scala) -- SQL (sql) -- Swift (swift) -- TypeScript (typescript) -- VimScript (vimscript) - -### save - -``` -]save 言語 -コード -``` -であなたのコードを保存します. -``` -]run saved -標準入力 -``` -で呼び出せます. +- **サーバーに追加**: 招待リンクからサーバーに Bot を追加すると,スラッシュコマンドに加えて + `]` プレフィックスのテキストコマンドが使えます. +- **ユーザーインストール**: 自分のアカウントにアプリとして追加すると,Bot がいないサーバーや + DM でもスラッシュコマンド(`/run`,`/tex` など)が使えます. -### tex +## コマンド -``` -]tex -数式コマンド -``` -であなたの数式コマンドを画像化します. +### /run -なおコードブロックのための "```" は無視されます. +言語を選ぶ(オートコンプリート対応)とコード入力用のフォームが開きます. +コードと標準入力を入れて送信すると [Wandbox](https://wandbox.org) で実行されます. -LaTeX の数式モード(gather*環境)を使っています. +対応言語は Wandbox から動的に取得されるため,オートコンプリートに出るものがすべてです. -#### 例 +### ]run(サーバー専用) ``` -]tex -\int_0^1 f(x)\,dx +]run 言語 +コード ``` -### texp +コードブロックのための ``` は無視されます. ``` -]texp -LaTeXによるテキスト +]run python +print('hello') ``` -であなたの LaTeX テキストを画像化します. -なおコードブロックのための "```" は無視されます. +### /tex -LaTeX のテキストモードを使っています. +数式入力用のフォームが開きます.`env` オプションで align / gather 環境を選べ, +`spoiler` オプションでスポイラー画像にできます. -#### 例 +### ]tex / ]stex(サーバー専用) ``` -]texp -実定数$a,b,c$に対して,複素数$x$に関する二次方程式 -\[ - ax^2+bx+c=0 -\] -の解は -\[ - x=\frac{-b\pm\sqrt{b^2-4ac}}{2a} -\] -と表される. +]tex x^2 + y^2 = r^2 ``` -### stex +LaTeX(数式モード)を画像にします.`]stex` はスポイラー画像になります. -``` -]stex -数式コマンド -``` -[tex](#tex) コマンドでスポイラーをかけます. +### メッセージを右クリック → アプリ → escape -### stexp - -``` -]stexp -LaTeXによるテキスト -``` -[stex](#stex) + [texp](#texp) +メッセージの Markdown とメンションをエスケープした生テキストを表示します(自分にのみ表示). -### texpdf +### /privacy-policy, /opt-out, /opt-in -``` -]texpdf -LaTeX文書 -``` -で LaTeX により PDF を作成します. +プライバシーポリシーの表示と,メッセージ内容の処理のオプトアウト/再開ができます. +詳しくは[プライバシーポリシー](../../discord/config/privacy-policy.md)を参照してください. -#### 例 +## 便利な挙動 -``` -]texpdf -\documentclass[uplatex]{jsarticle} -\usepackage{amsmath} -\begin{document} -実定数$a,b,c$に対して,複素数$x$に関する二次方程式 -\[ - ax^2+bx+c=0 -\] -の解は -\[ - x=\frac{-b\pm\sqrt{b^2-4ac}}{2a} -\] -と表される. -\end{document} -``` \ No newline at end of file +- Bot の返信には **Delete ボタン**が付き,コマンドを実行した本人だけが削除できます. +- `]run` や `]tex` の呼び出しメッセージを**編集すると,Bot の返信も自動で更新**されます. From 65d6b862f638cf8261b3551b91aed96c21bca526 Mon Sep 17 00:00:00 2001 From: Gakuto Furuya Date: Sat, 18 Jul 2026 15:17:13 +0900 Subject: [PATCH 09/15] Migrate TeX rendering to MathJax v4 Replace mathjax-full v3 with @mathjax/src and the newcm font package. TeX extensions are now enumerated explicitly (AllPackages is gone in v4), compile errors throw via formatError instead of being scraped out of data-mjx-error markup, and conversion goes through convertPromise so the dynamically split font data can load on demand. --- discord/package.json | 3 +- discord/pnpm-lock.yaml | 135 ++++++++++------ discord/src/features/tex/renderer.test.ts | 18 ++- discord/src/features/tex/renderer.ts | 180 +++++++++++++++++----- 4 files changed, 247 insertions(+), 89 deletions(-) diff --git a/discord/package.json b/discord/package.json index 689d25c..f12ac3b 100644 --- a/discord/package.json +++ b/discord/package.json @@ -18,11 +18,12 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1089.0", + "@mathjax/mathjax-newcm-font": "^4.1.3", + "@mathjax/src": "^4.1.3", "discord.js": "^14.27.0", "dotenv": "^17.4.2", "i18next": "^26.3.6", "iso-639-1": "^3.1.6", - "mathjax-full": "^3.2.2", "openai": "^6.48.0", "pino": "^10.3.1", "sharp": "^0.35.3" diff --git a/discord/pnpm-lock.yaml b/discord/pnpm-lock.yaml index 10ad49b..c0944a9 100644 --- a/discord/pnpm-lock.yaml +++ b/discord/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@aws-sdk/client-s3': specifier: ^3.1089.0 version: 3.1089.0 + '@mathjax/mathjax-newcm-font': + specifier: ^4.1.3 + version: 4.1.3 + '@mathjax/src': + specifier: ^4.1.3 + version: 4.1.3 discord.js: specifier: ^14.27.0 version: 14.27.0 @@ -23,9 +29,6 @@ importers: iso-639-1: specifier: ^3.1.6 version: 3.1.6 - mathjax-full: - specifier: ^3.2.2 - version: 3.2.2 openai: specifier: ^6.48.0 version: 6.48.0(@aws-sdk/credential-provider-node@3.972.70)(@smithy/signature-v4@5.6.6)(ws@8.21.1) @@ -248,6 +251,9 @@ packages: '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} @@ -572,6 +578,12 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@mathjax/mathjax-newcm-font@4.1.3': + resolution: {integrity: sha512-gzAB3dFHilHX1l5x2xUqRL+1jDQt3Fyza1DkEMVXWC4E8SvsGdlgEza47HYi2WhVcgfkvf4zgUGzuhbq3Pjlew==} + + '@mathjax/src@4.1.3': + resolution: {integrity: sha512-rIrWquuBSoJuoMBdC/1qD+AUHTorlccPicoVy6P2xbUgnuDBpCcpbHtOAsB8L3hdCHtNBg92lF8e3Fz+pkcQbw==} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -752,9 +764,6 @@ packages: '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} - '@types/node@26.1.1': - resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} - '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -934,9 +943,9 @@ packages: resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} engines: {node: '>=18'} - commander@13.1.0: - resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} - engines: {node: '>=18'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} commander@15.0.0: resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} @@ -1065,10 +1074,6 @@ packages: resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - esm@3.2.25: - resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} - engines: {node: '>=6'} - esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -1124,6 +1129,10 @@ packages: get-own-enumerable-property-symbols@3.0.2: resolution: {integrity: sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==} + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + global-directory@4.0.1: resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==} engines: {node: '>=18'} @@ -1302,6 +1311,10 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + madge@8.0.0: resolution: {integrity: sha512-9sSsi3TBPhmkTCIpVQF0SPiChj1L7Rq9kU2KDG1o6v2XH9cCw086MopjVCD+vuoL5v8S77DTbVopTO8OUiQpIw==} engines: {node: '>=18'} @@ -1318,10 +1331,6 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - mathjax-full@3.2.2: - resolution: {integrity: sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w==} - deprecated: Version 4 replaces this package with the scoped package @mathjax/src - mhchemparser@4.2.1: resolution: {integrity: sha512-kYmyrCirqJf3zZ9t/0wGgRZ4/ZJw//VwaRVGA75C4nhE60vtnIzhl9J9ndkX/h6hxSN7pjg/cE0VxbnNM+bnDQ==} @@ -1336,8 +1345,12 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - mj-context-menu@0.6.1: - resolution: {integrity: sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mj-context-menu@1.0.0: + resolution: {integrity: sha512-OSgBFQRCVhrZwRa9lhqnKcJU92dd/YXgBjup0uyeuj9bOaVsf4myOyrjU4PfhYkdDk6AqVUTov7v5uFMvIbduA==} module-definition@6.0.2: resolution: {integrity: sha512-SvAU3lB0+Yjbq55yHY3wkRZBOh+fhU1SnIF3IFbTewv6mtAh7yUT8ACHAJ2mGIJ7tCes2QuCL/cl6m0JSZ/ArA==} @@ -1397,6 +1410,9 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse-ms@2.1.0: resolution: {integrity: sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==} engines: {node: '>=6'} @@ -1404,6 +1420,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1505,6 +1525,11 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} + rimraf@6.1.3: + resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} + engines: {node: 20 || >=22} + hasBin: true + rolldown@1.1.5: resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1559,8 +1584,8 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} - speech-rule-engine@4.1.4: - resolution: {integrity: sha512-i/VCLG1fvRc95pMHRqG4aQNscv+9aIsqA2oI7ZQS51sTdUcDHYX6cpT8/tqZ+enjs1tKVwbRBWgxut9SWn+f9g==} + speech-rule-engine@5.0.0-rc.4: + resolution: {integrity: sha512-3dFcH2QtQNhtvUUc09TxoaEK4WG03B9Z57krFG9jocrKykKGu35BhIkWr0vgEAxZZomEWkeluff69y/EbYzP4Q==} hasBin: true split2@4.2.0: @@ -1673,9 +1698,6 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici-types@8.3.0: - resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} - undici@6.27.0: resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} engines: {node: '>=18.17'} @@ -2081,6 +2103,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.2': dependencies: tslib: 2.8.1 @@ -2253,7 +2280,7 @@ snapshots: '@img/sharp-wasm32@0.35.3': dependencies: - '@emnapi/runtime': 1.11.1 + '@emnapi/runtime': 1.11.2 optional: true '@img/sharp-webcontainers-wasm32@0.35.3': @@ -2272,6 +2299,15 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@mathjax/mathjax-newcm-font@4.1.3': {} + + '@mathjax/src@4.1.3': + dependencies: + '@mathjax/mathjax-newcm-font': 4.1.3 + mhchemparser: 4.2.1 + mj-context-menu: 1.0.0 + speech-rule-engine: 5.0.0-rc.4 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 @@ -2411,13 +2447,9 @@ snapshots: dependencies: undici-types: 7.18.2 - '@types/node@26.1.1': - dependencies: - undici-types: 8.3.0 - '@types/ws@8.18.1': dependencies: - '@types/node': 26.1.1 + '@types/node': 24.13.3 '@typescript-eslint/project-service@8.64.0(typescript@5.9.3)': dependencies: @@ -2607,7 +2639,7 @@ snapshots: commander@12.1.0: {} - commander@13.1.0: {} + commander@14.0.3: {} commander@15.0.0: {} @@ -2789,8 +2821,6 @@ snapshots: eslint-visitor-keys@5.0.1: {} - esm@3.2.25: {} - esprima@4.0.1: {} estraverse@5.3.0: {} @@ -2837,6 +2867,12 @@ snapshots: get-own-enumerable-property-symbols@3.0.2: {} + glob@13.0.6: + dependencies: + minimatch: 10.2.5 + minipass: 7.1.3 + path-scurry: 2.0.2 + global-directory@4.0.1: dependencies: ini: 4.1.1 @@ -2954,6 +2990,8 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + lru-cache@11.5.2: {} + madge@8.0.0(typescript@6.0.3): dependencies: chalk: 4.1.2 @@ -2979,13 +3017,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - mathjax-full@3.2.2: - dependencies: - esm: 3.2.25 - mhchemparser: 4.2.1 - mj-context-menu: 0.6.1 - speech-rule-engine: 4.1.4 - mhchemparser@4.2.1: {} mimic-fn@2.1.0: {} @@ -2996,7 +3027,11 @@ snapshots: minimist@1.2.8: {} - mj-context-menu@0.6.1: {} + minipass@7.1.3: {} + + mj-context-menu@1.0.0: + dependencies: + rimraf: 6.1.3 module-definition@6.0.2: dependencies: @@ -3043,10 +3078,17 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + package-json-from-dist@1.0.1: {} + parse-ms@2.1.0: {} path-parse@1.0.7: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + pathe@2.0.3: {} picocolors@1.1.1: {} @@ -3167,6 +3209,11 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 + rimraf@6.1.3: + dependencies: + glob: 13.0.6 + package-json-from-dist: 1.0.1 + rolldown@1.1.5: dependencies: '@oxc-project/types': 0.139.0 @@ -3251,10 +3298,10 @@ snapshots: source-map@0.6.1: optional: true - speech-rule-engine@4.1.4: + speech-rule-engine@5.0.0-rc.4: dependencies: '@xmldom/xmldom': 0.9.10 - commander: 13.1.0 + commander: 14.0.3 wicked-good-xpath: 1.3.0 split2@4.2.0: {} @@ -3352,8 +3399,6 @@ snapshots: undici-types@7.18.2: {} - undici-types@8.3.0: {} - undici@6.27.0: {} util-deprecate@1.0.2: {} diff --git a/discord/src/features/tex/renderer.test.ts b/discord/src/features/tex/renderer.test.ts index 71a4e25..364ed15 100644 --- a/discord/src/features/tex/renderer.test.ts +++ b/discord/src/features/tex/renderer.test.ts @@ -10,12 +10,22 @@ describe("TeX renderer", () => { ); }); - it("throws a typed error for invalid TeX", () => { - expect(() => renderTexToSvg("\\invalid")).toThrow(TexRenderError); + it("throws a typed error for invalid TeX", async () => { + await expect(renderTexToSvg("\\frac{1}{")).rejects.toThrow(TexRenderError); }); - it("sets the Japanese serif fallback on SVG text nodes", () => { - const svg = renderTexToSvg("\\text{日本語}"); + it("throws a typed error for undefined macros", async () => { + await expect(renderTexToSvg("\\notarealmacro")).rejects.toThrow( + TexRenderError, + ); + }); + + it("reports the original TeX message for invalid input", async () => { + await expect(renderTexToSvg("{")).rejects.toThrow(/missing close brace/iu); + }); + + it("sets the Japanese serif fallback on SVG text nodes", async () => { + const svg = await renderTexToSvg("\\text{日本語}"); const textTags = svg.match(/]*>/gu) ?? []; expect(textTags.length).toBeGreaterThan(0); diff --git a/discord/src/features/tex/renderer.ts b/discord/src/features/tex/renderer.ts index a392748..e553559 100644 --- a/discord/src/features/tex/renderer.ts +++ b/discord/src/features/tex/renderer.ts @@ -1,22 +1,115 @@ -import * as liteAdaptorModule from "mathjax-full/js/adaptors/liteAdaptor.js"; -import * as htmlHandlerModule from "mathjax-full/js/handlers/html.js"; -import * as allPackagesModule from "mathjax-full/js/input/tex/AllPackages.js"; -import * as texModule from "mathjax-full/js/input/tex.js"; -import * as mathjaxModule from "mathjax-full/js/mathjax.js"; -import * as svgModule from "mathjax-full/js/output/svg.js"; +/** + * In-process LaTeX → SVG → PNG renderer built on MathJax v4. + * + * This is the discord.js-free adapter of the tex feature: it knows nothing + * about Discord and exposes plain functions returning SVG text / PNG buffers, + * so it can be unit-tested directly. It replaces the standalone tex web + * service the Python bot used to call over HTTP. + */ +import { MathJaxNewcmFont } from "@mathjax/mathjax-newcm-font/js/svg.js"; +import { liteAdaptor } from "@mathjax/src/js/adaptors/liteAdaptor.js"; +import { RegisterHTMLHandler } from "@mathjax/src/js/handlers/html.js"; +import TexError from "@mathjax/src/js/input/tex/TexError.js"; +import { TeX } from "@mathjax/src/js/input/tex.js"; +import { mathjax } from "@mathjax/src/js/mathjax.js"; +import { SVG } from "@mathjax/src/js/output/svg.js"; +// The newcm font data is split into dynamically loaded pieces; this import +// teaches MathJax to load them with ESM import() when a glyph range is first +// needed (which is also why conversion must go through convertPromise()). +import "@mathjax/src/js/util/asyncLoad/esm.js"; import sharp from "sharp"; +// MathJax v4 dropped the v3 AllPackages bundle, so every TeX extension has to +// be imported for its registration side effect and listed in `packages` +// below. This is the full set shipped in @mathjax/src, minus: +// - noundefined: would render unknown macros as red text instead of letting +// them raise the TexError we turn into a typed render failure +// - noerrors: would swallow TeX errors and typeset the raw source +// - autoload / require: dynamic extension loading is pointless (and +// undesirable) when everything is preloaded +// - setoptions: lets TeX input override parser options at runtime +// - tagformat: only useful with a custom tag-format configuration +// - colorv2: superseded by (and conflicting with) the color package +// - fontsizev3: compatibility shim for v3 font-size behavior +// - texhtml: embeds raw HTML in the output, which we never want +import "@mathjax/src/js/input/tex/action/ActionConfiguration.js"; +import "@mathjax/src/js/input/tex/ams/AmsConfiguration.js"; +import "@mathjax/src/js/input/tex/amscd/AmsCdConfiguration.js"; +import "@mathjax/src/js/input/tex/bbm/BbmConfiguration.js"; +import "@mathjax/src/js/input/tex/bboldx/BboldxConfiguration.js"; +import "@mathjax/src/js/input/tex/bbox/BboxConfiguration.js"; +import "@mathjax/src/js/input/tex/begingroup/BegingroupConfiguration.js"; +import "@mathjax/src/js/input/tex/boldsymbol/BoldsymbolConfiguration.js"; +import "@mathjax/src/js/input/tex/braket/BraketConfiguration.js"; +import "@mathjax/src/js/input/tex/bussproofs/BussproofsConfiguration.js"; +import "@mathjax/src/js/input/tex/cancel/CancelConfiguration.js"; +import "@mathjax/src/js/input/tex/cases/CasesConfiguration.js"; +import "@mathjax/src/js/input/tex/centernot/CenternotConfiguration.js"; +import "@mathjax/src/js/input/tex/color/ColorConfiguration.js"; +import "@mathjax/src/js/input/tex/colortbl/ColortblConfiguration.js"; +import "@mathjax/src/js/input/tex/configmacros/ConfigMacrosConfiguration.js"; +import "@mathjax/src/js/input/tex/dsfont/DsfontConfiguration.js"; +import "@mathjax/src/js/input/tex/empheq/EmpheqConfiguration.js"; +import "@mathjax/src/js/input/tex/enclose/EncloseConfiguration.js"; +import "@mathjax/src/js/input/tex/extpfeil/ExtpfeilConfiguration.js"; +import "@mathjax/src/js/input/tex/gensymb/GensymbConfiguration.js"; +import "@mathjax/src/js/input/tex/html/HtmlConfiguration.js"; +import "@mathjax/src/js/input/tex/mathtools/MathtoolsConfiguration.js"; +import "@mathjax/src/js/input/tex/mhchem/MhchemConfiguration.js"; +import "@mathjax/src/js/input/tex/newcommand/NewcommandConfiguration.js"; +import "@mathjax/src/js/input/tex/physics/PhysicsConfiguration.js"; +import "@mathjax/src/js/input/tex/textcomp/TextcompConfiguration.js"; +import "@mathjax/src/js/input/tex/textmacros/TextMacrosConfiguration.js"; +import "@mathjax/src/js/input/tex/unicode/UnicodeConfiguration.js"; +import "@mathjax/src/js/input/tex/units/UnitsConfiguration.js"; +import "@mathjax/src/js/input/tex/upgreek/UpgreekConfiguration.js"; +import "@mathjax/src/js/input/tex/verb/VerbConfiguration.js"; + +const RENDER_PACKAGES = [ + "base", + "action", + "ams", + "amscd", + "bbm", + "bboldx", + "bbox", + "begingroup", + "boldsymbol", + "braket", + "bussproofs", + "cancel", + "cases", + "centernot", + "color", + "colortbl", + "configmacros", + "dsfont", + "empheq", + "enclose", + "extpfeil", + "gensymb", + "html", + "mathtools", + "mhchem", + "newcommand", + "physics", + "textcomp", + "textmacros", + "unicode", + "units", + "upgreek", + "verb", +]; + +// The newcm font has no CJK glyphs, so e.g. \text{日本語} comes out as plain +// SVG elements. Forcing this family makes librsvg (inside sharp) pick +// the Noto Serif CJK JP installed in the container instead of a fallback that +// lacks the glyphs. const TEXT_FONT_FAMILY = "Noto Serif CJK JP, serif"; const PADDING = 20; -const adaptor = liteAdaptorModule.liteAdaptor(); -htmlHandlerModule.RegisterHTMLHandler(adaptor); - -// `noundefined` turns unknown commands into red text and prevents MathJax from -// producing the data-mjx-error/title pair needed for typed render failures. -const renderPackages = allPackagesModule.AllPackages.filter( - (packageName) => packageName !== "noundefined", -); +const adaptor = liteAdaptor(); +RegisterHTMLHandler(adaptor); export class TexRenderError extends Error { constructor(message: string) { @@ -25,32 +118,50 @@ export class TexRenderError extends Error { } } -export function renderTexToSvg(latex: string): string { - const tex = new texModule.TeX({ packages: renderPackages }); - const svg = new svgModule.SVG(); - const document = mathjaxModule.mathjax.document("", { +export async function renderTexToSvg(latex: string): Promise { + // A fresh TeX input jax and document per call keeps parser state (e.g. + // \newcommand definitions, \begingroup scopes) from leaking across renders. + const tex = new TeX({ + packages: RENDER_PACKAGES, + // By default MathJax typesets compile errors as red text; throw + // instead so callers get a typed failure with the original TeX message. + formatError(_jax: unknown, error: unknown) { + throw error; + }, + }); + const svg = new SVG({ fontData: MathJaxNewcmFont }); + const document = mathjax.document("", { InputJax: tex, OutputJax: svg, }); - const converted = document.convert(latex, { - display: true, - em: 16, - ex: 8, - containerWidth: 80, - }); + + // convertPromise (not convert) so dynamically loaded font pieces can be + // awaited mid-conversion; see the asyncLoad/esm import above. + const converted = await document + .convertPromise(latex, { + display: true, + em: 16, + ex: 8, + containerWidth: 80, + }) + .catch((error: unknown) => { + if (error instanceof TexError) { + throw new TexRenderError(error.message); + } + throw error; + }); + const svgElement = adaptor.tags(converted, "svg")[0]; if (svgElement === undefined) { throw new Error("MathJax did not produce an SVG element"); } + // Defensive fallback: formatError should surface every compile error as an + // exception, but fail loudly if an merror node slips through anyway. const rendered = adaptor.outerHTML(svgElement); if (rendered.includes("data-mjx-error")) { const title = rendered.match(/\btitle=(['"])(.*?)\1/u)?.[2]; - throw new TexRenderError( - title === undefined - ? "Unknown LaTeX rendering error" - : decodeXmlEntities(title), - ); + throw new TexRenderError(title ?? "Unknown LaTeX rendering error"); } for (const textElement of adaptor.tags(svgElement, "text")) { @@ -60,7 +171,7 @@ export function renderTexToSvg(latex: string): string { } export async function renderTexToPng(latex: string): Promise { - const svg = renderTexToSvg(latex); + const svg = await renderTexToSvg(latex); const white = { r: 255, g: 255, b: 255 }; return sharp(Buffer.from(svg)) .resize({ height: 500 }) @@ -75,12 +186,3 @@ export async function renderTexToPng(latex: string): Promise { .png() .toBuffer(); } - -function decodeXmlEntities(value: string): string { - return value - .replaceAll(""", '"') - .replaceAll("'", "'") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll("&", "&"); -} From e8d90a85595327e716935eaee34428d0670c7dac Mon Sep 17 00:00:00 2001 From: Gakuto Furuya Date: Sat, 18 Jul 2026 15:26:35 +0900 Subject: [PATCH 10/15] Document the codebase Add file-header comments describing each module's architectural role and inline comments for the non-obvious constraints: Discord API limits (ack window, autocomplete cap, Components V2 text budget), the latest-wins generation scheme in the reply coordinator, snowflake/string and S3 compatibility details in the state layer, and the history behind the pinned Wandbox compiler map. --- discord/src/config.ts | 3 +++ discord/src/env.test.ts | 3 +++ discord/src/env.ts | 5 +++++ discord/src/features/code/index.ts | 6 ++++++ discord/src/features/code/presentation.test.ts | 3 +++ discord/src/features/code/presentation.ts | 3 +++ discord/src/features/code/wandbox.test.ts | 3 +++ discord/src/features/code/wandbox.ts | 10 ++++++++++ discord/src/features/misc/index.test.ts | 3 +++ discord/src/features/misc/index.ts | 8 ++++++++ discord/src/features/ping/index.ts | 3 +++ discord/src/features/privacy/index.ts | 3 +++ discord/src/features/tex/index.ts | 5 +++++ discord/src/features/tex/presentation.test.ts | 3 +++ discord/src/features/tex/presentation.ts | 3 +++ discord/src/features/tex/renderer.test.ts | 3 +++ discord/src/features/translate/index.test.ts | 3 +++ discord/src/features/translate/index.ts | 5 +++++ discord/src/features/wolfram/index.ts | 4 ++++ discord/src/features/wolfram/wolframClient.test.ts | 3 +++ discord/src/features/wolfram/wolframClient.ts | 4 ++++ discord/src/index.ts | 4 ++++ discord/src/platform/discord/components.ts | 4 ++++ discord/src/platform/discord/context.ts | 3 +++ discord/src/platform/discord/errorPresenter.ts | 5 +++++ discord/src/platform/discord/paginator.ts | 6 ++++++ discord/src/platform/discord/replyCoordinator.test.ts | 3 +++ discord/src/platform/discord/replyCoordinator.ts | 10 ++++++++++ discord/src/platform/discord/router.test.ts | 3 +++ discord/src/platform/discord/router.ts | 10 ++++++++++ discord/src/shared/i18n.ts | 4 ++++ discord/src/shared/limitedMap.test.ts | 3 +++ discord/src/shared/limitedMap.ts | 3 +++ discord/src/shared/logger.ts | 3 +++ discord/src/shared/state.test.ts | 3 +++ discord/src/shared/state.ts | 8 ++++++++ discord/src/types.ts | 3 +++ 37 files changed, 161 insertions(+) diff --git a/discord/src/config.ts b/discord/src/config.ts index 6c1b8c6..5f44aae 100644 --- a/discord/src/config.ts +++ b/discord/src/config.ts @@ -1,3 +1,6 @@ +/** + * Defines bot profiles used by the composition root to select features and defaults. + */ import type { BotName } from "./env.js"; import type { FeatureId } from "./types.js"; diff --git a/discord/src/env.test.ts b/discord/src/env.test.ts index 265beb9..d51f339 100644 --- a/discord/src/env.test.ts +++ b/discord/src/env.test.ts @@ -1,3 +1,6 @@ +/** + * Covers environment parsing at the composition boundary, including local and S3 state modes. + */ import { describe, expect, it } from "vitest"; import { loadEnv } from "./env.js"; diff --git a/discord/src/env.ts b/discord/src/env.ts index ff8deae..6ac1be0 100644 --- a/discord/src/env.ts +++ b/discord/src/env.ts @@ -1,3 +1,7 @@ +/** + * Loads and validates process configuration at the application's environment boundary. + * No other source file reads process.env, as enforced by Biome's noProcessEnv rule. + */ import "dotenv/config"; export type BotName = "coderunbot" | "gaato-bot"; @@ -42,6 +46,7 @@ const DEFAULT_OPENAI_CHAT_MODEL_LITE = "gpt-5-mini"; const DEFAULT_OPENAI_TRANSLATE_MODEL = "gpt-5-mini"; function nonEmpty(value: string | undefined): string | undefined { + // Empty strings count as unset, matching the Python bot's truthiness check for GAATO_BOT. return value === undefined || value.trim().length === 0 ? undefined : value; } diff --git a/discord/src/features/code/index.ts b/discord/src/features/code/index.ts index 1e2d33c..fb47853 100644 --- a/discord/src/features/code/index.ts +++ b/discord/src/features/code/index.ts @@ -1,3 +1,6 @@ +/** + * Defines the code-execution feature and its Discord command entry points. + */ import { ActionRowBuilder, ApplicationCommandType, @@ -97,6 +100,7 @@ export function createCodeFeature(dependencies: FeatureDependencies): Feature { new ActionRowBuilder().addComponents(code), new ActionRowBuilder().addComponents(stdin), ); + // Discord accepts a modal only as the interaction's first response. await interaction.showModal(modal); return undefined; }, @@ -108,6 +112,7 @@ export function createCodeFeature(dependencies: FeatureDependencies): Feature { wandbox .getLanguageChoices() .filter(({ value }) => value.startsWith(focused)) + // Discord caps autocomplete responses at 25 choices. .slice(0, 25), ); }, @@ -135,6 +140,7 @@ export function createCodeFeature(dependencies: FeatureDependencies): Feature { ); const code = interaction.fields.getTextInputValue("code"); const stdin = interaction.fields.getTextInputValue("stdin"); + // Acknowledge within Discord's three-second interaction window before remote work. await interaction.deferReply(); return runCode(wandbox, languageKey, code, stdin, context.locale); }, diff --git a/discord/src/features/code/presentation.test.ts b/discord/src/features/code/presentation.test.ts index e848a48..a8383fd 100644 --- a/discord/src/features/code/presentation.test.ts +++ b/discord/src/features/code/presentation.test.ts @@ -1,3 +1,6 @@ +/** + * Verifies code command parsing and Discord presentation payloads for the code feature. + */ import { ContainerBuilder } from "discord.js"; import { describe, expect, it } from "vitest"; import { getFixedT } from "../../shared/i18n.js"; diff --git a/discord/src/features/code/presentation.ts b/discord/src/features/code/presentation.ts index 4a785ee..445ab25 100644 --- a/discord/src/features/code/presentation.ts +++ b/discord/src/features/code/presentation.ts @@ -1,3 +1,6 @@ +/** + * Translates code feature results into Discord reply values at the feature boundary. + */ import { type AttachmentBuilder, ContainerBuilder, diff --git a/discord/src/features/code/wandbox.test.ts b/discord/src/features/code/wandbox.test.ts index eed4628..1d4a0c3 100644 --- a/discord/src/features/code/wandbox.test.ts +++ b/discord/src/features/code/wandbox.test.ts @@ -1,3 +1,6 @@ +/** + * Tests the discord.js-free Wandbox adapter's schema guards, selection, and cache behavior. + */ import { describe, expect, it, vi } from "vitest"; import { languageChoices, diff --git a/discord/src/features/code/wandbox.ts b/discord/src/features/code/wandbox.ts index 87d9c28..755f7ca 100644 --- a/discord/src/features/code/wandbox.ts +++ b/discord/src/features/code/wandbox.ts @@ -1,3 +1,7 @@ +/** + * Adapts Wandbox compiler discovery and execution for the code feature. + * The adapter is discord.js-free and unit-tested directly. + */ import type { AppLogger } from "../../shared/logger.js"; const LIST_URL = "https://wandbox.org/api/list.json"; @@ -11,6 +15,8 @@ const NIM_COMPILER_OPTIONS = [ "--hint[SuccessX]:off", ].join("\n"); +// Dynamic selection broke twice: an API format changed, then a HEAD compiler won the heuristic. +// Prefer known compiler pins and fall back to a non-development candidate when a pin disappears. export const PINNED_COMPILERS: Readonly> = { python: "cpython-3.14.0", "c++": "gcc-13.2.0", @@ -109,6 +115,7 @@ export function normalizeLanguageKey(language: string): string { } export function parseCompilerList(value: unknown): WandboxCompiler[] { + // Guard the remote schema so an upstream format change produces a typed, loud failure. if (!Array.isArray(value)) { throw new WandboxSchemaError("Wandbox compiler list is not an array"); } @@ -174,6 +181,7 @@ export function resolveCompilerFromList( } function parseCompileResult(value: unknown): WandboxCompileResult { + // Apply the same schema guard to compile output so response changes fail loudly. if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new WandboxSchemaError("Wandbox compile result is not an object"); } @@ -203,6 +211,7 @@ export class WandboxClient { readonly #now: () => number; readonly #cacheTtlMs: number; #compilers: WandboxCompiler[] = []; + // The compiler-list cache refreshes on a TTL rather than on every command. #lastRefreshAttempt = Number.NEGATIVE_INFINITY; #refreshing?: Promise; @@ -343,6 +352,7 @@ export class WandboxClient { } } } catch (error) { + // A failed refresh leaves #compilers untouched so commands can use stale valid data. if (error instanceof WandboxSchemaError) { this.#logger.error( { error }, diff --git a/discord/src/features/misc/index.test.ts b/discord/src/features/misc/index.test.ts index ec57937..53b6e99 100644 --- a/discord/src/features/misc/index.test.ts +++ b/discord/src/features/misc/index.test.ts @@ -1,3 +1,6 @@ +/** + * Covers mention throttling, parsed-mention gating, and private-history filtering. + */ import type { Message } from "discord.js"; import { describe, expect, it, vi } from "vitest"; import type { FeatureDependencies } from "../../types.js"; diff --git a/discord/src/features/misc/index.ts b/discord/src/features/misc/index.ts index 0d4b5f9..1fc1b12 100644 --- a/discord/src/features/misc/index.ts +++ b/discord/src/features/misc/index.ts @@ -1,3 +1,6 @@ +/** + * Implements the miscellaneous mention-response feature over Discord and OpenAI. + */ import type { Message } from "discord.js"; import OpenAI from "openai"; import type { ChatCompletionMessageParam } from "openai/resources/chat/completions"; @@ -52,6 +55,7 @@ export async function fetchRecentHistory( limit = 10, ): Promise { const fetched = await message.channel.messages.fetch({ limit }); + // Opted-out users' messages must never enter the OpenAI conversation context. return [...fetched.values()] .reverse() .filter((entry) => !optOutUsers.has(entry.author.id)) @@ -80,6 +84,7 @@ export function createMiscFeature( if ( message.author.bot || botUser === null || + // Parsed mentions avoid the old bot's false positives from substring-matching its ID. !message.mentions.has(botUser) || !limiter.allow(message.author.id) ) { @@ -87,6 +92,7 @@ export function createMiscFeature( } if ("sendTyping" in message.channel) { + // Messages use typing for feedback; interactions must ack within three seconds instead. await message.channel.sendTyping(); } const history = await fetchRecentHistory( @@ -109,6 +115,8 @@ export function createMiscFeature( ...history, ], }); + // Replies directly instead of returning an OutgoingReply, matching the old bot: + // chat responses get no Delete button and no edit-follow tracking. await message.reply({ content: response.choices[0]?.message.content ?? "", allowedMentions: { parse: [], repliedUser: true }, diff --git a/discord/src/features/ping/index.ts b/discord/src/features/ping/index.ts index e80da58..39ec1f8 100644 --- a/discord/src/features/ping/index.ts +++ b/discord/src/features/ping/index.ts @@ -1,3 +1,6 @@ +/** + * Supplies a small feature-layer smoke test through a Discord slash command. + */ import { SlashCommandBuilder } from "discord.js"; import { successContainer } from "../../platform/discord/components.js"; import type { Feature, FeatureDependencies } from "../../types.js"; diff --git a/discord/src/features/privacy/index.ts b/discord/src/features/privacy/index.ts index 0cb0a65..4fee0ce 100644 --- a/discord/src/features/privacy/index.ts +++ b/discord/src/features/privacy/index.ts @@ -1,3 +1,6 @@ +/** + * Implements privacy-policy and opt-out commands in the feature layer. + */ import { readFile } from "node:fs/promises"; import { SlashCommandBuilder } from "discord.js"; import { getFixedT } from "../../shared/i18n.js"; diff --git a/discord/src/features/tex/index.ts b/discord/src/features/tex/index.ts index a7a9d47..73d15b1 100644 --- a/discord/src/features/tex/index.ts +++ b/discord/src/features/tex/index.ts @@ -1,3 +1,6 @@ +/** + * Defines TeX feature commands and delegates rendering to the Discord-free adapter. + */ import { ActionRowBuilder, ModalBuilder, @@ -92,6 +95,7 @@ export function createTexFeature(_dependencies: FeatureDependencies): Feature { .addComponents( new ActionRowBuilder().addComponents(input), ); + // Discord accepts a modal only as the interaction's first response. await interaction.showModal(modal); return undefined; }, @@ -102,6 +106,7 @@ export function createTexFeature(_dependencies: FeatureDependencies): Feature { const [, spoilerValue] = interaction.customId.split(":"); const spoiler = spoilerValue === "true"; const latex = interaction.fields.getTextInputValue("latex"); + // Acknowledge within Discord's three-second interaction window before rendering. await interaction.deferReply(); return renderReply(latex, spoiler, context.locale); }, diff --git a/discord/src/features/tex/presentation.test.ts b/discord/src/features/tex/presentation.test.ts index ae2ba4f..4651009 100644 --- a/discord/src/features/tex/presentation.test.ts +++ b/discord/src/features/tex/presentation.test.ts @@ -1,3 +1,6 @@ +/** + * Verifies TeX input cleanup, hints, and Discord presentation payloads. + */ import { ContainerBuilder } from "discord.js"; import { describe, expect, it } from "vitest"; import { getFixedT } from "../../shared/i18n.js"; diff --git a/discord/src/features/tex/presentation.ts b/discord/src/features/tex/presentation.ts index f693469..b343505 100644 --- a/discord/src/features/tex/presentation.ts +++ b/discord/src/features/tex/presentation.ts @@ -1,3 +1,6 @@ +/** + * Builds Discord reply values from TeX feature results at the presentation boundary. + */ import { AttachmentBuilder, ContainerBuilder, diff --git a/discord/src/features/tex/renderer.test.ts b/discord/src/features/tex/renderer.test.ts index 364ed15..365f7d2 100644 --- a/discord/src/features/tex/renderer.test.ts +++ b/discord/src/features/tex/renderer.test.ts @@ -1,3 +1,6 @@ +/** + * Exercises the discord.js-free TeX rendering adapter against SVG, PNG, and error cases. + */ import { describe, expect, it } from "vitest"; import { renderTexToPng, renderTexToSvg, TexRenderError } from "./renderer.js"; diff --git a/discord/src/features/translate/index.test.ts b/discord/src/features/translate/index.test.ts index c6e405b..dad32e5 100644 --- a/discord/src/features/translate/index.test.ts +++ b/discord/src/features/translate/index.test.ts @@ -1,3 +1,6 @@ +/** + * Tests translation-language autocomplete and resolution without Discord transport calls. + */ import { describe, expect, it } from "vitest"; import { autocompleteLanguages, resolveLanguage } from "./index.js"; diff --git a/discord/src/features/translate/index.ts b/discord/src/features/translate/index.ts index 29e5209..6d648cb 100644 --- a/discord/src/features/translate/index.ts +++ b/discord/src/features/translate/index.ts @@ -1,3 +1,6 @@ +/** + * Implements the translation feature and its Discord presentation over OpenAI. + */ import { type AttachmentBuilder, ContainerBuilder, @@ -42,6 +45,7 @@ export function autocompleteLanguages( return name.length === 0 ? [] : [{ name, value: normalized }]; } + // Discord caps autocomplete responses at 25 choices. return languageNames .filter((name) => name.toLowerCase().startsWith(normalized)) .sort( @@ -130,6 +134,7 @@ export function createTranslateFeature( } const original = interaction.options.getString("text", true); + // Acknowledge within Discord's three-second interaction window before the API call. await interaction.deferReply(); const response = await completion.create({ model: dependencies.env.openAITranslateModel, diff --git a/discord/src/features/wolfram/index.ts b/discord/src/features/wolfram/index.ts index d901520..1d71c1f 100644 --- a/discord/src/features/wolfram/index.ts +++ b/discord/src/features/wolfram/index.ts @@ -1,3 +1,6 @@ +/** + * Implements the Wolfram feature and paginates adapter results for Discord. + */ import { ContainerBuilder, MediaGalleryBuilder, @@ -66,6 +69,7 @@ export function createWolframFeature( if (!interaction.isChatInputCommand()) { return undefined; } + // Acknowledge within Discord's three-second interaction window before the API call. await interaction.deferReply(); return execute( interaction.options.getString("query", true), diff --git a/discord/src/features/wolfram/wolframClient.test.ts b/discord/src/features/wolfram/wolframClient.test.ts index 5403061..15d3cd3 100644 --- a/discord/src/features/wolfram/wolframClient.test.ts +++ b/discord/src/features/wolfram/wolframClient.test.ts @@ -1,3 +1,6 @@ +/** + * Tests the discord.js-free Wolfram adapter's response parsing and schema failures. + */ import { describe, expect, it } from "vitest"; import { parseWolframResponse, WolframResponseError } from "./wolframClient.js"; diff --git a/discord/src/features/wolfram/wolframClient.ts b/discord/src/features/wolfram/wolframClient.ts index 47aebec..447cce6 100644 --- a/discord/src/features/wolfram/wolframClient.ts +++ b/discord/src/features/wolfram/wolframClient.ts @@ -1,3 +1,7 @@ +/** + * Adapts Wolfram|Alpha HTTP responses into feature-domain query results. + * The adapter is discord.js-free and unit-tested directly. + */ const WOLFRAM_QUERY_URL = "http://api.wolframalpha.com/v2/query"; export interface WolframImage { diff --git a/discord/src/index.ts b/discord/src/index.ts index 7bdb5c7..fd46fb7 100644 --- a/discord/src/index.ts +++ b/discord/src/index.ts @@ -1,3 +1,7 @@ +/** + * Composes the selected bot profile, shared services, features, and Discord platform router. + * This is the application's single composition root. + */ import { Client, GatewayIntentBits, Partials, REST } from "discord.js"; import { getBotProfile } from "./config.js"; import { loadEnv } from "./env.js"; diff --git a/discord/src/platform/discord/components.ts b/discord/src/platform/discord/components.ts index b73e7fc..ced1649 100644 --- a/discord/src/platform/discord/components.ts +++ b/discord/src/platform/discord/components.ts @@ -1,3 +1,6 @@ +/** + * Builds reusable Discord Components V2 presentation primitives for the platform layer. + */ import { ActionRowBuilder, AttachmentBuilder, @@ -51,6 +54,7 @@ export function textComponent( filename = "output.txt", ): TextComponentResult { const lineCount = text.length === 0 ? 0 : text.split("\n").length; + // Components V2 caps total text at 4,000 characters, so >1,000 chars or >100 lines use a file. if ( text.length > TEXT_FILE_CHARACTER_LIMIT || lineCount > TEXT_FILE_LINE_LIMIT diff --git a/discord/src/platform/discord/context.ts b/discord/src/platform/discord/context.ts index 31b9d65..b5d032b 100644 --- a/discord/src/platform/discord/context.ts +++ b/discord/src/platform/discord/context.ts @@ -1,3 +1,6 @@ +/** + * Normalizes Discord interactions and messages into the platform's request context. + */ import type { ButtonInteraction, ChatInputCommandInteraction, diff --git a/discord/src/platform/discord/errorPresenter.ts b/discord/src/platform/discord/errorPresenter.ts index 7282cc7..8575afc 100644 --- a/discord/src/platform/discord/errorPresenter.ts +++ b/discord/src/platform/discord/errorPresenter.ts @@ -1,3 +1,7 @@ +/** + * Converts handler failures into user replies and reports diagnostics through Discord. + * This platform service keeps error delivery separate from feature handlers. + */ import { AttachmentBuilder, type Client, @@ -57,6 +61,7 @@ export class ErrorPresenter { async #logToDiscord(invocation: string, error: Error): Promise { const stack = error.stack ?? `${error.name}: ${error.message}`; + // Try the channel cache, then an API fetch; any lookup or send failure falls back to Pino. let channel = this.#client.channels.cache.get(this.#logChannelId); if (channel === undefined) { diff --git a/discord/src/platform/discord/paginator.ts b/discord/src/platform/discord/paginator.ts index ef75920..4bec702 100644 --- a/discord/src/platform/discord/paginator.ts +++ b/discord/src/platform/discord/paginator.ts @@ -1,3 +1,6 @@ +/** + * Adds process-local, collector-driven pagination to platform reply components. + */ import { randomUUID } from "node:crypto"; import { ActionRowBuilder, @@ -62,6 +65,8 @@ export function paginatedReply(options: PaginatorOptions): OutgoingReply { throw new Error("paginator requires at least one page"); } + // The collector owns pg: interactions, so the reserved namespace bypasses the central router. + // Page state lives only in this process; after a restart, old buttons stop working by design. const token = randomUUID(); let pageIndex = 0; const messageComponents = (disabled = false) => [ @@ -77,6 +82,7 @@ export function paginatedReply(options: PaginatorOptions): OutgoingReply { navigationRow(token, 0, options.pages.length), ], async onDelivered(message) { + // A message-scoped collector keeps each paginator's mutable page index out of the router. const collector = message.createMessageComponentCollector({ componentType: ComponentType.Button, filter: (interaction) => diff --git a/discord/src/platform/discord/replyCoordinator.test.ts b/discord/src/platform/discord/replyCoordinator.test.ts index 10b254b..82ea076 100644 --- a/discord/src/platform/discord/replyCoordinator.test.ts +++ b/discord/src/platform/discord/replyCoordinator.test.ts @@ -1,3 +1,6 @@ +/** + * Exercises latest-wins generations and editable reply reuse in the Discord platform service. + */ import type { MessageEditOptions } from "discord.js"; import { describe, expect, it, vi } from "vitest"; import type { ReplyTarget } from "./context.js"; diff --git a/discord/src/platform/discord/replyCoordinator.ts b/discord/src/platform/discord/replyCoordinator.ts index b00b661..493494f 100644 --- a/discord/src/platform/discord/replyCoordinator.ts +++ b/discord/src/platform/discord/replyCoordinator.ts @@ -1,3 +1,7 @@ +/** + * Delivers feature reply values through Discord and tracks editable message replies. + * This platform service applies latest-wins coordination to message edits. + */ import { type BaseMessageOptions, type InteractionEditReplyOptions, @@ -70,6 +74,7 @@ const discordReplyTransport: ReplyTransport = { content: payload.content, components: payload.components, files: payload.files, + // API v10 accepts an empty attachment set to replace files left by the prior reply. attachments: payload.replaceAttachments ? [] : undefined, flags: editFlags(payload), }; @@ -92,6 +97,7 @@ const discordReplyTransport: ReplyTransport = { content: payload.content ?? null, components: payload.components, files: payload.files, + // API v10 attachment replacement needs [] or stale files survive a message edit. attachments: [], flags: editFlags(payload), }; @@ -123,6 +129,7 @@ export type DeliveryResult = "sent" | "edited" | "stale"; export class ReplyCoordinator { readonly #capacity: number; readonly #transport: ReplyTransport; + // Per-feature caps bound source-message tracking memory in long-running processes. readonly #trackers = new Map>(); constructor( @@ -136,6 +143,7 @@ export class ReplyCoordinator { begin(featureId: FeatureId, sourceMessageId: string): number { const tracker = this.#tracker(featureId); const current = tracker.get(sourceMessageId); + // Each source edit starts a generation; only the latest async invocation may publish. const generation = (current?.generation ?? 0) + 1; tracker.set(sourceMessageId, { ...current, generation }); return generation; @@ -160,6 +168,7 @@ export class ReplyCoordinator { const generation = request.generation; const tracker = this.#tracker(request.featureId); const state = tracker.get(sourceMessageId); + // A newer invocation finished or remains in flight, so discard this stale result. if (state?.generation !== generation) { return "stale"; } @@ -178,6 +187,7 @@ export class ReplyCoordinator { const reply = await this.#transport.send(request.target, payload); const latest = tracker.get(sourceMessageId); if (latest?.generation !== generation) { + // The generation changed during send; delete the reply that lost the race. await this.#transport.delete(reply); return "stale"; } diff --git a/discord/src/platform/discord/router.test.ts b/discord/src/platform/discord/router.test.ts index 87181a0..a21be9f 100644 --- a/discord/src/platform/discord/router.test.ts +++ b/discord/src/platform/discord/router.test.ts @@ -1,3 +1,6 @@ +/** + * Tests startup route validation and prefix parsing in the Discord platform router. + */ import { describe, expect, it } from "vitest"; import type { Feature } from "../../types.js"; import { buildRouteRegistry, parsePrefixCommand } from "./router.js"; diff --git a/discord/src/platform/discord/router.ts b/discord/src/platform/discord/router.ts index 3c249ce..dcc7fc6 100644 --- a/discord/src/platform/discord/router.ts +++ b/discord/src/platform/discord/router.ts @@ -1,3 +1,6 @@ +/** + * Registers feature routes and dispatches Discord events in the platform layer. + */ import { ApplicationIntegrationType, type AutocompleteInteraction, @@ -66,6 +69,7 @@ function addUnique( value: Value, routeType: string, ): void { + // Registry construction happens in the router constructor, so duplicates fail at startup. if (map.has(route)) { throw new Error(`duplicate ${routeType} route: ${route}`); } @@ -77,6 +81,7 @@ export function buildRouteRegistry( ): RouteRegistry { const slash = new Map(); const prefix = new Map(); + // customIds use namespace:...; the first colon-delimited segment selects the route. const custom = new Map(); const featureIds = new Set(); @@ -129,6 +134,7 @@ export function buildRouteRegistry( } } + // Delete handling and collector-based pagination own these platform namespaces. for (const reserved of ["delete", "pg"]) { if (custom.has(reserved)) { throw new Error(`customId namespace is reserved: ${reserved}`); @@ -224,6 +230,7 @@ export class DiscordRouter { async #onReady(client: Client): Promise { const body = [...this.#routes.slash.values()].map(({ command }) => ({ ...command.data.toJSON(), + // User-installable apps require both install types and all supported invocation contexts. integration_types: [ ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall, @@ -236,6 +243,7 @@ export class DiscordRouter { })); try { + // ClientReady provides the application ID; REST.put bulk-overwrites commands idempotently. await this.#rest.put(Routes.applicationCommands(client.application.id), { body, }); @@ -369,6 +377,8 @@ export class DiscordRouter { if (prefixRoute !== undefined && parsed !== undefined) { handledFeatures.add(prefixRoute.featureId); try { + // Messages have no defer API; typing gives feedback while interactions must + // acknowledge within three seconds. if ("sendTyping" in message.channel) { await message.channel.sendTyping(); } diff --git a/discord/src/shared/i18n.ts b/discord/src/shared/i18n.ts index dda51bd..cfff36e 100644 --- a/discord/src/shared/i18n.ts +++ b/discord/src/shared/i18n.ts @@ -1,3 +1,6 @@ +/** + * Owns shared translation resources and request-scoped translator creation. + */ import i18next from "i18next"; const resources = { @@ -142,5 +145,6 @@ await instance.init({ }); export function getFixedT(locale: string) { + // A fixed translator avoids global changeLanguage calls bleeding across concurrent requests. return instance.getFixedT(locale); } diff --git a/discord/src/shared/limitedMap.test.ts b/discord/src/shared/limitedMap.test.ts index bd0f4ee..e7fab94 100644 --- a/discord/src/shared/limitedMap.test.ts +++ b/discord/src/shared/limitedMap.test.ts @@ -1,3 +1,6 @@ +/** + * Verifies the shared bounded map's validation and oldest-entry eviction behavior. + */ import { describe, expect, it } from "vitest"; import { LimitedSizeMap } from "./limitedMap.js"; diff --git a/discord/src/shared/limitedMap.ts b/discord/src/shared/limitedMap.ts index ffae370..adb74d1 100644 --- a/discord/src/shared/limitedMap.ts +++ b/discord/src/shared/limitedMap.ts @@ -1,3 +1,6 @@ +/** + * Provides a shared insertion-ordered map with a fixed upper bound. + */ export class LimitedSizeMap implements Iterable<[Key, Value]> { readonly #entries = new Map(); readonly #capacity: number; diff --git a/discord/src/shared/logger.ts b/discord/src/shared/logger.ts index a757d1e..295b3a1 100644 --- a/discord/src/shared/logger.ts +++ b/discord/src/shared/logger.ts @@ -1,3 +1,6 @@ +/** + * Exposes the shared application logger type and its Pino-backed factory. + */ import pino, { type Logger, type LoggerOptions } from "pino"; export type AppLogger = Logger; diff --git a/discord/src/shared/state.test.ts b/discord/src/shared/state.test.ts index 573aca2..6527800 100644 --- a/discord/src/shared/state.test.ts +++ b/discord/src/shared/state.test.ts @@ -1,3 +1,6 @@ +/** + * Tests the discord.js-free state adapters and serialized opt-out persistence directly. + */ import { mkdtemp, readFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; diff --git a/discord/src/shared/state.ts b/discord/src/shared/state.ts index 1fc4ad5..c9ae427 100644 --- a/discord/src/shared/state.ts +++ b/discord/src/shared/state.ts @@ -1,3 +1,7 @@ +/** + * Adapts local files or S3-compatible storage to the shared opt-out state contract. + * The adapter is discord.js-free and unit-tested directly. + */ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { @@ -55,6 +59,7 @@ export class S3ObjectState implements StateBackend { endpoint: options.endpoint, region: options.region, credentials: options.credentials, + // OCI Object Storage exposes an S3 endpoint that requires path-style bucket addressing. forcePathStyle: true, }); this.#bucket = options.bucket; @@ -71,6 +76,7 @@ export class S3ObjectState implements StateBackend { if (!isMissingS3Object(error)) { throw error; } + // NoSuchKey represents an uninitialized store, so seed the empty state once. await this.write(""); return ""; } @@ -90,7 +96,9 @@ export class S3ObjectState implements StateBackend { export class OptOutUsers { readonly #backend: StateBackend; + // Discord snowflakes exceed Number.MAX_SAFE_INTEGER, so user IDs remain strings end to end. #users = new Set(); + // This promise-chain mutex serializes writes within the bot's single writer process. #writeChain: Promise = Promise.resolve(); constructor(backend: StateBackend) { diff --git a/discord/src/types.ts b/discord/src/types.ts index dd47d06..b9af50c 100644 --- a/discord/src/types.ts +++ b/discord/src/types.ts @@ -1,3 +1,6 @@ +/** + * Defines contracts shared across features, platform code, and the composition root. + */ import type { AutocompleteInteraction, BaseMessageOptions, From 7376d4891f969ab117d5d480b1f8859240c2eda5 Mon Sep 17 00:00:00 2001 From: Gakuto Furuya Date: Sat, 18 Jul 2026 15:28:21 +0900 Subject: [PATCH 11/15] Replace Docker Compose with Podman instructions Production runs on Podman quadlets and local runs need nothing more than podman build/run, so drop compose.yaml and the duplicate Dockerfile (Containerfile stays for the image workflow). --- README.md | 12 +++++++++--- compose.yaml | 11 ----------- discord/Dockerfile | 36 ------------------------------------ 3 files changed, 9 insertions(+), 50 deletions(-) delete mode 100644 compose.yaml delete mode 100644 discord/Dockerfile diff --git a/README.md b/README.md index 1771004..5b6e0fe 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,16 @@ pnpm build pnpm start ``` -### with Docker Compose +### with Podman (or Docker) ```bash -docker compose up --build coderunbot +podman build -t coderunbot discord +podman run --rm --env-file coderunbot.env coderunbot ``` +Add `-v ./discord/data:/app/data` to persist the opt-out list across runs +when using the default local state backend. + ## How to run (gaato bot) Basically the same as CodeRunBot, but set the environment file to include `GAATO_BOT_TOKEN` and `WOLFRAM_APPID`, then run: @@ -49,13 +53,15 @@ cd discord GAATO_BOT=1 pnpm start ``` -With Docker Compose, run `docker compose up --build gaato-bot` instead. +With Podman, add `-e GAATO_BOT=1` to the `podman run` command instead. ## For developer Pull requests are welcome. Please use [pnpm](https://pnpm.io/) to manage dependencies. Do not rewrite `pnpm-lock.yaml` by hand. +TypeScript is pinned to the 6.x line because dependency-cruiser 18 does not +support TypeScript 7 yet. ## Environment diff --git a/compose.yaml b/compose.yaml deleted file mode 100644 index 8f9b258..0000000 --- a/compose.yaml +++ /dev/null @@ -1,11 +0,0 @@ -services: - coderunbot: - build: discord - env_file: .env - restart: unless-stopped - gaato-bot: - build: discord - env_file: .env - environment: - - GAATO_BOT=1 - restart: unless-stopped diff --git a/discord/Dockerfile b/discord/Dockerfile deleted file mode 100644 index 0b3de2a..0000000 --- a/discord/Dockerfile +++ /dev/null @@ -1,36 +0,0 @@ -FROM node:24-slim AS build - -WORKDIR /app - -RUN corepack enable - -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -RUN pnpm install --frozen-lockfile - -COPY tsconfig.json ./ -COPY src ./src -RUN pnpm build \ - && pnpm prune --prod - -FROM node:24-slim AS runtime - -WORKDIR /app - -ENV NODE_ENV=production - -RUN apt-get update \ - && apt-get install -y --no-install-recommends fonts-noto-cjk fonts-noto-cjk-extra \ - && rm -rf /var/lib/apt/lists/* \ - && groupadd --gid 10001 app \ - && useradd --uid 10001 --gid app --home-dir /app --shell /usr/sbin/nologin --no-create-home app \ - && mkdir -p /app/data \ - && chown app:app /app/data - -COPY --from=build --chown=app:app /app/node_modules ./node_modules -COPY --from=build --chown=app:app /app/dist ./dist -COPY --chown=app:app package.json ./package.json -COPY --chown=app:app config ./config - -USER app - -CMD ["node", "dist/index.js"] From f5c70195305fab29c5040800667fb4bcb8082d20 Mon Sep 17 00:00:00 2001 From: Gakuto Furuya Date: Sat, 18 Jul 2026 15:28:56 +0900 Subject: [PATCH 12/15] Relicense under AGPL-3.0-or-later The TypeScript rewrite removed the only externally contributed code, and Apache-2.0 history is one-way compatible with AGPLv3 anyway, so this is the clean point to switch. The privacy policy now links the source code to satisfy the section 13 network-source offer. --- LICENSE | 862 ++++++++++++++++++++++++------- README.md | 9 + discord/config/privacy-policy.md | 5 + discord/package.json | 1 + 4 files changed, 676 insertions(+), 201 deletions(-) diff --git a/LICENSE b/LICENSE index 261eeb9..be3f7b2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,661 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md index 5b6e0fe..bdff352 100644 --- a/README.md +++ b/README.md @@ -68,3 +68,12 @@ support TypeScript 7 yet. For containerized or service-manager based deployment, keep secrets outside the repo and point the process at an environment file. - Example file: [`coderunbot.env.example`](coderunbot.env.example) + +## License + +Copyright (C) Gakuto Furuya + +This program is free software, licensed under the +[GNU Affero General Public License v3.0 or later](LICENSE) (AGPL-3.0-or-later). +If you run a modified version of this bot as a network service, the AGPL +requires you to offer its source code to the users interacting with it. diff --git a/discord/config/privacy-policy.md b/discord/config/privacy-policy.md index 516b581..8b2d549 100644 --- a/discord/config/privacy-policy.md +++ b/discord/config/privacy-policy.md @@ -46,6 +46,11 @@ commands, mention replies, edit tracking, and inclusion of your messages in OpenAI context. Explicitly invoked slash commands and context menu commands keep working, since using them is itself consent to process that input. +## Source code + +The bots are free software (AGPL-3.0-or-later). The full source code is +available at . + ## Data deletion We do not build up a store of user data: message content is discarded after a diff --git a/discord/package.json b/discord/package.json index f12ac3b..cc808d5 100644 --- a/discord/package.json +++ b/discord/package.json @@ -2,6 +2,7 @@ "name": "coderunbot-discord", "version": "0.1.0", "private": true, + "license": "AGPL-3.0-or-later", "type": "module", "packageManager": "pnpm@11.9.0", "engines": { From 242913580347f8158064aa2a836a713ac54635ea Mon Sep 17 00:00:00 2001 From: Gakuto Furuya Date: Sat, 18 Jul 2026 15:38:14 +0900 Subject: [PATCH 13/15] Add a /source command for the AGPL source offer Both bots now answer /source with the repository URL, making the AGPL-3.0 section 13 source offer available directly in Discord in addition to the privacy policy link. --- discord/config/privacy-policy.md | 3 ++- discord/src/features/privacy/index.ts | 19 +++++++++++++++++++ discord/src/shared/i18n.ts | 4 ++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/discord/config/privacy-policy.md b/discord/config/privacy-policy.md index 8b2d549..869953b 100644 --- a/discord/config/privacy-policy.md +++ b/discord/config/privacy-policy.md @@ -49,7 +49,8 @@ keep working, since using them is itself consent to process that input. ## Source code The bots are free software (AGPL-3.0-or-later). The full source code is -available at . +available at , also shown by the +`/source` command. ## Data deletion diff --git a/discord/src/features/privacy/index.ts b/discord/src/features/privacy/index.ts index 4fee0ce..16a03b3 100644 --- a/discord/src/features/privacy/index.ts +++ b/discord/src/features/privacy/index.ts @@ -11,6 +11,10 @@ const privacyPolicyUrl = new URL( import.meta.url, ); +// AGPL-3.0 §13: users interacting with the bot over the network must be offered +// its Corresponding Source. If you run a modified version, point this at your fork. +const SOURCE_URL = "https://github.com/gaato/coderunbot"; + export function createPrivacyFeature({ optOutUsers, }: FeatureDependencies): Feature { @@ -31,6 +35,21 @@ export function createPrivacyFeature({ }; }, }, + { + data: new SlashCommandBuilder() + .setName("source") + .setDescription("Show where to get this bot's source code") + .setDescriptionLocalizations({ + ja: "このボットのソースコードの入手先を表示します", + }), + async execute(_interaction, context) { + const t = getFixedT(context.locale); + return { + kind: "plain", + content: t("privacy.source", { url: SOURCE_URL }), + }; + }, + }, { data: new SlashCommandBuilder() .setName("opt-out") diff --git a/discord/src/shared/i18n.ts b/discord/src/shared/i18n.ts index cfff36e..1fb1f6f 100644 --- a/discord/src/shared/i18n.ts +++ b/discord/src/shared/i18n.ts @@ -12,6 +12,8 @@ const resources = { report: "Please report this problem to us: {{supportLink}}", }, privacy: { + source: + "This bot is free software licensed under the GNU AGPL v3 or later. The full source code is available at {{url}}", optOut: { already: "Your message content is already off-track. To use other commands, please use the /opt-in command.", @@ -77,6 +79,8 @@ const resources = { report: "この問題を報告してください: {{supportLink}}", }, privacy: { + source: + "このボットは GNU AGPL v3 以降のライセンスで公開されている自由ソフトウェアです。ソースコードはこちら: {{url}}", optOut: { already: "メッセージ内容はすでに処理対象外です。他のコマンドを使用するには /opt-in コマンドを使用してください。", From a4e914abcb14510505e0373c00e8c76d09e92dd2 Mon Sep 17 00:00:00 2001 From: Gakuto Furuya Date: Sat, 18 Jul 2026 15:58:57 +0900 Subject: [PATCH 14/15] Collect anonymous usage statistics Aggregate monthly counters (command x outcome, /run language, guild count) are persisted per bot through the existing state backend to inform development decisions. No user IDs, guild IDs, message content, or command arguments are recorded, and the privacy policy documents the counters. Counters flush every five minutes and on SIGTERM/SIGINT. --- discord/config/privacy-policy.md | 4 + discord/src/env.test.ts | 15 ++ discord/src/env.ts | 21 +- discord/src/features/code/index.ts | 13 +- discord/src/features/misc/index.test.ts | 3 + discord/src/features/misc/index.ts | 1 + discord/src/index.ts | 46 +++- discord/src/platform/discord/router.test.ts | 266 +++++++++++++++++++- discord/src/platform/discord/router.ts | 52 +++- discord/src/shared/state.ts | 2 +- discord/src/shared/usageStats.test.ts | 162 ++++++++++++ discord/src/shared/usageStats.ts | 199 +++++++++++++++ discord/src/types.ts | 2 + 13 files changed, 768 insertions(+), 18 deletions(-) create mode 100644 discord/src/shared/usageStats.test.ts create mode 100644 discord/src/shared/usageStats.ts diff --git a/discord/config/privacy-policy.md b/discord/config/privacy-policy.md index 869953b..483c073 100644 --- a/discord/config/privacy-policy.md +++ b/discord/config/privacy-policy.md @@ -21,6 +21,10 @@ Gakuto Furuya (gaato.) - **Error logs**: when a command fails unexpectedly, the invoking command text and the internal error trace are posted to a private developer-only Discord channel for debugging. +- **Usage statistics**: anonymous aggregate counters — which command was used, + whether it succeeded, the language chosen for `/run`, and the number of servers + the bot is in — are stored in cloud object storage. These counters contain no + user IDs, no server IDs, and no message content. ## Third-party services diff --git a/discord/src/env.test.ts b/discord/src/env.test.ts index d51f339..8e61927 100644 --- a/discord/src/env.test.ts +++ b/discord/src/env.test.ts @@ -34,6 +34,7 @@ describe("loadEnv state configuration", () => { expect(loadEnv(baseEnv).state).toEqual({ backend: "local", filePath: "data/opt-out-users.txt", + usageStatsFilePath: "data/usage-stats-coderunbot.json", }); }); @@ -55,11 +56,25 @@ describe("loadEnv state configuration", () => { region: "test-region-1", bucket: "state", key: "bots/coderunbot/opt-out-users.txt", + usageStatsKey: "bots/coderunbot/usage-stats-coderunbot.json", accessKeyId: "access-key", secretAccessKey: "secret-key", }); }); + it("uses a separate usage statistics object for gaato bot", () => { + expect( + loadEnv({ + GAATO_BOT: "1", + GAATO_BOT_TOKEN: "token", + }).state, + ).toEqual({ + backend: "local", + filePath: "data/opt-out-users.txt", + usageStatsFilePath: "data/usage-stats-gaato-bot.json", + }); + }); + it("fails fast when an S3 setting is missing", () => { expect(() => loadEnv({ diff --git a/discord/src/env.ts b/discord/src/env.ts index 6ac1be0..b760f68 100644 --- a/discord/src/env.ts +++ b/discord/src/env.ts @@ -8,7 +8,8 @@ export type BotName = "coderunbot" | "gaato-bot"; export interface LocalStateEnv { readonly backend: "local"; - readonly filePath: "data/opt-out-users.txt"; + readonly filePath: string; + readonly usageStatsFilePath: string; } export interface S3StateEnv { @@ -17,6 +18,7 @@ export interface S3StateEnv { readonly region: string; readonly bucket: string; readonly key: string; + readonly usageStatsKey: string; readonly accessKeyId: string; readonly secretAccessKey: string; } @@ -50,10 +52,14 @@ function nonEmpty(value: string | undefined): string | undefined { return value === undefined || value.trim().length === 0 ? undefined : value; } -function loadStateEnv(source: NodeJS.ProcessEnv): StateEnv { +function loadStateEnv(source: NodeJS.ProcessEnv, botName: BotName): StateEnv { const backend = nonEmpty(source.BOT_STATE_BACKEND) ?? "local"; if (backend === "local") { - return { backend, filePath: "data/opt-out-users.txt" }; + return { + backend, + filePath: "data/opt-out-users.txt", + usageStatsFilePath: `data/usage-stats-${botName}.json`, + }; } if (backend !== "s3") { throw new Error('BOT_STATE_BACKEND must be either "local" or "s3"'); @@ -86,6 +92,10 @@ function loadStateEnv(source: NodeJS.ProcessEnv): StateEnv { bucket: required.S3_BUCKET as string, key: prefix.length > 0 ? `${prefix}/opt-out-users.txt` : "opt-out-users.txt", + usageStatsKey: + prefix.length > 0 + ? `${prefix}/usage-stats-${botName}.json` + : `usage-stats-${botName}.json`, accessKeyId: required.S3_ACCESS_KEY_ID as string, secretAccessKey: required.S3_SECRET_ACCESS_KEY as string, }; @@ -93,6 +103,7 @@ function loadStateEnv(source: NodeJS.ProcessEnv): StateEnv { export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { const gaatoBot = nonEmpty(source.GAATO_BOT) !== undefined; + const botName = gaatoBot ? "gaato-bot" : "coderunbot"; const tokenEnvName = gaatoBot ? "GAATO_BOT_TOKEN" : "CODERUNBOT_TOKEN"; const token = nonEmpty(source[tokenEnvName]); @@ -101,7 +112,7 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { } return { - botName: gaatoBot ? "gaato-bot" : "coderunbot", + botName, token, tokenEnvName, logChannelId: nonEmpty(source.LOG_CHANNEL_ID) ?? DEFAULT_LOG_CHANNEL_ID, @@ -116,6 +127,6 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): Env { nonEmpty(source.OPENAI_CHAT_MODEL_LITE) ?? DEFAULT_OPENAI_CHAT_MODEL_LITE, openAITranslateModel: nonEmpty(source.OPENAI_TRANSLATE_MODEL) ?? DEFAULT_OPENAI_TRANSLATE_MODEL, - state: loadStateEnv(source), + state: loadStateEnv(source, botName), }; } diff --git a/discord/src/features/code/index.ts b/discord/src/features/code/index.ts index fb47853..fac54c8 100644 --- a/discord/src/features/code/index.ts +++ b/discord/src/features/code/index.ts @@ -12,6 +12,7 @@ import { TextInputStyle, } from "discord.js"; import { getFixedT } from "../../shared/i18n.js"; +import type { UsageStats } from "../../shared/usageStats.js"; import type { Feature, FeatureDependencies, @@ -63,6 +64,7 @@ export function createCodeFeature(dependencies: FeatureDependencies): Feature { const input = parsePrefixRunInput(args); return runCode( wandbox, + dependencies.usageStats, input.languageKey, input.code, "", @@ -142,7 +144,14 @@ export function createCodeFeature(dependencies: FeatureDependencies): Feature { const stdin = interaction.fields.getTextInputValue("stdin"); // Acknowledge within Discord's three-second interaction window before remote work. await interaction.deferReply(); - return runCode(wandbox, languageKey, code, stdin, context.locale); + return runCode( + wandbox, + dependencies.usageStats, + languageKey, + code, + stdin, + context.locale, + ); }, }, }; @@ -150,6 +159,7 @@ export function createCodeFeature(dependencies: FeatureDependencies): Feature { async function runCode( wandbox: WandboxClient, + usageStats: Pick, languageKey: string, code: string, stdin: string, @@ -160,6 +170,7 @@ async function runCode( if (resolution === undefined) { return supportedLanguagesReply(wandbox.getLanguageChoices(), t); } + usageStats.recordRunLanguage(languageKey); try { const result = await wandbox.compile(resolution.compiler, code, stdin); diff --git a/discord/src/features/misc/index.test.ts b/discord/src/features/misc/index.test.ts index 53b6e99..2d02381 100644 --- a/discord/src/features/misc/index.test.ts +++ b/discord/src/features/misc/index.test.ts @@ -43,9 +43,11 @@ describe("misc mention response", () => { choices: [{ message: { content: "short answer" } }], }); const completion = { create } as ChatCompletionClient; + const recordCommand = vi.fn(); const dependencies = { client: { user: { id: "bot" } }, optOutUsers: { has: (id: string) => id === "opted-out" }, + usageStats: { recordCommand }, env: { openAIApiKey: "key", developerId: "developer", @@ -71,5 +73,6 @@ describe("misc mention response", () => { content: "short answer", allowedMentions: { parse: [], repliedUser: true }, }); + expect(recordCommand).toHaveBeenCalledWith("mention", "misc", "ok"); }); }); diff --git a/discord/src/features/misc/index.ts b/discord/src/features/misc/index.ts index 1fc1b12..4a57a02 100644 --- a/discord/src/features/misc/index.ts +++ b/discord/src/features/misc/index.ts @@ -121,6 +121,7 @@ export function createMiscFeature( content: response.choices[0]?.message.content ?? "", allowedMentions: { parse: [], repliedUser: true }, }); + dependencies.usageStats.recordCommand("mention", "misc", "ok"); return undefined; }, }; diff --git a/discord/src/index.ts b/discord/src/index.ts index fd46fb7..9c9bb9a 100644 --- a/discord/src/index.ts +++ b/discord/src/index.ts @@ -23,6 +23,7 @@ import { S3ObjectState, type StateBackend, } from "./shared/state.js"; +import { UsageStats } from "./shared/usageStats.js"; import type { Feature, FeatureFactory, FeatureId } from "./types.js"; const featureFactories: Record = { @@ -42,8 +43,10 @@ export async function bootstrap(): Promise { getFixedT(profile.defaultLocale); let stateBackend: StateBackend; + let usageStatsBackend: StateBackend; if (env.state.backend === "local") { stateBackend = new LocalFileState(env.state.filePath); + usageStatsBackend = new LocalFileState(env.state.usageStatsFilePath); } else { stateBackend = new S3ObjectState({ endpoint: env.state.endpoint, @@ -55,9 +58,21 @@ export async function bootstrap(): Promise { secretAccessKey: env.state.secretAccessKey, }, }); + usageStatsBackend = new S3ObjectState({ + endpoint: env.state.endpoint, + region: env.state.region, + bucket: env.state.bucket, + key: env.state.usageStatsKey, + credentials: { + accessKeyId: env.state.accessKeyId, + secretAccessKey: env.state.secretAccessKey, + }, + }); } const optOutUsers = new OptOutUsers(stateBackend); await optOutUsers.init(); + const usageStats = new UsageStats(usageStatsBackend, { logger }); + await usageStats.init(); const client = new Client({ intents: [ GatewayIntentBits.Guilds, @@ -67,7 +82,35 @@ export async function bootstrap(): Promise { ], partials: [Partials.Channel, Partials.Message], }); - const featureDependencies = { optOutUsers, logger, env, client }; + let shuttingDown = false; + const shutdown = async (): Promise => { + if (shuttingDown) { + return; + } + shuttingDown = true; + try { + await usageStats.dispose(); + } catch (error) { + logger.error({ error }, "Failed to flush usage statistics on shutdown"); + } + // destroy() is async in discord.js v14; await it so the gateway close frame is sent. + await client.destroy().catch(() => undefined); + process.exit(0); + }; + process.once("SIGTERM", () => { + void shutdown(); + }); + process.once("SIGINT", () => { + void shutdown(); + }); + + const featureDependencies = { + optOutUsers, + usageStats, + logger, + env, + client, + }; if (env.botName === "gaato-bot") { if (env.wolframAppId === undefined) { logger.warn( @@ -110,6 +153,7 @@ export async function bootstrap(): Promise { replyCoordinator, errorPresenter, logger, + usageStats, messageGate: (message) => !optOutUsers.has(message.author.id), }); router.bind(); diff --git a/discord/src/platform/discord/router.test.ts b/discord/src/platform/discord/router.test.ts index a21be9f..b037d00 100644 --- a/discord/src/platform/discord/router.test.ts +++ b/discord/src/platform/discord/router.test.ts @@ -1,9 +1,24 @@ /** - * Tests startup route validation and prefix parsing in the Discord platform router. + * Tests startup route validation, prefix parsing, and anonymous dispatch instrumentation. */ -import { describe, expect, it } from "vitest"; +import { + type Client, + Events, + type REST, + SlashCommandBuilder, +} from "discord.js"; +import { describe, expect, it, vi } from "vitest"; +import type { BotProfile } from "../../config.js"; +import type { AppLogger } from "../../shared/logger.js"; +import type { UsageStats } from "../../shared/usageStats.js"; import type { Feature } from "../../types.js"; -import { buildRouteRegistry, parsePrefixCommand } from "./router.js"; +import type { ErrorPresenter } from "./errorPresenter.js"; +import type { ReplyCoordinator } from "./replyCoordinator.js"; +import { + buildRouteRegistry, + DiscordRouter, + parsePrefixCommand, +} from "./router.js"; describe("buildRouteRegistry", () => { it("fails fast when customId namespaces overlap across handler kinds", () => { @@ -47,3 +62,248 @@ describe("parsePrefixCommand", () => { }); }); }); + +describe("DiscordRouter usage statistics", () => { + it("records successful slash, prefix, modal, and button dispatches", async () => { + const slashExecute = vi.fn().mockResolvedValue(undefined); + const prefixExecute = vi.fn().mockResolvedValue(undefined); + const modalHandler = vi.fn().mockResolvedValue(undefined); + const buttonHandler = vi.fn().mockResolvedValue(undefined); + const feature: Feature = { + id: "code", + slashCommands: [ + { + data: new SlashCommandBuilder() + .setName("test") + .setDescription("Test command"), + execute: slashExecute, + }, + ], + prefixCommands: [ + { + name: "run", + aliases: ["r"], + execute: prefixExecute, + }, + ], + modalHandlers: { form: modalHandler }, + componentHandlers: { action: buttonHandler }, + }; + const harness = createHarness([feature]); + + harness.dispatch( + Events.InteractionCreate, + interaction({ + commandName: "test", + isChatInputCommand: () => true, + toString: () => "/test", + }), + ); + await waitForCalls(harness.recordCommand, 1); + + harness.dispatch(Events.MessageCreate, { + id: "message", + content: "]r python print(1)", + author: { id: "user", bot: false }, + guild: null, + inGuild: () => false, + channel: { sendTyping: vi.fn().mockResolvedValue(undefined) }, + }); + await waitForCalls(harness.recordCommand, 2); + + harness.dispatch( + Events.InteractionCreate, + interaction({ + customId: "form:value", + isModalSubmit: () => true, + }), + ); + await waitForCalls(harness.recordCommand, 3); + + harness.dispatch( + Events.InteractionCreate, + interaction({ + customId: "action:value", + isButton: () => true, + }), + ); + await waitForCalls(harness.recordCommand, 4); + + expect(harness.recordCommand.mock.calls).toEqual([ + ["slash", "test", "ok"], + ["prefix", "run", "ok"], + ["modal", "form", "ok"], + ["button", "action", "ok"], + ]); + }); + + it("records a slash handler failure even when the error is presented", async () => { + const error = new Error("handler failed"); + const feature: Feature = { + id: "code", + slashCommands: [ + { + data: new SlashCommandBuilder() + .setName("test") + .setDescription("Test command"), + execute: vi.fn().mockRejectedValue(error), + }, + ], + }; + const harness = createHarness([feature]); + + harness.dispatch( + Events.InteractionCreate, + interaction({ + commandName: "test", + isChatInputCommand: () => true, + toString: () => "/test", + }), + ); + + await vi.waitFor(() => { + expect(harness.presentError).toHaveBeenCalledOnce(); + expect(harness.deliver).toHaveBeenCalledOnce(); + }); + expect(harness.recordCommand).toHaveBeenCalledOnce(); + expect(harness.recordCommand).toHaveBeenCalledWith( + "slash", + "test", + "error", + ); + }); + + it("does not record reserved delete or pagination buttons", async () => { + const harness = createHarness([]); + const deleteMessage = vi.fn().mockResolvedValue(undefined); + const deferUpdate = vi.fn().mockResolvedValue(undefined); + + harness.dispatch( + Events.InteractionCreate, + interaction({ + customId: "pg:next", + isButton: () => true, + }), + ); + harness.dispatch( + Events.InteractionCreate, + interaction({ + customId: "delete:user", + isButton: () => true, + deferUpdate, + message: { delete: deleteMessage }, + }), + ); + + await vi.waitFor(() => expect(deleteMessage).toHaveBeenCalledOnce()); + expect(deferUpdate).toHaveBeenCalledOnce(); + expect(harness.recordCommand).not.toHaveBeenCalled(); + }); + + it("records guild counts on ready, join, and leave events", async () => { + const harness = createHarness([]); + harness.guildCache.set("one", {}); + harness.guildCache.set("two", {}); + + harness.dispatch(Events.ClientReady, harness.client); + await vi.waitFor(() => expect(harness.put).toHaveBeenCalledOnce()); + harness.guildCache.set("three", {}); + harness.dispatch(Events.GuildCreate, {}); + harness.guildCache.delete("three"); + harness.dispatch(Events.GuildDelete, {}); + + expect(harness.recordGuildCount.mock.calls).toEqual([[2], [3], [2]]); + }); +}); + +type Listener = (...args: never[]) => void; + +function createHarness(features: readonly Feature[]) { + const listeners = new Map(); + const guildCache = new Map(); + const client = { + application: { id: "application" }, + guilds: { cache: guildCache }, + once: vi.fn((event: string, listener: Listener) => { + listeners.set(event, listener); + }), + on: vi.fn((event: string, listener: Listener) => { + listeners.set(event, listener); + }), + }; + const put = vi.fn().mockResolvedValue(undefined); + const deliver = vi.fn().mockResolvedValue("sent"); + const presentError = vi.fn().mockResolvedValue({ + kind: "plain", + content: "error", + }); + const recordCommand = vi.fn(); + const recordGuildCount = vi.fn(); + const profile: BotProfile = { + name: "coderunbot", + prefix: "]", + defaultLocale: "en", + features: [], + }; + const router = new DiscordRouter({ + client: client as unknown as Client, + rest: { put } as unknown as REST, + profile, + features, + replyCoordinator: { + begin: vi.fn().mockReturnValue(1), + deliver, + deleteReplies: vi.fn().mockResolvedValue(undefined), + } as unknown as ReplyCoordinator, + errorPresenter: { present: presentError } as unknown as ErrorPresenter, + logger: { + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as unknown as AppLogger, + usageStats: { + recordCommand, + recordGuildCount, + } as unknown as UsageStats, + }); + router.bind(); + + return { + client, + deliver, + dispatch(event: string, ...args: unknown[]) { + const listener = listeners.get(event); + if (listener === undefined) { + throw new Error(`event is not bound: ${event}`); + } + listener(...(args as never[])); + }, + guildCache, + presentError, + put, + recordCommand, + recordGuildCount, + }; +} + +function interaction(overrides: Record): unknown { + return { + user: { id: "user" }, + locale: "en-US", + guild: null, + deferred: false, + replied: false, + inGuild: () => false, + isAutocomplete: () => false, + isChatInputCommand: () => false, + isContextMenuCommand: () => false, + isButton: () => false, + isModalSubmit: () => false, + toString: () => "interaction", + ...overrides, + }; +} + +async function waitForCalls(mock: ReturnType, count: number) { + await vi.waitFor(() => expect(mock).toHaveBeenCalledTimes(count)); +} diff --git a/discord/src/platform/discord/router.ts b/discord/src/platform/discord/router.ts index dcc7fc6..eccd5a1 100644 --- a/discord/src/platform/discord/router.ts +++ b/discord/src/platform/discord/router.ts @@ -19,6 +19,7 @@ import { } from "discord.js"; import type { BotProfile } from "../../config.js"; import type { AppLogger } from "../../shared/logger.js"; +import type { UsageStats } from "../../shared/usageStats.js"; import type { ComponentHandler, Feature, @@ -183,6 +184,7 @@ export interface DiscordRouterOptions { readonly replyCoordinator: ReplyCoordinator; readonly errorPresenter: ErrorPresenter; readonly logger: AppLogger; + readonly usageStats?: UsageStats; readonly messageGate?: MessageGate; } @@ -195,6 +197,7 @@ export class DiscordRouter { readonly #replyCoordinator: ReplyCoordinator; readonly #errorPresenter: ErrorPresenter; readonly #logger: AppLogger; + readonly #usageStats?: UsageStats; readonly #messageGate: MessageGate; constructor(options: DiscordRouterOptions) { @@ -206,6 +209,7 @@ export class DiscordRouter { this.#replyCoordinator = options.replyCoordinator; this.#errorPresenter = options.errorPresenter; this.#logger = options.logger; + this.#usageStats = options.usageStats; this.#messageGate = options.messageGate ?? (() => true); } @@ -225,9 +229,16 @@ export class DiscordRouter { this.#client.on(Events.MessageDelete, (message) => { void this.#replyCoordinator.deleteReplies(message.id); }); + this.#client.on(Events.GuildCreate, () => { + this.#usageStats?.recordGuildCount(this.#client.guilds.cache.size); + }); + this.#client.on(Events.GuildDelete, () => { + this.#usageStats?.recordGuildCount(this.#client.guilds.cache.size); + }); } async #onReady(client: Client): Promise { + this.#usageStats?.recordGuildCount(client.guilds.cache.size); const body = [...this.#routes.slash.values()].map(({ command }) => ({ ...command.data.toJSON(), // User-installable apps require both install types and all supported invocation contexts. @@ -291,8 +302,15 @@ export class DiscordRouter { interaction, this.#profile.defaultLocale, ); - await this.#invoke(route.featureId, context, interaction.toString(), () => - route.command.execute(interaction, context), + await this.#invoke( + route.featureId, + context, + interaction.toString(), + () => route.command.execute(interaction, context), + { + kind: interaction.isChatInputCommand() ? "slash" : "context-menu", + name: interaction.commandName, + }, ); } @@ -344,8 +362,12 @@ export class DiscordRouter { interaction, this.#profile.defaultLocale, ); - await this.#invoke(route.featureId, context, interaction.customId, () => - route.handler(interaction, context), + await this.#invoke( + route.featureId, + context, + interaction.customId, + () => route.handler(interaction, context), + { kind: "button", name: namespace }, ); } @@ -359,8 +381,12 @@ export class DiscordRouter { interaction, this.#profile.defaultLocale, ); - await this.#invoke(route.featureId, context, interaction.customId, () => - route.handler(interaction, context), + await this.#invoke( + route.featureId, + context, + interaction.customId, + () => route.handler(interaction, context), + { kind: "modal", name: namespace }, ); } @@ -390,6 +416,7 @@ export class DiscordRouter { context, message.content, () => prefixRoute.command.execute(message, parsed.args, context), + { kind: "prefix", name: prefixRoute.command.name }, ); } @@ -419,10 +446,11 @@ export class DiscordRouter { context: RequestContext, invocation: string, handler: () => ReturnType, + stat?: { readonly kind: string; readonly name: string }, ): Promise { const sourceMessageId = (context.replyTarget as Message).id; const generation = this.#replyCoordinator.begin(featureId, sourceMessageId); - await this.#invoke(featureId, context, invocation, handler, { + await this.#invoke(featureId, context, invocation, handler, stat, { sourceMessageId, generation, }); @@ -433,6 +461,7 @@ export class DiscordRouter { context: RequestContext, invocation: string, handler: () => ReturnType, + stat?: { readonly kind: string; readonly name: string }, tracking?: { readonly sourceMessageId: string; readonly generation: number; @@ -441,6 +470,9 @@ export class DiscordRouter { try { const outgoing = await handler(); if (outgoing === undefined) { + if (stat !== undefined) { + this.#usageStats?.recordCommand(stat.kind, stat.name, "ok"); + } return; } await this.#replyCoordinator.deliver({ @@ -450,7 +482,13 @@ export class DiscordRouter { outgoing, ...tracking, }); + if (stat !== undefined) { + this.#usageStats?.recordCommand(stat.kind, stat.name, "ok"); + } } catch (error) { + if (stat !== undefined) { + this.#usageStats?.recordCommand(stat.kind, stat.name, "error"); + } try { const outgoing = await this.#errorPresenter.present( error, diff --git a/discord/src/shared/state.ts b/discord/src/shared/state.ts index c9ae427..2f17318 100644 --- a/discord/src/shared/state.ts +++ b/discord/src/shared/state.ts @@ -1,5 +1,5 @@ /** - * Adapts local files or S3-compatible storage to the shared opt-out state contract. + * Adapts local files or S3-compatible storage to the shared persistent state contract. * The adapter is discord.js-free and unit-tested directly. */ import { mkdir, readFile, writeFile } from "node:fs/promises"; diff --git a/discord/src/shared/usageStats.test.ts b/discord/src/shared/usageStats.test.ts new file mode 100644 index 0000000..c6d94d6 --- /dev/null +++ b/discord/src/shared/usageStats.test.ts @@ -0,0 +1,162 @@ +/** + * Tests anonymous usage aggregation, recovery, serialization, and restart persistence. + */ +import { describe, expect, it, vi } from "vitest"; +import type { AppLogger } from "./logger.js"; +import type { StateBackend } from "./state.js"; +import { UsageStats } from "./usageStats.js"; + +class MemoryState implements StateBackend { + readonly writes: string[] = []; + + constructor(public value = "") {} + + async read(): Promise { + return this.value; + } + + async write(value: string): Promise { + this.value = value; + this.writes.push(value); + } +} + +describe("UsageStats", () => { + it("buckets command outcomes and languages by UTC month", async () => { + const state = new MemoryState(); + let now = new Date("2026-07-31T23:59:59.000Z"); + const stats = new UsageStats(state, { now: () => now }); + await stats.init(); + + stats.recordCommand("slash", "tex", "ok"); + stats.recordCommand("slash", "tex", "error"); + stats.recordCommand("slash", "tex", "ok"); + stats.recordRunLanguage("python"); + now = new Date("2026-08-01T00:00:00.000Z"); + stats.recordCommand("prefix", "run", "ok"); + stats.recordRunLanguage("cpp"); + await stats.flush(); + + expect(JSON.parse(state.value)).toEqual({ + version: 1, + months: { + "2026-07": { + commands: { "slash/tex": { ok: 2, error: 1 } }, + runLanguages: { python: 1 }, + guilds: {}, + }, + "2026-08": { + commands: { "prefix/run": { ok: 1, error: 0 } }, + runLanguages: { cpp: 1 }, + guilds: {}, + }, + }, + }); + await stats.dispose(); + }); + + it("overwrites the guild snapshot within a UTC day", async () => { + const state = new MemoryState(); + let now = new Date("2026-07-18T00:00:01.000Z"); + const stats = new UsageStats(state, { now: () => now }); + await stats.init(); + + stats.recordGuildCount(40); + now = new Date("2026-07-18T23:59:59.000Z"); + stats.recordGuildCount(42); + now = new Date("2026-07-19T00:00:00.000Z"); + stats.recordGuildCount(41); + await stats.flush(); + + expect(JSON.parse(state.value).months["2026-07"].guilds).toEqual({ + "2026-07-18": 42, + "2026-07-19": 41, + }); + await stats.dispose(); + }); + + it("recovers from corrupt JSON and warns through the optional logger", async () => { + const state = new MemoryState("{not json"); + const warn = vi.fn(); + const logger = { warn } as unknown as AppLogger; + const stats = new UsageStats(state, { + now: () => new Date("2026-07-18T12:00:00.000Z"), + logger, + }); + + await stats.init(); + stats.recordCommand("slash", "run", "ok"); + await stats.flush(); + + expect(warn).toHaveBeenCalledOnce(); + expect(JSON.parse(state.value)).toEqual({ + version: 1, + months: { + "2026-07": { + commands: { "slash/run": { ok: 1, error: 0 } }, + runLanguages: {}, + guilds: {}, + }, + }, + }); + await stats.dispose(); + }); + + it("writes pretty-printed JSON with a trailing newline only when dirty", async () => { + const state = new MemoryState(); + const stats = new UsageStats(state, { + now: () => new Date("2026-07-18T12:00:00.000Z"), + }); + await stats.init(); + + await stats.flush(); + expect(state.writes).toEqual([]); + stats.recordRunLanguage("python"); + await stats.flush(); + + expect(state.writes).toEqual([ + `${JSON.stringify( + { + version: 1, + months: { + "2026-07": { + commands: {}, + runLanguages: { python: 1 }, + guilds: {}, + }, + }, + }, + null, + 2, + )}\n`, + ]); + await stats.dispose(); + }); + + it("loads persisted counters and continues them after a restart", async () => { + const state = new MemoryState(); + const now = () => new Date("2026-07-18T12:00:00.000Z"); + const first = new UsageStats(state, { now }); + await first.init(); + first.recordCommand("slash", "tex", "ok"); + first.recordRunLanguage("python"); + await first.dispose(); + + const second = new UsageStats(state, { now }); + await second.init(); + second.recordCommand("slash", "tex", "error"); + second.recordRunLanguage("python"); + await second.dispose(); + + expect(JSON.parse(state.value)).toEqual({ + version: 1, + months: { + "2026-07": { + commands: { "slash/tex": { ok: 1, error: 1 } }, + runLanguages: { python: 2 }, + guilds: {}, + }, + }, + }); + }); +}); diff --git a/discord/src/shared/usageStats.ts b/discord/src/shared/usageStats.ts new file mode 100644 index 0000000..3b8c57f --- /dev/null +++ b/discord/src/shared/usageStats.ts @@ -0,0 +1,199 @@ +/** + * Stores anonymous monthly usage counters through the shared state backend. + * The collector is discord.js-free and keeps no invocation-level identifiers or content. + */ +import type { AppLogger } from "./logger.js"; +import type { StateBackend } from "./state.js"; + +interface CommandCounts { + ok: number; + error: number; +} + +interface MonthStats { + commands: Record; + runLanguages: Record; + guilds: Record; +} + +interface UsageStatsDocument { + version: 1; + months: Record; +} + +export interface UsageStatsOptions { + readonly flushIntervalMs?: number; + readonly now?: () => Date; + readonly logger?: AppLogger; +} + +const DEFAULT_FLUSH_INTERVAL_MS = 300_000; + +export class UsageStats { + readonly #backend: StateBackend; + readonly #flushIntervalMs: number; + readonly #now: () => Date; + readonly #logger?: AppLogger; + #state: UsageStatsDocument = emptyDocument(); + #dirty = false; + #flushTimer?: NodeJS.Timeout; + // This promise-chain mutex serializes writes within the bot's single writer process. + #writeChain: Promise = Promise.resolve(); + + constructor(backend: StateBackend, options: UsageStatsOptions = {}) { + this.#backend = backend; + this.#flushIntervalMs = + options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS; + this.#now = options.now ?? (() => new Date()); + this.#logger = options.logger; + } + + async init(): Promise { + const value = await this.#backend.read(); + if (value.trim().length === 0) { + this.#state = emptyDocument(); + } else { + try { + this.#state = parseDocument(value); + } catch (error) { + this.#logger?.warn( + { error }, + "Usage statistics state is corrupt; starting with empty counters", + ); + this.#state = emptyDocument(); + } + } + this.#dirty = false; + this.#flushTimer = setInterval(() => { + void this.flush().catch((error) => { + this.#logger?.error( + { error }, + "Failed to flush usage statistics state", + ); + }); + }, this.#flushIntervalMs); + this.#flushTimer.unref(); + } + + recordCommand(kind: string, name: string, outcome: "ok" | "error"): void { + const month = this.#currentMonth(); + const key = `${kind}/${name}`; + let counts = month.commands[key]; + if (counts === undefined) { + counts = { ok: 0, error: 0 }; + month.commands[key] = counts; + } + counts[outcome] += 1; + this.#dirty = true; + } + + recordRunLanguage(languageKey: string): void { + const month = this.#currentMonth(); + month.runLanguages[languageKey] = + (month.runLanguages[languageKey] ?? 0) + 1; + this.#dirty = true; + } + + recordGuildCount(count: number): void { + const now = this.#now(); + const month = this.#month(now); + month.guilds[now.toISOString().slice(0, 10)] = count; + this.#dirty = true; + } + + async flush(): Promise { + if (!this.#dirty) { + await this.#writeChain; + return; + } + + this.#dirty = false; + const value = `${JSON.stringify(this.#state, null, 2)}\n`; + const write = this.#enqueueWrite(value); + try { + await write; + } catch (error) { + this.#dirty = true; + throw error; + } + } + + async dispose(): Promise { + if (this.#flushTimer !== undefined) { + clearInterval(this.#flushTimer); + this.#flushTimer = undefined; + } + await this.flush(); + } + + #currentMonth(): MonthStats { + return this.#month(this.#now()); + } + + #month(now: Date): MonthStats { + const key = now.toISOString().slice(0, 7); + let month = this.#state.months[key]; + if (month === undefined) { + month = emptyMonth(); + this.#state.months[key] = month; + } + return month; + } + + #enqueueWrite(value: string): Promise { + const write = this.#writeChain.then(() => this.#backend.write(value)); + this.#writeChain = write.catch(() => undefined); + return write; + } +} + +function emptyDocument(): UsageStatsDocument { + return { version: 1, months: {} }; +} + +function emptyMonth(): MonthStats { + return { commands: {}, runLanguages: {}, guilds: {} }; +} + +function parseDocument(value: string): UsageStatsDocument { + const parsed: unknown = JSON.parse(value); + if (!isRecord(parsed) || parsed.version !== 1 || !isRecord(parsed.months)) { + throw new Error("usage statistics state has an unsupported shape"); + } + + for (const month of Object.values(parsed.months)) { + if ( + !isRecord(month) || + !isRecord(month.commands) || + !isRecord(month.runLanguages) || + !isRecord(month.guilds) + ) { + throw new Error("usage statistics month has an unsupported shape"); + } + for (const counts of Object.values(month.commands)) { + if ( + !isRecord(counts) || + !isCounter(counts.ok) || + !isCounter(counts.error) + ) { + throw new Error("usage statistics command counts are invalid"); + } + } + if ( + !Object.values(month.runLanguages).every(isCounter) || + !Object.values(month.guilds).every(isCounter) + ) { + throw new Error("usage statistics aggregate counts are invalid"); + } + } + + return parsed as unknown as UsageStatsDocument; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isCounter(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} diff --git a/discord/src/types.ts b/discord/src/types.ts index b9af50c..0b8079b 100644 --- a/discord/src/types.ts +++ b/discord/src/types.ts @@ -16,6 +16,7 @@ import type { Env } from "./env.js"; import type { RequestContext } from "./platform/discord/context.js"; import type { AppLogger } from "./shared/logger.js"; import type { OptOutUsers } from "./shared/state.js"; +import type { UsageStats } from "./shared/usageStats.js"; export type FeatureId = | "tex" @@ -69,6 +70,7 @@ export interface Feature { export interface FeatureDependencies { readonly optOutUsers: OptOutUsers; + readonly usageStats: UsageStats; readonly logger: AppLogger; readonly env: Env; readonly client: import("discord.js").Client; From 79b7fab106eb6f992f4ab9bb670a0919da7372d4 Mon Sep 17 00:00:00 2001 From: Gakuto Furuya Date: Sat, 18 Jul 2026 16:51:58 +0900 Subject: [PATCH 15/15] Fix replies that Discord rejected during live testing The privacy policy outgrew the 2,000-character plain-content limit, so /privacy-policy now replies with a Components V2 text display, with a regression test pinning the document under the 4,000-character budget. The escape context menu failed on messages without text content (e.g. the bot's own Components V2 replies) and would also fail once escaping pushed a long message past the content limit; it now explains the empty case and falls back to an attached file for oversized results. --- discord/src/features/code/escape.test.ts | 38 ++++++++++++++++++++ discord/src/features/code/index.ts | 41 +++++++++++++++++----- discord/src/features/privacy/index.test.ts | 41 ++++++++++++++++++++++ discord/src/features/privacy/index.ts | 12 +++++-- discord/src/shared/i18n.ts | 6 ++++ 5 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 discord/src/features/code/escape.test.ts create mode 100644 discord/src/features/privacy/index.test.ts diff --git a/discord/src/features/code/escape.test.ts b/discord/src/features/code/escape.test.ts new file mode 100644 index 0000000..f2900cf --- /dev/null +++ b/discord/src/features/code/escape.test.ts @@ -0,0 +1,38 @@ +/** + * Covers the escape context-menu reply for empty, normal, and oversized targets. + */ +import { describe, expect, it } from "vitest"; +import { escapeMentions, escapeReply } from "./index.js"; + +describe("escapeMentions", () => { + it("neutralizes mentions with a zero-width space", () => { + expect(escapeMentions("@everyone")).toBe("@​everyone"); + }); +}); + +describe("escapeReply", () => { + it("explains instead of failing when the target has no text", () => { + const reply = escapeReply("", "en"); + expect(reply.kind).toBe("plain"); + if (reply.kind === "plain") { + expect(reply.content).toBe("That message has no text to escape."); + } + expect(reply.ephemeral).toBe(true); + }); + + it("returns escaped markdown as plain ephemeral content", () => { + const reply = escapeReply("*hi* @everyone", "en"); + expect(reply.kind).toBe("plain"); + if (reply.kind === "plain") { + expect(reply.content).toBe("\\*hi\\* @​everyone"); + } + }); + + it("falls back to a file when escaping outgrows the content limit", () => { + // 1,500 asterisks escape to 3,000 characters, past the 2,000 limit. + const reply = escapeReply("*".repeat(1_500), "en"); + expect(reply.kind).toBe("components-v2"); + expect(reply.files).toHaveLength(1); + expect(reply.ephemeral).toBe(true); + }); +}); diff --git a/discord/src/features/code/index.ts b/discord/src/features/code/index.ts index fac54c8..feec1b2 100644 --- a/discord/src/features/code/index.ts +++ b/discord/src/features/code/index.ts @@ -4,8 +4,10 @@ import { ActionRowBuilder, ApplicationCommandType, + AttachmentBuilder, ContextMenuCommandBuilder, escapeMarkdown, + FileBuilder, ModalBuilder, SlashCommandBuilder, TextInputBuilder, @@ -34,6 +36,35 @@ export function escapeMentions(content: string): string { return content.replaceAll("@", "@\u200b"); } +const PLAIN_CONTENT_LIMIT = 2_000; + +export function escapeReply(content: string, locale: string): OutgoingReply { + const t = getFixedT(locale); + // Components V2 messages and attachment-only messages have no text content. + if (content.length === 0) { + return { + kind: "plain", + content: t("code.escape.noText"), + ephemeral: true, + }; + } + + const escaped = escapeMentions(escapeMarkdown(content)); + // Escaping adds backslashes, so a long message can outgrow the content limit. + if (escaped.length > PLAIN_CONTENT_LIMIT) { + return { + kind: "components-v2", + components: [new FileBuilder().setURL("attachment://escaped.txt")], + files: [ + new AttachmentBuilder(Buffer.from(escaped), { name: "escaped.txt" }), + ], + ephemeral: true, + }; + } + + return { kind: "plain", content: escaped, ephemeral: true }; +} + export function createCodeFeature(dependencies: FeatureDependencies): Feature { const wandbox = new WandboxClient({ logger: dependencies.logger }); const runCommand = new SlashCommandBuilder() @@ -121,17 +152,11 @@ export function createCodeFeature(dependencies: FeatureDependencies): Feature { }, { data: escapeCommand, - async execute(interaction) { + async execute(interaction, context) { if (!interaction.isMessageContextMenuCommand()) { return undefined; } - return { - kind: "plain", - content: escapeMentions( - escapeMarkdown(interaction.targetMessage.content), - ), - ephemeral: true, - }; + return escapeReply(interaction.targetMessage.content, context.locale); }, }, ], diff --git a/discord/src/features/privacy/index.test.ts b/discord/src/features/privacy/index.test.ts new file mode 100644 index 0000000..3f3da27 --- /dev/null +++ b/discord/src/features/privacy/index.test.ts @@ -0,0 +1,41 @@ +/** + * Covers the privacy-policy reply shape and its Components V2 size budget. + */ +import { readFile } from "node:fs/promises"; +import type { ChatInputCommandInteraction } from "discord.js"; +import { describe, expect, it } from "vitest"; +import type { RequestContext } from "../../platform/discord/context.js"; +import type { FeatureDependencies } from "../../types.js"; +import { createPrivacyFeature } from "./index.js"; + +const COMPONENTS_V2_TEXT_LIMIT = 4_000; + +function findCommand(name: string) { + const feature = createPrivacyFeature({} as FeatureDependencies); + const command = feature.slashCommands?.find( + (candidate) => candidate.data.name === name, + ); + if (command === undefined) { + throw new Error(`missing /${name}`); + } + return command; +} + +describe("privacy-policy command", () => { + it("fits the policy document within the Components V2 text budget", async () => { + const policy = await readFile( + new URL("../../../config/privacy-policy.md", import.meta.url), + "utf8", + ); + expect(policy.length).toBeLessThanOrEqual(COMPONENTS_V2_TEXT_LIMIT); + }); + + it("replies with a Components V2 text display, not plain content", async () => { + const command = findCommand("privacy-policy"); + const outgoing = await command.execute( + {} as ChatInputCommandInteraction, + { locale: "en", userId: "user" } as RequestContext, + ); + expect(outgoing?.kind).toBe("components-v2"); + }); +}); diff --git a/discord/src/features/privacy/index.ts b/discord/src/features/privacy/index.ts index 16a03b3..d7eefb3 100644 --- a/discord/src/features/privacy/index.ts +++ b/discord/src/features/privacy/index.ts @@ -2,7 +2,7 @@ * Implements privacy-policy and opt-out commands in the feature layer. */ import { readFile } from "node:fs/promises"; -import { SlashCommandBuilder } from "discord.js"; +import { SlashCommandBuilder, TextDisplayBuilder } from "discord.js"; import { getFixedT } from "../../shared/i18n.js"; import type { Feature, FeatureDependencies } from "../../types.js"; @@ -29,9 +29,15 @@ export function createPrivacyFeature({ ja: "プライバシーポリシーを表示します", }), async execute() { + // The policy exceeds the 2,000-character plain-content limit; a Components V2 + // text display raises the ceiling to 4,000 characters for the whole message. return { - kind: "plain", - content: await readFile(privacyPolicyUrl, "utf8"), + kind: "components-v2", + components: [ + new TextDisplayBuilder().setContent( + await readFile(privacyPolicyUrl, "utf8"), + ), + ], }; }, }, diff --git a/discord/src/shared/i18n.ts b/discord/src/shared/i18n.ts index 1fb1f6f..0994e4f 100644 --- a/discord/src/shared/i18n.ts +++ b/discord/src/shared/i18n.ts @@ -38,6 +38,9 @@ const resources = { }, }, code: { + escape: { + noText: "That message has no text to escape.", + }, supportedLanguagesHeading: "The following languages are supported", noLanguagesAvailable: "The language list is temporarily unavailable. Please try again later.", @@ -105,6 +108,9 @@ const resources = { }, }, code: { + escape: { + noText: "そのメッセージにはエスケープできるテキストがありません。", + }, supportedLanguagesHeading: "対応している言語", noLanguagesAvailable: "言語一覧を一時的に取得できません。しばらくしてからもう一度お試しください。",