sanook-cli 0.5.1 → 0.5.5
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.
- package/.env.example +161 -3
- package/CHANGELOG.md +148 -10
- package/README.md +255 -26
- package/README.th.md +95 -7
- package/dist/approval.js +13 -0
- package/dist/bin.js +3552 -155
- package/dist/brain-consolidate.js +335 -0
- package/dist/brain-context.js +262 -0
- package/dist/brain-doctor.js +318 -0
- package/dist/brain-eval.js +186 -0
- package/dist/brain-final.js +377 -0
- package/dist/brain-metrics.js +277 -0
- package/dist/brain-new.js +402 -0
- package/dist/brain-pack.js +210 -0
- package/dist/brain-repair.js +280 -0
- package/dist/brain-review.js +382 -0
- package/dist/brain.js +15 -1
- package/dist/brand.js +1 -1
- package/dist/cli-args.js +190 -0
- package/dist/cli-option-values.js +16 -0
- package/dist/clipboard.js +65 -0
- package/dist/commands.js +266 -27
- package/dist/compaction.js +96 -11
- package/dist/config.js +149 -33
- package/dist/context-compression.js +191 -0
- package/dist/context-pack.js +145 -0
- package/dist/cost.js +49 -15
- package/dist/dashboard/api-helpers.js +87 -0
- package/dist/dashboard/server.js +179 -0
- package/dist/dashboard/static/app.js +277 -0
- package/dist/dashboard/static/index.html +39 -0
- package/dist/dashboard/static/styles.css +85 -0
- package/dist/diff.js +10 -2
- package/dist/first-run.js +21 -0
- package/dist/gateway/auth.js +49 -9
- package/dist/gateway/bluebubbles.js +205 -0
- package/dist/gateway/config.js +929 -0
- package/dist/gateway/deliver.js +399 -0
- package/dist/gateway/discord.js +124 -0
- package/dist/gateway/doctor.js +456 -0
- package/dist/gateway/email.js +501 -0
- package/dist/gateway/googlechat.js +207 -0
- package/dist/gateway/homeassistant.js +256 -0
- package/dist/gateway/ledger.js +38 -1
- package/dist/gateway/line.js +171 -0
- package/dist/gateway/lock.js +3 -1
- package/dist/gateway/matrix.js +366 -0
- package/dist/gateway/mattermost.js +322 -0
- package/dist/gateway/ntfy.js +218 -0
- package/dist/gateway/schedule.js +31 -4
- package/dist/gateway/serve.js +267 -7
- package/dist/gateway/server.js +253 -19
- package/dist/gateway/service.js +224 -0
- package/dist/gateway/session.js +362 -0
- package/dist/gateway/signal.js +351 -0
- package/dist/gateway/slack.js +124 -0
- package/dist/gateway/sms.js +169 -0
- package/dist/gateway/targets.js +576 -0
- package/dist/gateway/teams.js +106 -0
- package/dist/gateway/telegram.js +38 -15
- package/dist/gateway/webhooks.js +220 -0
- package/dist/gateway/whatsapp.js +230 -0
- package/dist/hooks.js +13 -2
- package/dist/hotkeys.js +21 -0
- package/dist/i18n/en.js +98 -0
- package/dist/i18n/index.js +19 -0
- package/dist/i18n/th.js +98 -0
- package/dist/i18n/types.js +1 -0
- package/dist/insights-args.js +55 -0
- package/dist/insights.js +86 -0
- package/dist/knowledge.js +55 -29
- package/dist/loop.js +157 -29
- package/dist/lsp/index.js +23 -5
- package/dist/mcp-hub.js +33 -0
- package/dist/mcp-registry.js +494 -0
- package/dist/mcp-risk.js +71 -0
- package/dist/mcp-server.js +1 -1
- package/dist/mcp.js +120 -10
- package/dist/memory-log.js +90 -0
- package/dist/memory-store.js +37 -1
- package/dist/memory.js +148 -37
- package/dist/model-picker.js +58 -0
- package/dist/orchestrate.js +51 -19
- package/dist/personality.js +58 -0
- package/dist/plan-handoff.js +17 -0
- package/dist/polyglot.js +162 -0
- package/dist/process-runner.js +96 -0
- package/dist/project-init.js +91 -0
- package/dist/project-registry.js +143 -0
- package/dist/project-scaffold.js +124 -0
- package/dist/prompt-size.js +155 -0
- package/dist/providers/codex-login.js +138 -0
- package/dist/providers/codex.js +89 -43
- package/dist/providers/keys.js +22 -1
- package/dist/providers/models.js +2 -2
- package/dist/providers/registry.js +14 -47
- package/dist/search/chunk.js +7 -8
- package/dist/search/cli.js +83 -0
- package/dist/search/embed-store.js +3 -0
- package/dist/search/embedding-config.js +22 -0
- package/dist/search/engine.js +2 -13
- package/dist/search/indexer.js +44 -1
- package/dist/search/store.js +23 -1
- package/dist/session-distill.js +84 -0
- package/dist/session.js +92 -16
- package/dist/skill-install.js +53 -13
- package/dist/skills.js +33 -0
- package/dist/slash-completion.js +155 -0
- package/dist/support-dump.js +206 -0
- package/dist/tool-catalog.js +59 -0
- package/dist/tools/edit.js +45 -15
- package/dist/tools/git.js +10 -5
- package/dist/tools/homeassistant.js +106 -0
- package/dist/tools/index.js +10 -0
- package/dist/tools/list.js +19 -6
- package/dist/tools/permission.js +992 -12
- package/dist/tools/polyglot.js +126 -0
- package/dist/tools/read.js +16 -4
- package/dist/tools/sandbox.js +38 -13
- package/dist/tools/schedule.js +19 -3
- package/dist/tools/search.js +226 -15
- package/dist/tools/task.js +40 -9
- package/dist/tools/timeout.js +23 -3
- package/dist/tools/web-fetch-tool.js +33 -0
- package/dist/trust.js +11 -1
- package/dist/turn-retrieval.js +83 -0
- package/dist/ui/app.js +878 -32
- package/dist/ui/banner.js +78 -4
- package/dist/ui/history.js +37 -5
- package/dist/ui/markdown.js +122 -0
- package/dist/ui/mentions.js +3 -2
- package/dist/ui/overlay.js +496 -0
- package/dist/ui/queue.js +23 -0
- package/dist/ui/render.js +20 -1
- package/dist/ui/session-panel.js +115 -0
- package/dist/ui/setup-providers.js +40 -0
- package/dist/ui/setup.js +172 -46
- package/dist/ui/status.js +142 -0
- package/dist/ui/thinking-panel.js +36 -0
- package/dist/ui/tool-trail.js +97 -0
- package/dist/ui/transcript.js +26 -0
- package/dist/ui/useBusyElapsed.js +19 -0
- package/dist/ui/useEditor.js +144 -5
- package/dist/ui/useGitBranch.js +57 -0
- package/dist/update.js +56 -17
- package/dist/web-fetch.js +637 -0
- package/dist/web-surface.js +190 -0
- package/dist/worktree.js +175 -4
- package/package.json +5 -5
- package/second-brain/AGENTS.md +6 -4
- package/second-brain/CLAUDE.md +7 -1
- package/second-brain/Evals/_Index.md +10 -2
- package/second-brain/Evals/quality-ledger.md +9 -1
- package/second-brain/Evals/second-brain-benchmarks.md +62 -0
- package/second-brain/GEMINI.md +5 -4
- package/second-brain/Home.md +1 -1
- package/second-brain/Projects/_Index.md +19 -4
- package/second-brain/Projects/sanook-cli/_Index.md +30 -0
- package/second-brain/Projects/sanook-cli/context.md +35 -0
- package/second-brain/Projects/sanook-cli/current-state.md +32 -0
- package/second-brain/Projects/sanook-cli/overview.md +41 -0
- package/second-brain/Projects/sanook-cli/repo.md +34 -0
- package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +197 -0
- package/second-brain/README.md +1 -1
- package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
- package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
- package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
- package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
- package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
- package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
- package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
- package/second-brain/Research/_Index.md +8 -1
- package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
- package/second-brain/Reviews/_Index.md +1 -1
- package/second-brain/Runbooks/_Index.md +6 -1
- package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
- package/second-brain/SANOOK.md +45 -0
- package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
- package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
- package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
- package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
- package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
- package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
- package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
- package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
- package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
- package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
- package/second-brain/Sessions/_Index.md +15 -1
- package/second-brain/Shared/AI-Context-Index.md +22 -0
- package/second-brain/Shared/Context-Packs/_Index.md +9 -1
- package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
- package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
- package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
- package/second-brain/Shared/Operating-State/current-state.md +14 -4
- package/second-brain/Shared/Scripts/_Index.md +3 -1
- package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
- package/second-brain/Shared/Tech-Standards/_Index.md +6 -1
- package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
- package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
- package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
- package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
- package/second-brain/Shared/User-Memory/_Index.md +4 -1
- package/second-brain/Shared/User-Memory/response-examples.md +98 -0
- package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
- package/second-brain/Templates/_Index.md +9 -0
- package/second-brain/Templates/final-lite.md +111 -0
- package/second-brain/Templates/final.md +231 -0
- package/second-brain/Templates/project-workspace/_Index.md +31 -0
- package/second-brain/Templates/project-workspace/context.md +28 -0
- package/second-brain/Templates/project-workspace/current-state.md +29 -0
- package/second-brain/Templates/project-workspace/overview.md +39 -0
- package/second-brain/Templates/project-workspace/repo.md +33 -0
- package/second-brain/Vault Structure Map.md +2 -1
- package/skills/structured-output-llm/SKILL.md +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Sanook Dashboard</title>
|
|
7
|
+
<link rel="stylesheet" href="/styles.css" />
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="shell">
|
|
11
|
+
<aside class="sidebar">
|
|
12
|
+
<div class="brand">
|
|
13
|
+
<div class="brand-mark">S</div>
|
|
14
|
+
<div>
|
|
15
|
+
<div class="brand-title" data-i18n="productName">Sanook Dashboard</div>
|
|
16
|
+
<div class="brand-tagline" data-i18n="tagline">Configure your agent</div>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
<nav id="nav"></nav>
|
|
20
|
+
<div class="sidebar-footer">
|
|
21
|
+
<label class="locale-switch">
|
|
22
|
+
<span>Lang</span>
|
|
23
|
+
<select id="locale-select">
|
|
24
|
+
<option value="en">EN</option>
|
|
25
|
+
<option value="th">TH</option>
|
|
26
|
+
</select>
|
|
27
|
+
</label>
|
|
28
|
+
</div>
|
|
29
|
+
</aside>
|
|
30
|
+
<main class="main">
|
|
31
|
+
<header class="page-header">
|
|
32
|
+
<h1 id="page-title">Home</h1>
|
|
33
|
+
</header>
|
|
34
|
+
<div id="page" class="page"></div>
|
|
35
|
+
</main>
|
|
36
|
+
</div>
|
|
37
|
+
<script type="module" src="/app.js"></script>
|
|
38
|
+
</body>
|
|
39
|
+
</html>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: dark;
|
|
3
|
+
--bg: #0b1020;
|
|
4
|
+
--panel: #121829;
|
|
5
|
+
--panel-2: #1a2236;
|
|
6
|
+
--border: #2a3550;
|
|
7
|
+
--text: #e8edf7;
|
|
8
|
+
--muted: #9aa7c0;
|
|
9
|
+
--accent: #38bdf8;
|
|
10
|
+
--accent-2: #22d3ee;
|
|
11
|
+
--good: #4ade80;
|
|
12
|
+
--warn: #fbbf24;
|
|
13
|
+
font-family: "IBM Plex Sans", "Noto Sans Thai", system-ui, sans-serif;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
* { box-sizing: border-box; }
|
|
17
|
+
body {
|
|
18
|
+
margin: 0;
|
|
19
|
+
background: radial-gradient(circle at top left, #132038, var(--bg) 45%);
|
|
20
|
+
color: var(--text);
|
|
21
|
+
min-height: 100vh;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; }
|
|
25
|
+
.sidebar {
|
|
26
|
+
background: rgba(10, 14, 28, 0.92);
|
|
27
|
+
border-right: 1px solid var(--border);
|
|
28
|
+
padding: 1.25rem 1rem;
|
|
29
|
+
display: flex;
|
|
30
|
+
flex-direction: column;
|
|
31
|
+
gap: 1rem;
|
|
32
|
+
}
|
|
33
|
+
.brand { display: flex; gap: 0.75rem; align-items: center; }
|
|
34
|
+
.brand-mark {
|
|
35
|
+
width: 42px; height: 42px; border-radius: 12px;
|
|
36
|
+
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
|
37
|
+
display: grid; place-items: center; font-weight: 700; color: #041018;
|
|
38
|
+
}
|
|
39
|
+
.brand-title { font-weight: 700; font-size: 1.05rem; }
|
|
40
|
+
.brand-tagline { color: var(--muted); font-size: 0.78rem; line-height: 1.3; }
|
|
41
|
+
nav { display: flex; flex-direction: column; gap: 0.35rem; flex: 1; }
|
|
42
|
+
.nav-link {
|
|
43
|
+
color: var(--muted);
|
|
44
|
+
text-decoration: none;
|
|
45
|
+
padding: 0.55rem 0.75rem;
|
|
46
|
+
border-radius: 10px;
|
|
47
|
+
border: 1px solid transparent;
|
|
48
|
+
}
|
|
49
|
+
.nav-link.active, .nav-link:hover {
|
|
50
|
+
color: var(--text);
|
|
51
|
+
background: var(--panel-2);
|
|
52
|
+
border-color: var(--border);
|
|
53
|
+
}
|
|
54
|
+
.sidebar-footer { border-top: 1px solid var(--border); padding-top: 0.75rem; }
|
|
55
|
+
.locale-switch { display: flex; justify-content: space-between; align-items: center; color: var(--muted); font-size: 0.85rem; }
|
|
56
|
+
.locale-switch select {
|
|
57
|
+
background: var(--panel-2);
|
|
58
|
+
color: var(--text);
|
|
59
|
+
border: 1px solid var(--border);
|
|
60
|
+
border-radius: 8px;
|
|
61
|
+
padding: 0.25rem 0.5rem;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.main { padding: 1.5rem 2rem; }
|
|
65
|
+
.page-header h1 { margin: 0 0 1rem; font-size: 1.6rem; }
|
|
66
|
+
.page { display: grid; gap: 1rem; }
|
|
67
|
+
.card {
|
|
68
|
+
background: rgba(18, 24, 41, 0.88);
|
|
69
|
+
border: 1px solid var(--border);
|
|
70
|
+
border-radius: 16px;
|
|
71
|
+
padding: 1rem 1.1rem;
|
|
72
|
+
}
|
|
73
|
+
.card h2 { margin: 0 0 0.75rem; font-size: 1rem; color: var(--muted); font-weight: 600; }
|
|
74
|
+
.kv { display: grid; grid-template-columns: 180px 1fr; gap: 0.5rem 1rem; }
|
|
75
|
+
.kv dt { color: var(--muted); }
|
|
76
|
+
.kv dd { margin: 0; word-break: break-all; }
|
|
77
|
+
.table { width: 100%; border-collapse: collapse; }
|
|
78
|
+
.table th, .table td { text-align: left; padding: 0.55rem 0.4rem; border-bottom: 1px solid var(--border); }
|
|
79
|
+
.pill { display: inline-block; padding: 0.15rem 0.55rem; border-radius: 999px; background: rgba(56, 189, 248, 0.15); color: var(--accent); font-size: 0.8rem; }
|
|
80
|
+
.hint { color: var(--muted); font-size: 0.9rem; }
|
|
81
|
+
|
|
82
|
+
@media (max-width: 900px) {
|
|
83
|
+
.shell { grid-template-columns: 1fr; }
|
|
84
|
+
.sidebar { border-right: none; border-bottom: 1px solid var(--border); }
|
|
85
|
+
}
|
package/dist/diff.js
CHANGED
|
@@ -28,9 +28,17 @@ export function renderEditDiff(oldStr, newStr) {
|
|
|
28
28
|
}
|
|
29
29
|
/** สรุปการ write — จำนวนบรรทัด/ตัวอักษร + ถ้าเขียนทับ บอก before→after */
|
|
30
30
|
export function summarizeWrite(content, previous) {
|
|
31
|
-
const lines = content
|
|
31
|
+
const lines = countLogicalLines(content);
|
|
32
32
|
if (previous === undefined)
|
|
33
33
|
return `เขียนใหม่ ${lines} บรรทัด (${content.length} ตัวอักษร)`;
|
|
34
|
-
const prevLines = previous
|
|
34
|
+
const prevLines = countLogicalLines(previous);
|
|
35
35
|
return `เขียนทับ ${prevLines} → ${lines} บรรทัด (${content.length} ตัวอักษร)`;
|
|
36
36
|
}
|
|
37
|
+
function countLogicalLines(content) {
|
|
38
|
+
if (content === '')
|
|
39
|
+
return 0;
|
|
40
|
+
const lines = content.split(/\r\n|\n|\r/);
|
|
41
|
+
if (/(\r\n|\n|\r)$/.test(content))
|
|
42
|
+
lines.pop();
|
|
43
|
+
return lines.length;
|
|
44
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { detectCodex } from './providers/codex.js';
|
|
2
|
+
import { hasUsableEnvKey, parseSpec, PROVIDERS } from './providers/registry.js';
|
|
3
|
+
/**
|
|
4
|
+
* First-run can skip the setup wizard only when the selected provider is genuinely
|
|
5
|
+
* ready to run: cloud providers need a policy-valid API key, local providers need
|
|
6
|
+
* no key, and delegate providers like Codex must have their official CLI auth ready.
|
|
7
|
+
*/
|
|
8
|
+
export async function providerCanSkipSetup(provider, detect = detectCodex) {
|
|
9
|
+
const cfg = PROVIDERS[provider];
|
|
10
|
+
if (!cfg)
|
|
11
|
+
return false;
|
|
12
|
+
if (cfg.kind === 'delegate') {
|
|
13
|
+
const s = await detect();
|
|
14
|
+
return s.installed && s.loggedIn;
|
|
15
|
+
}
|
|
16
|
+
return hasUsableEnvKey(provider);
|
|
17
|
+
}
|
|
18
|
+
export async function modelNeedsSetup(modelSpec, detect = detectCodex) {
|
|
19
|
+
const { provider } = parseSpec(modelSpec);
|
|
20
|
+
return !(await providerCanSkipSetup(provider, detect));
|
|
21
|
+
}
|
package/dist/gateway/auth.js
CHANGED
|
@@ -1,22 +1,62 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
|
|
1
|
+
import { readFile, writeFile, mkdir, chmod, link, unlink } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
4
4
|
import { appHomePath } from '../brand.js';
|
|
5
5
|
const GATEWAY_DIR = appHomePath('gateway');
|
|
6
6
|
const TOKEN_FILE = join(GATEWAY_DIR, 'token');
|
|
7
|
+
const TOKEN_FILE_PATTERN = /^([a-f0-9]{64})(?:\r?\n)?$/;
|
|
8
|
+
export async function ensureGatewayDir() {
|
|
9
|
+
await mkdir(GATEWAY_DIR, { recursive: true, mode: 0o700 });
|
|
10
|
+
await chmod(GATEWAY_DIR, 0o700).catch(() => { });
|
|
11
|
+
}
|
|
7
12
|
/** โหลด bearer token ของ gateway; ไม่มี → สร้าง 256-bit ใหม่ เก็บ chmod 600 */
|
|
8
13
|
export async function loadOrCreateToken() {
|
|
14
|
+
for (;;) {
|
|
15
|
+
const existingToken = await readTokenIfPresent();
|
|
16
|
+
if (existingToken !== undefined)
|
|
17
|
+
return existingToken;
|
|
18
|
+
const token = randomBytes(32).toString('hex');
|
|
19
|
+
await ensureGatewayDir();
|
|
20
|
+
try {
|
|
21
|
+
await createTokenFile(token);
|
|
22
|
+
}
|
|
23
|
+
catch (e) {
|
|
24
|
+
if (e.code === 'EEXIST')
|
|
25
|
+
continue;
|
|
26
|
+
throw new Error(`ไม่สามารถเขียน gateway token ที่ ${TOKEN_FILE}: ${e.message}`);
|
|
27
|
+
}
|
|
28
|
+
return token;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
async function createTokenFile(token) {
|
|
32
|
+
const tempFile = join(GATEWAY_DIR, `.token-${process.pid}-${Date.now()}-${randomBytes(6).toString('hex')}.tmp`);
|
|
33
|
+
try {
|
|
34
|
+
await writeFile(tempFile, `${token}\n`, { mode: 0o600, flag: 'wx' });
|
|
35
|
+
await chmod(tempFile, 0o600).catch(() => { });
|
|
36
|
+
await link(tempFile, TOKEN_FILE);
|
|
37
|
+
await chmod(TOKEN_FILE, 0o600).catch(() => { });
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
await unlink(tempFile).catch(() => { });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function readTokenIfPresent() {
|
|
44
|
+
let rawToken;
|
|
9
45
|
try {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
46
|
+
rawToken = await readFile(TOKEN_FILE, 'utf8');
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
if (e.code !== 'ENOENT') {
|
|
50
|
+
throw new Error(`ไม่สามารถอ่าน gateway token ที่ ${TOKEN_FILE}: ${e.message}`);
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
13
53
|
}
|
|
14
|
-
|
|
15
|
-
|
|
54
|
+
const tokenMatch = TOKEN_FILE_PATTERN.exec(rawToken);
|
|
55
|
+
if (!tokenMatch) {
|
|
56
|
+
throw new Error(`gateway token ที่ ${TOKEN_FILE} ไม่ถูกต้อง: ต้องเป็น hex 64 ตัวอักษร`);
|
|
16
57
|
}
|
|
17
|
-
const token =
|
|
18
|
-
await
|
|
19
|
-
await writeFile(TOKEN_FILE, `${token}\n`, { mode: 0o600 });
|
|
58
|
+
const token = tokenMatch[1];
|
|
59
|
+
await chmod(GATEWAY_DIR, 0o700).catch(() => { });
|
|
20
60
|
await chmod(TOKEN_FILE, 0o600).catch(() => { });
|
|
21
61
|
return token;
|
|
22
62
|
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { redactKey } from '../providers/keys.js';
|
|
3
|
+
const BLUEBUBBLES_TEXT_LIMIT = 4_000;
|
|
4
|
+
const GUID_CACHE_SIZE = 500;
|
|
5
|
+
const chatGuidCache = new Map();
|
|
6
|
+
function chatGuidCacheKey(config, target) {
|
|
7
|
+
return `${normalizeBlueBubblesServerUrl(config.serverUrl) ?? ''}|${target}`;
|
|
8
|
+
}
|
|
9
|
+
function rememberChatGuid(config, target, guid) {
|
|
10
|
+
chatGuidCache.set(chatGuidCacheKey(config, target), guid);
|
|
11
|
+
if (chatGuidCache.size <= GUID_CACHE_SIZE)
|
|
12
|
+
return;
|
|
13
|
+
const first = chatGuidCache.keys().next().value;
|
|
14
|
+
if (first)
|
|
15
|
+
chatGuidCache.delete(first);
|
|
16
|
+
}
|
|
17
|
+
function redactBlueBubblesDetail(raw, secrets) {
|
|
18
|
+
let safe = redactKey(raw);
|
|
19
|
+
for (const secret of secrets) {
|
|
20
|
+
const value = secret?.trim();
|
|
21
|
+
if (value)
|
|
22
|
+
safe = safe.split(value).join('<secret>');
|
|
23
|
+
}
|
|
24
|
+
return safe;
|
|
25
|
+
}
|
|
26
|
+
export function normalizeBlueBubblesServerUrl(raw) {
|
|
27
|
+
let value = raw?.trim();
|
|
28
|
+
if (!value)
|
|
29
|
+
return undefined;
|
|
30
|
+
if (!/^https?:\/\//i.test(value))
|
|
31
|
+
value = `http://${value}`;
|
|
32
|
+
try {
|
|
33
|
+
const url = new URL(value);
|
|
34
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
35
|
+
return undefined;
|
|
36
|
+
return url.toString().replace(/\/+$/, '');
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function normalizeBlueBubblesWebhookPath(raw) {
|
|
43
|
+
const value = raw?.trim() || '/bluebubbles-webhook';
|
|
44
|
+
return value.startsWith('/') ? value : `/${value}`;
|
|
45
|
+
}
|
|
46
|
+
export function blueBubblesApiUrl(config, path) {
|
|
47
|
+
const baseUrl = normalizeBlueBubblesServerUrl(config.serverUrl);
|
|
48
|
+
const password = config.password?.trim();
|
|
49
|
+
if (!baseUrl)
|
|
50
|
+
throw new Error('BlueBubbles server URL ต้องเป็น http:// หรือ https:// URL');
|
|
51
|
+
if (!password)
|
|
52
|
+
throw new Error('ยังไม่ได้ตั้ง BlueBubbles password');
|
|
53
|
+
const url = new URL(path, `${baseUrl}/`);
|
|
54
|
+
url.searchParams.set('password', password);
|
|
55
|
+
return url.toString();
|
|
56
|
+
}
|
|
57
|
+
export function parseBlueBubblesTarget(config, explicitTarget) {
|
|
58
|
+
const target = explicitTarget?.trim() || config.homeChannel?.trim();
|
|
59
|
+
if (!target)
|
|
60
|
+
throw new Error('ต้องระบุ BlueBubbles target หรือ home channel ใน gateway config');
|
|
61
|
+
const stripped = target.replace(/^(?:chat|guid)[:/]/i, '').trim();
|
|
62
|
+
if (!stripped || /\s/.test(stripped))
|
|
63
|
+
throw new Error('BlueBubbles target ต้องเป็น chat GUID, email, หรือเบอร์โทรที่ไม่มีช่องว่าง');
|
|
64
|
+
if (stripped.includes(';'))
|
|
65
|
+
return { value: stripped, chatGuid: stripped };
|
|
66
|
+
return { value: stripped };
|
|
67
|
+
}
|
|
68
|
+
export function formatBlueBubblesText(raw) {
|
|
69
|
+
return raw
|
|
70
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
71
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
72
|
+
.replace(/`{1,3}([^`]+)`{1,3}/g, '$1')
|
|
73
|
+
.replace(/(\*\*|__)(.*?)\1/g, '$2')
|
|
74
|
+
.trim();
|
|
75
|
+
}
|
|
76
|
+
export function chunkBlueBubblesText(raw, limit = BLUEBUBBLES_TEXT_LIMIT) {
|
|
77
|
+
const text = formatBlueBubblesText(raw) || '(ไม่มีผลลัพธ์)';
|
|
78
|
+
const paragraphs = text.split(/\n\s*\n/).map((part) => part.trim()).filter(Boolean);
|
|
79
|
+
const chunks = [];
|
|
80
|
+
for (const paragraph of paragraphs.length ? paragraphs : [text]) {
|
|
81
|
+
if (paragraph.length <= limit) {
|
|
82
|
+
chunks.push(paragraph);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
for (let index = 0; index < paragraph.length; index += limit) {
|
|
86
|
+
chunks.push(paragraph.slice(index, index + limit));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return chunks;
|
|
90
|
+
}
|
|
91
|
+
async function readBlueBubblesJsonOrThrow(response, label, secrets = []) {
|
|
92
|
+
const text = await response.text().catch(() => '');
|
|
93
|
+
if (!response.ok)
|
|
94
|
+
throw new Error(`${label} ${response.status}${text ? `: ${redactBlueBubblesDetail(text, secrets).slice(0, 240)}` : ''}`);
|
|
95
|
+
let json;
|
|
96
|
+
try {
|
|
97
|
+
json = (text ? JSON.parse(text) : {});
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new Error(`${label} ${response.status}: response ไม่ใช่ JSON: ${redactBlueBubblesDetail(text, secrets).slice(0, 240)}`);
|
|
101
|
+
}
|
|
102
|
+
const status = typeof json.status === 'number' ? json.status : response.status;
|
|
103
|
+
if (status >= 400 || json.error) {
|
|
104
|
+
const detail = json.error?.error || json.error?.type || json.message || 'unknown error';
|
|
105
|
+
throw new Error(`${label} status ${status}: ${redactBlueBubblesDetail(detail, secrets).slice(0, 200)}`);
|
|
106
|
+
}
|
|
107
|
+
return json;
|
|
108
|
+
}
|
|
109
|
+
async function postBlueBubbles(config, path, body) {
|
|
110
|
+
const r = await fetch(blueBubblesApiUrl(config, path), {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'content-type': 'application/json' },
|
|
113
|
+
body: JSON.stringify(body),
|
|
114
|
+
});
|
|
115
|
+
return readBlueBubblesJsonOrThrow(r, `BlueBubbles ${path}`, [config.password]);
|
|
116
|
+
}
|
|
117
|
+
async function getBlueBubbles(config, path) {
|
|
118
|
+
const r = await fetch(blueBubblesApiUrl(config, path));
|
|
119
|
+
return readBlueBubblesJsonOrThrow(r, `BlueBubbles ${path}`, [config.password]);
|
|
120
|
+
}
|
|
121
|
+
async function resolveBlueBubblesChatGuid(config, target) {
|
|
122
|
+
if (target.chatGuid)
|
|
123
|
+
return target.chatGuid;
|
|
124
|
+
const cached = chatGuidCache.get(chatGuidCacheKey(config, target.value));
|
|
125
|
+
if (cached)
|
|
126
|
+
return cached;
|
|
127
|
+
const res = await postBlueBubbles(config, '/api/v1/chat/query', {
|
|
128
|
+
limit: 100,
|
|
129
|
+
offset: 0,
|
|
130
|
+
with: ['participants'],
|
|
131
|
+
});
|
|
132
|
+
for (const chat of res.data ?? []) {
|
|
133
|
+
const guid = chat.guid || chat.chatGuid;
|
|
134
|
+
const identifier = chat.chatIdentifier || chat.identifier;
|
|
135
|
+
if (guid && identifier === target.value) {
|
|
136
|
+
rememberChatGuid(config, target.value, guid);
|
|
137
|
+
return guid;
|
|
138
|
+
}
|
|
139
|
+
for (const participant of chat.participants ?? []) {
|
|
140
|
+
if (guid && participant.address?.trim() === target.value) {
|
|
141
|
+
rememberChatGuid(config, target.value, guid);
|
|
142
|
+
return guid;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
async function canCreateBlueBubblesChat(config) {
|
|
149
|
+
try {
|
|
150
|
+
const info = await getBlueBubbles(config, '/api/v1/server/info');
|
|
151
|
+
return Boolean(info.data?.private_api);
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function createBlueBubblesChat(config, target, message) {
|
|
158
|
+
const res = await postBlueBubbles(config, '/api/v1/chat/new', {
|
|
159
|
+
addresses: [target],
|
|
160
|
+
message,
|
|
161
|
+
text: message,
|
|
162
|
+
tempGuid: `temp-${randomUUID()}`,
|
|
163
|
+
});
|
|
164
|
+
const messageId = res.data?.guid || res.data?.messageGuid || 'ok';
|
|
165
|
+
return { target, messageIds: [messageId], messageCount: 1 };
|
|
166
|
+
}
|
|
167
|
+
function looksLikeBlueBubblesAddress(target) {
|
|
168
|
+
return target.includes('@') || /^\+\d{7,15}$/.test(target);
|
|
169
|
+
}
|
|
170
|
+
export async function sendBlueBubblesMessage(config, text, explicitTarget) {
|
|
171
|
+
if (!normalizeBlueBubblesServerUrl(config.serverUrl) || !config.password?.trim()) {
|
|
172
|
+
throw new Error('ยังไม่ได้ตั้ง BlueBubbles server URL/password');
|
|
173
|
+
}
|
|
174
|
+
const target = parseBlueBubblesTarget(config, explicitTarget);
|
|
175
|
+
const chunks = chunkBlueBubblesText(text);
|
|
176
|
+
const messageIds = [];
|
|
177
|
+
let chatGuid;
|
|
178
|
+
for (const chunk of chunks) {
|
|
179
|
+
chatGuid = await resolveBlueBubblesChatGuid(config, target);
|
|
180
|
+
if (!chatGuid) {
|
|
181
|
+
if (looksLikeBlueBubblesAddress(target.value) && (await canCreateBlueBubblesChat(config))) {
|
|
182
|
+
if (chunks.length > 1) {
|
|
183
|
+
throw new Error('BlueBubbles new chat ยังไม่รองรับข้อความหลายส่วนแบบปลอดภัย — ส่งข้อความแรกให้สั้นลงหรือระบุ chat GUID ที่มีอยู่');
|
|
184
|
+
}
|
|
185
|
+
return createBlueBubblesChat(config, target.value, chunk);
|
|
186
|
+
}
|
|
187
|
+
throw new Error(`BlueBubbles chat not found for target: ${target.value}`);
|
|
188
|
+
}
|
|
189
|
+
const res = await postBlueBubbles(config, '/api/v1/message/text', {
|
|
190
|
+
chatGuid,
|
|
191
|
+
tempGuid: `temp-${randomUUID()}`,
|
|
192
|
+
message: chunk,
|
|
193
|
+
text: chunk,
|
|
194
|
+
});
|
|
195
|
+
const messageId = res.data?.guid || res.data?.messageGuid || 'ok';
|
|
196
|
+
if (messageId)
|
|
197
|
+
messageIds.push(String(messageId));
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
target: target.value,
|
|
201
|
+
chatGuid,
|
|
202
|
+
messageIds,
|
|
203
|
+
messageCount: messageIds.length || chunks.length,
|
|
204
|
+
};
|
|
205
|
+
}
|