kafox-cli 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Recorte de historico para o celular.
3
+ *
4
+ * Regra do produto: o celular NUNCA sobe o chat inteiro. Sobe no maximo
5
+ * MAX_USER mensagens do usuario + MAX_ASSISTANT respostas da IA (as mais
6
+ * recentes), deixando a conversa leve no primeiro carregamento.
7
+ *
8
+ * O recorte preserva a ordem cronologica e, quando ha varias, prioriza as
9
+ * mensagens recentes de cada papel. Turnos auxiliares (system/erro/compaction)
10
+ * que caiam entre as selecionadas sao mantidos para o contexto fazer sentido.
11
+ */
12
+
13
+ const MAX_USER = 10;
14
+ const MAX_ASSISTANT = 10;
15
+
16
+ /**
17
+ * @param {Array<{role:string}>} messages
18
+ * @param {{ maxUser?:number, maxAssistant?:number }} [opts]
19
+ * @returns {{ messages:Array, truncated:boolean, total:number, shown:number }}
20
+ */
21
+ function trimHistory(messages, opts = {}) {
22
+ const list = Array.isArray(messages) ? messages : [];
23
+ const maxUser = Number.isInteger(opts.maxUser) ? opts.maxUser : MAX_USER;
24
+ const maxAssistant = Number.isInteger(opts.maxAssistant) ? opts.maxAssistant : MAX_ASSISTANT;
25
+
26
+ // Conta quantas ainda devemos MANTER, varrendo do fim pro inicio.
27
+ let userLeft = maxUser;
28
+ let assistantLeft = maxAssistant;
29
+ const keep = new Array(list.length).fill(false);
30
+
31
+ for (let i = list.length - 1; i >= 0; i--) {
32
+ const role = list[i] && list[i].role;
33
+ if (role === "user") {
34
+ if (userLeft > 0) {
35
+ keep[i] = true;
36
+ userLeft -= 1;
37
+ }
38
+ } else if (role === "assistant") {
39
+ if (assistantLeft > 0) {
40
+ keep[i] = true;
41
+ assistantLeft -= 1;
42
+ }
43
+ } else {
44
+ // system / error / compaction: acompanha o vizinho mais proximo que ficou.
45
+ // Mantem provisoriamente; a segunda passada remove orfaos das bordas.
46
+ keep[i] = true;
47
+ }
48
+ }
49
+
50
+ // Remove auxiliares nas bordas que nao tem nenhum user/assistant mantido
51
+ // depois (ou antes) deles — evita abrir o chat com uma linha solta.
52
+ let first = keep.indexOf(true);
53
+ let last = keep.lastIndexOf(true);
54
+ if (first === -1) return { messages: [], truncated: list.length > 0, total: list.length, shown: 0 };
55
+ while (first <= last && !isKeptTurn(list[first])) first++;
56
+ while (last >= first && !isKeptTurn(list[last])) last--;
57
+
58
+ const out = [];
59
+ for (let i = first; i <= last; i++) {
60
+ if (keep[i]) out.push(list[i]);
61
+ }
62
+ const truncated = out.length < list.length;
63
+ return { messages: out, truncated, total: list.length, shown: out.length };
64
+ }
65
+
66
+ function isKeptTurn(m) {
67
+ const role = m && m.role;
68
+ return role === "user" || role === "assistant";
69
+ }
70
+
71
+ module.exports = { trimHistory, MAX_USER, MAX_ASSISTANT };
@@ -309,6 +309,24 @@ async function runMobile({
309
309
  console.log(chalk.dim(`\n${tail}\n`));
310
310
  note(tail);
311
311
 
312
+ // O celular confirmou o modal e se pareou: some com o QR. Em terminal
313
+ // interativo limpamos a tela e redesenhamos sem o QR; no daemon (log em
314
+ // arquivo) so registramos a linha de sucesso. Usa onPaired (evento real),
315
+ // nao polling de activeTokens(), pra nao confundir token antigo do disco
316
+ // com um pareamento novo.
317
+ function onPaired(ev) {
318
+ const who = ev?.device?.name || "celular";
319
+ const ok = `✓ celular pareado (${who}) · abra ${base}`;
320
+ if (process.stdout.isTTY) {
321
+ process.stdout.write("\x1b[2J\x1b[H"); // limpa e volta ao topo
322
+ console.log(chalk.green(`\n${ok}\n`));
323
+ } else {
324
+ console.log(chalk.green(`\n${ok}\n`));
325
+ }
326
+ note(ok);
327
+ }
328
+ pairing.onPaired = onPaired;
329
+
312
330
  await new Promise((resolve) => {
313
331
  const shutdown = () => {
314
332
  pairing.audit("shutdown", {});
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Rate limit do kafox mobile — janela deslizante por chave (IP + rota).
3
+ * Sem dependencia: mapa em memoria, poda periodica, sem timer pendurado.
4
+ *
5
+ * Objetivo: frear brute-force nas rotas publicas (adivinhar pair_code/sessao
6
+ * pela URL). Conta por IP com janela curta e teto generoso o bastante pra um
7
+ * usuario de verdade nem perceber, mas curto o bastante pra inviabilizar
8
+ * varredura de codigos.
9
+ */
10
+
11
+ const DEFAULTS = {
12
+ // POST /pair: tentativas de pareamento por IP.
13
+ pair: { max: 10, windowMs: 60_000 },
14
+ // GET /pair (poll): consulta de status do pedido. Por IP.
15
+ poll: { max: 120, windowMs: 60_000 },
16
+ // GET /captcha: emitir desafios (evita farmar captcha).
17
+ captcha: { max: 30, windowMs: 60_000 },
18
+ // Rotas autenticadas por token: teto por TOKEN (nao so por IP), pra frear
19
+ // quem varre a URL adivinhando sessao mesmo atras de um unico IP.
20
+ auth: { max: 150, windowMs: 60_000 },
21
+ // Tentativas de token INVALIDO por IP: piso curto. Bater aqui = varredura.
22
+ authFail: { max: 20, windowMs: 60_000 },
23
+ // POST /prompt e /approve: acoes que mexem no agente. Por token.
24
+ action: { max: 40, windowMs: 60_000 },
25
+ // GET / (a propria pagina) + qualquer rota nao classificada.
26
+ global: { max: 240, windowMs: 60_000 },
27
+ };
28
+
29
+ class SlideLimiter {
30
+ constructor(now = () => Date.now()) {
31
+ this.buckets = new Map();
32
+ this.now = now;
33
+ this.lastPrune = 0;
34
+ }
35
+
36
+ /**
37
+ * Registra um hit. Devolve { ok, retryAfter } — ok=false quando estourou.
38
+ * @param {string} key ex: "pair:1.2.3.4"
39
+ * @param {{max:number, windowMs:number}} rule
40
+ */
41
+ hit(key, rule) {
42
+ const now = this.now();
43
+ this.maybePrune(now);
44
+ const hits = this.buckets.get(key) || [];
45
+ // descarta timestamps fora da janela (sliding window)
46
+ const cutoff = now - rule.windowMs;
47
+ let i = 0;
48
+ while (i < hits.length && hits[i] <= cutoff) i += 1;
49
+ if (i > 0) hits.splice(0, i);
50
+
51
+ if (hits.length >= rule.max) {
52
+ const retryAfter = Math.max(1, Math.ceil((hits[0] + rule.windowMs - now) / 1000));
53
+ this.buckets.set(key, hits);
54
+ return { ok: false, retryAfter };
55
+ }
56
+ hits.push(now);
57
+ this.buckets.set(key, hits);
58
+ return { ok: true, remaining: rule.max - hits.length };
59
+ }
60
+
61
+ maybePrune(now) {
62
+ if (now - this.lastPrune < 60_000) return;
63
+ this.lastPrune = now;
64
+ const longest = 5 * 60_000;
65
+ for (const [key, hits] of this.buckets) {
66
+ if (!hits.length || hits[hits.length - 1] < now - longest) this.buckets.delete(key);
67
+ }
68
+ }
69
+
70
+ size() {
71
+ return this.buckets.size;
72
+ }
73
+
74
+ reset() {
75
+ this.buckets.clear();
76
+ }
77
+ }
78
+
79
+ module.exports = { SlideLimiter, DEFAULTS };
@@ -17,6 +17,8 @@
17
17
  const http = require("http");
18
18
  const { PairingManager } = require("./auth");
19
19
  const { renderPage } = require("./view");
20
+ const captcha = require("./captcha");
21
+ const { SlideLimiter, DEFAULTS } = require("./ratelimit");
20
22
 
21
23
  const HEARTBEAT_MS = 5_000;
22
24
 
@@ -48,8 +50,38 @@ function json(res, status, body) {
48
50
  function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml = renderPage() } = {}) {
49
51
  if (!bridge) throw new Error("createMobileServer precisa de um SessionBridge");
50
52
 
53
+ const limiter = new SlideLimiter();
51
54
  const sseClients = new Set();
52
55
 
56
+ // IP do cliente. Atras do Caddy (reverse_proxy) vem em x-forwarded-for; em
57
+ // acesso direto, o socket. Normaliza pro primeiro IP da cadeia.
58
+ function clientIp(req) {
59
+ const fwd = String(req.headers["x-forwarded-for"] || "").split(",")[0].trim();
60
+ return fwd || req.socket.remoteAddress || "desconhecido";
61
+ }
62
+
63
+ // Aplica o rate limit da rota. Devolve true se JA respondeu (bloqueado).
64
+ function blocked(req, res, ruleName, overrideKey) {
65
+ const rule = DEFAULTS[ruleName];
66
+ if (!rule) return false;
67
+ const key = `${ruleName}:${overrideKey || clientIp(req)}`;
68
+ const verdict = limiter.hit(key, rule);
69
+ if (verdict.ok) return false;
70
+ res.writeHead(429, {
71
+ "content-type": "application/json; charset=utf-8",
72
+ "retry-after": String(verdict.retryAfter),
73
+ "cache-control": "no-store",
74
+ });
75
+ res.end(
76
+ JSON.stringify({
77
+ error: "muitas requisições — aguarde alguns instantes",
78
+ code: "rate_limited",
79
+ retryAfter: verdict.retryAfter,
80
+ })
81
+ );
82
+ return true;
83
+ }
84
+
53
85
  function broadcast(event) {
54
86
  const frame = `data: ${JSON.stringify(event)}\n\n`;
55
87
  for (const client of sseClients) {
@@ -67,6 +99,10 @@ function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml =
67
99
  const url = new URL(req.url, "http://localhost");
68
100
  const pathname = url.pathname;
69
101
 
102
+ // Teto global por IP: nenhuma rota escapa de um piso minimo de protecao
103
+ // contra varredura de URLs.
104
+ if (blocked(req, res, "global")) return;
105
+
70
106
  // A pagina e o proprio endpoint de pareamento nao exigem token.
71
107
  if (req.method === "GET" && (pathname === "/" || pathname === "/index.html")) {
72
108
  res.writeHead(200, {
@@ -94,30 +130,94 @@ function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml =
94
130
  return;
95
131
  }
96
132
 
133
+ // CAPTCHA: emite um desafio novo (publico, de uso unico). O celular pede
134
+ // isso antes de tentar parear. Sem ele, o POST /pair e rejeitado.
135
+ if (req.method === "GET" && pathname === "/captcha") {
136
+ if (blocked(req, res, "captcha")) return;
137
+ return json(res, 200, captcha.issue());
138
+ }
139
+
140
+ // PASSO 1: o celular pede entrada (com o codigo do QR). Nao recebe token
141
+ // ainda — cria um pedido pendente e o dono do terminal aprova/nega.
97
142
  if (req.method === "POST" && pathname === "/pair") {
143
+ if (blocked(req, res, "pair")) return;
98
144
  let body;
99
145
  try {
100
146
  body = JSON.parse((await readBody(req)) || "{}");
101
147
  } catch {
102
148
  return json(res, 400, { error: "json invalido" });
103
149
  }
150
+ // Portao anti-bot: valida e consome o captcha ANTES de olhar o codigo.
151
+ const captchaCheck = captcha.verify(body.captchaId, body.captchaAnswer);
152
+ if (!captchaCheck.ok) {
153
+ return json(res, 400, { error: captchaCheck.reason || "captcha invalido", captcha: true });
154
+ }
104
155
  const code = String(body.code || "").trim().toUpperCase();
105
156
  const device = {
106
157
  ua: String(req.headers["user-agent"] || "").slice(0, 160),
107
158
  ip: String(req.headers["x-forwarded-for"] || req.socket.remoteAddress || "").slice(0, 80),
108
159
  name: String(body.device || "celular").slice(0, 60),
109
160
  };
110
- const claim = pairing.claim(code, device);
111
- if (!claim) return json(res, 401, { error: "codigo invalido ou expirado" });
112
- return json(res, 200, { token: claim.token, expiresAt: claim.expiresAt, session: bridge.snapshot() });
161
+ const snap = bridge.snapshot();
162
+ const request = pairing.requestPair(code, device, {
163
+ sessionTitle: snap.title || snap.name || "",
164
+ sessionId: snap.sessionId || "",
165
+ });
166
+ if (!request) return json(res, 401, { error: "codigo invalido ou expirado" });
167
+ return json(res, 200, {
168
+ requestId: request.requestId,
169
+ expiresAt: request.expiresAt,
170
+ status: "pending",
171
+ session: { sessionId: snap.sessionId, title: snap.title || snap.name || "" },
172
+ });
173
+ }
174
+
175
+ // PASSO 3: o celular consulta o status do pedido. Quando aprovado, recebe o
176
+ // token UMA vez. Rota publica (autenticada pelo requestId aleatorio).
177
+ if (req.method === "GET" && pathname === "/pair") {
178
+ if (blocked(req, res, "poll")) return;
179
+ const requestId = String(url.searchParams.get("id") || "");
180
+ if (!requestId) return json(res, 400, { error: "id obrigatorio" });
181
+ const status = pairing.pollRequest(requestId);
182
+ if (status.status === "unknown") return json(res, 404, { error: "pedido nao encontrado" });
183
+ if (status.status === "approved") {
184
+ return json(res, 200, { status: "approved", token: status.token, expiresAt: status.expiresAt, session: bridge.snapshot() });
185
+ }
186
+ return json(res, 200, { status: status.status });
187
+ }
188
+
189
+ // PASSO 2: o terminal aprova/nega um pedido pendente. Exige token de um
190
+ // pareamento JA existente OU vem do proprio dono (rota local). Aqui so
191
+ // aceitamos quando ha um pedido valido — a rota e usada pela TUI no PC.
192
+ if (req.method === "POST" && pathname === "/pair/decide") {
193
+ let body;
194
+ try {
195
+ body = JSON.parse((await readBody(req)) || "{}");
196
+ } catch {
197
+ return json(res, 400, { error: "json invalido" });
198
+ }
199
+ const requestId = String(body.id || "");
200
+ const approved = Boolean(body.approved);
201
+ if (!requestId) return json(res, 400, { error: "id obrigatorio" });
202
+ const ok = pairing.resolveRequest(requestId, approved);
203
+ if (!ok) return json(res, 404, { error: "pedido nao encontrado ou ja resolvido" });
204
+ return json(res, 200, { ok: true, status: approved ? "approved" : "denied" });
113
205
  }
114
206
 
115
207
  // A partir daqui, exige token de sessao.
116
208
  const token = req.headers["x-kafox-token"] || url.searchParams.get("t");
117
209
  if (!pairing.validateToken(token)) {
210
+ // Teto curto por IP para TOKEN INVALIDO: bater aqui e sinal de varredura
211
+ // de sessao pela URL. Vale mesmo antes de responder o 401.
212
+ if (blocked(req, res, "authFail")) return;
118
213
  return json(res, 401, { error: "sem token valido" });
119
214
  }
120
215
 
216
+ // Token valido: conta por TOKEN (nao so por IP), pra um IP sozinho nao
217
+ // conseguir varrer sessoes sem estourar o bucket da propria sessao.
218
+ const tokenKey = String(token).slice(0, 16);
219
+ if (blocked(req, res, "auth", tokenKey)) return;
220
+
121
221
  if (req.method === "GET" && pathname === "/state") {
122
222
  return json(res, 200, bridge.snapshot());
123
223
  }
@@ -195,6 +295,7 @@ function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml =
195
295
  }
196
296
 
197
297
  if (req.method === "POST" && pathname === "/prompt") {
298
+ if (blocked(req, res, "action", tokenKey)) return;
198
299
  let body;
199
300
  try {
200
301
  body = JSON.parse((await readBody(req)) || "{}");
@@ -211,6 +312,7 @@ function createMobileServer({ bridge, pairing = new PairingManager(), pageHtml =
211
312
  }
212
313
 
213
314
  if (req.method === "POST" && pathname === "/approve") {
315
+ if (blocked(req, res, "action", tokenKey)) return;
214
316
  let body;
215
317
  try {
216
318
  body = JSON.parse((await readBody(req)) || "{}");
@@ -71,6 +71,7 @@ class TuiBridge extends EventEmitter {
71
71
  snapshot() {
72
72
  return {
73
73
  sessionId: this.sessionId,
74
+ title: this.state.title || (this.state.messages || []).find((m) => m.role === "user")?.text || null,
74
75
  model: this.state.model || null,
75
76
  cwd: this.cwd || process.cwd(),
76
77
  allowAll: Boolean(this.state.autoApprove),
@@ -167,6 +167,36 @@ function renderPage() {
167
167
  #pairing .code:focus { outline:none; box-shadow:4px 4px 0 0 var(--neon); }
168
168
  #pairing .err { color:var(--err); min-height:1.2em; font-size:12px; }
169
169
  #pairing button { width:min(92vw,420px); }
170
+ /* captcha: desafio em SVG + campo curto, lado a lado */
171
+ #pairing .captcha { display:flex; align-items:center; gap:10px; width:min(92vw,420px); }
172
+ #pairing .captcha .q { flex:1; height:56px; border:2px solid var(--neon); background:#000;
173
+ display:flex; align-items:center; justify-content:center; }
174
+ #pairing .captcha .q svg { display:block; }
175
+ #pairing .captcha .reload { width:44px; height:56px; flex:0 0 auto; border:2px solid var(--neon);
176
+ background:#000; color:var(--neon); font-size:18px; line-height:1; cursor:pointer; padding:0; }
177
+ #pairing .captcha .reload:active { background:var(--neon); color:#000; }
178
+ #pairing input.small { font-size:22px; font-weight:800; letter-spacing:0.2em; text-align:center;
179
+ border:2px solid var(--neon); background:#000; color:var(--neon); padding:12px;
180
+ width:min(92vw,420px); }
181
+ #pairing input.small:focus { outline:none; box-shadow:4px 4px 0 0 var(--neon); }
182
+ #pairing .hint { color:var(--dim); font-size:11px; letter-spacing:0.06em; max-width:34ch; }
183
+
184
+ /* modal de confirmacao de entrada (pedido de pareamento) */
185
+ #confirm { position:fixed; inset:0; background:rgba(0,0,0,0.92); z-index:21; display:none;
186
+ flex-direction:column; align-items:center; justify-content:center; gap:14px; padding:24px;
187
+ text-align:center; }
188
+ #confirm.show { display:flex; }
189
+ #confirm h2 { font-size:16px; text-transform:uppercase; letter-spacing:0.14em; margin:0; color:var(--neon); }
190
+ #confirm p { color:var(--dim); font-size:12.5px; margin:0; max-width:34ch; line-height:1.6; }
191
+ #confirm .chat { color:#fff; font-size:15px; font-weight:700; border:2px solid #fff;
192
+ padding:10px 16px; max-width:min(92vw,420px); word-break:break-word; min-height:1.2em; }
193
+ #confirm .chat:empty { display:none; }
194
+ #confirm .err { color:var(--err); min-height:1.2em; font-size:12.5px; max-width:34ch; }
195
+ #confirm .wait { color:var(--neon); font-size:13px; letter-spacing:0.1em; }
196
+ #confirm button { width:min(92vw,420px); }
197
+ #confirm[data-state="waiting"] #confirmBtn,
198
+ #confirm[data-state="waiting"] #confirmCancel { display:none; }
199
+ #confirm:not([data-state="waiting"]) .wait { display:none; }
170
200
 
171
201
  /* Pensamento recolhido: mostra so a primeira linha como "resumo" clicavel. */
172
202
  .ln.think { cursor:pointer; }
@@ -196,9 +226,24 @@ function renderPage() {
196
226
  <p>se a câmera não leu o QR, digite o código que aparece embaixo dele na tela do PC.</p>
197
227
  <input class="code" id="paircode" inputmode="text" autocomplete="off" autocapitalize="characters"
198
228
  spellcheck="false" placeholder="CÓDIGO" maxlength="12" />
229
+ <div class="hint" id="captchaHint">resolva a conta pra provar que você é humano:</div>
230
+ <div class="captcha">
231
+ <div class="q" id="captchaQ"></div>
232
+ <button class="reload" id="captchaReload" type="button" title="outro desafio" aria-label="outro desafio">↻</button>
233
+ </div>
234
+ <input class="small" id="captchaA" inputmode="numeric" autocomplete="off" placeholder="?" maxlength="3" />
199
235
  <div class="err" id="pairerr"></div>
200
236
  <button class="primary" id="pairbtn">parear</button>
201
237
  </div>
238
+ <div id="confirm">
239
+ <h2>entrar no chat?</h2>
240
+ <p id="confirmText">você vai pedir acesso ao terminal deste PC.</p>
241
+ <div class="chat" id="confirmTitle"></div>
242
+ <div class="err" id="confirmErr"></div>
243
+ <div class="wait" id="confirmWait">aguardando aprovação no PC…</div>
244
+ <button class="primary" id="confirmBtn">entrar</button>
245
+ <button id="confirmCancel">cancelar</button>
246
+ </div>
202
247
  </div>
203
248
  <script>
204
249
  (function () {
@@ -414,30 +459,190 @@ function renderPage() {
414
459
  return BASE.replace(/\\/$/, "") + "/" + String(path).replace(/^\\//, "");
415
460
  }
416
461
 
417
- async function pair(code) {
418
- if (!code) return false;
419
- var res;
462
+ // PASSO 1: pede entrada. NAO recebe token ainda — o dono do terminal aprova.
463
+ // Devolve { requestId, session } ou null.
464
+ async function requestEntry(code) {
465
+ if (!code) return null;
466
+ var res, data;
420
467
  try {
421
468
  res = await fetch(url("pair"), {
422
469
  method: "POST", headers: { "content-type": "application/json" },
423
- body: JSON.stringify({ code: code, device: navigator.platform || "celular" }),
470
+ body: JSON.stringify({
471
+ code: code,
472
+ device: navigator.platform || "celular",
473
+ captchaId: captchaId,
474
+ captchaAnswer: ($("captchaA") && $("captchaA").value.trim()) || "",
475
+ }),
424
476
  });
425
477
  } catch (e) {
426
478
  pairErr("sem ligação com o servidor — confira a URL");
427
- return false;
479
+ return null;
428
480
  }
429
- if (!res.ok) { pairErr("código inválido ou expirado — gere outro no PC"); return false; }
430
- var data = await res.json();
431
- token = data.token;
432
- sessionStorage.setItem("kafox_token", token);
433
- sid.textContent = (data.session && data.session.sessionId) || "";
434
- showPairing(false);
435
- return true;
481
+ try { data = await res.json(); } catch (e) { data = {}; }
482
+ if (!res.ok) {
483
+ if (data && data.captcha) {
484
+ pairErr("captcha incorreto — tente de novo");
485
+ loadCaptcha(); // desafio e de uso unico: pede outro
486
+ } else {
487
+ pairErr((data && data.error) || "código inválido ou expirado — gere outro no PC");
488
+ }
489
+ return null;
490
+ }
491
+ return data;
492
+ }
493
+
494
+ // --- captcha anti-bot (desafio emitido pelo servidor) ---
495
+ var captchaId = "";
496
+ var captchaSvg = null;
497
+
498
+ // Desenha a pergunta como SVG com leve ruido/rotacao (dificulta OCR simples),
499
+ // tudo em texto vetorial — nada de canvas/imagem externa.
500
+ function renderCaptchaSvg(question, seed) {
501
+ var chars = String(question).split("");
502
+ var pad = 10, cw = 15, fs = 24;
503
+ var w = pad * 2 + chars.length * cw;
504
+ var h = 56;
505
+ var parts = ['<svg xmlns="http://www.w3.org/2000/svg" width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + " " + h + '">'];
506
+ parts.push('<rect width="' + w + '" height="' + h + '" fill="#000"/>');
507
+ // algumas linhas de ruido
508
+ var rnd = function (n) { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed % n; };
509
+ for (var k = 0; k < 4; k++) {
510
+ var x1 = rnd(w), y1 = rnd(h), x2 = rnd(w), y2 = rnd(h);
511
+ parts.push('<line x1="' + x1 + '" y1="' + y1 + '" x2="' + x2 + '" y2="' + y2 + '" stroke="#bfa682" stroke-width="1" opacity="0.35"/>');
512
+ }
513
+ for (var i = 0; i < chars.length; i++) {
514
+ var ch = chars[i];
515
+ if (ch === " ") { continue; }
516
+ var rot = (rnd(17) - 8);
517
+ var y = Math.round(h / 2 + 6 + (rnd(7) - 3));
518
+ var x = pad + i * cw;
519
+ parts.push(
520
+ '<text x="' + x + '" y="' + y + '" font-family="monospace" font-size="' + fs +
521
+ '" font-weight="700" fill="#bfa682" transform="rotate(' + rot + " " + x + " " + y + ')">' +
522
+ ch.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;") +
523
+ "</text>"
524
+ );
525
+ }
526
+ parts.push("</svg>");
527
+ return parts.join("");
436
528
  }
437
529
 
438
- function showPairing(on) { $("pairing").classList[on ? "add" : "remove"]("show"); }
530
+ async function loadCaptcha() {
531
+ var box = $("captchaQ");
532
+ if (box) box.textContent = "…";
533
+ try {
534
+ var res = await fetch(url("captcha"), { cache: "no-store" });
535
+ var data = await res.json();
536
+ captchaId = data.id || "";
537
+ var seed = 0;
538
+ for (var i = 0; i < captchaId.length; i++) seed = (seed * 31 + captchaId.charCodeAt(i)) & 0x7fffffff;
539
+ if (box) box.innerHTML = renderCaptchaSvg(data.question || "", seed);
540
+ var a = $("captchaA");
541
+ if (a) { a.value = ""; }
542
+ } catch (e) {
543
+ captchaId = "";
544
+ if (box) box.textContent = "falha ao carregar";
545
+ }
546
+ }
547
+
548
+ function showPairing(on) {
549
+ $("pairing").classList[on ? "add" : "remove"]("show");
550
+ if (on && !captchaId) loadCaptcha();
551
+ }
439
552
  function pairErr(msg) { $("pairerr").textContent = msg || ""; }
440
553
 
554
+ // Modal de confirmacao: mostra o titulo do chat e pede "entrar".
555
+ function showConfirm(state, msg) {
556
+ var el = $("confirm");
557
+ if (!state) { el.classList.remove("show"); el.removeAttribute("data-state"); return; }
558
+ $("confirmErr").textContent = msg || "";
559
+ el.setAttribute("data-state", state);
560
+ el.classList.add("show");
561
+ }
562
+ function confirmErr(msg) { $("confirmErr").textContent = msg || ""; }
563
+
564
+ // Pareamento (Opção A): o celular se auto-aprova apos confirmar no modal.
565
+ // 1. pede entrada (sem token) -> cria um pedido PENDENTE no servidor;
566
+ // 2. mostra o modal "entrar no chat? <titulo>" com os botoes entrar/cancelar;
567
+ // ao clicar em "entrar", ESTE navegador chama /pair/decide {approved:true},
568
+ // o token de sessao e emitido para ele e o terminal detecta (some o QR).
569
+ // Cancelar nega o pedido (approved:false).
570
+ // Devolve true se pareou; false se negado/expirado/erro/cancelado.
571
+ function pair(code) {
572
+ if (!code) return false;
573
+ return new Promise(async function (resolve) {
574
+ // garante um captcha valido (fluxo QR nao passa por showPairing).
575
+ if (!captchaId) await loadCaptcha();
576
+ // valida o codigo primeiro (rapido). Se for valido, mostra direto o modal
577
+ // "entrar no chat?" — o proprio usuario confirma no celular.
578
+ var req = await requestEntry(code);
579
+ if (!req || !req.requestId) { showConfirm(null); resolve(false); return; }
580
+ $("confirmTitle").textContent = ((req.session && req.session.title) || req.title || "conversa sem título");
581
+ confirmErr("");
582
+ showConfirm("ask");
583
+
584
+ var settled = false;
585
+ function finish(ok, msg) {
586
+ if (settled) return;
587
+ settled = true;
588
+ $("confirmBtn").onclick = null;
589
+ $("confirmCancel").onclick = null;
590
+ if (ok) { showConfirm(null); resolve(true); }
591
+ else { showConfirm("ask", msg); resolve(false); }
592
+ }
593
+
594
+ function decide(approved) {
595
+ return fetch(url("pair/decide"), {
596
+ method: "POST", headers: { "content-type": "application/json" },
597
+ body: JSON.stringify({ id: req.requestId, approved: approved }),
598
+ }).catch(function () { return null; });
599
+ }
600
+
601
+ $("confirmBtn").onclick = function () {
602
+ $("confirmBtn").disabled = true;
603
+ // aprova explicitamente ("tem certeza?") e espera o token desta sessao.
604
+ (async function () {
605
+ await decide(true);
606
+ var decision;
607
+ while (!settled) {
608
+ decision = await awaitDecisionOnce(req.requestId);
609
+ if (decision.status !== "pending") break;
610
+ await new Promise(function (r) { setTimeout(r, 1000); });
611
+ }
612
+ $("confirmBtn").disabled = false;
613
+ if (decision && decision.status === "approved") finish(true);
614
+ else if (decision && decision.status === "denied") finish(false, "entrada negada — gere outro código");
615
+ else finish(false, "pedido expirou — gere outro código no PC");
616
+ })();
617
+ };
618
+ $("confirmCancel").onclick = function () {
619
+ decide(false);
620
+ finish(false, "cancelado — escaneie o QR ou digite o código de novo");
621
+ };
622
+ });
623
+ }
624
+
625
+ // Uma consulta de status (sem loop), para o modal controlar o polling.
626
+ async function awaitDecisionOnce(requestId) {
627
+ var res;
628
+ try {
629
+ res = await fetch(url("pair") + "?id=" + encodeURIComponent(requestId), { cache: "no-store" });
630
+ } catch (e) {
631
+ return { status: "pending" };
632
+ }
633
+ if (res.status === 404) return { status: "expired" };
634
+ var data = await res.json().catch(function () { return {}; });
635
+ if (data.status === "approved" && data.token) {
636
+ token = data.token;
637
+ sessionStorage.setItem("kafox_token", token);
638
+ sid.textContent = (data.session && data.session.sessionId) || "";
639
+ return { status: "approved" };
640
+ }
641
+ if (data.status === "denied") return { status: "denied" };
642
+ if (data.status === "expired" || data.status === "unknown") return { status: "expired" };
643
+ return { status: "pending" };
644
+ }
645
+
441
646
  // Reconstroi o log a partir do historico (um turno por mensagem do usuario,
442
647
  // com as respostas/tools/pensamentos abaixo).
443
648
  function renderHistory(messages) {
@@ -663,14 +868,17 @@ function renderPage() {
663
868
 
664
869
  async function boot() {
665
870
  var m = location.hash.match(/p=([A-Za-z0-9]+)/);
871
+ var scanned = m ? m[1] : "";
666
872
  if (m) {
667
873
  await pair(m[1]);
668
874
  history.replaceState(null, "", location.pathname);
669
875
  }
670
876
  if (!token) {
671
877
  // Sem token (nem por QR, nem salvo): mostra a tela de pareamento manual.
878
+ // Se veio do QR, deixa o codigo preenchido pra so resolver o captcha.
879
+ if (scanned && !$("paircode").value) $("paircode").value = scanned.toUpperCase();
672
880
  showPairing(true);
673
- $("paircode").focus();
881
+ $("captchaA").focus();
674
882
  return;
675
883
  }
676
884
  var okState = await loadState();
@@ -692,6 +900,7 @@ function renderPage() {
692
900
  }
693
901
  }
694
902
  $("pairbtn").onclick = pairManual;
903
+ $("captchaReload").onclick = function () { pairErr(""); loadCaptcha(); };
695
904
  $("paircode").addEventListener("input", function () {
696
905
  this.value = this.value.toUpperCase().replace(/[^A-Z0-9]/g, "");
697
906
  });