sanook-cli 0.5.0 → 0.5.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.
- package/.env.example +161 -3
- package/CHANGELOG.md +83 -5
- package/README.md +240 -23
- package/README.th.md +87 -6
- package/dist/approval.js +6 -0
- package/dist/bin.js +3045 -210
- package/dist/brain-context.js +223 -0
- package/dist/brain-doctor.js +318 -0
- package/dist/brain-eval.js +186 -0
- package/dist/brain-final.js +371 -0
- package/dist/brain-review.js +382 -0
- package/dist/brain.js +12 -1
- package/dist/brand.js +1 -1
- package/dist/cli-args.js +152 -0
- package/dist/cli-option-values.js +16 -0
- package/dist/commands.js +172 -13
- package/dist/compaction.js +96 -11
- package/dist/config.js +118 -28
- package/dist/context-compression.js +191 -0
- package/dist/cost.js +49 -15
- package/dist/first-run.js +21 -0
- package/dist/gateway/auth.js +37 -8
- package/dist/gateway/bluebubbles.js +205 -0
- package/dist/gateway/config.js +929 -0
- package/dist/gateway/deliver.js +357 -0
- package/dist/gateway/discord.js +124 -0
- package/dist/gateway/email.js +472 -0
- package/dist/gateway/googlechat.js +207 -0
- package/dist/gateway/homeassistant.js +256 -0
- package/dist/gateway/ledger.js +18 -0
- 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 +343 -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/insights-args.js +35 -0
- package/dist/insights.js +86 -0
- package/dist/loop.js +123 -24
- package/dist/lsp/index.js +23 -5
- package/dist/mcp-registry.js +350 -0
- package/dist/mcp-server.js +1 -1
- package/dist/mcp.js +44 -6
- package/dist/memory.js +100 -33
- package/dist/orchestrate.js +49 -19
- package/dist/personality.js +58 -0
- package/dist/providers/codex.js +86 -38
- package/dist/providers/keys.js +1 -1
- package/dist/providers/models.js +22 -6
- package/dist/providers/registry.js +38 -49
- package/dist/search/chunk.js +7 -8
- package/dist/search/cli.js +75 -0
- package/dist/search/embed-store.js +3 -0
- package/dist/search/indexer.js +44 -1
- package/dist/search/store.js +23 -1
- package/dist/session.js +93 -7
- package/dist/skill-install.js +29 -12
- package/dist/support-dump.js +175 -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 +5 -0
- package/dist/tools/list.js +19 -6
- package/dist/tools/permission.js +923 -9
- package/dist/tools/read.js +16 -4
- package/dist/tools/schedule.js +19 -3
- package/dist/tools/search.js +217 -13
- package/dist/tools/task.js +18 -7
- package/dist/tools/timeout.js +21 -3
- package/dist/trust.js +11 -1
- package/dist/ui/app.js +57 -11
- package/dist/ui/brain-wizard.js +2 -2
- package/dist/ui/history.js +37 -5
- package/dist/ui/mentions.js +3 -2
- package/dist/ui/render.js +55 -15
- package/dist/ui/setup.js +107 -10
- package/dist/update.js +24 -11
- package/dist/worktree.js +175 -4
- package/package.json +4 -4
- 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 +3 -1
- package/second-brain/Projects/sanook-cli/_Index.md +26 -0
- package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +156 -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-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
- package/second-brain/Research/_Index.md +6 -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 +22 -3
- 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 +4 -1
- package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
- package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -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/Vault Structure Map.md +2 -1
- package/skills/structured-output-llm/SKILL.md +1 -1
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { BRAND } from '../brand.js';
|
|
2
|
+
import { redactKey } from '../providers/keys.js';
|
|
3
|
+
import { runGatewayAgent } from './session.js';
|
|
4
|
+
const NTFY_MESSAGE_LIMIT_BYTES = 4096;
|
|
5
|
+
const NTFY_REPLY_TITLE = BRAND.productName;
|
|
6
|
+
const runningTargets = new Set();
|
|
7
|
+
function truthyHeader(value) {
|
|
8
|
+
return value === 'true' || value === '1' || value === 'yes';
|
|
9
|
+
}
|
|
10
|
+
export function ntfyAuthHeader(token) {
|
|
11
|
+
const trimmed = token?.trim();
|
|
12
|
+
if (!trimmed)
|
|
13
|
+
return undefined;
|
|
14
|
+
if (trimmed.includes(':'))
|
|
15
|
+
return `Basic ${Buffer.from(trimmed, 'utf8').toString('base64')}`;
|
|
16
|
+
return `Bearer ${trimmed}`;
|
|
17
|
+
}
|
|
18
|
+
export function ntfyTopicUrl(serverUrl, topic, suffix = '') {
|
|
19
|
+
const base = (serverUrl || 'https://ntfy.sh').replace(/\/+$/, '');
|
|
20
|
+
return `${base}/${encodeURIComponent(topic.trim())}${suffix}`;
|
|
21
|
+
}
|
|
22
|
+
export function truncateNtfyMessage(raw, limitBytes = NTFY_MESSAGE_LIMIT_BYTES) {
|
|
23
|
+
const text = raw.trim() || '(ไม่มีผลลัพธ์)';
|
|
24
|
+
if (Buffer.byteLength(text, 'utf8') <= limitBytes)
|
|
25
|
+
return { text, truncated: false };
|
|
26
|
+
const suffix = '...';
|
|
27
|
+
const budget = Math.max(1, limitBytes - Buffer.byteLength(suffix, 'utf8'));
|
|
28
|
+
let out = '';
|
|
29
|
+
let used = 0;
|
|
30
|
+
for (const ch of text) {
|
|
31
|
+
const next = Buffer.byteLength(ch, 'utf8');
|
|
32
|
+
if (used + next > budget)
|
|
33
|
+
break;
|
|
34
|
+
out += ch;
|
|
35
|
+
used += next;
|
|
36
|
+
}
|
|
37
|
+
return { text: `${out.trimEnd()}${suffix}`, truncated: true };
|
|
38
|
+
}
|
|
39
|
+
export async function sendNtfyMessage(config, topic, text, options = {}) {
|
|
40
|
+
const targetTopic = topic.trim();
|
|
41
|
+
if (!targetTopic)
|
|
42
|
+
throw new Error('ntfy topic ว่าง');
|
|
43
|
+
const body = truncateNtfyMessage(text);
|
|
44
|
+
const headers = {
|
|
45
|
+
'content-type': options.markdown ?? config.markdown ? 'text/markdown; charset=utf-8' : 'text/plain; charset=utf-8',
|
|
46
|
+
title: options.title?.trim() || NTFY_REPLY_TITLE,
|
|
47
|
+
};
|
|
48
|
+
const auth = ntfyAuthHeader(config.token);
|
|
49
|
+
if (auth)
|
|
50
|
+
headers.authorization = auth;
|
|
51
|
+
if (options.markdown ?? config.markdown)
|
|
52
|
+
headers.markdown = 'yes';
|
|
53
|
+
const r = await fetch(ntfyTopicUrl(config.serverUrl, targetTopic), {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers,
|
|
56
|
+
body: body.text,
|
|
57
|
+
});
|
|
58
|
+
if (!r.ok) {
|
|
59
|
+
const detail = await r.text().catch(() => '');
|
|
60
|
+
throw new Error(`ntfy publish ${r.status}${detail ? `: ${redactKey(detail).slice(0, 200)}` : ''}`);
|
|
61
|
+
}
|
|
62
|
+
const parsed = (await r.json().catch(() => ({})));
|
|
63
|
+
return { topic: targetTopic, messageId: parsed.id, messageCount: 1, truncated: body.truncated };
|
|
64
|
+
}
|
|
65
|
+
export function parseNtfyJsonLine(line) {
|
|
66
|
+
const trimmed = line.trim();
|
|
67
|
+
if (!trimmed)
|
|
68
|
+
return null;
|
|
69
|
+
const parsed = JSON.parse(trimmed);
|
|
70
|
+
if (!parsed || typeof parsed !== 'object')
|
|
71
|
+
return null;
|
|
72
|
+
const event = parsed;
|
|
73
|
+
if (event.event !== 'message')
|
|
74
|
+
return null;
|
|
75
|
+
if (typeof event.message !== 'string' || !event.message.trim())
|
|
76
|
+
return null;
|
|
77
|
+
return event;
|
|
78
|
+
}
|
|
79
|
+
export function isAllowedNtfyTopic(config, topic) {
|
|
80
|
+
if (config.allowAllUsers)
|
|
81
|
+
return true;
|
|
82
|
+
const target = topic?.trim();
|
|
83
|
+
if (!target)
|
|
84
|
+
return false;
|
|
85
|
+
const allowed = new Set([config.topic, config.homeChannel, ...config.allowedUsers].filter((v) => Boolean(v?.trim())));
|
|
86
|
+
return allowed.has(target);
|
|
87
|
+
}
|
|
88
|
+
function ntfyPrompt(event, topic) {
|
|
89
|
+
const parts = [`ntfy topic ${topic}:`, event.message?.trim() || '(empty)'];
|
|
90
|
+
if (event.title?.trim() && event.title !== NTFY_REPLY_TITLE)
|
|
91
|
+
parts.splice(1, 0, `title: ${event.title.trim()}`);
|
|
92
|
+
return parts.join('\n');
|
|
93
|
+
}
|
|
94
|
+
export async function handleNtfyEvent(opts) {
|
|
95
|
+
const event = opts.event;
|
|
96
|
+
if (event.event !== 'message' || !event.message?.trim())
|
|
97
|
+
return { handled: false, reason: 'ignored_event' };
|
|
98
|
+
if (event.title === NTFY_REPLY_TITLE || truthyHeader(String(event.sanookReply ?? ''))) {
|
|
99
|
+
return { handled: false, reason: 'self_message' };
|
|
100
|
+
}
|
|
101
|
+
const topic = event.topic?.trim() || opts.config.topic?.trim();
|
|
102
|
+
if (!isAllowedNtfyTopic(opts.config, topic)) {
|
|
103
|
+
opts.onLog?.(`ntfy: ปฏิเสธ topic ${topic ?? '(unknown)'} (ไม่อยู่ใน allowlist)`);
|
|
104
|
+
return { handled: false, reason: 'not_allowed' };
|
|
105
|
+
}
|
|
106
|
+
if (!topic)
|
|
107
|
+
return { handled: false, reason: 'missing_topic' };
|
|
108
|
+
const running = opts.runningTargets ?? runningTargets;
|
|
109
|
+
if (running.has(topic))
|
|
110
|
+
return { handled: false, reason: 'busy' };
|
|
111
|
+
running.add(topic);
|
|
112
|
+
try {
|
|
113
|
+
const result = await runGatewayAgent({
|
|
114
|
+
platform: 'ntfy',
|
|
115
|
+
target: topic,
|
|
116
|
+
model: opts.model,
|
|
117
|
+
prompt: ntfyPrompt(event, topic),
|
|
118
|
+
userText: event.message.trim(),
|
|
119
|
+
budgetUsd: opts.budgetUsd,
|
|
120
|
+
permissionMode: opts.permissionMode ?? 'ask',
|
|
121
|
+
});
|
|
122
|
+
if (!result.suppressDelivery) {
|
|
123
|
+
await sendNtfyMessage(opts.config, opts.config.publishTopic || topic, result.text || '(ไม่มีผลลัพธ์)', { title: NTFY_REPLY_TITLE });
|
|
124
|
+
}
|
|
125
|
+
return { handled: true };
|
|
126
|
+
}
|
|
127
|
+
catch (e) {
|
|
128
|
+
opts.onLog?.(`ntfy run error (${topic}): ${redactKey(e.message)}`);
|
|
129
|
+
await sendNtfyMessage(opts.config, opts.config.publishTopic || topic, 'เกิดข้อผิดพลาดภายใน', { title: NTFY_REPLY_TITLE }).catch(() => { });
|
|
130
|
+
return { handled: false, reason: 'error' };
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
running.delete(topic);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async function delay(ms, signal) {
|
|
137
|
+
if (signal.aborted)
|
|
138
|
+
return;
|
|
139
|
+
await new Promise((resolve) => {
|
|
140
|
+
const timer = setTimeout(resolve, ms);
|
|
141
|
+
signal.addEventListener('abort', () => {
|
|
142
|
+
clearTimeout(timer);
|
|
143
|
+
resolve();
|
|
144
|
+
}, { once: true });
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
async function readNtfyStream(response, opts, signal) {
|
|
148
|
+
if (!response.body)
|
|
149
|
+
throw new Error('ntfy stream ไม่มี response body');
|
|
150
|
+
const reader = response.body.getReader();
|
|
151
|
+
const decoder = new TextDecoder();
|
|
152
|
+
let pending = '';
|
|
153
|
+
for (;;) {
|
|
154
|
+
const { done, value } = await reader.read();
|
|
155
|
+
pending += decoder.decode(value, { stream: !done });
|
|
156
|
+
const lines = pending.split(/\r?\n/);
|
|
157
|
+
pending = lines.pop() ?? '';
|
|
158
|
+
for (const line of lines) {
|
|
159
|
+
if (signal.aborted)
|
|
160
|
+
return;
|
|
161
|
+
try {
|
|
162
|
+
const event = parseNtfyJsonLine(line);
|
|
163
|
+
if (event)
|
|
164
|
+
await handleNtfyEvent({ ...opts, event, runningTargets });
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
opts.onLog?.(`ntfy parse error: ${redactKey(e.message)}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (done)
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
const last = pending.trim();
|
|
174
|
+
if (last && !signal.aborted) {
|
|
175
|
+
const event = parseNtfyJsonLine(last);
|
|
176
|
+
if (event)
|
|
177
|
+
await handleNtfyEvent({ ...opts, event, runningTargets });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
export function startNtfy(opts) {
|
|
181
|
+
const topic = opts.config.topic?.trim();
|
|
182
|
+
if (!topic) {
|
|
183
|
+
opts.onLog?.('ntfy ไม่เริ่ม: ต้องตั้ง NTFY_TOPIC หรือ gateway setup ntfy --topic');
|
|
184
|
+
return () => { };
|
|
185
|
+
}
|
|
186
|
+
if (!isAllowedNtfyTopic(opts.config, topic)) {
|
|
187
|
+
opts.onLog?.('ntfy ไม่เริ่ม: ต้องตั้ง NTFY_ALLOWED_USERS ให้รวม topic หรือระบุ --allow-all-users เพื่อ fail-closed');
|
|
188
|
+
return () => { };
|
|
189
|
+
}
|
|
190
|
+
const controller = new AbortController();
|
|
191
|
+
const reconnectMs = opts.reconnectMs ?? 5000;
|
|
192
|
+
const headers = { accept: 'application/x-ndjson' };
|
|
193
|
+
const auth = ntfyAuthHeader(opts.config.token);
|
|
194
|
+
if (auth)
|
|
195
|
+
headers.authorization = auth;
|
|
196
|
+
const loop = async () => {
|
|
197
|
+
opts.onLog?.(`ntfy: subscribe ${ntfyTopicUrl(opts.config.serverUrl, topic, '/json?since=1s')}`);
|
|
198
|
+
while (!controller.signal.aborted) {
|
|
199
|
+
try {
|
|
200
|
+
const r = await fetch(ntfyTopicUrl(opts.config.serverUrl, topic, '/json?since=1s'), {
|
|
201
|
+
method: 'GET',
|
|
202
|
+
headers,
|
|
203
|
+
signal: controller.signal,
|
|
204
|
+
});
|
|
205
|
+
if (!r.ok)
|
|
206
|
+
throw new Error(`ntfy subscribe ${r.status}`);
|
|
207
|
+
await readNtfyStream(r, opts, controller.signal);
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
if (!controller.signal.aborted)
|
|
211
|
+
opts.onLog?.(`ntfy stream error: ${redactKey(e.message)}; reconnecting`);
|
|
212
|
+
}
|
|
213
|
+
await delay(reconnectMs, controller.signal);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
void loop();
|
|
217
|
+
return () => controller.abort();
|
|
218
|
+
}
|
package/dist/gateway/schedule.js
CHANGED
|
@@ -2,7 +2,23 @@
|
|
|
2
2
|
// support: interval ("every 30m" / "2h"), daily ("09:00"), ISO timestamp (one-shot), "now"
|
|
3
3
|
// pure — รับ now เป็น param (ไม่เรียก Date.now ใน body หลัก) เพื่อ test ได้
|
|
4
4
|
const UNIT_MS = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
5
|
+
const MAX_DATE_MS = 8_640_000_000_000_000;
|
|
5
6
|
const pad = (n) => String(n).padStart(2, '0');
|
|
7
|
+
function isValidEpochMs(value) {
|
|
8
|
+
return Number.isSafeInteger(value) && Math.abs(value) <= MAX_DATE_MS;
|
|
9
|
+
}
|
|
10
|
+
function isLeapYear(year) {
|
|
11
|
+
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
12
|
+
}
|
|
13
|
+
function isValidCalendarDate(year, month, day) {
|
|
14
|
+
if (month < 1 || month > 12 || day < 1)
|
|
15
|
+
return false;
|
|
16
|
+
const daysInMonth = [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
17
|
+
return day <= daysInMonth[month - 1];
|
|
18
|
+
}
|
|
19
|
+
function isValidClockTime(hour, minute) {
|
|
20
|
+
return hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59;
|
|
21
|
+
}
|
|
6
22
|
/** next occurrence ของ HH:MM (local time) หลัง now */
|
|
7
23
|
function nextDaily(minutesOfDay, now) {
|
|
8
24
|
const target = new Date(now);
|
|
@@ -15,6 +31,8 @@ export function parseSchedule(input, now) {
|
|
|
15
31
|
const s = input.trim().toLowerCase();
|
|
16
32
|
if (!s)
|
|
17
33
|
return null;
|
|
34
|
+
if (!isValidEpochMs(now))
|
|
35
|
+
return null;
|
|
18
36
|
if (s === 'now' || s === 'immediately') {
|
|
19
37
|
return { runAt: now, recurring: false, kind: 'once', normalized: 'now' };
|
|
20
38
|
}
|
|
@@ -24,10 +42,11 @@ export function parseSchedule(input, now) {
|
|
|
24
42
|
const n = parseInt(iv[1], 10);
|
|
25
43
|
const unit = iv[2][0]; // s/m/h/d (ตัวแรกพอ)
|
|
26
44
|
const ms = n * (UNIT_MS[unit] ?? 0);
|
|
45
|
+
const runAt = now + ms;
|
|
27
46
|
// กัน overflow → runAt เป็น Invalid Date ที่ due() ไม่มีวันยิง
|
|
28
|
-
if (!Number.isSafeInteger(ms) || ms <= 0 || !
|
|
47
|
+
if (!Number.isSafeInteger(ms) || ms <= 0 || !isValidEpochMs(runAt))
|
|
29
48
|
return null;
|
|
30
|
-
return { runAt
|
|
49
|
+
return { runAt, recurring: true, kind: 'cron', normalized: `every ${n}${unit}` };
|
|
31
50
|
}
|
|
32
51
|
// daily time: "09:00" | "at 9:00" | "daily 09:30"
|
|
33
52
|
const dt = s.match(/^(?:at\s+|daily\s+(?:at\s+)?)?(\d{1,2}):(\d{2})$/);
|
|
@@ -37,7 +56,10 @@ export function parseSchedule(input, now) {
|
|
|
37
56
|
if (hh > 23 || mm > 59)
|
|
38
57
|
return null;
|
|
39
58
|
const mins = hh * 60 + mm;
|
|
40
|
-
|
|
59
|
+
const runAt = nextDaily(mins, now);
|
|
60
|
+
if (!isValidEpochMs(runAt))
|
|
61
|
+
return null;
|
|
62
|
+
return { runAt, recurring: true, kind: 'cron', normalized: `${pad(hh)}:${pad(mm)}` };
|
|
41
63
|
}
|
|
42
64
|
// ── NL ภาษาไทย / aliases → map เป็น canonical แล้ว parse ซ้ำ ──
|
|
43
65
|
if (/^(ทุก\s*ๆ?\s*)?(ชั่วโมง|ชม\.?|hourly)$/.test(s))
|
|
@@ -57,7 +79,12 @@ export function parseSchedule(input, now) {
|
|
|
57
79
|
return parseSchedule(`${thDaily[1]}:${thDaily[2]}`, now);
|
|
58
80
|
// ISO timestamp (one-shot) — รับเฉพาะรูปแบบที่มี date จริง (กัน Date.parse รับ bare number/year-only กำกวม)
|
|
59
81
|
const raw = input.trim();
|
|
60
|
-
|
|
82
|
+
const iso = raw.match(/^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})|$)/);
|
|
83
|
+
if (iso) {
|
|
84
|
+
if (!isValidCalendarDate(Number(iso[1]), Number(iso[2]), Number(iso[3])))
|
|
85
|
+
return null;
|
|
86
|
+
if (iso[4] !== undefined && !isValidClockTime(Number(iso[4]), Number(iso[5])))
|
|
87
|
+
return null;
|
|
61
88
|
const t = Date.parse(raw);
|
|
62
89
|
if (!Number.isNaN(t)) {
|
|
63
90
|
if (t < now)
|
package/dist/gateway/serve.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { mkdir } from 'node:fs/promises';
|
|
2
1
|
import { join } from 'node:path';
|
|
3
2
|
import { acquireSingleton } from './lock.js';
|
|
4
|
-
import { loadOrCreateToken } from './auth.js';
|
|
3
|
+
import { ensureGatewayDir, loadOrCreateToken } from './auth.js';
|
|
5
4
|
import { startServer } from './server.js';
|
|
6
5
|
import { startScheduler } from './scheduler.js';
|
|
7
6
|
import { appHomePath, BRAND, BRAND_ENV, envFlag } from '../brand.js';
|
|
7
|
+
import { readGatewayConfig, resolveDiscordConfig, resolveEmailConfig, resolveHomeAssistantConfig, resolveLineConfig, resolveMattermostConfig, resolveMatrixConfig, resolveNtfyConfig, resolveSignalConfig, resolveSlackConfig, resolveSmsConfig, resolveTelegramConfig, resolveWhatsAppConfig, resolveWebhookConfig, } from './config.js';
|
|
8
8
|
const GATEWAY_DIR = appHomePath('gateway');
|
|
9
9
|
const SERVE_LOCK = join(GATEWAY_DIR, 'serve.lock');
|
|
10
10
|
/**
|
|
@@ -15,7 +15,7 @@ const SERVE_LOCK = join(GATEWAY_DIR, 'serve.lock');
|
|
|
15
15
|
*/
|
|
16
16
|
export async function startGateway(opts) {
|
|
17
17
|
const log = opts.onLog ?? ((m) => console.log(`[gateway] ${m}`));
|
|
18
|
-
await
|
|
18
|
+
await ensureGatewayDir();
|
|
19
19
|
const release = await acquireSingleton(SERVE_LOCK);
|
|
20
20
|
if (!release) {
|
|
21
21
|
throw new Error(`มี ${BRAND.cliName} gateway รันอยู่แล้ว (เจอ serve.lock) — ปิดตัวเดิมก่อน หรือถ้าค้างให้ลบ ${appHomePath('gateway', 'serve.lock')}`);
|
|
@@ -35,25 +35,285 @@ export async function startGateway(opts) {
|
|
|
35
35
|
permissionMode: opts.permissionMode ?? (envFlag(BRAND_ENV.gatewayAllowWrite) ? 'auto' : 'ask'),
|
|
36
36
|
tickMs: opts.tickMs,
|
|
37
37
|
onLog: log,
|
|
38
|
+
deliver: async (task, output) => {
|
|
39
|
+
if (!task.deliver)
|
|
40
|
+
return;
|
|
41
|
+
const { deliverToTarget } = await import('./deliver.js');
|
|
42
|
+
const result = await deliverToTarget(task.deliver, output, { subject: `${BRAND.productName} task ${task.id}` });
|
|
43
|
+
log(`delivered ${task.id} → ${result.target}`);
|
|
44
|
+
},
|
|
38
45
|
});
|
|
39
|
-
// Telegram channel (
|
|
46
|
+
// Telegram channel (env หรือ ~/.sanook/gateway/config.json) — long-polling, ไม่ต้อง public URL
|
|
40
47
|
let stopTelegram;
|
|
41
|
-
|
|
48
|
+
let stopDiscord;
|
|
49
|
+
let stopSlack;
|
|
50
|
+
let stopMattermost;
|
|
51
|
+
let stopHomeAssistant;
|
|
52
|
+
let stopEmail;
|
|
53
|
+
let stopNtfy;
|
|
54
|
+
let stopSignal;
|
|
55
|
+
let stopMatrix;
|
|
56
|
+
const gatewayConfig = await readGatewayConfig();
|
|
57
|
+
const telegram = resolveTelegramConfig(gatewayConfig);
|
|
58
|
+
if (telegram.enabled && telegram.token) {
|
|
42
59
|
const { startTelegram, parseAllowedChats } = await import('./telegram.js');
|
|
43
60
|
stopTelegram = startTelegram({
|
|
44
|
-
token:
|
|
61
|
+
token: telegram.token,
|
|
45
62
|
model: opts.model,
|
|
46
63
|
budgetUsd: opts.budgetUsd,
|
|
47
|
-
allowedChatIds: parseAllowedChats(process.env.TELEGRAM_ALLOWED_CHATS),
|
|
64
|
+
allowedChatIds: process.env.TELEGRAM_ALLOWED_CHATS ? parseAllowedChats(process.env.TELEGRAM_ALLOWED_CHATS) : telegram.allowedChatIds,
|
|
65
|
+
allowWrite: telegram.allowWrite,
|
|
48
66
|
onLog: log,
|
|
49
67
|
});
|
|
50
68
|
// หมายเหตุ: log "เริ่มแล้ว" อยู่ใน startTelegram (success path) — ถ้า fail-closed จะ log "ไม่เริ่ม" แทน
|
|
51
69
|
}
|
|
70
|
+
const discord = resolveDiscordConfig(gatewayConfig);
|
|
71
|
+
if (discord.enabled && discord.token) {
|
|
72
|
+
const { startDiscord } = await import('./discord.js');
|
|
73
|
+
try {
|
|
74
|
+
stopDiscord = startDiscord({
|
|
75
|
+
token: discord.token,
|
|
76
|
+
model: opts.model,
|
|
77
|
+
budgetUsd: opts.budgetUsd,
|
|
78
|
+
allowedChannelIds: discord.allowedChannelIds,
|
|
79
|
+
defaultChannelId: discord.defaultChannelId,
|
|
80
|
+
allowWrite: discord.allowWrite,
|
|
81
|
+
onLog: log,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
log(`Discord ไม่เริ่ม: ${e.message}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const slack = resolveSlackConfig(gatewayConfig);
|
|
89
|
+
if (slack.enabled && slack.botToken) {
|
|
90
|
+
if (!slack.appToken) {
|
|
91
|
+
log('Slack ไม่เริ่ม: ต้องตั้ง SLACK_APP_TOKEN หรือ gateway setup slack --app-token สำหรับ Socket Mode');
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
const { startSlack } = await import('./slack.js');
|
|
95
|
+
try {
|
|
96
|
+
stopSlack = await startSlack({
|
|
97
|
+
botToken: slack.botToken,
|
|
98
|
+
appToken: slack.appToken,
|
|
99
|
+
model: opts.model,
|
|
100
|
+
budgetUsd: opts.budgetUsd,
|
|
101
|
+
allowedChannelIds: slack.allowedChannelIds,
|
|
102
|
+
defaultChannelId: slack.defaultChannelId,
|
|
103
|
+
allowWrite: slack.allowWrite,
|
|
104
|
+
onLog: log,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
log(`Slack ไม่เริ่ม: ${e.message}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const mattermost = resolveMattermostConfig(gatewayConfig);
|
|
113
|
+
if (mattermost.enabled && (mattermost.serverUrl || mattermost.token || mattermost.homeChannel || mattermost.allowedUsers.length || mattermost.allowedChannels.length)) {
|
|
114
|
+
if (!mattermost.serverUrl) {
|
|
115
|
+
log('Mattermost ไม่เริ่ม: ต้องตั้ง MATTERMOST_URL เช่น https://mm.example.com');
|
|
116
|
+
}
|
|
117
|
+
else if (!mattermost.token) {
|
|
118
|
+
log('Mattermost ไม่เริ่ม: ต้องตั้ง MATTERMOST_TOKEN');
|
|
119
|
+
}
|
|
120
|
+
else if (!mattermost.allowAllUsers && !mattermost.allowedUsers.length) {
|
|
121
|
+
log('Mattermost ไม่เริ่ม: ต้องตั้ง MATTERMOST_ALLOWED_USERS เพื่อ fail-closed');
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
const { startMattermost } = await import('./mattermost.js');
|
|
125
|
+
try {
|
|
126
|
+
stopMattermost = await startMattermost({
|
|
127
|
+
config: mattermost,
|
|
128
|
+
model: opts.model,
|
|
129
|
+
budgetUsd: opts.budgetUsd,
|
|
130
|
+
permissionMode: opts.permissionMode ?? (envFlag(BRAND_ENV.gatewayAllowWrite) ? 'auto' : 'ask'),
|
|
131
|
+
onLog: log,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
log(`Mattermost ไม่เริ่ม: ${e.message}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const homeassistant = resolveHomeAssistantConfig(gatewayConfig);
|
|
140
|
+
if (homeassistant.enabled && (homeassistant.token || homeassistant.homeChannel || homeassistant.watchAll || homeassistant.watchDomains.length || homeassistant.watchEntities.length)) {
|
|
141
|
+
if (!homeassistant.token) {
|
|
142
|
+
log('Home Assistant ไม่เริ่ม: ต้องตั้ง HASS_TOKEN');
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
const { startHomeAssistant } = await import('./homeassistant.js');
|
|
146
|
+
try {
|
|
147
|
+
stopHomeAssistant = startHomeAssistant({
|
|
148
|
+
config: homeassistant,
|
|
149
|
+
model: opts.model,
|
|
150
|
+
budgetUsd: opts.budgetUsd,
|
|
151
|
+
permissionMode: opts.permissionMode ?? (envFlag(BRAND_ENV.gatewayAllowWrite) ? 'auto' : 'ask'),
|
|
152
|
+
onLog: log,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
catch (e) {
|
|
156
|
+
log(`Home Assistant ไม่เริ่ม: ${e.message}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const email = resolveEmailConfig(gatewayConfig);
|
|
161
|
+
if (email.enabled && email.address) {
|
|
162
|
+
if (!email.password || !email.imapHost || !email.smtpHost) {
|
|
163
|
+
log('Email ไม่เริ่ม: ต้องตั้ง password, imapHost และ smtpHost ให้ครบ');
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
const { startEmail } = await import('./email.js');
|
|
167
|
+
stopEmail = startEmail({
|
|
168
|
+
address: email.address,
|
|
169
|
+
password: email.password,
|
|
170
|
+
imapHost: email.imapHost,
|
|
171
|
+
imapPort: email.imapPort,
|
|
172
|
+
smtpHost: email.smtpHost,
|
|
173
|
+
smtpPort: email.smtpPort,
|
|
174
|
+
homeAddress: email.homeAddress,
|
|
175
|
+
allowedUsers: email.allowedUsers,
|
|
176
|
+
allowAllUsers: email.allowAllUsers,
|
|
177
|
+
pollIntervalSeconds: email.pollIntervalSeconds,
|
|
178
|
+
model: opts.model,
|
|
179
|
+
budgetUsd: opts.budgetUsd,
|
|
180
|
+
allowWrite: false,
|
|
181
|
+
onLog: log,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const line = resolveLineConfig(gatewayConfig);
|
|
186
|
+
if (line.enabled && line.channelAccessToken) {
|
|
187
|
+
if (!line.channelSecret) {
|
|
188
|
+
log('LINE webhook ไม่เริ่ม: ต้องตั้ง LINE_CHANNEL_SECRET หรือ gateway setup line --channel-secret');
|
|
189
|
+
}
|
|
190
|
+
else if (!line.homeChannel && !line.allowedUsers.length && !line.allowedGroups.length && !line.allowedRooms.length && !line.allowAllUsers) {
|
|
191
|
+
log('LINE webhook ไม่เริ่ม: ต้องตั้ง home channel หรือ allowlist เพื่อ fail-closed');
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
const publicBase = line.publicUrl ? `${line.publicUrl.replace(/\/+$/, '')}/line/webhook` : `http://127.0.0.1:${opts.port}/line/webhook`;
|
|
195
|
+
log(`LINE: webhook ready at ${publicBase}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const sms = resolveSmsConfig(gatewayConfig);
|
|
199
|
+
if (sms.enabled && (sms.accountSid || sms.authToken || sms.phoneNumber)) {
|
|
200
|
+
if (!sms.accountSid || !sms.authToken || !sms.phoneNumber) {
|
|
201
|
+
log('SMS webhook ไม่เริ่ม: ต้องตั้ง TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN และ TWILIO_PHONE_NUMBER ให้ครบ');
|
|
202
|
+
}
|
|
203
|
+
else if (!sms.insecureNoSignature && !sms.webhookUrl) {
|
|
204
|
+
log('SMS webhook ไม่เริ่ม: ต้องตั้ง SMS_WEBHOOK_URL ให้ตรงกับ Twilio Console เพื่อ verify signature');
|
|
205
|
+
}
|
|
206
|
+
else if (!sms.homeChannel && !sms.allowedUsers.length && !sms.allowAllUsers) {
|
|
207
|
+
log('SMS webhook ไม่เริ่ม: ต้องตั้ง home channel หรือ allowlist เพื่อ fail-closed');
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
const publicBase = sms.webhookUrl || `http://127.0.0.1:${opts.port}/sms/webhook`;
|
|
211
|
+
log(`SMS: Twilio webhook ready at ${publicBase}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const ntfy = resolveNtfyConfig(gatewayConfig);
|
|
215
|
+
if (ntfy.enabled && (ntfy.topic || ntfy.publishTopic || ntfy.homeChannel || ntfy.token)) {
|
|
216
|
+
if (!ntfy.topic) {
|
|
217
|
+
log('ntfy ไม่เริ่ม: ต้องตั้ง NTFY_TOPIC หรือ gateway setup ntfy --topic สำหรับ inbound subscribe');
|
|
218
|
+
}
|
|
219
|
+
else if (!ntfy.allowAllUsers && ![ntfy.topic, ntfy.homeChannel, ...ntfy.allowedUsers].filter(Boolean).includes(ntfy.topic)) {
|
|
220
|
+
log('ntfy ไม่เริ่ม: ต้องตั้ง NTFY_ALLOWED_USERS ให้รวม topic หรือระบุ --allow-all-users เพื่อ fail-closed');
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
const { startNtfy } = await import('./ntfy.js');
|
|
224
|
+
stopNtfy = startNtfy({
|
|
225
|
+
config: ntfy,
|
|
226
|
+
model: opts.model,
|
|
227
|
+
budgetUsd: opts.budgetUsd,
|
|
228
|
+
permissionMode: opts.permissionMode ?? (envFlag(BRAND_ENV.gatewayAllowWrite) ? 'auto' : 'ask'),
|
|
229
|
+
onLog: log,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const signal = resolveSignalConfig(gatewayConfig);
|
|
234
|
+
if (signal.enabled && (signal.account || signal.homeChannel || signal.allowedUsers.length || signal.groupAllowedUsers.length)) {
|
|
235
|
+
if (!signal.account) {
|
|
236
|
+
log('Signal ไม่เริ่ม: ต้องตั้ง SIGNAL_ACCOUNT หรือ gateway setup signal --account <+E.164>');
|
|
237
|
+
}
|
|
238
|
+
else if (!signal.allowAllUsers && !signal.homeChannel && !signal.allowedUsers.length && !signal.groupAllowedUsers.length) {
|
|
239
|
+
log('Signal ไม่เริ่ม: ต้องตั้ง home channel หรือ allowlist เพื่อ fail-closed');
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
const { startSignal } = await import('./signal.js');
|
|
243
|
+
stopSignal = startSignal({
|
|
244
|
+
config: signal,
|
|
245
|
+
model: opts.model,
|
|
246
|
+
budgetUsd: opts.budgetUsd,
|
|
247
|
+
permissionMode: opts.permissionMode ?? (envFlag(BRAND_ENV.gatewayAllowWrite) ? 'auto' : 'ask'),
|
|
248
|
+
onLog: log,
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const whatsapp = resolveWhatsAppConfig(gatewayConfig);
|
|
253
|
+
if (whatsapp.enabled && (whatsapp.phoneNumberId || whatsapp.accessToken || whatsapp.homeChannel || whatsapp.allowedUsers.length)) {
|
|
254
|
+
if (!whatsapp.phoneNumberId || !whatsapp.accessToken) {
|
|
255
|
+
log('WhatsApp Cloud webhook ไม่เริ่ม: ต้องตั้ง WHATSAPP_CLOUD_PHONE_NUMBER_ID และ WHATSAPP_CLOUD_ACCESS_TOKEN ให้ครบ');
|
|
256
|
+
}
|
|
257
|
+
else if (!whatsapp.appSecret) {
|
|
258
|
+
log('WhatsApp Cloud webhook ไม่เริ่ม: ต้องตั้ง WHATSAPP_CLOUD_APP_SECRET เพื่อ verify X-Hub-Signature-256');
|
|
259
|
+
}
|
|
260
|
+
else if (!whatsapp.verifyToken) {
|
|
261
|
+
log('WhatsApp Cloud webhook ไม่เริ่ม: ต้องตั้ง WHATSAPP_CLOUD_VERIFY_TOKEN สำหรับ Meta webhook verify handshake');
|
|
262
|
+
}
|
|
263
|
+
else if (!whatsapp.homeChannel && !whatsapp.allowedUsers.length && !whatsapp.allowAllUsers) {
|
|
264
|
+
log('WhatsApp Cloud webhook ไม่เริ่ม: ต้องตั้ง home channel หรือ allowlist เพื่อ fail-closed');
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
const publicBase = whatsapp.publicUrl ? `${whatsapp.publicUrl.replace(/\/+$/, '')}/whatsapp/webhook` : `http://127.0.0.1:${opts.port}/whatsapp/webhook`;
|
|
268
|
+
log(`WhatsApp Cloud: webhook ready at ${publicBase}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const matrix = resolveMatrixConfig(gatewayConfig);
|
|
272
|
+
if (matrix.enabled && (matrix.homeserver || matrix.accessToken || matrix.userId || matrix.homeRoom || matrix.allowedUsers.length || matrix.allowedRooms.length)) {
|
|
273
|
+
if (!matrix.homeserver) {
|
|
274
|
+
log('Matrix ไม่เริ่ม: ต้องตั้ง MATRIX_HOMESERVER เช่น https://matrix.org');
|
|
275
|
+
}
|
|
276
|
+
else if (!matrix.accessToken && (!matrix.userId || !matrix.password)) {
|
|
277
|
+
log('Matrix ไม่เริ่ม: ต้องตั้ง MATRIX_ACCESS_TOKEN หรือ MATRIX_USER_ID/MATRIX_PASSWORD');
|
|
278
|
+
}
|
|
279
|
+
else if (!matrix.allowAllUsers && !matrix.allowedUsers.length) {
|
|
280
|
+
log('Matrix ไม่เริ่ม: ต้องตั้ง MATRIX_ALLOWED_USERS เพื่อ fail-closed');
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
const { startMatrix } = await import('./matrix.js');
|
|
284
|
+
stopMatrix = startMatrix({
|
|
285
|
+
config: matrix,
|
|
286
|
+
model: opts.model,
|
|
287
|
+
budgetUsd: opts.budgetUsd,
|
|
288
|
+
permissionMode: opts.permissionMode ?? (envFlag(BRAND_ENV.gatewayAllowWrite) ? 'auto' : 'ask'),
|
|
289
|
+
onLog: log,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const webhooks = resolveWebhookConfig(gatewayConfig);
|
|
294
|
+
if (webhooks.enabled) {
|
|
295
|
+
const routes = Object.keys(webhooks.routes);
|
|
296
|
+
if (!routes.length) {
|
|
297
|
+
log('Webhooks เปิดอยู่ แต่ยังไม่มี route — เพิ่มด้วย sanook webhook subscribe <name>');
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
const base = webhooks.publicUrl ? `${webhooks.publicUrl.replace(/\/+$/, '')}/webhooks` : `http://127.0.0.1:${opts.port}/webhooks`;
|
|
301
|
+
log(`Webhooks: ${routes.length} route(s) ready at ${base}/<route>`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
52
304
|
log(`scheduler tick ทุก ${(opts.tickMs ?? 60_000) / 1000}s · token: ${appHomePath('gateway', 'token')} (chmod 600)`);
|
|
53
305
|
return () => {
|
|
54
306
|
stopServer();
|
|
55
307
|
stopScheduler();
|
|
56
308
|
stopTelegram?.();
|
|
309
|
+
stopDiscord?.();
|
|
310
|
+
stopSlack?.();
|
|
311
|
+
stopMattermost?.();
|
|
312
|
+
stopHomeAssistant?.();
|
|
313
|
+
stopEmail?.();
|
|
314
|
+
stopNtfy?.();
|
|
315
|
+
stopSignal?.();
|
|
316
|
+
stopMatrix?.();
|
|
57
317
|
release(); // ปล่อย single-instance lock (sync — ทันก่อน process.exit ตัด event loop)
|
|
58
318
|
};
|
|
59
319
|
}
|