sanook-cli 0.5.2 → 0.5.7
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/CHANGELOG.md +112 -2
- package/README.md +15 -3
- package/README.th.md +8 -1
- package/dist/approval.js +7 -0
- package/dist/bin.js +637 -56
- package/dist/brain-consolidate.js +335 -0
- package/dist/brain-context.js +42 -3
- package/dist/brain-final.js +15 -9
- package/dist/brain-link.js +73 -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.js +3 -0
- package/dist/brand.js +4 -0
- package/dist/cli-args.js +47 -9
- package/dist/cli-option-values.js +1 -1
- package/dist/clipboard.js +65 -0
- package/dist/commands.js +98 -15
- package/dist/config.js +66 -34
- package/dist/context-pack.js +145 -0
- package/dist/cost.js +20 -0
- 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/gateway/auth.js +14 -3
- package/dist/gateway/deliver.js +45 -3
- package/dist/gateway/doctor.js +456 -0
- package/dist/gateway/email.js +30 -1
- package/dist/gateway/ledger.js +20 -1
- package/dist/gateway/session.js +34 -11
- 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 +24 -4
- package/dist/knowledge.js +55 -29
- package/dist/loop.js +65 -9
- package/dist/mcp-hub.js +33 -0
- package/dist/mcp-registry.js +153 -9
- package/dist/mcp-risk.js +71 -0
- package/dist/mcp.js +77 -5
- package/dist/memory-log.js +90 -0
- package/dist/memory-store.js +37 -1
- package/dist/memory.js +51 -7
- package/dist/model-picker.js +58 -0
- package/dist/orchestrate.js +7 -5
- 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 +20 -8
- package/dist/providers/keys.js +21 -0
- package/dist/providers/models.js +1 -1
- package/dist/providers/registry.js +11 -1
- package/dist/search/cli.js +9 -1
- package/dist/search/embedding-config.js +22 -0
- package/dist/search/engine.js +2 -13
- package/dist/search/indexer.js +10 -10
- package/dist/session-brain.js +103 -0
- package/dist/session-distill.js +84 -0
- package/dist/session.js +1 -11
- package/dist/skill-install.js +24 -1
- package/dist/skills.js +33 -0
- package/dist/slash-completion.js +155 -0
- package/dist/support-dump.js +31 -0
- package/dist/tool-catalog.js +59 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/permission.js +82 -16
- package/dist/tools/polyglot.js +126 -0
- package/dist/tools/sandbox.js +38 -13
- package/dist/tools/search.js +9 -2
- package/dist/tools/task.js +22 -2
- package/dist/tools/timeout.js +7 -5
- package/dist/tools/web-fetch-tool.js +33 -0
- package/dist/turn-retrieval.js +83 -0
- package/dist/ui/app.js +874 -35
- package/dist/ui/banner.js +78 -4
- package/dist/ui/markdown.js +122 -0
- package/dist/ui/overlay.js +496 -0
- package/dist/ui/queue.js +23 -0
- package/dist/ui/render.js +30 -2
- package/dist/ui/session-panel.js +115 -0
- package/dist/ui/setup-providers.js +40 -0
- package/dist/ui/setup.js +163 -50
- 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 +32 -6
- package/dist/usage-cli.js +160 -0
- package/dist/usage-ledger.js +169 -0
- package/dist/web-fetch.js +637 -0
- package/dist/web-surface.js +190 -0
- package/package.json +4 -3
- package/scripts/postinstall.mjs +4 -4
- package/second-brain/Projects/_Index.md +17 -4
- package/second-brain/Projects/sanook-cli/_Index.md +7 -3
- 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 +52 -11
- package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -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 +2 -0
- package/second-brain/Shared/Operating-State/current-state.md +14 -23
- package/second-brain/Shared/Tech-Standards/_Index.md +2 -0
- package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
- package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -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/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
|
+
}
|
package/dist/gateway/auth.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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';
|
|
@@ -18,17 +18,28 @@ export async function loadOrCreateToken() {
|
|
|
18
18
|
const token = randomBytes(32).toString('hex');
|
|
19
19
|
await ensureGatewayDir();
|
|
20
20
|
try {
|
|
21
|
-
await
|
|
21
|
+
await createTokenFile(token);
|
|
22
22
|
}
|
|
23
23
|
catch (e) {
|
|
24
24
|
if (e.code === 'EEXIST')
|
|
25
25
|
continue;
|
|
26
26
|
throw new Error(`ไม่สามารถเขียน gateway token ที่ ${TOKEN_FILE}: ${e.message}`);
|
|
27
27
|
}
|
|
28
|
-
await chmod(TOKEN_FILE, 0o600).catch(() => { });
|
|
29
28
|
return token;
|
|
30
29
|
}
|
|
31
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
|
+
}
|
|
32
43
|
async function readTokenIfPresent() {
|
|
33
44
|
let rawToken;
|
|
34
45
|
try {
|
package/dist/gateway/deliver.js
CHANGED
|
@@ -16,9 +16,51 @@ import { formatTarget, parseSendTarget } from './targets.js';
|
|
|
16
16
|
import { sendTelegramMessage } from './telegram.js';
|
|
17
17
|
import { sendTeamsMessage } from './teams.js';
|
|
18
18
|
import { normalizeWhatsAppId, redactWhatsAppId, sendWhatsAppMessage } from './whatsapp.js';
|
|
19
|
-
|
|
19
|
+
const MOBILE_CHAT_PLATFORMS = new Set([
|
|
20
|
+
'telegram',
|
|
21
|
+
'discord',
|
|
22
|
+
'slack',
|
|
23
|
+
'mattermost',
|
|
24
|
+
'line',
|
|
25
|
+
'signal',
|
|
26
|
+
'whatsapp',
|
|
27
|
+
'matrix',
|
|
28
|
+
'googlechat',
|
|
29
|
+
'bluebubbles',
|
|
30
|
+
'teams',
|
|
31
|
+
'sms',
|
|
32
|
+
]);
|
|
33
|
+
/** Shorten agent output for phone-sized chat surfaces — truncate fenced code, cap overall length. */
|
|
34
|
+
export function formatMobileChatReply(message, options = {}) {
|
|
35
|
+
const maxCodeBlockLines = options.maxCodeBlockLines ?? 8;
|
|
36
|
+
const maxCodeBlockChars = options.maxCodeBlockChars ?? 400;
|
|
37
|
+
const maxSummaryChars = options.maxSummaryChars ?? 3500;
|
|
38
|
+
let text = message.replace(/\r\n/g, '\n');
|
|
39
|
+
text = text.replace(/```([a-zA-Z0-9_-]*)\n?([\s\S]*?)```/g, (_match, lang, body) => {
|
|
40
|
+
const normalized = body.replace(/^\n/, '');
|
|
41
|
+
const lines = normalized.split('\n');
|
|
42
|
+
const tooLong = lines.length > maxCodeBlockLines || normalized.length > maxCodeBlockChars;
|
|
43
|
+
if (!tooLong)
|
|
44
|
+
return `\`\`\`${lang}\n${normalized}\`\`\``;
|
|
45
|
+
const truncated = lines.slice(0, maxCodeBlockLines).join('\n').slice(0, maxCodeBlockChars).trimEnd();
|
|
46
|
+
const label = lang ? lang : 'code';
|
|
47
|
+
return `\`\`\`${lang}\n${truncated}\n… (${label} truncated for mobile)\`\`\``;
|
|
48
|
+
});
|
|
49
|
+
text = text.replace(/\n{3,}/g, '\n\n').trim();
|
|
50
|
+
if (text.length > maxSummaryChars) {
|
|
51
|
+
text = `${text.slice(0, maxSummaryChars).trimEnd()}\n\n… (summary truncated for mobile)`;
|
|
52
|
+
}
|
|
53
|
+
return text || '(ไม่มีผลลัพธ์)';
|
|
54
|
+
}
|
|
55
|
+
function isMobileChatPlatform(platform) {
|
|
56
|
+
return MOBILE_CHAT_PLATFORMS.has(platform);
|
|
57
|
+
}
|
|
58
|
+
function deliveryText(message, platform) {
|
|
20
59
|
const trimmed = message.trim();
|
|
21
|
-
|
|
60
|
+
const base = trimmed || '(ไม่มีผลลัพธ์)';
|
|
61
|
+
if (platform && isMobileChatPlatform(platform))
|
|
62
|
+
return formatMobileChatReply(base);
|
|
63
|
+
return base;
|
|
22
64
|
}
|
|
23
65
|
function normalizeBlueBubblesAllowTarget(config, raw) {
|
|
24
66
|
const value = raw?.trim();
|
|
@@ -35,7 +77,7 @@ export async function deliverToTarget(rawTarget, message, options = {}) {
|
|
|
35
77
|
const target = parseSendTarget(rawTarget);
|
|
36
78
|
const config = options.config ?? (await readGatewayConfig());
|
|
37
79
|
const env = options.env ?? process.env;
|
|
38
|
-
const text = deliveryText(message);
|
|
80
|
+
const text = deliveryText(message, target.platform);
|
|
39
81
|
if (target.platform === 'telegram') {
|
|
40
82
|
const telegram = resolveTelegramConfig(config, env);
|
|
41
83
|
if (!telegram.token)
|
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { BRAND } from '../brand.js';
|
|
2
|
+
import { readGatewayConfig, resolveBlueBubblesConfig, resolveDiscordConfig, resolveEmailConfig, resolveGoogleChatConfig, resolveHomeAssistantConfig, resolveLineConfig, resolveMattermostConfig, resolveMatrixConfig, resolveNtfyConfig, resolveSignalConfig, resolveSlackConfig, resolveSmsConfig, resolveTeamsConfig, resolveTelegramConfig, resolveWebhookConfig, resolveWhatsAppConfig, } from './config.js';
|
|
3
|
+
import { listTasks } from './ledger.js';
|
|
4
|
+
const CHAT_INBOUND_CHANNELS = new Set([
|
|
5
|
+
'telegram',
|
|
6
|
+
'discord',
|
|
7
|
+
'slack',
|
|
8
|
+
'mattermost',
|
|
9
|
+
'line',
|
|
10
|
+
'signal',
|
|
11
|
+
'whatsapp',
|
|
12
|
+
'matrix',
|
|
13
|
+
'googlechat',
|
|
14
|
+
'bluebubbles',
|
|
15
|
+
'teams',
|
|
16
|
+
'sms',
|
|
17
|
+
'email',
|
|
18
|
+
'ntfy',
|
|
19
|
+
]);
|
|
20
|
+
function check(id, channel, status, message, details) {
|
|
21
|
+
return { id, channel, status, message, details };
|
|
22
|
+
}
|
|
23
|
+
function isHttpUrl(raw, opts = {}) {
|
|
24
|
+
const value = raw?.trim();
|
|
25
|
+
if (!value)
|
|
26
|
+
return false;
|
|
27
|
+
try {
|
|
28
|
+
const url = new URL(value);
|
|
29
|
+
if (opts.requireHttps && url.protocol !== 'https:')
|
|
30
|
+
return false;
|
|
31
|
+
return url.protocol === 'http:' || url.protocol === 'https:';
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function probeOk(fetchImpl, url, init, predicate) {
|
|
38
|
+
try {
|
|
39
|
+
const response = await fetchImpl(url, { ...init, signal: AbortSignal.timeout(8_000) });
|
|
40
|
+
const body = await response.json().catch(() => ({}));
|
|
41
|
+
if (predicate ? predicate(response, body) : response.ok)
|
|
42
|
+
return { ok: true };
|
|
43
|
+
return { ok: false, detail: `HTTP ${response.status}` };
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
return { ok: false, detail: error.message || 'request failed' };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function allowlistDetail(items, emptyLabel) {
|
|
50
|
+
return items.length ? items.map(String) : [emptyLabel];
|
|
51
|
+
}
|
|
52
|
+
async function checkTelegram(config, env, fetchImpl, skipNetwork) {
|
|
53
|
+
const resolved = resolveTelegramConfig(config, env);
|
|
54
|
+
if (!resolved.token)
|
|
55
|
+
return [check('telegram.configured', 'telegram', 'skip', 'not configured')];
|
|
56
|
+
const checks = [
|
|
57
|
+
check('telegram.token', 'telegram', 'pass', `bot token set (${resolved.source})`),
|
|
58
|
+
];
|
|
59
|
+
if (!resolved.allowedChatIds.length) {
|
|
60
|
+
checks.push(check('telegram.allowlist', 'telegram', 'fail', 'allowed chat ids empty — inbound fail-closed', [
|
|
61
|
+
`รัน: ${BRAND.cliName} gateway setup telegram --allowed-chats <id>`,
|
|
62
|
+
]));
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
checks.push(check('telegram.allowlist', 'telegram', 'pass', `${resolved.allowedChatIds.length} allowed chat id(s)`, allowlistDetail(resolved.allowedChatIds, '(none)')));
|
|
66
|
+
}
|
|
67
|
+
if (!skipNetwork) {
|
|
68
|
+
const probe = await probeOk(fetchImpl, `https://api.telegram.org/bot${resolved.token}/getMe`, undefined, (r, body) => {
|
|
69
|
+
const parsed = body;
|
|
70
|
+
return r.ok && parsed.ok === true;
|
|
71
|
+
});
|
|
72
|
+
checks.push(check('telegram.token.live', 'telegram', probe.ok ? 'pass' : 'fail', probe.ok ? 'getMe OK' : `getMe failed${probe.detail ? `: ${probe.detail}` : ''}`));
|
|
73
|
+
}
|
|
74
|
+
return checks;
|
|
75
|
+
}
|
|
76
|
+
async function checkDiscord(config, env, fetchImpl, skipNetwork) {
|
|
77
|
+
const resolved = resolveDiscordConfig(config, env);
|
|
78
|
+
if (!resolved.token)
|
|
79
|
+
return [check('discord.configured', 'discord', 'skip', 'not configured')];
|
|
80
|
+
const checks = [check('discord.token', 'discord', 'pass', `bot token set (${resolved.source})`)];
|
|
81
|
+
if (!resolved.defaultChannelId && !resolved.allowedChannelIds.length) {
|
|
82
|
+
checks.push(check('discord.allowlist', 'discord', 'warn', 'no default channel or allowed channels — outbound may fail'));
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
checks.push(check('discord.allowlist', 'discord', 'pass', 'delivery targets configured', [
|
|
86
|
+
resolved.defaultChannelId ? `default: ${resolved.defaultChannelId}` : '(no default)',
|
|
87
|
+
...allowlistDetail(resolved.allowedChannelIds, '(no explicit allowlist)'),
|
|
88
|
+
]));
|
|
89
|
+
}
|
|
90
|
+
if (!skipNetwork) {
|
|
91
|
+
const probe = await probeOk(fetchImpl, 'https://discord.com/api/v10/users/@me', { headers: { authorization: `Bot ${resolved.token}` } }, (r) => r.ok);
|
|
92
|
+
checks.push(check('discord.token.live', 'discord', probe.ok ? 'pass' : 'fail', probe.ok ? 'users/@me OK' : `token probe failed${probe.detail ? `: ${probe.detail}` : ''}`));
|
|
93
|
+
}
|
|
94
|
+
return checks;
|
|
95
|
+
}
|
|
96
|
+
async function checkSlack(config, env, fetchImpl, skipNetwork) {
|
|
97
|
+
const resolved = resolveSlackConfig(config, env);
|
|
98
|
+
if (!resolved.botToken)
|
|
99
|
+
return [check('slack.configured', 'slack', 'skip', 'not configured')];
|
|
100
|
+
const checks = [check('slack.token', 'slack', 'pass', `bot token set (${resolved.source})`)];
|
|
101
|
+
if (!resolved.appToken) {
|
|
102
|
+
checks.push(check('slack.app_token', 'slack', 'warn', 'app token missing — Socket Mode inbound unavailable'));
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
checks.push(check('slack.app_token', 'slack', 'pass', 'app token set'));
|
|
106
|
+
}
|
|
107
|
+
if (!resolved.defaultChannelId && !resolved.allowedChannelIds.length) {
|
|
108
|
+
checks.push(check('slack.allowlist', 'slack', 'warn', 'no default channel or allowed channels'));
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
checks.push(check('slack.allowlist', 'slack', 'pass', 'delivery targets configured'));
|
|
112
|
+
}
|
|
113
|
+
if (!skipNetwork) {
|
|
114
|
+
const probe = await probeOk(fetchImpl, 'https://slack.com/api/auth.test', {
|
|
115
|
+
method: 'POST',
|
|
116
|
+
headers: { authorization: `Bearer ${resolved.botToken}`, 'content-type': 'application/x-www-form-urlencoded' },
|
|
117
|
+
}, (r, body) => {
|
|
118
|
+
const parsed = body;
|
|
119
|
+
return r.ok && parsed.ok === true;
|
|
120
|
+
});
|
|
121
|
+
checks.push(check('slack.token.live', 'slack', probe.ok ? 'pass' : 'fail', probe.ok ? 'auth.test OK' : `auth.test failed${probe.detail ? `: ${probe.detail}` : ''}`));
|
|
122
|
+
}
|
|
123
|
+
return checks;
|
|
124
|
+
}
|
|
125
|
+
async function checkMattermost(config, env, fetchImpl, skipNetwork) {
|
|
126
|
+
const resolved = resolveMattermostConfig(config, env);
|
|
127
|
+
if (!resolved.serverUrl && !resolved.token)
|
|
128
|
+
return [check('mattermost.configured', 'mattermost', 'skip', 'not configured')];
|
|
129
|
+
const checks = [];
|
|
130
|
+
if (!resolved.serverUrl)
|
|
131
|
+
checks.push(check('mattermost.url', 'mattermost', 'fail', 'server URL missing'));
|
|
132
|
+
else
|
|
133
|
+
checks.push(check('mattermost.url', 'mattermost', isHttpUrl(resolved.serverUrl) ? 'pass' : 'fail', resolved.serverUrl));
|
|
134
|
+
if (!resolved.token)
|
|
135
|
+
checks.push(check('mattermost.token', 'mattermost', 'fail', 'token missing'));
|
|
136
|
+
else
|
|
137
|
+
checks.push(check('mattermost.token', 'mattermost', 'pass', 'token set'));
|
|
138
|
+
const hasAllow = resolved.allowAllUsers ||
|
|
139
|
+
resolved.homeChannel ||
|
|
140
|
+
resolved.allowedChannels.length ||
|
|
141
|
+
resolved.allowedUsers.length;
|
|
142
|
+
checks.push(check('mattermost.allowlist', 'mattermost', hasAllow ? 'pass' : 'fail', hasAllow ? 'inbound/outbound allow rules configured' : 'no home channel, allowed users/channels, or allow-all'));
|
|
143
|
+
if (!skipNetwork && resolved.serverUrl && resolved.token) {
|
|
144
|
+
const probe = await probeOk(fetchImpl, `${resolved.serverUrl}/api/v4/users/me`, { headers: { authorization: `Bearer ${resolved.token}` } }, (r) => r.ok);
|
|
145
|
+
checks.push(check('mattermost.token.live', 'mattermost', probe.ok ? 'pass' : 'fail', probe.ok ? 'users/me OK' : `token probe failed${probe.detail ? `: ${probe.detail}` : ''}`));
|
|
146
|
+
}
|
|
147
|
+
return checks;
|
|
148
|
+
}
|
|
149
|
+
function checkHomeAssistant(config, env) {
|
|
150
|
+
const resolved = resolveHomeAssistantConfig(config, env);
|
|
151
|
+
const configured = Boolean(resolved.token || resolved.url !== 'http://homeassistant.local:8123');
|
|
152
|
+
if (!configured)
|
|
153
|
+
return [check('homeassistant.configured', 'homeassistant', 'skip', 'not configured')];
|
|
154
|
+
const checks = [
|
|
155
|
+
check('homeassistant.url', 'homeassistant', isHttpUrl(resolved.url) ? 'pass' : 'fail', resolved.url),
|
|
156
|
+
];
|
|
157
|
+
if (!resolved.token)
|
|
158
|
+
checks.push(check('homeassistant.token', 'homeassistant', 'fail', 'token missing'));
|
|
159
|
+
else
|
|
160
|
+
checks.push(check('homeassistant.token', 'homeassistant', 'pass', 'token set'));
|
|
161
|
+
if (!resolved.watchAll && !resolved.watchDomains.length && !resolved.watchEntities.length) {
|
|
162
|
+
checks.push(check('homeassistant.watch', 'homeassistant', 'warn', 'no watch domains/entities — events may be sparse'));
|
|
163
|
+
}
|
|
164
|
+
return checks;
|
|
165
|
+
}
|
|
166
|
+
function checkEmail(config, env) {
|
|
167
|
+
const resolved = resolveEmailConfig(config, env);
|
|
168
|
+
if (!resolved.address)
|
|
169
|
+
return [check('email.configured', 'email', 'skip', 'not configured')];
|
|
170
|
+
const checks = [check('email.address', 'email', 'pass', resolved.address)];
|
|
171
|
+
if (!resolved.password)
|
|
172
|
+
checks.push(check('email.password', 'email', 'fail', 'password missing'));
|
|
173
|
+
if (!resolved.imapHost || !resolved.smtpHost)
|
|
174
|
+
checks.push(check('email.hosts', 'email', 'fail', 'IMAP/SMTP hosts incomplete'));
|
|
175
|
+
if (!resolved.allowAllUsers && !resolved.allowedUsers.length) {
|
|
176
|
+
checks.push(check('email.allowlist', 'email', 'fail', 'allowed senders empty — inbound fail-closed'));
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
checks.push(check('email.allowlist', 'email', 'pass', resolved.allowAllUsers ? 'allow all senders' : `${resolved.allowedUsers.length} allowed sender(s)`));
|
|
180
|
+
}
|
|
181
|
+
return checks;
|
|
182
|
+
}
|
|
183
|
+
async function checkLine(config, env, fetchImpl, skipNetwork) {
|
|
184
|
+
const resolved = resolveLineConfig(config, env);
|
|
185
|
+
if (!resolved.channelAccessToken)
|
|
186
|
+
return [check('line.configured', 'line', 'skip', 'not configured')];
|
|
187
|
+
const checks = [check('line.token', 'line', 'pass', `channel access token set (${resolved.source})`)];
|
|
188
|
+
if (!resolved.channelSecret)
|
|
189
|
+
checks.push(check('line.secret', 'line', 'warn', 'channel secret missing — webhook signature verification disabled'));
|
|
190
|
+
if (resolved.publicUrl) {
|
|
191
|
+
checks.push(check('line.public_url', 'line', isHttpUrl(resolved.publicUrl, { requireHttps: true }) ? 'pass' : 'fail', resolved.publicUrl));
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
checks.push(check('line.public_url', 'line', 'warn', 'public URL not set — webhook inbound needs a reachable URL'));
|
|
195
|
+
}
|
|
196
|
+
const hasAllow = resolved.allowAllUsers || resolved.homeChannel || resolved.allowedUsers.length || resolved.allowedGroups.length || resolved.allowedRooms.length;
|
|
197
|
+
checks.push(check('line.allowlist', 'line', hasAllow ? 'pass' : 'fail', hasAllow ? 'targets configured' : 'no home/allowed targets'));
|
|
198
|
+
if (!skipNetwork) {
|
|
199
|
+
const probe = await probeOk(fetchImpl, 'https://api.line.me/v2/bot/info', { headers: { authorization: `Bearer ${resolved.channelAccessToken}` } }, (r) => r.ok);
|
|
200
|
+
checks.push(check('line.token.live', 'line', probe.ok ? 'pass' : 'fail', probe.ok ? 'bot/info OK' : `token probe failed${probe.detail ? `: ${probe.detail}` : ''}`));
|
|
201
|
+
}
|
|
202
|
+
return checks;
|
|
203
|
+
}
|
|
204
|
+
async function checkSms(config, env, fetchImpl, skipNetwork) {
|
|
205
|
+
const resolved = resolveSmsConfig(config, env);
|
|
206
|
+
if (!resolved.accountSid && !resolved.authToken && !resolved.phoneNumber)
|
|
207
|
+
return [check('sms.configured', 'sms', 'skip', 'not configured')];
|
|
208
|
+
const checks = [];
|
|
209
|
+
if (!resolved.accountSid || !resolved.authToken || !resolved.phoneNumber) {
|
|
210
|
+
checks.push(check('sms.credentials', 'sms', 'fail', 'Twilio accountSid/authToken/phoneNumber incomplete'));
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
checks.push(check('sms.credentials', 'sms', 'pass', 'Twilio credentials set'));
|
|
214
|
+
}
|
|
215
|
+
if (resolved.webhookUrl) {
|
|
216
|
+
checks.push(check('sms.webhook_url', 'sms', isHttpUrl(resolved.webhookUrl, { requireHttps: true }) ? 'pass' : 'fail', resolved.webhookUrl));
|
|
217
|
+
}
|
|
218
|
+
else if (!resolved.insecureNoSignature) {
|
|
219
|
+
checks.push(check('sms.webhook_url', 'sms', 'warn', 'webhook URL not set — inbound SMS webhook unavailable'));
|
|
220
|
+
}
|
|
221
|
+
const hasAllow = resolved.allowAllUsers || resolved.homeChannel || resolved.allowedUsers.length;
|
|
222
|
+
checks.push(check('sms.allowlist', 'sms', hasAllow ? 'pass' : 'fail', hasAllow ? 'targets configured' : 'no home/allowed users'));
|
|
223
|
+
if (!skipNetwork && resolved.accountSid && resolved.authToken) {
|
|
224
|
+
const auth = Buffer.from(`${resolved.accountSid}:${resolved.authToken}`, 'utf8').toString('base64');
|
|
225
|
+
const probe = await probeOk(fetchImpl, `https://api.twilio.com/2010-04-01/Accounts/${encodeURIComponent(resolved.accountSid)}.json`, { headers: { authorization: `Basic ${auth}` } }, (r) => r.ok);
|
|
226
|
+
checks.push(check('sms.token.live', 'sms', probe.ok ? 'pass' : 'fail', probe.ok ? 'Twilio account OK' : `credential probe failed${probe.detail ? `: ${probe.detail}` : ''}`));
|
|
227
|
+
}
|
|
228
|
+
return checks;
|
|
229
|
+
}
|
|
230
|
+
function checkNtfy(config, env) {
|
|
231
|
+
const resolved = resolveNtfyConfig(config, env);
|
|
232
|
+
if (!resolved.topic && !resolved.token)
|
|
233
|
+
return [check('ntfy.configured', 'ntfy', 'skip', 'not configured')];
|
|
234
|
+
const checks = [
|
|
235
|
+
check('ntfy.server', 'ntfy', isHttpUrl(resolved.serverUrl) ? 'pass' : 'fail', resolved.serverUrl),
|
|
236
|
+
];
|
|
237
|
+
if (!resolved.topic)
|
|
238
|
+
checks.push(check('ntfy.topic', 'ntfy', 'fail', 'topic missing'));
|
|
239
|
+
else
|
|
240
|
+
checks.push(check('ntfy.topic', 'ntfy', 'pass', resolved.topic));
|
|
241
|
+
const hasAllow = resolved.allowAllUsers || resolved.topic || resolved.homeChannel || resolved.allowedUsers.length;
|
|
242
|
+
checks.push(check('ntfy.allowlist', 'ntfy', hasAllow ? 'pass' : 'fail', hasAllow ? 'topics configured' : 'no allowed topics'));
|
|
243
|
+
return checks;
|
|
244
|
+
}
|
|
245
|
+
async function checkSignal(config, env, fetchImpl, skipNetwork) {
|
|
246
|
+
const resolved = resolveSignalConfig(config, env);
|
|
247
|
+
if (!resolved.account)
|
|
248
|
+
return [check('signal.configured', 'signal', 'skip', 'not configured')];
|
|
249
|
+
const checks = [
|
|
250
|
+
check('signal.http_url', 'signal', isHttpUrl(resolved.httpUrl) ? 'pass' : 'fail', resolved.httpUrl),
|
|
251
|
+
check('signal.account', 'signal', 'pass', 'account configured'),
|
|
252
|
+
];
|
|
253
|
+
const hasAllow = resolved.allowAllUsers || resolved.homeChannel || resolved.allowedUsers.length || resolved.groupAllowedUsers.length;
|
|
254
|
+
checks.push(check('signal.allowlist', 'signal', hasAllow ? 'pass' : 'fail', hasAllow ? 'targets configured' : 'no home/allowed users/groups'));
|
|
255
|
+
if (!skipNetwork) {
|
|
256
|
+
const probe = await probeOk(fetchImpl, `${resolved.httpUrl}/v1/about`, undefined, (r) => r.ok);
|
|
257
|
+
checks.push(check('signal.reachable', 'signal', probe.ok ? 'pass' : 'warn', probe.ok ? 'signal-cli HTTP reachable' : `signal-cli HTTP probe failed${probe.detail ? `: ${probe.detail}` : ''}`));
|
|
258
|
+
}
|
|
259
|
+
return checks;
|
|
260
|
+
}
|
|
261
|
+
function checkWhatsApp(config, env) {
|
|
262
|
+
const resolved = resolveWhatsAppConfig(config, env);
|
|
263
|
+
if (!resolved.phoneNumberId && !resolved.accessToken)
|
|
264
|
+
return [check('whatsapp.configured', 'whatsapp', 'skip', 'not configured')];
|
|
265
|
+
const checks = [];
|
|
266
|
+
if (!resolved.phoneNumberId || !resolved.accessToken)
|
|
267
|
+
checks.push(check('whatsapp.credentials', 'whatsapp', 'fail', 'phoneNumberId/accessToken incomplete'));
|
|
268
|
+
else
|
|
269
|
+
checks.push(check('whatsapp.credentials', 'whatsapp', 'pass', 'Cloud API credentials set'));
|
|
270
|
+
if (!resolved.appSecret)
|
|
271
|
+
checks.push(check('whatsapp.app_secret', 'whatsapp', 'warn', 'app secret missing — webhook signature verification disabled'));
|
|
272
|
+
if (resolved.publicUrl) {
|
|
273
|
+
checks.push(check('whatsapp.public_url', 'whatsapp', isHttpUrl(resolved.publicUrl, { requireHttps: true }) ? 'pass' : 'fail', resolved.publicUrl));
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
checks.push(check('whatsapp.public_url', 'whatsapp', 'warn', 'public URL not set — Meta webhook needs a reachable URL'));
|
|
277
|
+
}
|
|
278
|
+
const hasAllow = resolved.allowAllUsers || resolved.homeChannel || resolved.allowedUsers.length;
|
|
279
|
+
checks.push(check('whatsapp.allowlist', 'whatsapp', hasAllow ? 'pass' : 'fail', hasAllow ? 'targets configured' : 'no home/allowed users'));
|
|
280
|
+
return checks;
|
|
281
|
+
}
|
|
282
|
+
async function checkMatrix(config, env, fetchImpl, skipNetwork) {
|
|
283
|
+
const resolved = resolveMatrixConfig(config, env);
|
|
284
|
+
if (!resolved.homeserver && !resolved.accessToken && !resolved.userId)
|
|
285
|
+
return [check('matrix.configured', 'matrix', 'skip', 'not configured')];
|
|
286
|
+
const checks = [];
|
|
287
|
+
if (!resolved.homeserver)
|
|
288
|
+
checks.push(check('matrix.homeserver', 'matrix', 'fail', 'homeserver missing'));
|
|
289
|
+
else
|
|
290
|
+
checks.push(check('matrix.homeserver', 'matrix', isHttpUrl(resolved.homeserver) ? 'pass' : 'fail', resolved.homeserver));
|
|
291
|
+
if (!resolved.accessToken && !(resolved.userId && resolved.password)) {
|
|
292
|
+
checks.push(check('matrix.auth', 'matrix', 'fail', 'access token or userId/password required'));
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
checks.push(check('matrix.auth', 'matrix', 'pass', resolved.accessToken ? 'access token set' : 'password auth configured'));
|
|
296
|
+
}
|
|
297
|
+
const hasAllow = resolved.allowAllUsers || resolved.homeRoom || resolved.allowedRooms.length || resolved.allowedUsers.length;
|
|
298
|
+
checks.push(check('matrix.allowlist', 'matrix', hasAllow ? 'pass' : 'fail', hasAllow ? 'rooms configured' : 'no home/allowed rooms'));
|
|
299
|
+
if (!skipNetwork && resolved.homeserver && resolved.accessToken) {
|
|
300
|
+
const probe = await probeOk(fetchImpl, `${resolved.homeserver}/_matrix/client/v3/account/whoami`, { headers: { authorization: `Bearer ${resolved.accessToken}` } }, (r) => r.ok);
|
|
301
|
+
checks.push(check('matrix.token.live', 'matrix', probe.ok ? 'pass' : 'fail', probe.ok ? 'whoami OK' : `token probe failed${probe.detail ? `: ${probe.detail}` : ''}`));
|
|
302
|
+
}
|
|
303
|
+
return checks;
|
|
304
|
+
}
|
|
305
|
+
function checkGoogleChat(config, env) {
|
|
306
|
+
const resolved = resolveGoogleChatConfig(config, env);
|
|
307
|
+
if (!resolved.serviceAccountJson && !resolved.incomingWebhookUrl)
|
|
308
|
+
return [check('googlechat.configured', 'googlechat', 'skip', 'not configured')];
|
|
309
|
+
const checks = [];
|
|
310
|
+
if (resolved.incomingWebhookUrl) {
|
|
311
|
+
checks.push(check('googlechat.webhook_url', 'googlechat', isHttpUrl(resolved.incomingWebhookUrl, { requireHttps: true }) ? 'pass' : 'fail', 'incoming webhook configured'));
|
|
312
|
+
}
|
|
313
|
+
if (resolved.serviceAccountJson)
|
|
314
|
+
checks.push(check('googlechat.service_account', 'googlechat', 'pass', 'service account configured'));
|
|
315
|
+
if (!resolved.incomingWebhookUrl && !resolved.serviceAccountJson) {
|
|
316
|
+
checks.push(check('googlechat.delivery', 'googlechat', 'fail', 'no webhook or Chat API credentials'));
|
|
317
|
+
}
|
|
318
|
+
const hasAllow = resolved.allowAllSpaces || resolved.homeChannel || resolved.allowedSpaces.length;
|
|
319
|
+
checks.push(check('googlechat.allowlist', 'googlechat', hasAllow ? 'pass' : 'fail', hasAllow ? 'spaces configured' : 'no home/allowed spaces'));
|
|
320
|
+
return checks;
|
|
321
|
+
}
|
|
322
|
+
function checkBlueBubbles(config, env) {
|
|
323
|
+
const resolved = resolveBlueBubblesConfig(config, env);
|
|
324
|
+
if (!resolved.serverUrl && !resolved.password)
|
|
325
|
+
return [check('bluebubbles.configured', 'bluebubbles', 'skip', 'not configured')];
|
|
326
|
+
const checks = [];
|
|
327
|
+
if (!resolved.serverUrl)
|
|
328
|
+
checks.push(check('bluebubbles.server', 'bluebubbles', 'fail', 'server URL missing'));
|
|
329
|
+
else
|
|
330
|
+
checks.push(check('bluebubbles.server', 'bluebubbles', isHttpUrl(resolved.serverUrl) ? 'pass' : 'fail', resolved.serverUrl));
|
|
331
|
+
if (!resolved.password)
|
|
332
|
+
checks.push(check('bluebubbles.password', 'bluebubbles', 'fail', 'password missing'));
|
|
333
|
+
else
|
|
334
|
+
checks.push(check('bluebubbles.password', 'bluebubbles', 'pass', 'password set'));
|
|
335
|
+
checks.push(check('bluebubbles.webhook', 'bluebubbles', 'pass', `local webhook ${resolved.webhookHost}:${resolved.webhookPort}${resolved.webhookPath}`));
|
|
336
|
+
const hasAllow = resolved.allowAllUsers || resolved.homeChannel || resolved.allowedUsers.length;
|
|
337
|
+
checks.push(check('bluebubbles.allowlist', 'bluebubbles', hasAllow ? 'pass' : 'fail', hasAllow ? 'targets configured' : 'no home/allowed users'));
|
|
338
|
+
return checks;
|
|
339
|
+
}
|
|
340
|
+
function checkTeams(config, env) {
|
|
341
|
+
const resolved = resolveTeamsConfig(config, env);
|
|
342
|
+
if (!resolved.incomingWebhookUrl && !resolved.graphAccessToken && !resolved.clientId) {
|
|
343
|
+
return [check('teams.configured', 'teams', 'skip', 'not configured')];
|
|
344
|
+
}
|
|
345
|
+
const checks = [check('teams.mode', 'teams', 'pass', `delivery mode: ${resolved.deliveryMode}`)];
|
|
346
|
+
if (resolved.incomingWebhookUrl) {
|
|
347
|
+
checks.push(check('teams.webhook_url', 'teams', isHttpUrl(resolved.incomingWebhookUrl, { requireHttps: true }) ? 'pass' : 'fail', 'incoming webhook configured'));
|
|
348
|
+
}
|
|
349
|
+
if (resolved.deliveryMode === 'graph' && !resolved.graphAccessToken && !(resolved.clientId && resolved.clientSecret && resolved.tenantId)) {
|
|
350
|
+
checks.push(check('teams.graph', 'teams', 'fail', 'graph mode needs access token or client credentials'));
|
|
351
|
+
}
|
|
352
|
+
const hasAllow = resolved.allowAllUsers || resolved.homeChannel || resolved.allowedUsers.length || resolved.incomingWebhookUrl || resolved.chatId;
|
|
353
|
+
checks.push(check('teams.allowlist', 'teams', hasAllow ? 'pass' : 'warn', hasAllow ? 'delivery target configured' : 'no home/chat/webhook target'));
|
|
354
|
+
return checks;
|
|
355
|
+
}
|
|
356
|
+
function checkWebhooks(config, env) {
|
|
357
|
+
const resolved = resolveWebhookConfig(config, env);
|
|
358
|
+
if (!resolved.enabled && resolved.source === 'none')
|
|
359
|
+
return [check('webhooks.configured', 'webhooks', 'skip', 'not enabled')];
|
|
360
|
+
const checks = [check('webhooks.enabled', 'webhooks', resolved.enabled ? 'pass' : 'warn', resolved.enabled ? 'enabled' : 'disabled in config')];
|
|
361
|
+
if (resolved.publicUrl) {
|
|
362
|
+
checks.push(check('webhooks.public_url', 'webhooks', isHttpUrl(resolved.publicUrl, { requireHttps: true }) ? 'pass' : 'fail', resolved.publicUrl));
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
checks.push(check('webhooks.public_url', 'webhooks', 'warn', 'public URL not set — external systems cannot reach routes'));
|
|
366
|
+
}
|
|
367
|
+
const routeNames = Object.keys(resolved.routes);
|
|
368
|
+
if (!routeNames.length)
|
|
369
|
+
checks.push(check('webhooks.routes', 'webhooks', 'warn', 'no routes configured'));
|
|
370
|
+
else {
|
|
371
|
+
const missingDeliver = routeNames.filter((name) => !resolved.routes[name]?.deliver);
|
|
372
|
+
checks.push(check('webhooks.routes', 'webhooks', missingDeliver.length ? 'warn' : 'pass', `${routeNames.length} route(s)`, missingDeliver.length ? missingDeliver.map((name) => `${name}: deliver target missing`) : undefined));
|
|
373
|
+
}
|
|
374
|
+
if (!resolved.secret)
|
|
375
|
+
checks.push(check('webhooks.secret', 'webhooks', 'warn', 'global webhook secret not set'));
|
|
376
|
+
return checks;
|
|
377
|
+
}
|
|
378
|
+
export async function checkGateway(options = {}) {
|
|
379
|
+
const config = options.config ?? (await readGatewayConfig());
|
|
380
|
+
const env = options.env ?? process.env;
|
|
381
|
+
const skipNetwork = options.skipNetwork === true;
|
|
382
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
383
|
+
const groups = await Promise.all([
|
|
384
|
+
checkTelegram(config, env, fetchImpl, skipNetwork),
|
|
385
|
+
checkDiscord(config, env, fetchImpl, skipNetwork),
|
|
386
|
+
checkSlack(config, env, fetchImpl, skipNetwork),
|
|
387
|
+
checkMattermost(config, env, fetchImpl, skipNetwork),
|
|
388
|
+
Promise.resolve(checkHomeAssistant(config, env)),
|
|
389
|
+
Promise.resolve(checkEmail(config, env)),
|
|
390
|
+
checkLine(config, env, fetchImpl, skipNetwork),
|
|
391
|
+
checkSms(config, env, fetchImpl, skipNetwork),
|
|
392
|
+
Promise.resolve(checkNtfy(config, env)),
|
|
393
|
+
checkSignal(config, env, fetchImpl, skipNetwork),
|
|
394
|
+
Promise.resolve(checkWhatsApp(config, env)),
|
|
395
|
+
checkMatrix(config, env, fetchImpl, skipNetwork),
|
|
396
|
+
Promise.resolve(checkGoogleChat(config, env)),
|
|
397
|
+
Promise.resolve(checkBlueBubbles(config, env)),
|
|
398
|
+
Promise.resolve(checkTeams(config, env)),
|
|
399
|
+
Promise.resolve(checkWebhooks(config, env)),
|
|
400
|
+
]);
|
|
401
|
+
const checks = groups.flat();
|
|
402
|
+
const configured = checks.some((item) => item.status !== 'skip');
|
|
403
|
+
if (!configured) {
|
|
404
|
+
checks.unshift(check('gateway.configured', 'gateway', 'warn', 'no gateway channels configured'));
|
|
405
|
+
}
|
|
406
|
+
return { ok: !checks.some((item) => item.status === 'fail'), checks };
|
|
407
|
+
}
|
|
408
|
+
export function summarizeChannelHealth(checks) {
|
|
409
|
+
const byChannel = new Map();
|
|
410
|
+
const rank = { fail: 4, warn: 3, pass: 2, skip: 1 };
|
|
411
|
+
for (const item of checks) {
|
|
412
|
+
if (item.channel === 'gateway')
|
|
413
|
+
continue;
|
|
414
|
+
const current = byChannel.get(item.channel);
|
|
415
|
+
if (!current || rank[item.status] > rank[current])
|
|
416
|
+
byChannel.set(item.channel, item.status);
|
|
417
|
+
}
|
|
418
|
+
return [...byChannel.entries()]
|
|
419
|
+
.map(([channel, status]) => ({ channel, status }))
|
|
420
|
+
.sort((a, b) => a.channel.localeCompare(b.channel));
|
|
421
|
+
}
|
|
422
|
+
export async function listPendingCronJobs(now = Date.now()) {
|
|
423
|
+
return (await listTasks())
|
|
424
|
+
.filter((task) => task.kind === 'cron' && task.status === 'queued')
|
|
425
|
+
.sort((a, b) => a.runAt - b.runAt || a.createdAt - b.createdAt);
|
|
426
|
+
}
|
|
427
|
+
export async function listRecentDeliveryFailures(limit = 5) {
|
|
428
|
+
return (await listTasks())
|
|
429
|
+
.filter((task) => Boolean(task.deliver?.trim() && task.lastError?.trim()))
|
|
430
|
+
.sort((a, b) => (b.lastRun ?? b.createdAt) - (a.lastRun ?? a.createdAt))
|
|
431
|
+
.slice(0, limit)
|
|
432
|
+
.map((task) => ({
|
|
433
|
+
taskId: task.id,
|
|
434
|
+
deliver: task.deliver.trim(),
|
|
435
|
+
spec: task.spec,
|
|
436
|
+
error: task.lastError.trim(),
|
|
437
|
+
lastRun: task.lastRun,
|
|
438
|
+
status: task.status,
|
|
439
|
+
}));
|
|
440
|
+
}
|
|
441
|
+
export function formatGatewayDoctorStatus(status) {
|
|
442
|
+
return status.toUpperCase().padEnd(4);
|
|
443
|
+
}
|
|
444
|
+
export function formatGatewayDoctorReport(report) {
|
|
445
|
+
const lines = [`${BRAND.productName} gateway doctor`, ''];
|
|
446
|
+
for (const item of report.checks) {
|
|
447
|
+
lines.push(`[${formatGatewayDoctorStatus(item.status)}] ${item.channel}/${item.id} — ${item.message}`);
|
|
448
|
+
for (const detail of item.details ?? [])
|
|
449
|
+
lines.push(` - ${detail}`);
|
|
450
|
+
}
|
|
451
|
+
lines.push('', report.ok ? 'OK — no failing checks' : 'FAIL — fix failing checks above');
|
|
452
|
+
return lines.join('\n');
|
|
453
|
+
}
|
|
454
|
+
export function isInboundChatChannel(channel) {
|
|
455
|
+
return CHAT_INBOUND_CHANNELS.has(channel);
|
|
456
|
+
}
|
package/dist/gateway/email.js
CHANGED
|
@@ -229,8 +229,31 @@ export function parseRawEmail(uid, raw) {
|
|
|
229
229
|
autoSubmitted: headerValue(headers, 'Auto-Submitted'),
|
|
230
230
|
precedence: headerValue(headers, 'Precedence'),
|
|
231
231
|
listUnsubscribe: headerValue(headers, 'List-Unsubscribe'),
|
|
232
|
+
authResults: headerValue(headers, 'Authentication-Results'),
|
|
232
233
|
};
|
|
233
234
|
}
|
|
235
|
+
function domainMatches(claimed, fromDomain) {
|
|
236
|
+
const c = claimed.toLowerCase().replace(/^.*@/, '').replace(/[>\s]+$/, '').trim();
|
|
237
|
+
const f = fromDomain.toLowerCase().trim();
|
|
238
|
+
if (!c || !f)
|
|
239
|
+
return false;
|
|
240
|
+
return c === f || f.endsWith(`.${c}`) || c.endsWith(`.${f}`);
|
|
241
|
+
}
|
|
242
|
+
/** True only if Authentication-Results shows SPF or DKIM = pass, aligned to the From domain (DMARC-style). */
|
|
243
|
+
export function senderPassesAuth(authResults, fromDomain) {
|
|
244
|
+
if (!authResults || !fromDomain)
|
|
245
|
+
return false;
|
|
246
|
+
const ar = authResults.toLowerCase();
|
|
247
|
+
for (const m of ar.matchAll(/dkim=pass[^;]*?header\.d=([a-z0-9.\-]+)/g)) {
|
|
248
|
+
if (domainMatches(m[1], fromDomain))
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
for (const m of ar.matchAll(/spf=pass[^;]*?(?:smtp\.mailfrom|envelope-from)=([^;\s]+)/g)) {
|
|
252
|
+
if (domainMatches(m[1], fromDomain))
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
234
257
|
export function shouldProcessEmail(email, config) {
|
|
235
258
|
const from = extractEmailAddress(email.from);
|
|
236
259
|
const self = extractEmailAddress(config.address);
|
|
@@ -247,7 +270,13 @@ export function shouldProcessEmail(email, config) {
|
|
|
247
270
|
if (config.allowAllUsers)
|
|
248
271
|
return true;
|
|
249
272
|
const allowed = new Set((config.allowedUsers ?? []).map((s) => s.toLowerCase()));
|
|
250
|
-
|
|
273
|
+
if (!allowed.has(from))
|
|
274
|
+
return false;
|
|
275
|
+
// From is trivially spoofable — an allowlisted address must also pass SPF/DKIM aligned to its
|
|
276
|
+
// domain. Fail closed unless the operator explicitly opted out (MTA doesn't stamp auth results).
|
|
277
|
+
if (config.allowUnauthenticatedSenders)
|
|
278
|
+
return true;
|
|
279
|
+
return senderPassesAuth(email.authResults, from.split('@')[1] ?? '');
|
|
251
280
|
}
|
|
252
281
|
function imapQuote(raw) {
|
|
253
282
|
return `"${raw.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|