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,171 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { redactKey } from '../providers/keys.js';
|
|
3
|
+
import { runGatewayAgent } from './session.js';
|
|
4
|
+
const LINE_PUSH_URL = 'https://api.line.me/v2/bot/message/push';
|
|
5
|
+
const LINE_REPLY_URL = 'https://api.line.me/v2/bot/message/reply';
|
|
6
|
+
const LINE_TEXT_LIMIT = 5000;
|
|
7
|
+
const LINE_PUSH_MESSAGE_LIMIT = 5;
|
|
8
|
+
const runningTargets = new Set();
|
|
9
|
+
export function splitLineText(text) {
|
|
10
|
+
const trimmed = text.trim() || '(ไม่มีผลลัพธ์)';
|
|
11
|
+
const chunks = [];
|
|
12
|
+
for (let i = 0; i < trimmed.length && chunks.length < LINE_PUSH_MESSAGE_LIMIT; i += LINE_TEXT_LIMIT) {
|
|
13
|
+
chunks.push(trimmed.slice(i, i + LINE_TEXT_LIMIT));
|
|
14
|
+
}
|
|
15
|
+
return chunks.length ? chunks : ['(ไม่มีผลลัพธ์)'];
|
|
16
|
+
}
|
|
17
|
+
function lineTextMessages(text) {
|
|
18
|
+
return splitLineText(text).map((chunk) => ({ type: 'text', text: chunk }));
|
|
19
|
+
}
|
|
20
|
+
export async function sendLineMessage(channelAccessToken, to, text) {
|
|
21
|
+
const messages = lineTextMessages(text);
|
|
22
|
+
const r = await fetch(LINE_PUSH_URL, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: {
|
|
25
|
+
authorization: `Bearer ${channelAccessToken}`,
|
|
26
|
+
'content-type': 'application/json',
|
|
27
|
+
},
|
|
28
|
+
body: JSON.stringify({ to, messages }),
|
|
29
|
+
});
|
|
30
|
+
if (!r.ok)
|
|
31
|
+
throw new Error(`LINE push message ${r.status}`);
|
|
32
|
+
return { to, messageCount: messages.length };
|
|
33
|
+
}
|
|
34
|
+
export async function replyLineMessage(channelAccessToken, replyToken, text) {
|
|
35
|
+
const messages = lineTextMessages(text);
|
|
36
|
+
const r = await fetch(LINE_REPLY_URL, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: {
|
|
39
|
+
authorization: `Bearer ${channelAccessToken}`,
|
|
40
|
+
'content-type': 'application/json',
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify({ replyToken, messages }),
|
|
43
|
+
});
|
|
44
|
+
if (!r.ok)
|
|
45
|
+
throw new Error(`LINE reply message ${r.status}`);
|
|
46
|
+
return { to: replyToken, messageCount: messages.length };
|
|
47
|
+
}
|
|
48
|
+
export function verifyLineSignature(channelSecret, rawBody, signature) {
|
|
49
|
+
if (!channelSecret || !signature)
|
|
50
|
+
return false;
|
|
51
|
+
const expected = createHmac('sha256', channelSecret).update(rawBody).digest('base64');
|
|
52
|
+
const a = Buffer.from(signature);
|
|
53
|
+
const b = Buffer.from(expected);
|
|
54
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
55
|
+
}
|
|
56
|
+
export function lineSourceTarget(source) {
|
|
57
|
+
if (!source)
|
|
58
|
+
return undefined;
|
|
59
|
+
if (source.type === 'user')
|
|
60
|
+
return source.userId;
|
|
61
|
+
if (source.type === 'group')
|
|
62
|
+
return source.groupId;
|
|
63
|
+
if (source.type === 'room')
|
|
64
|
+
return source.roomId;
|
|
65
|
+
return source.userId ?? source.groupId ?? source.roomId;
|
|
66
|
+
}
|
|
67
|
+
export function isAllowedLineSource(config, source) {
|
|
68
|
+
if (config.allowAllUsers)
|
|
69
|
+
return true;
|
|
70
|
+
const target = lineSourceTarget(source);
|
|
71
|
+
if (!target)
|
|
72
|
+
return false;
|
|
73
|
+
if (target === config.homeChannel)
|
|
74
|
+
return true;
|
|
75
|
+
if (source?.type === 'user')
|
|
76
|
+
return config.allowedUsers.includes(target);
|
|
77
|
+
if (source?.type === 'group')
|
|
78
|
+
return config.allowedGroups.includes(target);
|
|
79
|
+
if (source?.type === 'room')
|
|
80
|
+
return config.allowedRooms.includes(target);
|
|
81
|
+
return [...config.allowedUsers, ...config.allowedGroups, ...config.allowedRooms].includes(target);
|
|
82
|
+
}
|
|
83
|
+
function parseWebhookPayload(rawBody) {
|
|
84
|
+
const parsed = JSON.parse(rawBody);
|
|
85
|
+
if (!parsed || typeof parsed !== 'object')
|
|
86
|
+
return {};
|
|
87
|
+
const payload = parsed;
|
|
88
|
+
return Array.isArray(payload.events) ? payload : {};
|
|
89
|
+
}
|
|
90
|
+
function linePrompt(event, target) {
|
|
91
|
+
const text = event.message?.text?.trim() || '';
|
|
92
|
+
const source = event.source;
|
|
93
|
+
const actor = source?.userId && source.userId !== target ? ` from user ${source.userId}` : '';
|
|
94
|
+
return [`LINE ${source?.type ?? 'unknown'} ${target}${actor}:`, text].join('\n');
|
|
95
|
+
}
|
|
96
|
+
async function replyOrPush(config, event, target, text) {
|
|
97
|
+
if (!config.channelAccessToken)
|
|
98
|
+
throw new Error('LINE channel access token is not configured');
|
|
99
|
+
if (event.replyToken) {
|
|
100
|
+
try {
|
|
101
|
+
await replyLineMessage(config.channelAccessToken, event.replyToken, text);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Reply tokens can expire; fall through to Push so long runs can still deliver.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
await sendLineMessage(config.channelAccessToken, target, text);
|
|
109
|
+
}
|
|
110
|
+
export async function handleLineWebhook(opts) {
|
|
111
|
+
if (!opts.config.channelAccessToken || !opts.config.channelSecret) {
|
|
112
|
+
return { status: 503, body: { error: 'line_not_configured' } };
|
|
113
|
+
}
|
|
114
|
+
if (!verifyLineSignature(opts.config.channelSecret, opts.rawBody, opts.signature)) {
|
|
115
|
+
return { status: 401, body: { error: 'invalid_signature' } };
|
|
116
|
+
}
|
|
117
|
+
let payload;
|
|
118
|
+
try {
|
|
119
|
+
payload = parseWebhookPayload(opts.rawBody);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return { status: 400, body: { error: 'invalid_json' } };
|
|
123
|
+
}
|
|
124
|
+
let accepted = 0;
|
|
125
|
+
let ignored = 0;
|
|
126
|
+
for (const event of payload.events ?? []) {
|
|
127
|
+
const target = lineSourceTarget(event.source);
|
|
128
|
+
const text = event.type === 'message' && event.message?.type === 'text' ? event.message.text?.trim() : undefined;
|
|
129
|
+
if (!target || !text) {
|
|
130
|
+
ignored += 1;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!isAllowedLineSource(opts.config, event.source)) {
|
|
134
|
+
ignored += 1;
|
|
135
|
+
opts.onLog?.(`LINE: ปฏิเสธ target ${target} (ไม่อยู่ใน allowlist)`);
|
|
136
|
+
if (event.replyToken)
|
|
137
|
+
await replyLineMessage(opts.config.channelAccessToken, event.replyToken, 'ไม่ได้รับอนุญาตให้ใช้ bot นี้').catch(() => { });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (runningTargets.has(target)) {
|
|
141
|
+
ignored += 1;
|
|
142
|
+
if (event.replyToken)
|
|
143
|
+
await replyLineMessage(opts.config.channelAccessToken, event.replyToken, 'กำลังทำงานก่อนหน้าอยู่ รอสักครู่').catch(() => { });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
accepted += 1;
|
|
147
|
+
runningTargets.add(target);
|
|
148
|
+
try {
|
|
149
|
+
const result = await runGatewayAgent({
|
|
150
|
+
platform: 'line',
|
|
151
|
+
target,
|
|
152
|
+
model: opts.model,
|
|
153
|
+
prompt: linePrompt(event, target),
|
|
154
|
+
userText: text,
|
|
155
|
+
budgetUsd: opts.budgetUsd,
|
|
156
|
+
permissionMode: opts.permissionMode ?? 'ask',
|
|
157
|
+
});
|
|
158
|
+
if (!result.suppressDelivery)
|
|
159
|
+
await replyOrPush(opts.config, event, target, result.text || '(ไม่มีผลลัพธ์)');
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
opts.onLog?.(`LINE run error (${target}): ${redactKey(e.message)}`);
|
|
163
|
+
if (event.replyToken)
|
|
164
|
+
await replyLineMessage(opts.config.channelAccessToken, event.replyToken, 'เกิดข้อผิดพลาดภายใน').catch(() => { });
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
runningTargets.delete(target);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return { status: 200, body: { ok: true, accepted, ignored } };
|
|
171
|
+
}
|
package/dist/gateway/lock.js
CHANGED
|
@@ -6,12 +6,14 @@ import { unlinkSync } from 'node:fs';
|
|
|
6
6
|
// แคบมาก (ต้องมี 2 mutator พร้อมกัน + holder ตายเป๊ะจังหวะ) ซึ่งแทบเป็นไปไม่ได้ใน workload นี้
|
|
7
7
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
8
8
|
const LOCK_TTL_MS = 5 * 60_000; // mutate สั้นระดับ ms — lock เก่ากว่านี้ = ค้างแน่ → ยึดได้ (กัน pid-reuse deadlock)
|
|
9
|
+
const LOCK_WRITE_GRACE_MS = 1_000; // open('wx') สร้างไฟล์ก่อนเขียน pid; อย่า evict lock สดที่ยังเขียนไม่จบ
|
|
9
10
|
/** holder ตายไหม — เช็คจาก pid อย่างเดียว (ใช้กับ singleton ที่ถือยาว, ไม่มี TTL) */
|
|
10
11
|
async function holderDead(lockPath) {
|
|
11
12
|
try {
|
|
13
|
+
const st = await stat(lockPath);
|
|
12
14
|
const pid = parseInt((await readFile(lockPath, 'utf8')).trim(), 10);
|
|
13
15
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
14
|
-
return
|
|
16
|
+
return Date.now() - st.mtimeMs > LOCK_WRITE_GRACE_MS; // pid ยังไม่ถูกเขียน/พัง → รอ grace สั้นๆ ก่อนยึด
|
|
15
17
|
try {
|
|
16
18
|
process.kill(pid, 0); // เช็คว่ามี process นี้ (ไม่ส่ง signal จริง)
|
|
17
19
|
return false;
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { BRAND } from '../brand.js';
|
|
2
|
+
import { redactKey } from '../providers/keys.js';
|
|
3
|
+
import { runGatewayAgent } from './session.js';
|
|
4
|
+
const MATRIX_TEXT_LIMIT = 4000;
|
|
5
|
+
const MATRIX_STARTUP_GRACE_MS = 5000;
|
|
6
|
+
const runningTargets = new Set();
|
|
7
|
+
export function normalizeMatrixHomeserver(raw) {
|
|
8
|
+
const trimmed = raw?.trim().replace(/\/+$/, '');
|
|
9
|
+
if (!trimmed)
|
|
10
|
+
return undefined;
|
|
11
|
+
if (!/^https?:\/\//i.test(trimmed))
|
|
12
|
+
return undefined;
|
|
13
|
+
return trimmed;
|
|
14
|
+
}
|
|
15
|
+
export function normalizeMatrixUserId(raw) {
|
|
16
|
+
const trimmed = raw?.trim();
|
|
17
|
+
if (!trimmed)
|
|
18
|
+
return undefined;
|
|
19
|
+
return /^@[^:\s]+:[^:\s]+$/.test(trimmed) ? trimmed : undefined;
|
|
20
|
+
}
|
|
21
|
+
export function normalizeMatrixRoomId(raw) {
|
|
22
|
+
const trimmed = raw?.trim();
|
|
23
|
+
if (!trimmed)
|
|
24
|
+
return undefined;
|
|
25
|
+
return /^[!#][^:\s]+:[^:\s]+(?::\d+)?$/.test(trimmed) ? trimmed : undefined;
|
|
26
|
+
}
|
|
27
|
+
export function redactMatrixToken(raw) {
|
|
28
|
+
const token = raw?.trim();
|
|
29
|
+
if (!token)
|
|
30
|
+
return '(not set)';
|
|
31
|
+
if (token.length <= 10)
|
|
32
|
+
return '<redacted>';
|
|
33
|
+
return `${token.slice(0, 4)}…${token.slice(-4)}`;
|
|
34
|
+
}
|
|
35
|
+
export function matrixClientUrl(config, path, params) {
|
|
36
|
+
const homeserver = normalizeMatrixHomeserver(config.homeserver);
|
|
37
|
+
if (!homeserver)
|
|
38
|
+
throw new Error('Matrix homeserver ต้องเป็น URL เช่น https://matrix.org');
|
|
39
|
+
const url = new URL(`${homeserver}/_matrix/client/v3/${path.replace(/^\/+/, '')}`);
|
|
40
|
+
for (const [key, value] of Object.entries(params ?? {})) {
|
|
41
|
+
if (value != null && String(value).trim())
|
|
42
|
+
url.searchParams.set(key, String(value));
|
|
43
|
+
}
|
|
44
|
+
return url.toString();
|
|
45
|
+
}
|
|
46
|
+
export function matrixAuthHeaders(accessToken, extra = {}) {
|
|
47
|
+
const token = accessToken?.trim();
|
|
48
|
+
if (!token)
|
|
49
|
+
throw new Error('Matrix access token ว่าง');
|
|
50
|
+
return { authorization: `Bearer ${token}`, ...extra };
|
|
51
|
+
}
|
|
52
|
+
export function splitMatrixText(raw, limit = MATRIX_TEXT_LIMIT) {
|
|
53
|
+
let remaining = raw.trim() || '(ไม่มีผลลัพธ์)';
|
|
54
|
+
const chunks = [];
|
|
55
|
+
while (remaining.length > limit) {
|
|
56
|
+
const window = remaining.slice(0, limit + 1);
|
|
57
|
+
let cut = window.lastIndexOf('\n');
|
|
58
|
+
if (cut < Math.floor(limit * 0.4))
|
|
59
|
+
cut = window.lastIndexOf(' ');
|
|
60
|
+
if (cut < Math.floor(limit * 0.4))
|
|
61
|
+
cut = limit;
|
|
62
|
+
chunks.push(remaining.slice(0, cut).trim());
|
|
63
|
+
remaining = remaining.slice(cut).trimStart();
|
|
64
|
+
}
|
|
65
|
+
if (remaining)
|
|
66
|
+
chunks.push(remaining);
|
|
67
|
+
return chunks.length ? chunks : ['(ไม่มีผลลัพธ์)'];
|
|
68
|
+
}
|
|
69
|
+
async function readJsonOrThrow(response, label) {
|
|
70
|
+
const text = await response.text().catch(() => '');
|
|
71
|
+
if (!response.ok)
|
|
72
|
+
throw new Error(`${label} ${response.status}${text ? `: ${redactKey(text).slice(0, 200)}` : ''}`);
|
|
73
|
+
if (!text)
|
|
74
|
+
return {};
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(text);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw new Error(`${label} ${response.status}: response ไม่ใช่ JSON: ${redactKey(text).slice(0, 200)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export async function loginMatrix(config) {
|
|
83
|
+
if (config.accessToken?.trim())
|
|
84
|
+
return { accessToken: config.accessToken.trim(), userId: config.userId };
|
|
85
|
+
const user = config.userId?.trim();
|
|
86
|
+
const password = config.password?.trim();
|
|
87
|
+
if (!user || !password)
|
|
88
|
+
throw new Error('Matrix config ต้องมี accessToken หรือ userId/password');
|
|
89
|
+
const r = await fetch(matrixClientUrl(config, '/login'), {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'content-type': 'application/json' },
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
type: 'm.login.password',
|
|
94
|
+
identifier: { type: 'm.id.user', user },
|
|
95
|
+
password,
|
|
96
|
+
initial_device_display_name: `${BRAND.productName} Gateway`,
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
const parsed = await readJsonOrThrow(r, 'Matrix login');
|
|
100
|
+
if (!parsed.access_token)
|
|
101
|
+
throw new Error('Matrix login response ไม่มี access_token');
|
|
102
|
+
return { accessToken: parsed.access_token, userId: parsed.user_id ?? user, deviceId: parsed.device_id };
|
|
103
|
+
}
|
|
104
|
+
export async function matrixWhoami(config) {
|
|
105
|
+
const auth = await loginMatrix(config);
|
|
106
|
+
const r = await fetch(matrixClientUrl(config, '/account/whoami'), {
|
|
107
|
+
method: 'GET',
|
|
108
|
+
headers: matrixAuthHeaders(auth.accessToken),
|
|
109
|
+
});
|
|
110
|
+
const parsed = await readJsonOrThrow(r, 'Matrix whoami');
|
|
111
|
+
return { userId: parsed.user_id ?? auth.userId, deviceId: parsed.device_id ?? auth.deviceId };
|
|
112
|
+
}
|
|
113
|
+
export async function sendMatrixMessage(config, roomId, text) {
|
|
114
|
+
const room = normalizeMatrixRoomId(roomId);
|
|
115
|
+
if (!room)
|
|
116
|
+
throw new Error('Matrix room id ต้องขึ้นต้นด้วย ! หรือ # และมี homeserver เช่น !abc:matrix.org');
|
|
117
|
+
const auth = await loginMatrix(config);
|
|
118
|
+
const chunks = splitMatrixText(text);
|
|
119
|
+
const eventIds = [];
|
|
120
|
+
for (const body of chunks) {
|
|
121
|
+
const txnId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
122
|
+
const r = await fetch(matrixClientUrl(config, `/rooms/${encodeURIComponent(room)}/send/m.room.message/${encodeURIComponent(txnId)}`), {
|
|
123
|
+
method: 'PUT',
|
|
124
|
+
headers: matrixAuthHeaders(auth.accessToken, { 'content-type': 'application/json' }),
|
|
125
|
+
body: JSON.stringify({ msgtype: 'm.text', body }),
|
|
126
|
+
});
|
|
127
|
+
const parsed = await readJsonOrThrow(r, 'Matrix send');
|
|
128
|
+
if (parsed.event_id)
|
|
129
|
+
eventIds.push(parsed.event_id);
|
|
130
|
+
}
|
|
131
|
+
return { roomId: room, eventIds, messageCount: chunks.length };
|
|
132
|
+
}
|
|
133
|
+
export async function joinMatrixRoom(config, roomId) {
|
|
134
|
+
const room = normalizeMatrixRoomId(roomId);
|
|
135
|
+
if (!room)
|
|
136
|
+
return;
|
|
137
|
+
const auth = await loginMatrix(config);
|
|
138
|
+
const r = await fetch(matrixClientUrl(config, `/join/${encodeURIComponent(room)}`), {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
headers: matrixAuthHeaders(auth.accessToken, { 'content-type': 'application/json' }),
|
|
141
|
+
body: '{}',
|
|
142
|
+
});
|
|
143
|
+
await readJsonOrThrow(r, 'Matrix join');
|
|
144
|
+
}
|
|
145
|
+
export function matrixSyncUrl(config, since) {
|
|
146
|
+
return matrixClientUrl(config, '/sync', {
|
|
147
|
+
since,
|
|
148
|
+
timeout: config.pollTimeoutMs,
|
|
149
|
+
set_presence: 'online',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
export function extractMatrixDirectRooms(sync) {
|
|
153
|
+
const rooms = new Set();
|
|
154
|
+
for (const event of sync.account_data?.events ?? []) {
|
|
155
|
+
if (event.type !== 'm.direct' || !event.content)
|
|
156
|
+
continue;
|
|
157
|
+
for (const ids of Object.values(event.content)) {
|
|
158
|
+
if (Array.isArray(ids)) {
|
|
159
|
+
for (const room of ids)
|
|
160
|
+
if (normalizeMatrixRoomId(room))
|
|
161
|
+
rooms.add(room);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return rooms;
|
|
166
|
+
}
|
|
167
|
+
export function extractMatrixTextEvents(sync, config, nowMs = Date.now()) {
|
|
168
|
+
const directRooms = extractMatrixDirectRooms(sync);
|
|
169
|
+
const out = [];
|
|
170
|
+
const botUserId = normalizeMatrixUserId(config.userId);
|
|
171
|
+
const staleBefore = nowMs - MATRIX_STARTUP_GRACE_MS;
|
|
172
|
+
for (const [roomId, room] of Object.entries(sync.rooms?.join ?? {})) {
|
|
173
|
+
const normalizedRoom = normalizeMatrixRoomId(roomId);
|
|
174
|
+
if (!normalizedRoom)
|
|
175
|
+
continue;
|
|
176
|
+
const isDirect = directRooms.has(normalizedRoom) || (room.summary?.['m.joined_member_count'] ?? 0) <= 2;
|
|
177
|
+
for (const event of room.timeline?.events ?? []) {
|
|
178
|
+
if (event.type !== 'm.room.message')
|
|
179
|
+
continue;
|
|
180
|
+
const sender = normalizeMatrixUserId(event.sender);
|
|
181
|
+
const text = event.content?.body?.trim();
|
|
182
|
+
const msgtype = event.content?.msgtype;
|
|
183
|
+
if (!sender || !text || !['m.text', 'm.notice'].includes(String(msgtype)))
|
|
184
|
+
continue;
|
|
185
|
+
if (botUserId && sender === botUserId)
|
|
186
|
+
continue;
|
|
187
|
+
if (event.origin_server_ts != null && event.origin_server_ts < staleBefore)
|
|
188
|
+
continue;
|
|
189
|
+
out.push({
|
|
190
|
+
roomId: normalizedRoom,
|
|
191
|
+
sender,
|
|
192
|
+
text,
|
|
193
|
+
eventId: event.event_id,
|
|
194
|
+
originServerTs: event.origin_server_ts,
|
|
195
|
+
isDirect,
|
|
196
|
+
mentionsBot: matrixMentionsBot(text, event, botUserId),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
export function matrixMentionsBot(text, event, botUserId) {
|
|
203
|
+
if (!botUserId)
|
|
204
|
+
return false;
|
|
205
|
+
if (event.content?.['m.mentions']?.user_ids?.includes(botUserId))
|
|
206
|
+
return true;
|
|
207
|
+
const localpart = botUserId.slice(1).split(':')[0];
|
|
208
|
+
return text.includes(botUserId) || Boolean(localpart && new RegExp(`(^|\\s)@?${escapeRegExp(localpart)}(\\b|\\s|:)`, 'i').test(text));
|
|
209
|
+
}
|
|
210
|
+
function escapeRegExp(value) {
|
|
211
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
212
|
+
}
|
|
213
|
+
export function isAllowedMatrixEvent(config, event) {
|
|
214
|
+
if (config.allowedRooms.length && !event.isDirect && !config.allowedRooms.includes(event.roomId))
|
|
215
|
+
return false;
|
|
216
|
+
if (config.allowAllUsers)
|
|
217
|
+
return true;
|
|
218
|
+
return config.allowedUsers.includes(event.sender);
|
|
219
|
+
}
|
|
220
|
+
export function matrixShouldRespond(config, event) {
|
|
221
|
+
if (event.isDirect)
|
|
222
|
+
return true;
|
|
223
|
+
if (config.freeResponseRooms.includes(event.roomId))
|
|
224
|
+
return true;
|
|
225
|
+
if (!config.requireMention)
|
|
226
|
+
return true;
|
|
227
|
+
return event.mentionsBot;
|
|
228
|
+
}
|
|
229
|
+
function matrixSessionTarget(config, event) {
|
|
230
|
+
if (event.isDirect || !config.groupSessionsPerUser)
|
|
231
|
+
return event.roomId;
|
|
232
|
+
return `${event.roomId}:${event.sender}`;
|
|
233
|
+
}
|
|
234
|
+
function matrixUserText(event) {
|
|
235
|
+
return event.text.replace(/^!(new|reset|status|help)\b/i, '/$1').trim();
|
|
236
|
+
}
|
|
237
|
+
function matrixPrompt(event) {
|
|
238
|
+
return [`Matrix ${event.isDirect ? 'DM' : 'room'} ${event.roomId} from ${event.sender}:`, event.text].join('\n');
|
|
239
|
+
}
|
|
240
|
+
export async function handleMatrixEvent(opts) {
|
|
241
|
+
const event = opts.event;
|
|
242
|
+
if (!isAllowedMatrixEvent(opts.config, event)) {
|
|
243
|
+
opts.onLog?.(`Matrix: ปฏิเสธ ${event.sender} ใน ${event.roomId} (ไม่อยู่ใน allowlist)`);
|
|
244
|
+
return { handled: false, reason: 'not_allowed' };
|
|
245
|
+
}
|
|
246
|
+
if (!matrixShouldRespond(opts.config, event))
|
|
247
|
+
return { handled: false, reason: 'not_mentioned' };
|
|
248
|
+
const target = matrixSessionTarget(opts.config, event);
|
|
249
|
+
const running = opts.runningTargets ?? runningTargets;
|
|
250
|
+
if (running.has(target))
|
|
251
|
+
return { handled: false, reason: 'busy' };
|
|
252
|
+
running.add(target);
|
|
253
|
+
try {
|
|
254
|
+
const result = await runGatewayAgent({
|
|
255
|
+
platform: 'matrix',
|
|
256
|
+
target,
|
|
257
|
+
model: opts.model,
|
|
258
|
+
prompt: matrixPrompt(event),
|
|
259
|
+
userText: matrixUserText(event),
|
|
260
|
+
budgetUsd: opts.budgetUsd,
|
|
261
|
+
permissionMode: opts.permissionMode ?? 'ask',
|
|
262
|
+
});
|
|
263
|
+
if (!result.suppressDelivery)
|
|
264
|
+
await sendMatrixMessage(opts.config, event.roomId, result.text || '(ไม่มีผลลัพธ์)');
|
|
265
|
+
return { handled: true };
|
|
266
|
+
}
|
|
267
|
+
catch (e) {
|
|
268
|
+
opts.onLog?.(`Matrix run error (${event.roomId}): ${redactKey(e.message)}`);
|
|
269
|
+
await sendMatrixMessage(opts.config, event.roomId, 'เกิดข้อผิดพลาดภายใน').catch(() => { });
|
|
270
|
+
return { handled: false, reason: 'error' };
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
running.delete(target);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
export async function handleMatrixSync(opts) {
|
|
277
|
+
let handled = 0;
|
|
278
|
+
let ignored = 0;
|
|
279
|
+
let joined = 0;
|
|
280
|
+
if (opts.config.autoJoin) {
|
|
281
|
+
for (const roomId of Object.keys(opts.sync.rooms?.invite ?? {})) {
|
|
282
|
+
try {
|
|
283
|
+
await joinMatrixRoom(opts.config, roomId);
|
|
284
|
+
joined += 1;
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
opts.onLog?.(`Matrix join error (${roomId}): ${redactKey(e.message)}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
for (const event of extractMatrixTextEvents(opts.sync, opts.config, opts.startupMs ?? Date.now())) {
|
|
292
|
+
const result = await handleMatrixEvent({ ...opts, event });
|
|
293
|
+
if (result.handled)
|
|
294
|
+
handled += 1;
|
|
295
|
+
else
|
|
296
|
+
ignored += 1;
|
|
297
|
+
}
|
|
298
|
+
return { handled, ignored, joined };
|
|
299
|
+
}
|
|
300
|
+
async function delay(ms, signal) {
|
|
301
|
+
if (signal.aborted)
|
|
302
|
+
return;
|
|
303
|
+
await new Promise((resolve) => {
|
|
304
|
+
const timer = setTimeout(resolve, ms);
|
|
305
|
+
signal.addEventListener('abort', () => {
|
|
306
|
+
clearTimeout(timer);
|
|
307
|
+
resolve();
|
|
308
|
+
}, { once: true });
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
export function startMatrix(opts) {
|
|
312
|
+
if (!normalizeMatrixHomeserver(opts.config.homeserver)) {
|
|
313
|
+
opts.onLog?.('Matrix ไม่เริ่ม: ต้องตั้ง MATRIX_HOMESERVER เช่น https://matrix.org');
|
|
314
|
+
return () => { };
|
|
315
|
+
}
|
|
316
|
+
if (!opts.config.accessToken && (!opts.config.userId || !opts.config.password)) {
|
|
317
|
+
opts.onLog?.('Matrix ไม่เริ่ม: ต้องตั้ง MATRIX_ACCESS_TOKEN หรือ MATRIX_USER_ID/MATRIX_PASSWORD');
|
|
318
|
+
return () => { };
|
|
319
|
+
}
|
|
320
|
+
if (!opts.config.allowAllUsers && !opts.config.allowedUsers.length) {
|
|
321
|
+
opts.onLog?.('Matrix ไม่เริ่ม: ต้องตั้ง MATRIX_ALLOWED_USERS เพื่อ fail-closed');
|
|
322
|
+
return () => { };
|
|
323
|
+
}
|
|
324
|
+
const controller = new AbortController();
|
|
325
|
+
const reconnectMs = opts.reconnectMs ?? 5000;
|
|
326
|
+
const startupMs = opts.startupMs ?? Date.now();
|
|
327
|
+
const loop = async () => {
|
|
328
|
+
let since;
|
|
329
|
+
let runtimeConfig = opts.config;
|
|
330
|
+
opts.onLog?.(`Matrix: syncing ${opts.config.homeserver}`);
|
|
331
|
+
while (!controller.signal.aborted) {
|
|
332
|
+
try {
|
|
333
|
+
if (!runtimeConfig.userId) {
|
|
334
|
+
const whoami = await matrixWhoami(runtimeConfig);
|
|
335
|
+
runtimeConfig = { ...runtimeConfig, userId: whoami.userId };
|
|
336
|
+
}
|
|
337
|
+
const auth = await loginMatrix(runtimeConfig);
|
|
338
|
+
runtimeConfig = { ...runtimeConfig, accessToken: auth.accessToken, userId: runtimeConfig.userId ?? auth.userId };
|
|
339
|
+
const r = await fetch(matrixSyncUrl(runtimeConfig, since), {
|
|
340
|
+
method: 'GET',
|
|
341
|
+
headers: matrixAuthHeaders(auth.accessToken),
|
|
342
|
+
signal: controller.signal,
|
|
343
|
+
});
|
|
344
|
+
const sync = await readJsonOrThrow(r, 'Matrix sync');
|
|
345
|
+
since = sync.next_batch || since;
|
|
346
|
+
await handleMatrixSync({
|
|
347
|
+
config: runtimeConfig,
|
|
348
|
+
sync,
|
|
349
|
+
model: opts.model,
|
|
350
|
+
budgetUsd: opts.budgetUsd,
|
|
351
|
+
permissionMode: opts.permissionMode,
|
|
352
|
+
startupMs,
|
|
353
|
+
runningTargets,
|
|
354
|
+
onLog: opts.onLog,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
catch (e) {
|
|
358
|
+
if (!controller.signal.aborted)
|
|
359
|
+
opts.onLog?.(`Matrix sync error: ${redactKey(e.message)}; reconnecting`);
|
|
360
|
+
await delay(reconnectMs, controller.signal);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
void loop();
|
|
365
|
+
return () => controller.abort();
|
|
366
|
+
}
|