diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 1103d2a1b..38a844512 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -19,6 +19,9 @@ name: Release Desktop # - Push of a `desktop-v*` tag builds all platforms. # # Required repo secrets: +# Telegram feedback: +# ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN dedicated feedback bot token +# ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID numeric channel ID or @username # macOS: # MAC_CSC_LINK base64 of a .p12 containing the Developer # ID Application cert + private key @@ -124,6 +127,12 @@ jobs: ANTSEED_COMPARABLE_PRICES_URL: ${{ vars.ANTSEED_COMPARABLE_PRICES_URL }} run: node scripts/bake-comparable-prices-url.mjs --require + - name: Bake Telegram feedback configuration + env: + ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN: ${{ secrets.ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN }} + ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID: ${{ secrets.ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID }} + run: node scripts/bake-feedback-telegram-config.mjs --require + - name: Build monorepo (tiers 0-3) run: | pnpm run build:tier0 @@ -221,6 +230,13 @@ jobs: run: node scripts/bake-comparable-prices-url.mjs --require shell: pwsh + - name: Bake Telegram feedback configuration + env: + ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN: ${{ secrets.ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN }} + ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID: ${{ secrets.ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID }} + run: node scripts/bake-feedback-telegram-config.mjs --require + shell: pwsh + - name: Build monorepo (tiers 0-3) run: | pnpm run build:tier0 @@ -340,6 +356,12 @@ jobs: ANTSEED_COMPARABLE_PRICES_URL: ${{ vars.ANTSEED_COMPARABLE_PRICES_URL }} run: node scripts/bake-comparable-prices-url.mjs --require + - name: Bake Telegram feedback configuration + env: + ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN: ${{ secrets.ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN }} + ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID: ${{ secrets.ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID }} + run: node scripts/bake-feedback-telegram-config.mjs --require + - name: Build monorepo (tiers 0-3) run: | pnpm run build:tier0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 72ac05d78..baa58898c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,8 @@ This project uses selective package publishing. Each release entry lists the pub ### Added +- Desktop now includes an Emdash-inspired feedback modal in Help & Support, with optional contact email, image attachments, and opt-in privacy-redacted diagnostic logs delivered to the AntSeed Telegram feedback channel. + - Added `@antseed/web-sdk`, a browser buyer SDK: connects to unmodified sellers over WebRTC DataChannels (signaled through a relay) and runs the shared `@antseed/buyer-core` request/payment stack — 402 negotiation, in-browser EIP-712 ReserveAuth/SpendingAuth signing, SSE streaming, and chunked uploads. `AntseedWebClient.create()` durably commits complete channel recovery state to IndexedDB before transmitting authorizations, surfaces background storage failures through `onPersistenceError`, and uses an identity-scoped Web Lock to prevent concurrent signing from multiple tabs; the public constructor and explicit `ephemeral()` mode retain injectable/in-memory operation for compatibility, tests, and free interoperability experiments. The `BuyerChannelStore` contract now supports an atomic authorization commit, an optional async `flush()` durability barrier, and lifecycle cleanup; the buyer stack persists and recovers ReserveAuth/SpendingAuth signatures, reserve/top-up state, encoded metadata, and per-service usage. Browser uploads chunk above 192 KiB to stay under the ~256 KiB SCTP message ceiling (`ProxyMux` gained an `uploadThresholdBytes` option). The client takes any address-bearing ethers `AbstractSigner` (`BuyerSigner`) instead of requiring a concrete `Wallet`, supports full `RTCIceServer` entries (TURN credentials) plus `iceTransportPolicy`, reports the selected WebRTC path (`direct`/`relay`/`unknown`) via `onConnectionInfo` without exposing addresses, and ships a working browser example page (`packages/web-sdk/examples/example.html`, served by `pnpm --filter @antseed/web-sdk run example`). - Added `@antseed/relay`, the web relay browser buyers need: a DHT-discovered seller snapshot at `GET /sellers` and a `WS /bridge/` byte pipe to the seller's TCP signaling port. The bridge dials only cached seller endpoints, refuses private/internal address ranges for DHT-announced sellers (checked for IP literals and again at DNS resolution), enforces per-IP, per-seller, and global bridge caps plus a configurable WebSocket message-size limit, supports an optional browser Origin allowlist, exposes cache-aware readiness and privacy-safe aggregate metrics, and supports `RELAY_TRUST_PROXY=1` for deployments behind a TLS-terminating proxy. - CLI: `antseed buyer deposit` is now the deposit flow — it prints the node's funding address and a terminal QR code (EIP-681 payment request), serves the browser-wallet checkout page and prints its link for users who prefer depositing from a connected wallet, then watches and deposits incoming USDC into the buyer's credits automatically via the gasless relayer sweep. Works through a running buyer daemon or standalone with an ephemeral node; `--amount` prefills the QR request and checkout, `--no-watch` prints without waiting, and `antseed deposit` works as an alias. The previous `buyer deposit ` direct on-chain deposit (hot wallet pays gas) moved to `buyer deposit --onchain `. diff --git a/apps/desktop/README.md b/apps/desktop/README.md index ee0c69178..6b917fb9d 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -71,6 +71,33 @@ Build desktop assets: npm run build ``` +### Telegram feedback channel + +The Help & Support feedback action posts through a dedicated Telegram bot. Source builds +keep the action disabled unless both variables are configured: + +```bash +export ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN='123456:bot-token' +export ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID='@channelusername' # or -100… +npm run dev +``` + +Release CI runs `scripts/bake-feedback-telegram-config.mjs --require` before +compilation, using GitHub Actions secrets with the same names. The generated +source defaults remain `null` in git; the runtime environment overrides baked +values for local testing. + +Use a disposable bot that is an administrator only in the feedback channel and +grant only **Post Messages**. Never reuse the personal Telegram bridge bot. The +token is compiled into public Electron artifacts and can be extracted, so treat +it as exposed: monitor the channel, remove the bot or revoke the token to disable +submissions, and rotate it immediately if abused. A server-side relay is required +before this channel handles sensitive or high-volume production feedback. + +Diagnostic logs are opt-in and privacy-redacted before upload. The app masks +credentials, emails, local home paths, IP addresses, wallet addresses, and peer +IDs, retains at most 500 recent entries, and caps the log at 512 KiB. + Start app from built assets: ```bash diff --git a/apps/desktop/package.json b/apps/desktop/package.json index af2d783c8..80dd19fcd 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -9,7 +9,7 @@ "type": "module", "main": "dist/main/main.js", "scripts": { - "ensure:cli-dist": "pnpm -C ../../packages/api-adapter build && pnpm -C ../../packages/protocol build && pnpm -C ../../packages/buyer-core build && pnpm -C ../../packages/router-core build && pnpm -C ../../plugins/router-local build && pnpm -C ../../packages/node build && pnpm -C ../payments build:server && pnpm -C ../cli build", + "ensure:cli-dist": "pnpm -C ../../packages/api-adapter build && pnpm -C ../../packages/protocol build && pnpm -C ../../packages/buyer-core build && pnpm -C ../../packages/node build && pnpm -C ../../packages/provider-core build && pnpm -C ../../packages/ant-agent build && pnpm -C ../../packages/router-core build && pnpm -C ../../plugins/router-local build && pnpm -C ../payments build:server && pnpm -C ../cli build", "brand:electron-dev": "node ./scripts/brand-electron-dev.mjs", "models:update-metadata": "pnpm -C ../../packages/node build && node ./scripts/update-model-metadata.mjs", "ensure:native": "npm run ensure:runtime-native", diff --git a/apps/desktop/scripts/bake-feedback-telegram-config.test.mjs b/apps/desktop/scripts/bake-feedback-telegram-config.test.mjs new file mode 100644 index 000000000..ada74bdaf --- /dev/null +++ b/apps/desktop/scripts/bake-feedback-telegram-config.test.mjs @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { test } from 'node:test'; + +const repoRoot = resolve(import.meta.dirname, '..', '..', '..'); +const script = join(repoRoot, 'scripts', 'bake-feedback-telegram-config.mjs'); + +test('bakes Telegram feedback credentials without printing the token', () => { + const tempDir = mkdtempSync(join(tmpdir(), 'antseed-feedback-bake-')); + const target = join(tempDir, 'baked-defaults.ts'); + const token = ['123456789', 'abcdefghijklmnopqrstuvwxyz_ABCDEF'].join(':'); + writeFileSync(target, [ + 'export const BAKED_FEEDBACK_TELEGRAM_BOT_TOKEN: string | null = null;', + 'export const BAKED_FEEDBACK_TELEGRAM_CHAT_ID: string | null = null;', + '', + ].join('\n')); + + const result = spawnSync(process.execPath, [script, '--require', '--target', target], { + encoding: 'utf8', + env: { + ...process.env, + ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN: token, + ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID: '@antseed_feedback', + }, + }); + + assert.equal(result.status, 0, result.stderr); + assert.doesNotMatch(`${result.stdout}${result.stderr}`, new RegExp(token)); + const baked = readFileSync(target, 'utf8'); + assert.match(baked, new RegExp(token)); + assert.match(baked, /@antseed_feedback/); +}); + +test('required mode rejects incomplete configuration', () => { + const result = spawnSync(process.execPath, [script, '--require'], { + encoding: 'utf8', + env: { + ...process.env, + ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN: ['123456789', 'abcdefghijklmnopqrstuvwxyz_ABCDEF'].join(':'), + ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID: '', + }, + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /must both be set/); +}); diff --git a/apps/desktop/src/main/feedback/config.test.ts b/apps/desktop/src/main/feedback/config.test.ts new file mode 100644 index 000000000..a59f7a345 --- /dev/null +++ b/apps/desktop/src/main/feedback/config.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + parseTelegramChatId, + resolveFeedbackTelegramConfig, +} from './config.js'; + +const validToken = ['123456789', 'abcdefghijklmnopqrstuvwxyz_ABCDEF'].join(':'); + +test('reports missing source-build configuration', () => { + assert.deepEqual(resolveFeedbackTelegramConfig({}, { botToken: null, chatId: null }), { + configured: false, + error: 'Feedback is unavailable in this build.', + }); +}); + +test('resolves a valid runtime Telegram feedback configuration', () => { + const result = resolveFeedbackTelegramConfig({ + ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN: validToken, + ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID: '@antseed_feedback', + }); + assert.equal(result.configured, true); + if (result.configured) { + assert.equal(result.config.botToken, validToken); + assert.equal(result.config.chatId, '@antseed_feedback'); + } +}); + +test('uses baked configuration unless runtime values override it', () => { + const baked = { botToken: validToken, chatId: '@baked_feedback' }; + const bakedResult = resolveFeedbackTelegramConfig({}, baked); + assert.equal(bakedResult.configured, true); + if (bakedResult.configured) assert.equal(bakedResult.config.chatId, '@baked_feedback'); + + const runtimeResult = resolveFeedbackTelegramConfig({ + ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN: validToken, + ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID: '@runtime_feedback', + }, baked); + assert.equal(runtimeResult.configured, true); + if (runtimeResult.configured) assert.equal(runtimeResult.config.chatId, '@runtime_feedback'); +}); + +test('runtime override requires both values and can disable baked defaults', () => { + const baked = { botToken: validToken, chatId: '@baked_feedback' }; + const incomplete = resolveFeedbackTelegramConfig({ + ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN: validToken, + }, baked); + assert.equal(incomplete.configured, false); + + const disabled = resolveFeedbackTelegramConfig({ + ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN: '', + ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID: '', + }, baked); + assert.deepEqual(disabled, { configured: false, error: 'Feedback is unavailable in this build.' }); +}); + +test('rejects invalid tokens and parses numeric and public-channel chat identifiers', () => { + const invalidToken = resolveFeedbackTelegramConfig({ + ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN: 'not-a-token', + ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID: '@antseed_feedback', + }); + assert.deepEqual(invalidToken, { + configured: false, + error: 'Telegram feedback bot configuration is invalid.', + }); + assert.equal(parseTelegramChatId('-1001234567890'), -1001234567890); + assert.equal(parseTelegramChatId('@antseed_feedback'), '@antseed_feedback'); + assert.equal(parseTelegramChatId('not a chat'), null); +}); diff --git a/apps/desktop/src/main/feedback/config.ts b/apps/desktop/src/main/feedback/config.ts new file mode 100644 index 000000000..77dac5cce --- /dev/null +++ b/apps/desktop/src/main/feedback/config.ts @@ -0,0 +1,74 @@ +import { + BAKED_FEEDBACK_TELEGRAM_BOT_TOKEN, + BAKED_FEEDBACK_TELEGRAM_CHAT_ID, +} from '../generated/baked-defaults.js'; + +export const FEEDBACK_TELEGRAM_BOT_TOKEN_ENV = 'ANTSEED_FEEDBACK_TELEGRAM_BOT_TOKEN'; +export const FEEDBACK_TELEGRAM_CHAT_ID_ENV = 'ANTSEED_FEEDBACK_TELEGRAM_CHAT_ID'; + +export type FeedbackTelegramConfig = { + botToken: string; + chatId: number | string; +}; + +export type FeedbackTelegramConfigResolution = + | { configured: true; config: FeedbackTelegramConfig } + | { configured: false; error: string }; + +type FeedbackTelegramBakedDefaults = { + botToken: string | null; + chatId: string | null; +}; + +export function isValidTelegramBotToken(value: string): boolean { + return /^\d+:[A-Za-z0-9_-]{20,}$/.test(value); +} + +export function parseTelegramChatId(value: string): number | string | null { + const trimmed = value.trim(); + if (/^-?\d+$/.test(trimmed)) { + const parsed = Number(trimmed); + return Number.isSafeInteger(parsed) ? parsed : null; + } + if (/^@[A-Za-z][A-Za-z0-9_]{4,31}$/.test(trimmed)) { + return trimmed; + } + return null; +} + +export function resolveFeedbackTelegramConfig( + env: NodeJS.ProcessEnv = process.env, + baked: FeedbackTelegramBakedDefaults = { + botToken: BAKED_FEEDBACK_TELEGRAM_BOT_TOKEN, + chatId: BAKED_FEEDBACK_TELEGRAM_CHAT_ID, + }, +): FeedbackTelegramConfigResolution { + const hasRuntimeOverride = + Object.prototype.hasOwnProperty.call(env, FEEDBACK_TELEGRAM_BOT_TOKEN_ENV) + || Object.prototype.hasOwnProperty.call(env, FEEDBACK_TELEGRAM_CHAT_ID_ENV); + const botToken = ( + hasRuntimeOverride + ? env[FEEDBACK_TELEGRAM_BOT_TOKEN_ENV] + : baked.botToken + )?.trim() ?? ''; + const rawChatId = ( + hasRuntimeOverride + ? env[FEEDBACK_TELEGRAM_CHAT_ID_ENV] + : baked.chatId + )?.trim() ?? ''; + + if (!botToken && !rawChatId) { + return { configured: false, error: 'Feedback is unavailable in this build.' }; + } + if (!botToken || !rawChatId) { + return { configured: false, error: 'Telegram feedback configuration is incomplete.' }; + } + if (!isValidTelegramBotToken(botToken)) { + return { configured: false, error: 'Telegram feedback bot configuration is invalid.' }; + } + const chatId = parseTelegramChatId(rawChatId); + if (chatId === null) { + return { configured: false, error: 'Telegram feedback channel configuration is invalid.' }; + } + return { configured: true, config: { botToken, chatId } }; +} diff --git a/apps/desktop/src/main/feedback/diagnostic-log.test.ts b/apps/desktop/src/main/feedback/diagnostic-log.test.ts new file mode 100644 index 000000000..b36783d98 --- /dev/null +++ b/apps/desktop/src/main/feedback/diagnostic-log.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { LogEvent } from '../runtime/log-parser.js'; +import { buildFeedbackDiagnosticLog } from './diagnostic-log.js'; + +function log(line: string, timestamp = 1_700_000_000_000): LogEvent { + return { mode: 'connect', stream: 'system', line, timestamp }; +} + +test('privacy-redacts sensitive diagnostic values with stable placeholders', () => { + const peerId = 'abcdefabcdefabcdefabcdefabcdefabcdefabcd'; + const botToken = ['123456789', 'abcdefghijklmnopqrstuvwxyz_ABCDEF'].join(':'); + const webhook = ['https://discord.com/api/webhooks', '123456789', 'webhook-secret-value'].join('/'); + const output = new TextDecoder().decode(buildFeedbackDiagnosticLog([ + log(`token=${botToken} authorization=Basic-sensitive webhook=${webhook} email=user@example.com`), + log(`home=/Users/alex/project ip=192.168.1.10 wallet=0x${'a'.repeat(40)} peer=${peerId}`), + log('again user@example.com and 192.168.1.10'), + ])); + + assert.doesNotMatch(output, /abcdefghijklmnopqrstuvwxyz_ABCDEF/); + assert.doesNotMatch(output, /user@example\.com/); + assert.doesNotMatch(output, /webhook-secret-value|Basic-sensitive/); + assert.doesNotMatch(output, /\/Users\/alex/); + assert.doesNotMatch(output, /192\.168\.1\.10/); + assert.doesNotMatch(output, new RegExp(peerId)); + assert.equal(output.match(//g)?.length, 2); + assert.equal(output.match(//g)?.length, 2); + assert.match(output, //); + assert.match(output, //); + assert.match(output, //); + assert.match(output, //); +}); + +test('retains only the newest 500 runtime entries', () => { + const logs = Array.from({ length: 501 }, (_value, index) => log(`entry-${index}`, index)); + const output = new TextDecoder().decode(buildFeedbackDiagnosticLog(logs)); + assert.doesNotMatch(output, /entry-0(?:\D|$)/); + assert.match(output, /entry-1(?:\D|$)/); + assert.match(output, /entry-500(?:\D|$)/); +}); + +test('keeps the newest entries within the byte budget', () => { + const output = new TextDecoder().decode(buildFeedbackDiagnosticLog([ + log(`old-${'x'.repeat(100)}`, 1), + log(`middle-${'y'.repeat(100)}`, 2), + log('newest-entry', 3), + ], 260)); + assert.ok(Buffer.byteLength(output, 'utf8') <= 260); + assert.match(output, /newest-entry/); + assert.doesNotMatch(output, /old-/); + assert.match(output, /earlier log entries omitted/); +}); diff --git a/apps/desktop/src/main/feedback/diagnostic-log.ts b/apps/desktop/src/main/feedback/diagnostic-log.ts new file mode 100644 index 000000000..3a671aff7 --- /dev/null +++ b/apps/desktop/src/main/feedback/diagnostic-log.ts @@ -0,0 +1,93 @@ +import type { LogEvent } from '../runtime/log-parser.js'; + +export const FEEDBACK_DIAGNOSTIC_MAX_ENTRIES = 500; +export const FEEDBACK_DIAGNOSTIC_MAX_BYTES = 512 * 1024; + +type RedactionCategory = + | 'TOKEN' + | 'SECRET' + | 'WEBHOOK' + | 'EMAIL' + | 'HOME' + | 'IP' + | 'WALLET' + | 'PEER'; + +class StableRedactor { + private readonly values = new Map(); + private readonly counts = new Map(); + + private placeholder(category: RedactionCategory, value: string): string { + const key = `${category}:${value.toLowerCase()}`; + const existing = this.values.get(key); + if (existing) return existing; + const next = (this.counts.get(category) ?? 0) + 1; + this.counts.set(category, next); + const placeholder = `<${category}_${next}>`; + this.values.set(key, placeholder); + return placeholder; + } + + redact(input: string): string { + let output = input; + output = output.replace( + /https?:\/\/(?:discord(?:app)?\.com\/api\/webhooks|hooks\.slack\.com\/services)\/\S+/gi, + (value) => this.placeholder('WEBHOOK', value), + ); + output = output.replace(/\b\d{6,}:[A-Za-z0-9_-]{20,}\b/g, (value) => this.placeholder('TOKEN', value)); + output = output.replace(/\bBearer\s+[^\s,;]+/gi, (value) => `Bearer ${this.placeholder('TOKEN', value)}`); + output = output.replace( + /\b(api[_-]?key|token|secret|password|private[_-]?key|authorization)(\s*[:=]\s*)([^\s,;]+)/gi, + (_match, key: string, separator: string, value: string) => `${key}${separator}${this.placeholder('SECRET', value)}`, + ); + output = output.replace(/\b0x[0-9a-f]{64}\b/gi, (value) => this.placeholder('SECRET', value)); + output = output.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, (value) => this.placeholder('EMAIL', value)); + output = output.replace(/(?:\/Users\/[^/\s]+|\/home\/[^/\s]+|[A-Z]:\\Users\\[^\\\s]+)/gi, (value) => this.placeholder('HOME', value)); + output = output.replace(/\b(?:\d{1,3}\.){3}\d{1,3}(?::\d{1,5})?\b/g, (value) => this.placeholder('IP', value)); + output = output.replace(/\b(?:[0-9a-f]{0,4}:){2,}[0-9a-f]{0,4}(?::\d{1,5})?\b/gi, (value) => this.placeholder('IP', value)); + output = output.replace(/\b0x[0-9a-f]{40}\b/gi, (value) => this.placeholder('WALLET', value)); + output = output.replace(/\b[0-9a-f]{40}\b/gi, (value) => this.placeholder('PEER', value)); + return output; + } +} + +function formatEntry(entry: LogEvent, redactor: StableRedactor): string { + const timestamp = new Date(entry.timestamp).toISOString(); + return `${timestamp} [${entry.mode}] [${entry.stream}] ${redactor.redact(entry.line)}`; +} + +export function buildFeedbackDiagnosticLog( + logs: readonly LogEvent[], + maxBytes = FEEDBACK_DIAGNOSTIC_MAX_BYTES, +): Uint8Array { + if (maxBytes <= 0) return new Uint8Array(); + const redactor = new StableRedactor(); + const formatted = logs + .slice(-FEEDBACK_DIAGNOSTIC_MAX_ENTRIES) + .map((entry) => formatEntry(entry, redactor)); + const header = [ + 'AntSeed diagnostic log', + 'Privacy redaction applied: secrets, identity, network, and local path values are masked.', + '', + ]; + const selected: string[] = []; + let currentBytes = Buffer.byteLength(header.join('\n'), 'utf8'); + + for (let index = formatted.length - 1; index >= 0; index -= 1) { + const line = formatted[index]!; + const lineBytes = Buffer.byteLength(`${line}\n`, 'utf8'); + if (currentBytes + lineBytes > maxBytes) break; + selected.unshift(line); + currentBytes += lineBytes; + } + + const omitted = formatted.length - selected.length; + const omissionLine = omitted > 0 ? `[${omitted} earlier log entries omitted]\n` : ''; + let text = `${header.join('\n')}${omissionLine}${selected.join('\n')}${selected.length > 0 ? '\n' : ''}`; + let bytes = Buffer.from(text, 'utf8'); + if (bytes.byteLength > maxBytes) { + text = 'AntSeed diagnostic log\n[Log omitted because the remaining attachment budget was too small.]\n'; + bytes = Buffer.from(text, 'utf8').subarray(0, maxBytes); + } + return new Uint8Array(bytes); +} diff --git a/apps/desktop/src/main/feedback/service.test.ts b/apps/desktop/src/main/feedback/service.test.ts new file mode 100644 index 000000000..2f39a76a3 --- /dev/null +++ b/apps/desktop/src/main/feedback/service.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import type { FeedbackSubmitRequest } from '../../shared/feedback.js'; +import { formatFeedbackMessage, submitTelegramFeedback, validateFeedbackRequest } from './service.js'; + +const originalFetch = globalThis.fetch; +const config = { + botToken: ['123456789', 'abcdefghijklmnopqrstuvwxyz_ABCDEF'].join(':'), + chatId: '@antseed_feedback', +}; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function request(overrides: Partial = {}): FeedbackSubmitRequest { + return { + feedback: 'The app is useful.', + contactEmail: 'user@example.com', + images: [], + includeDiagnosticLogs: false, + ...overrides, + }; +} + +test('validates feedback and attachment limits', () => { + assert.throws(() => validateFeedbackRequest(request({ feedback: ' ' })), /enter some feedback/i); + assert.throws(() => validateFeedbackRequest(request({ feedback: 'x'.repeat(3_001) })), /3,000 characters/i); + assert.throws(() => validateFeedbackRequest(request({ contactEmail: 'invalid' })), /valid contact email/i); + assert.throws(() => validateFeedbackRequest(request({ + images: Array.from({ length: 11 }, (_value, index) => ({ + name: `${index}.png`, + mimeType: 'image/png', + size: 1, + dataBase64: 'YQ==', + })), + })), /no more than 10 images/i); + assert.doesNotThrow(() => validateFeedbackRequest(request())); +}); + +test('formats one plain root message with app metadata', () => { + const content = formatFeedbackMessage({ + feedbackId: 'ABC12345', + feedback: 'Great routing.', + contactEmail: '', + appVersion: '0.2.27', + platform: 'darwin arm64', + imageCount: 2, + diagnosticStatus: 'included', + }); + assert.match(content, /^AntSeed Feedback · ABC12345/); + assert.match(content, /AntSeed VPR: 0\.2\.27/); + assert.match(content, /Diagnostic logs: included \(privacy-redacted\)/); + + const maximumContent = formatFeedbackMessage({ + feedbackId: 'ABC12345', + feedback: 'x'.repeat(3_000), + contactEmail: `${'a'.repeat(242)}@example.com`, + appVersion: '0.2.27', + platform: 'darwin arm64 25.0.0', + imageCount: 10, + diagnosticStatus: 'included', + }); + assert.ok(maximumContent.length <= 4_096); +}); + +test('returns partial success when Telegram accepts text but rejects an image', async () => { + const methods: string[] = []; + globalThis.fetch = async (url) => { + const method = String(url).split('/').pop()!; + methods.push(method); + if (method === 'sendPhoto') { + return new Response(JSON.stringify({ ok: false, error_code: 400, description: 'Bad photo' }), { status: 400 }); + } + return new Response(JSON.stringify({ + ok: true, + result: { message_id: 77, chat: { id: -1001, type: 'channel' }, date: 1 }, + })); + }; + + const result = await submitTelegramFeedback({ + config, + request: request({ + images: [{ + name: 'image.png', + mimeType: 'image/png', + size: 3, + dataBase64: Buffer.from('abc').toString('base64'), + }], + includeDiagnosticLogs: true, + }), + logs: [{ mode: 'connect', stream: 'system', line: 'ready', timestamp: 1 }], + appVersion: '0.2.27', + }); + + assert.equal(result.ok, true); + assert.match(result.attachmentWarnings?.join(' ') ?? '', /image attachments/); + assert.deepEqual(methods, ['sendMessage', 'sendPhoto', 'sendDocument']); +}); + +test('retains failure semantics when the root message is rejected', async () => { + globalThis.fetch = async () => new Response(JSON.stringify({ + ok: false, + error_code: 401, + description: 'Unauthorized', + }), { status: 401 }); + const result = await submitTelegramFeedback({ + config, + request: request(), + logs: [], + appVersion: '0.2.27', + }); + assert.deepEqual(result, { ok: false, error: 'Unable to send feedback. Please try again.' }); +}); diff --git a/apps/desktop/src/main/feedback/service.ts b/apps/desktop/src/main/feedback/service.ts new file mode 100644 index 000000000..237f84141 --- /dev/null +++ b/apps/desktop/src/main/feedback/service.ts @@ -0,0 +1,196 @@ +import { randomUUID } from 'node:crypto'; +import { release } from 'node:os'; +import { + FEEDBACK_IMAGE_MIME_TYPES, + FEEDBACK_MAX_ATTACHMENT_BYTES, + FEEDBACK_MAX_EMAIL_LENGTH, + FEEDBACK_MAX_IMAGES, + FEEDBACK_MAX_TEXT_LENGTH, + type FeedbackImageInput, + type FeedbackSubmitRequest, + type FeedbackSubmitResult, +} from '../../shared/feedback.js'; +import type { LogEvent } from '../runtime/log-parser.js'; +import { createTelegramBotClient, type TgUpload } from '../telegram/bot-api.js'; +import type { FeedbackTelegramConfig } from './config.js'; +import { + buildFeedbackDiagnosticLog, + FEEDBACK_DIAGNOSTIC_MAX_BYTES, +} from './diagnostic-log.js'; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const MIN_DIAGNOSTIC_BYTES = 256; + +class FeedbackValidationError extends Error {} + +type PreparedFeedbackRequest = { + feedback: string; + contactEmail: string; + images: TgUpload[]; + imageBytes: number; + includeDiagnosticLogs: boolean; +}; + +function safeFilename(name: string, fallback: string): string { + const leaf = name.replace(/\\/g, '/').split('/').pop()?.trim() || fallback; + return leaf.replace(/[^A-Za-z0-9._ -]/g, '_').slice(0, 120) || fallback; +} + +function decodeImage(image: FeedbackImageInput, index: number): TgUpload { + if (!FEEDBACK_IMAGE_MIME_TYPES.includes(image.mimeType)) { + throw new FeedbackValidationError(`Image ${index + 1} has an unsupported format.`); + } + if (!Number.isSafeInteger(image.size) || image.size <= 0) { + throw new FeedbackValidationError(`Image ${index + 1} has an invalid size.`); + } + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(image.dataBase64) || image.dataBase64.length % 4 !== 0) { + throw new FeedbackValidationError(`Image ${index + 1} contains invalid data.`); + } + const bytes = Buffer.from(image.dataBase64, 'base64'); + if (bytes.byteLength !== image.size) { + throw new FeedbackValidationError(`Image ${index + 1} size does not match its data.`); + } + return { + data: new Uint8Array(bytes), + filename: safeFilename(image.name, `feedback-image-${index + 1}`), + mimeType: image.mimeType, + }; +} +export function validateFeedbackRequest(request: unknown): PreparedFeedbackRequest { + if (!request || typeof request !== 'object' || Array.isArray(request)) { + throw new FeedbackValidationError('Invalid feedback request.'); + } + const raw = request as Partial; + const feedback = typeof raw.feedback === 'string' ? raw.feedback.trim() : ''; + const contactEmail = typeof raw.contactEmail === 'string' ? raw.contactEmail.trim() : ''; + if (!feedback) throw new FeedbackValidationError('Please enter some feedback before sending.'); + if (feedback.length > FEEDBACK_MAX_TEXT_LENGTH) { + throw new FeedbackValidationError(`Feedback must be ${FEEDBACK_MAX_TEXT_LENGTH.toLocaleString()} characters or fewer.`); + } + if (contactEmail.length > FEEDBACK_MAX_EMAIL_LENGTH || (contactEmail && !EMAIL_PATTERN.test(contactEmail))) { + throw new FeedbackValidationError('Enter a valid contact email or leave it blank.'); + } + if (!Array.isArray(raw.images)) throw new FeedbackValidationError('Invalid image attachments.'); + if (raw.images.length > FEEDBACK_MAX_IMAGES) { + throw new FeedbackValidationError(`Attach no more than ${FEEDBACK_MAX_IMAGES} images.`); + } + const images = raw.images.map((image, index) => decodeImage(image, index)); + const imageBytes = images.reduce((total, image) => total + image.data.byteLength, 0); + if (imageBytes > FEEDBACK_MAX_ATTACHMENT_BYTES) { + throw new FeedbackValidationError('Attachments exceed the 8 MiB total limit.'); + } + return { + feedback, + contactEmail, + images, + imageBytes, + includeDiagnosticLogs: raw.includeDiagnosticLogs === true, + }; +} + +export function formatFeedbackMessage(input: { + feedbackId: string; + feedback: string; + contactEmail: string; + appVersion: string; + platform: string; + imageCount: number; + diagnosticStatus: 'included' | 'omitted' | 'not requested'; +}): string { + const metadata = [ + input.contactEmail ? `Contact: ${input.contactEmail}` : null, + `AntSeed VPR: ${input.appVersion}`, + `Platform: ${input.platform}`, + `Images: ${input.imageCount}`, + `Diagnostic logs: ${input.diagnosticStatus}${input.diagnosticStatus === 'included' ? ' (privacy-redacted)' : ''}`, + ].filter((line): line is string => Boolean(line)); + return [`AntSeed Feedback · ${input.feedbackId}`, '', input.feedback, '', ...metadata].join('\n'); +} + +export async function submitTelegramFeedback(input: { + config: FeedbackTelegramConfig; + request: unknown; + logs: readonly LogEvent[]; + appVersion: string; +}): Promise { + let prepared: PreparedFeedbackRequest; + try { + prepared = validateFeedbackRequest(input.request); + } catch (error) { + return { + ok: false, + error: error instanceof FeedbackValidationError ? error.message : 'Invalid feedback request.', + }; + } + + const feedbackId = randomUUID().slice(0, 8).toUpperCase(); + const warnings: string[] = []; + let diagnosticLog: TgUpload | null = null; + if (prepared.includeDiagnosticLogs) { + const remainingBytes = FEEDBACK_MAX_ATTACHMENT_BYTES - prepared.imageBytes; + if (remainingBytes < MIN_DIAGNOSTIC_BYTES) { + warnings.push('Diagnostic logs were omitted because the images use the attachment limit.'); + } else { + const data = buildFeedbackDiagnosticLog( + input.logs, + Math.min(remainingBytes, FEEDBACK_DIAGNOSTIC_MAX_BYTES), + ); + diagnosticLog = { + data, + filename: `antseed-diagnostics-${feedbackId.toLowerCase()}.txt`, + mimeType: 'text/plain', + }; + } + } + + const content = formatFeedbackMessage({ + feedbackId, + feedback: prepared.feedback, + contactEmail: prepared.contactEmail, + appVersion: input.appVersion, + platform: `${process.platform} ${process.arch} ${release()}`, + imageCount: prepared.images.length, + diagnosticStatus: diagnosticLog ? 'included' : prepared.includeDiagnosticLogs ? 'omitted' : 'not requested', + }); + const client = createTelegramBotClient(input.config.botToken); + let rootMessageId: number; + try { + const root = await client.sendMessage(input.config.chatId, content); + rootMessageId = root.message_id; + } catch (error) { + console.error('[feedback] Failed to send Telegram feedback:', error instanceof Error ? error.message : String(error)); + return { ok: false, error: 'Unable to send feedback. Please try again.' }; + } + + if (prepared.images.length > 0) { + try { + const options = { caption: `Feedback ${feedbackId}`, replyToMessageId: rootMessageId }; + if (prepared.images.length === 1) { + await client.sendPhoto(input.config.chatId, prepared.images[0]!, options); + } else { + await client.sendMediaGroup(input.config.chatId, prepared.images, options); + } + } catch (error) { + console.error('[feedback] Telegram accepted feedback but rejected image attachments:', error instanceof Error ? error.message : String(error)); + warnings.push('Feedback was sent, but the image attachments could not be uploaded.'); + } + } + + if (diagnosticLog) { + try { + await client.sendDocument(input.config.chatId, diagnosticLog, { + caption: `Feedback ${feedbackId} · privacy-redacted diagnostics`, + replyToMessageId: rootMessageId, + }); + } catch (error) { + console.error('[feedback] Telegram accepted feedback but rejected diagnostic logs:', error instanceof Error ? error.message : String(error)); + warnings.push('Feedback was sent, but the diagnostic log could not be uploaded.'); + } + } + + return { + ok: true, + feedbackId, + ...(warnings.length > 0 ? { attachmentWarnings: warnings } : {}), + }; +} diff --git a/apps/desktop/src/main/generated/baked-defaults.ts b/apps/desktop/src/main/generated/baked-defaults.ts index efbd731bf..5ad68a224 100644 --- a/apps/desktop/src/main/generated/baked-defaults.ts +++ b/apps/desktop/src/main/generated/baked-defaults.ts @@ -13,3 +13,9 @@ * disables the baseline entirely). */ export const BAKED_COMPARABLE_PRICES_URL: string | null = null; + +/** Dedicated Telegram bot used only for the public desktop feedback channel. */ +export const BAKED_FEEDBACK_TELEGRAM_BOT_TOKEN: string | null = null; + +/** Numeric channel ID or public @channelusername for desktop feedback. */ +export const BAKED_FEEDBACK_TELEGRAM_CHAT_ID: string | null = null; diff --git a/apps/desktop/src/main/ipc/feedback.ts b/apps/desktop/src/main/ipc/feedback.ts new file mode 100644 index 000000000..62cee02f6 --- /dev/null +++ b/apps/desktop/src/main/ipc/feedback.ts @@ -0,0 +1,26 @@ +import { app, ipcMain } from 'electron'; +import type { LogEvent } from '../runtime/log-parser.js'; +import { resolveFeedbackTelegramConfig } from '../feedback/config.js'; +import { submitTelegramFeedback } from '../feedback/service.js'; + +export function registerFeedbackIpc(deps: { logBuffer: readonly LogEvent[] }): void { + ipcMain.handle('feedback:get-status', () => { + const resolution = resolveFeedbackTelegramConfig(); + return resolution.configured + ? { configured: true } + : { configured: false, error: resolution.error }; + }); + + ipcMain.handle('feedback:submit', async (_event, request: unknown) => { + const resolution = resolveFeedbackTelegramConfig(); + if (!resolution.configured) { + return { ok: false, error: resolution.error }; + } + return submitTelegramFeedback({ + config: resolution.config, + request, + logs: deps.logBuffer, + appVersion: app.getVersion(), + }); + }); +} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index ee64edc0b..148b336ab 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -68,6 +68,7 @@ import { LOCALHOST_URL } from './constants.js'; import { registerAppIpc } from './ipc/app.js'; import { registerDesktopIpc } from './ipc/desktop.js'; import { registerFloatIpc } from './ipc/float.js'; +import { registerFeedbackIpc } from './ipc/feedback.js'; import { registerPaymentsIpc } from './ipc/payments.js'; import { registerRuntimeIpc } from './ipc/runtime.js'; import { registerSystemProxyIpc } from './ipc/system-proxy.js'; @@ -265,6 +266,7 @@ function getCombinedProcessState(): RuntimeProcessState[] { registerPaymentsIpc(); registerDesktopIpc(); registerAppIpc(); +registerFeedbackIpc({ logBuffer }); registerFloatIpc(); registerSystemProxyIpc({ processManager }); registerRuntimeIpc({ diff --git a/apps/desktop/src/main/preload.cts b/apps/desktop/src/main/preload.cts index 0010c5b93..41270cd61 100644 --- a/apps/desktop/src/main/preload.cts +++ b/apps/desktop/src/main/preload.cts @@ -1,5 +1,10 @@ import { contextBridge, ipcRenderer } from 'electron'; import type { RuntimeMode, RuntimeProcessState, StartOptions } from './runtime/process-manager.js'; +import type { + FeedbackStatus, + FeedbackSubmitRequest, + FeedbackSubmitResult, +} from '../shared/feedback.js'; type LogEvent = { mode: RuntimeMode; @@ -184,6 +189,12 @@ const api = { getAppVersion(): Promise { return ipcRenderer.invoke('app:get-version') as Promise; }, + feedbackGetStatus(): Promise { + return ipcRenderer.invoke('feedback:get-status') as Promise; + }, + feedbackSubmit(request: FeedbackSubmitRequest): Promise { + return ipcRenderer.invoke('feedback:submit', request) as Promise; + }, getOpenRouterReferencePrices(): Promise> { return ipcRenderer.invoke('openrouter:reference-prices') as Promise< Record diff --git a/apps/desktop/src/main/telegram/bot-api.test.ts b/apps/desktop/src/main/telegram/bot-api.test.ts new file mode 100644 index 000000000..533758f58 --- /dev/null +++ b/apps/desktop/src/main/telegram/bot-api.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { createTelegramBotClient } from './bot-api.js'; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function telegramMessage(messageId: number) { + return { message_id: messageId, chat: { id: -1001, type: 'channel' }, date: 1 }; +} + +test('sends a media group and diagnostic document as multipart replies', async () => { + const calls: Array<{ url: string; body: FormData }> = []; + globalThis.fetch = async (url, init) => { + calls.push({ url: String(url), body: init?.body as FormData }); + const result = String(url).endsWith('/sendMediaGroup') + ? [telegramMessage(2), telegramMessage(3)] + : telegramMessage(4); + return new Response(JSON.stringify({ ok: true, result }), { + headers: { 'content-type': 'application/json' }, + }); + }; + + const client = createTelegramBotClient(['123456789', 'abcdefghijklmnopqrstuvwxyz_ABCDEF'].join(':')); + const uploads = [ + { data: new Uint8Array([1]), filename: 'one.png', mimeType: 'image/png' }, + { data: new Uint8Array([2]), filename: 'two.png', mimeType: 'image/png' }, + ]; + await client.sendMediaGroup('@feedback', uploads, { caption: 'Feedback ABC', replyToMessageId: 1 }); + await client.sendDocument('@feedback', { + data: new TextEncoder().encode('logs'), + filename: 'logs.txt', + mimeType: 'text/plain', + }, { replyToMessageId: 1 }); + + assert.equal(calls.length, 2); + assert.match(calls[0]!.url, /sendMediaGroup$/); + assert.equal(calls[0]!.body.get('chat_id'), '@feedback'); + assert.equal(calls[0]!.body.get('reply_parameters'), JSON.stringify({ message_id: 1 })); + assert.match(String(calls[0]!.body.get('media')), /attach:\/\/photo0/); + assert.ok(calls[0]!.body.get('photo0') instanceof Blob); + assert.match(calls[1]!.url, /sendDocument$/); + assert.ok(calls[1]!.body.get('document') instanceof Blob); +}); + +test('sends one image as a multipart reply', async () => { + const calls: Array<{ url: string; body: FormData }> = []; + globalThis.fetch = async (url, init) => { + calls.push({ url: String(url), body: init?.body as FormData }); + return new Response(JSON.stringify({ ok: true, result: telegramMessage(2) }), { + headers: { 'content-type': 'application/json' }, + }); + }; + + const client = createTelegramBotClient(['123456789', 'abcdefghijklmnopqrstuvwxyz_ABCDEF'].join(':')); + await client.sendPhoto('@feedback', { + data: new Uint8Array([1]), + filename: 'one.webp', + mimeType: 'image/webp', + }, { caption: 'Feedback ABC', replyToMessageId: 1 }); + + assert.equal(calls.length, 1); + assert.match(calls[0]!.url, /sendPhoto$/); + assert.equal(calls[0]!.body.get('reply_parameters'), JSON.stringify({ message_id: 1 })); + assert.ok(calls[0]!.body.get('photo') instanceof Blob); +}); diff --git a/apps/desktop/src/main/telegram/bot-api.ts b/apps/desktop/src/main/telegram/bot-api.ts index 45555e39a..88ec5f372 100644 --- a/apps/desktop/src/main/telegram/bot-api.ts +++ b/apps/desktop/src/main/telegram/bot-api.ts @@ -68,6 +68,40 @@ type TgResponse = { parameters?: { retry_after?: number }; }; +export type TgChatId = number | string; + +export type TgUpload = { + data: Uint8Array; + filename: string; + mimeType: string; +}; + +function uploadBlob(upload: TgUpload): Blob { + const buffer = upload.data.buffer.slice( + upload.data.byteOffset, + upload.data.byteOffset + upload.data.byteLength, + ) as ArrayBuffer; + return new Blob([buffer], { type: upload.mimeType }); +} + +async function readTelegramResponse(method: string, response: Response): Promise { + let body: TgResponse; + try { + body = await response.json() as TgResponse; + } catch { + throw new TelegramApiError(method, response.status, 'Invalid response from Telegram'); + } + if (!body.ok || body.result === undefined) { + throw new TelegramApiError( + method, + body.error_code ?? response.status, + body.description ?? 'Unknown error', + body.parameters?.retry_after ?? null, + ); + } + return body.result; +} + async function tgCall( token: string, method: string, @@ -88,21 +122,28 @@ async function tgCall( clearTimeout(timer); } - let body: TgResponse; + return readTelegramResponse(method, response); +} + +async function tgMultipartCall( + token: string, + method: string, + formData: FormData, + timeoutMs = 30_000, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + let response: Response; try { - body = await response.json() as TgResponse; - } catch { - throw new TelegramApiError(method, response.status, 'Invalid response from Telegram'); - } - if (!body.ok || body.result === undefined) { - throw new TelegramApiError( - method, - body.error_code ?? response.status, - body.description ?? 'Unknown error', - body.parameters?.retry_after ?? null, - ); + response = await fetch(`${TELEGRAM_API_BASE}/bot${token}/${method}`, { + method: 'POST', + body: formData, + signal: controller.signal, + }); + } finally { + clearTimeout(timer); } - return body.result; + return readTelegramResponse(method, response); } export type TelegramBotClient = { @@ -112,10 +153,23 @@ export type TelegramBotClient = { * timeoutS: 0 for an immediate snapshot of the queued backlog only. */ getUpdates(offset: number | undefined, signal?: AbortSignal, timeoutS?: number): Promise; - sendMessage(chatId: number, text: string, options?: { + sendMessage(chatId: TgChatId, text: string, options?: { replyMarkup?: TgReplyMarkup; disableNotification?: boolean; parseMode?: 'HTML'; + replyToMessageId?: number; + }): Promise; + sendPhoto(chatId: TgChatId, photo: TgUpload, options?: { + caption?: string; + replyToMessageId?: number; + }): Promise; + sendMediaGroup(chatId: TgChatId, photos: TgUpload[], options?: { + caption?: string; + replyToMessageId?: number; + }): Promise; + sendDocument(chatId: TgChatId, document: TgUpload, options?: { + caption?: string; + replyToMessageId?: number; }): Promise; /** * Streams partial text into an ephemeral draft bubble (Bot API 9.3+). @@ -175,8 +229,48 @@ export function createTelegramBotClient(token: string): TelegramBotClient { ...(options?.replyMarkup ? { reply_markup: options.replyMarkup } : {}), ...(options?.disableNotification ? { disable_notification: true } : {}), ...(options?.parseMode ? { parse_mode: options.parseMode } : {}), + ...(options?.replyToMessageId ? { reply_parameters: { message_id: options.replyToMessageId } } : {}), }), + sendPhoto: (chatId, photo, options) => { + const formData = new FormData(); + formData.append('chat_id', String(chatId)); + formData.append('photo', uploadBlob(photo), photo.filename); + if (options?.caption) formData.append('caption', options.caption); + if (options?.replyToMessageId) { + formData.append('reply_parameters', JSON.stringify({ message_id: options.replyToMessageId })); + } + return tgMultipartCall(token, 'sendPhoto', formData); + }, + + sendMediaGroup: (chatId, photos, options) => { + const formData = new FormData(); + formData.append('chat_id', String(chatId)); + formData.append('media', JSON.stringify(photos.map((_photo, index) => ({ + type: 'photo', + media: `attach://photo${index}`, + ...(index === 0 && options?.caption ? { caption: options.caption } : {}), + })))); + photos.forEach((photo, index) => { + formData.append(`photo${index}`, uploadBlob(photo), photo.filename); + }); + if (options?.replyToMessageId) { + formData.append('reply_parameters', JSON.stringify({ message_id: options.replyToMessageId })); + } + return tgMultipartCall(token, 'sendMediaGroup', formData); + }, + + sendDocument: (chatId, document, options) => { + const formData = new FormData(); + formData.append('chat_id', String(chatId)); + formData.append('document', uploadBlob(document), document.filename); + if (options?.caption) formData.append('caption', options.caption); + if (options?.replyToMessageId) { + formData.append('reply_parameters', JSON.stringify({ message_id: options.replyToMessageId })); + } + return tgMultipartCall(token, 'sendDocument', formData); + }, + sendMessageDraft: async (chatId, draftId, text) => { await tgCall(token, 'sendMessageDraft', { chat_id: chatId, diff --git a/apps/desktop/src/renderer/types/bridge.ts b/apps/desktop/src/renderer/types/bridge.ts index b68e64816..cdd66ba4b 100644 --- a/apps/desktop/src/renderer/types/bridge.ts +++ b/apps/desktop/src/renderer/types/bridge.ts @@ -1,3 +1,9 @@ +import type { + FeedbackStatus, + FeedbackSubmitRequest, + FeedbackSubmitResult, +} from '../../shared/feedback'; + export type RuntimeMode = 'connect' | 'system-proxy'; export type RuntimeProcessState = { @@ -299,6 +305,8 @@ export type DesktopBridge = { getSystemLocale?: () => Promise; /** Current app version from Electron `app.getVersion()`. */ getAppVersion?: () => Promise; + feedbackGetStatus?: () => Promise; + feedbackSubmit?: (request: FeedbackSubmitRequest) => Promise; /** * OpenRouter reference/retail prices keyed by normalized model id/name * (USD per million tokens). Used to render the struck-through baseline on diff --git a/apps/desktop/src/renderer/ui/components/BottomNotice.module.scss b/apps/desktop/src/renderer/ui/components/BottomNotice.module.scss index 04b7427d9..df7cd2715 100644 --- a/apps/desktop/src/renderer/ui/components/BottomNotice.module.scss +++ b/apps/desktop/src/renderer/ui/components/BottomNotice.module.scss @@ -77,6 +77,20 @@ color: var(--danger); } +.warning .icon, +.warning .title { + color: rgb(var(--warning-rgb)); +} + +.toast.warning { + border-color: rgba(var(--warning-rgb), 0.32); +} + +.toast.warning .icon { + background: rgba(var(--warning-rgb), 0.12); + color: rgb(var(--warning-rgb)); +} + .text { display: flex; flex: 1 1 auto; @@ -133,6 +147,15 @@ background: rgba(var(--danger-rgb), 0.2); } +.warning .action { + background: rgba(var(--warning-rgb), 0.12); + color: rgb(var(--warning-rgb)); +} + +.warning .action:hover { + background: rgba(var(--warning-rgb), 0.2); +} + .dismiss { flex: 0 0 auto; display: inline-flex; diff --git a/apps/desktop/src/renderer/ui/components/BottomNotice.tsx b/apps/desktop/src/renderer/ui/components/BottomNotice.tsx index 8b5e312f0..97bcd2395 100644 --- a/apps/desktop/src/renderer/ui/components/BottomNotice.tsx +++ b/apps/desktop/src/renderer/ui/components/BottomNotice.tsx @@ -20,7 +20,7 @@ interface Props { priority?: 'default' | 'overlay'; role?: 'alert' | 'status'; title?: ReactNode; - tone?: 'danger' | 'success'; + tone?: 'danger' | 'success' | 'warning'; } export function BottomNotice({ diff --git a/apps/desktop/src/renderer/ui/components/FeedbackModal.module.scss b/apps/desktop/src/renderer/ui/components/FeedbackModal.module.scss new file mode 100644 index 000000000..b2e8caf20 --- /dev/null +++ b/apps/desktop/src/renderer/ui/components/FeedbackModal.module.scss @@ -0,0 +1,278 @@ +.modal { + border: 1px solid var(--border); + border-radius: 20px; + background: var(--bg-card); + box-shadow: var(--shadow-lg, 0 24px 72px rgba(0, 0, 0, 0.38)); + + :global(.as-modal__header) { + padding: 24px 24px 18px; + border-bottom: none; + } + + :global(.as-modal__title) { + color: var(--text-primary); + font-size: 16px; + font-weight: 500; + } + + :global(.as-modal__close) { + width: 32px; + height: 32px; + border: none; + border-radius: 9px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + } + + :global(.as-modal__close):hover { + background: var(--bg-surface); + color: var(--text-primary); + } +} +.modalBody { + padding: 0; +} + +.dropZone { + position: relative; +} + +.dropOverlay { + position: absolute; + z-index: 4; + inset: 0; + display: grid; + place-items: center; + border: 2px dashed var(--accent-green); + border-radius: 0 0 20px 20px; + background: rgba(var(--accent-green-rgb), 0.1); + color: var(--accent-green); + font-size: 14px; + font-weight: 600; + pointer-events: none; +} + +.form { + display: flex; + flex-direction: column; +} + +.fields { + display: grid; + gap: 16px; + padding: 18px 24px 24px; +} + +.textarea, +.email { + width: 100%; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-primary); + color: var(--text-primary); + font: inherit; + outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.textarea { + min-height: 132px; + padding: 16px; + resize: none; + font-size: 16px; + line-height: 1.5; +} + +.email { + height: 48px; + padding: 0 14px; + font-size: 14px; +} + +.textarea::placeholder, +.email::placeholder { + color: var(--text-faint); +} + +.textarea:focus, +.email:focus { + border-color: var(--text-muted); + box-shadow: 0 0 0 2px rgba(var(--brand-rgb), 0.18); +} + +.inputError { + border-color: var(--danger); +} + +.diagnosticsRow { + display: flex; + align-items: center; + gap: 8px; +} + +.checkboxLabel { + display: flex; + grid-template-columns: none; + align-items: center; + gap: 10px; + color: var(--text-primary); + font-size: 14px; + letter-spacing: 0; + cursor: pointer; +} + +.checkboxLabel input { + width: 18px; + height: 18px; + margin: 0; + accent-color: var(--accent-green); +} + +.infoButton { + display: inline-grid; + place-items: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + background: transparent; + color: var(--text-muted); + cursor: help; +} + +.attachments { + display: flex; + flex-wrap: wrap; + gap: 10px; + padding: 10px; + border: 1px dashed var(--border); + border-radius: 12px; +} + +.thumbnail { + position: relative; + width: 64px; + height: 64px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg-primary); +} + +.thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.thumbnail button { + position: absolute; + inset: 0; + display: grid; + place-items: center; + border: none; + background: rgba(0, 0, 0, 0.58); + color: white; + font-size: 24px; + opacity: 0; + cursor: pointer; + transition: opacity 0.15s ease; +} + +.thumbnail:hover button, +.thumbnail button:focus-visible { + opacity: 1; +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 24px; + border-top: 1px solid var(--border); + border-radius: 0 0 20px 20px; + background: var(--bg-secondary); +} + +.fileInput, +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.attachButton, +.submitButton { + min-height: 42px; +} + +.submitButton { + min-width: 174px; +} + +.submitButton kbd { + display: inline-grid; + place-items: center; + min-width: 23px; + height: 23px; + margin-left: 5px; + padding: 0 5px; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 6px; + background: rgba(255, 255, 255, 0.08); + color: currentColor; + font: inherit; + font-size: 11px; +} + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + margin-right: 7px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + vertical-align: -2px; + animation: feedback-spin 0.7s linear infinite; +} + +@keyframes feedback-spin { + to { transform: rotate(360deg); } +} + +.fieldError, +.submitError, +.unavailable { + margin: -9px 0 0; + font-size: 12px; + line-height: 1.45; +} + +.fieldError, +.submitError { + color: var(--danger); +} + +.unavailable { + color: var(--text-muted); +} + +@media (max-width: 620px) { + .footer { + align-items: stretch; + flex-direction: column; + } + + .attachButton, + .submitButton { + width: 100%; + } +} diff --git a/apps/desktop/src/renderer/ui/components/FeedbackModal.test.tsx b/apps/desktop/src/renderer/ui/components/FeedbackModal.test.tsx new file mode 100644 index 000000000..e745e0e79 --- /dev/null +++ b/apps/desktop/src/renderer/ui/components/FeedbackModal.test.tsx @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { test } from 'vitest'; +import { + FeedbackModal, + isFeedbackSubmitShortcut, + validateFeedbackAttachmentSelection, + validateFeedbackEmail, +} from './FeedbackModal'; +import { + FEEDBACK_MAX_ATTACHMENT_BYTES, + FEEDBACK_MAX_IMAGES, +} from '../../../shared/feedback'; + +test('validates optional contact email', () => { + assert.equal(validateFeedbackEmail(''), null); + assert.equal(validateFeedbackEmail('person@example.com'), null); + assert.match(validateFeedbackEmail('not-an-email') ?? '', /valid contact email/); +}); + +test('validates attachment type, count, and total size', () => { + assert.match(validateFeedbackAttachmentSelection([], [{ size: 1, type: 'image/gif' }]) ?? '', /JPEG/); + assert.match(validateFeedbackAttachmentSelection( + Array.from({ length: FEEDBACK_MAX_IMAGES }, () => ({ size: 1 })), + [{ size: 1, type: 'image/png' }], + ) ?? '', /no more than 10/); + assert.match(validateFeedbackAttachmentSelection([], [{ + size: FEEDBACK_MAX_ATTACHMENT_BYTES + 1, + type: 'image/webp', + }]) ?? '', /8 MiB/); + assert.equal(validateFeedbackAttachmentSelection([], [{ size: 1, type: 'image/jpeg' }]), null); +}); + +test('recognizes enabled Cmd/Ctrl+Enter submission shortcuts', () => { + assert.equal(isFeedbackSubmitShortcut({ ctrlKey: true, key: 'Enter', metaKey: false }, true), true); + assert.equal(isFeedbackSubmitShortcut({ ctrlKey: false, key: 'Enter', metaKey: true }, true), true); + assert.equal(isFeedbackSubmitShortcut({ ctrlKey: true, key: 'Enter', metaKey: false }, false), false); + assert.equal(isFeedbackSubmitShortcut({ ctrlKey: true, key: 'Space', metaKey: false }, true), false); +}); + +test('renders the accessible Emdash-inspired feedback form', () => { + const markup = renderToStaticMarkup( + {}} + onSubmitted={() => {}} + />, + ); + assert.match(markup, /role="dialog"/); + assert.match(markup, /aria-labelledby="[^"]+"/); + assert.match(markup, /]*>Feedback<\/h2>/); + assert.match(markup, /Feedback details/); + assert.match(markup, /Contact email/); + assert.match(markup, /Include diagnostic logs/); + assert.match(markup, /Attach image/); + assert.match(markup, /Send Feedback/); + assert.match(markup, /disabled=""/); +}); diff --git a/apps/desktop/src/renderer/ui/components/FeedbackModal.tsx b/apps/desktop/src/renderer/ui/components/FeedbackModal.tsx new file mode 100644 index 000000000..cbc76dfa9 --- /dev/null +++ b/apps/desktop/src/renderer/ui/components/FeedbackModal.tsx @@ -0,0 +1,315 @@ +import { useCallback, useEffect, useRef, useState, type DragEvent, type FormEvent, type KeyboardEvent } from 'react'; +import { Attachment02Icon, InformationCircleIcon } from '@hugeicons/core-free-icons'; +import { HugeiconsIcon } from '@hugeicons/react'; +import { Button, Modal } from '@antseed/ui'; +import { + FEEDBACK_IMAGE_MIME_TYPES, + FEEDBACK_MAX_ATTACHMENT_BYTES, + FEEDBACK_MAX_EMAIL_LENGTH, + FEEDBACK_MAX_IMAGES, + FEEDBACK_MAX_TEXT_LENGTH, + type FeedbackImageInput, + type FeedbackImageMimeType, + type FeedbackSubmitResult, +} from '../../../shared/feedback'; +import { InfoTooltip } from './InfoTooltip'; +import styles from './FeedbackModal.module.scss'; + +type FeedbackAttachment = FeedbackImageInput & { + id: string; + previewUrl: string; +}; + +type FeedbackModalProps = { + configured: boolean; + isOpen: boolean; + onClose: () => void; + onSubmitted: (result: FeedbackSubmitResult) => void; +}; + +const ACCEPTED_MIME_TYPES = new Set(FEEDBACK_IMAGE_MIME_TYPES); +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +type FeedbackAttachmentCandidate = Pick; +type FeedbackFileCandidate = Pick; + +export function validateFeedbackEmail(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + if (trimmed.length > FEEDBACK_MAX_EMAIL_LENGTH || !EMAIL_PATTERN.test(trimmed)) { + return 'Enter a valid contact email or leave it blank.'; + } + return null; +} + +export function validateFeedbackAttachmentSelection( + attachments: readonly FeedbackAttachmentCandidate[], + files: readonly FeedbackFileCandidate[], +): string | null { + if (files.some((file) => !ACCEPTED_MIME_TYPES.has(file.type))) { + return 'Attach JPEG, PNG, or WebP images only.'; + } + if (attachments.length + files.length > FEEDBACK_MAX_IMAGES) { + return `Attach no more than ${FEEDBACK_MAX_IMAGES} images.`; + } + const totalBytes = [...attachments, ...files].reduce((total, file) => total + file.size, 0); + if (totalBytes > FEEDBACK_MAX_ATTACHMENT_BYTES) { + return 'Attachments exceed the 8 MiB total limit.'; + } + return null; +} + +export function isFeedbackSubmitShortcut( + event: Pick, 'ctrlKey' | 'key' | 'metaKey'>, + canSubmit: boolean, +): boolean { + return canSubmit && (event.metaKey || event.ctrlKey) && event.key === 'Enter'; +} + +function encodeBase64(bytes: Uint8Array): string { + const chunkSize = 0x8000; + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + } + return btoa(binary); +} + +async function prepareAttachment(file: File): Promise { + const bytes = new Uint8Array(await file.arrayBuffer()); + const dataBase64 = encodeBase64(bytes); + return { + id: `${file.name}-${file.size}-${file.lastModified}-${crypto.randomUUID()}`, + name: file.name, + mimeType: file.type as FeedbackImageMimeType, + size: file.size, + dataBase64, + previewUrl: `data:${file.type};base64,${dataBase64}`, + }; +} + +export function FeedbackModal({ configured, isOpen, onClose, onSubmitted }: FeedbackModalProps) { + const [feedback, setFeedback] = useState(''); + const [contactEmail, setContactEmail] = useState(''); + const [includeDiagnosticLogs, setIncludeDiagnosticLogs] = useState(false); + const [attachments, setAttachments] = useState([]); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [contactEmailError, setContactEmailError] = useState(null); + const [isDraggingOver, setIsDraggingOver] = useState(false); + const fileInputRef = useRef(null); + + const reset = useCallback(() => { + setFeedback(''); + setContactEmail(''); + setIncludeDiagnosticLogs(false); + setAttachments([]); + setSubmitting(false); + setError(null); + setContactEmailError(null); + setIsDraggingOver(false); + }, []); + + useEffect(() => { + if (isOpen) reset(); + }, [isOpen, reset]); + + const addFiles = useCallback(async (files: File[]) => { + if (submitting || files.length === 0) return; + const selectionError = validateFeedbackAttachmentSelection(attachments, files); + if (selectionError) { + setError(selectionError); + return; + } + try { + const prepared = await Promise.all(files.map(prepareAttachment)); + setAttachments((current) => [...current, ...prepared]); + setError(null); + } catch { + setError('Unable to read one of the selected images.'); + } + }, [attachments, submitting]); + + const handleSubmit = useCallback(async (event: FormEvent) => { + event.preventDefault(); + const emailError = validateFeedbackEmail(contactEmail); + if (emailError) { + setContactEmailError(emailError); + return; + } + if (!configured) { + setError('Feedback is unavailable in this build.'); + return; + } + const bridge = window.antseedDesktop?.feedbackSubmit; + if (!bridge) { + setError('Feedback is unavailable in this build.'); + return; + } + setSubmitting(true); + setError(null); + try { + const result = await bridge({ + feedback, + contactEmail, + images: attachments.map(({ previewUrl: _previewUrl, id: _id, ...attachment }) => attachment), + includeDiagnosticLogs, + }); + if (!result.ok) { + setError(result.error ?? 'Unable to send feedback. Please try again.'); + return; + } + onSubmitted(result); + } catch { + setError('Unable to send feedback. Please try again.'); + } finally { + setSubmitting(false); + } + }, [attachments, configured, contactEmail, feedback, includeDiagnosticLogs, onSubmitted]); + + const canSubmit = configured && feedback.trim().length > 0 && !submitting; + const shortcutModifier = typeof window !== 'undefined' && window.antseedDesktop?.platform === 'darwin' + ? '⌘' + : 'Ctrl'; + + const handleFormKeyDown = useCallback((event: KeyboardEvent) => { + if (isFeedbackSubmitShortcut(event, canSubmit)) { + event.preventDefault(); + event.currentTarget.requestSubmit(); + } + }, [canSubmit]); + + const handleDrop = useCallback((event: DragEvent) => { + event.preventDefault(); + setIsDraggingOver(false); + void addFiles(Array.from(event.dataTransfer.files)); + }, [addFiles]); + + return ( + {} : onClose} + size="lg" + title="Feedback" + > +
{ event.preventDefault(); setIsDraggingOver(true); }} + onDragLeave={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setIsDraggingOver(false); + }} + onDragOver={(event) => event.preventDefault()} + onDrop={handleDrop} + > + {isDraggingOver &&
Drop images here
} +
+
+ +