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.
Files changed (217) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +148 -10
  3. package/README.md +255 -26
  4. package/README.th.md +95 -7
  5. package/dist/approval.js +13 -0
  6. package/dist/bin.js +3552 -155
  7. package/dist/brain-consolidate.js +335 -0
  8. package/dist/brain-context.js +262 -0
  9. package/dist/brain-doctor.js +318 -0
  10. package/dist/brain-eval.js +186 -0
  11. package/dist/brain-final.js +377 -0
  12. package/dist/brain-metrics.js +277 -0
  13. package/dist/brain-new.js +402 -0
  14. package/dist/brain-pack.js +210 -0
  15. package/dist/brain-repair.js +280 -0
  16. package/dist/brain-review.js +382 -0
  17. package/dist/brain.js +15 -1
  18. package/dist/brand.js +1 -1
  19. package/dist/cli-args.js +190 -0
  20. package/dist/cli-option-values.js +16 -0
  21. package/dist/clipboard.js +65 -0
  22. package/dist/commands.js +266 -27
  23. package/dist/compaction.js +96 -11
  24. package/dist/config.js +149 -33
  25. package/dist/context-compression.js +191 -0
  26. package/dist/context-pack.js +145 -0
  27. package/dist/cost.js +49 -15
  28. package/dist/dashboard/api-helpers.js +87 -0
  29. package/dist/dashboard/server.js +179 -0
  30. package/dist/dashboard/static/app.js +277 -0
  31. package/dist/dashboard/static/index.html +39 -0
  32. package/dist/dashboard/static/styles.css +85 -0
  33. package/dist/diff.js +10 -2
  34. package/dist/first-run.js +21 -0
  35. package/dist/gateway/auth.js +49 -9
  36. package/dist/gateway/bluebubbles.js +205 -0
  37. package/dist/gateway/config.js +929 -0
  38. package/dist/gateway/deliver.js +399 -0
  39. package/dist/gateway/discord.js +124 -0
  40. package/dist/gateway/doctor.js +456 -0
  41. package/dist/gateway/email.js +501 -0
  42. package/dist/gateway/googlechat.js +207 -0
  43. package/dist/gateway/homeassistant.js +256 -0
  44. package/dist/gateway/ledger.js +38 -1
  45. package/dist/gateway/line.js +171 -0
  46. package/dist/gateway/lock.js +3 -1
  47. package/dist/gateway/matrix.js +366 -0
  48. package/dist/gateway/mattermost.js +322 -0
  49. package/dist/gateway/ntfy.js +218 -0
  50. package/dist/gateway/schedule.js +31 -4
  51. package/dist/gateway/serve.js +267 -7
  52. package/dist/gateway/server.js +253 -19
  53. package/dist/gateway/service.js +224 -0
  54. package/dist/gateway/session.js +362 -0
  55. package/dist/gateway/signal.js +351 -0
  56. package/dist/gateway/slack.js +124 -0
  57. package/dist/gateway/sms.js +169 -0
  58. package/dist/gateway/targets.js +576 -0
  59. package/dist/gateway/teams.js +106 -0
  60. package/dist/gateway/telegram.js +38 -15
  61. package/dist/gateway/webhooks.js +220 -0
  62. package/dist/gateway/whatsapp.js +230 -0
  63. package/dist/hooks.js +13 -2
  64. package/dist/hotkeys.js +21 -0
  65. package/dist/i18n/en.js +98 -0
  66. package/dist/i18n/index.js +19 -0
  67. package/dist/i18n/th.js +98 -0
  68. package/dist/i18n/types.js +1 -0
  69. package/dist/insights-args.js +55 -0
  70. package/dist/insights.js +86 -0
  71. package/dist/knowledge.js +55 -29
  72. package/dist/loop.js +157 -29
  73. package/dist/lsp/index.js +23 -5
  74. package/dist/mcp-hub.js +33 -0
  75. package/dist/mcp-registry.js +494 -0
  76. package/dist/mcp-risk.js +71 -0
  77. package/dist/mcp-server.js +1 -1
  78. package/dist/mcp.js +120 -10
  79. package/dist/memory-log.js +90 -0
  80. package/dist/memory-store.js +37 -1
  81. package/dist/memory.js +148 -37
  82. package/dist/model-picker.js +58 -0
  83. package/dist/orchestrate.js +51 -19
  84. package/dist/personality.js +58 -0
  85. package/dist/plan-handoff.js +17 -0
  86. package/dist/polyglot.js +162 -0
  87. package/dist/process-runner.js +96 -0
  88. package/dist/project-init.js +91 -0
  89. package/dist/project-registry.js +143 -0
  90. package/dist/project-scaffold.js +124 -0
  91. package/dist/prompt-size.js +155 -0
  92. package/dist/providers/codex-login.js +138 -0
  93. package/dist/providers/codex.js +89 -43
  94. package/dist/providers/keys.js +22 -1
  95. package/dist/providers/models.js +2 -2
  96. package/dist/providers/registry.js +14 -47
  97. package/dist/search/chunk.js +7 -8
  98. package/dist/search/cli.js +83 -0
  99. package/dist/search/embed-store.js +3 -0
  100. package/dist/search/embedding-config.js +22 -0
  101. package/dist/search/engine.js +2 -13
  102. package/dist/search/indexer.js +44 -1
  103. package/dist/search/store.js +23 -1
  104. package/dist/session-distill.js +84 -0
  105. package/dist/session.js +92 -16
  106. package/dist/skill-install.js +53 -13
  107. package/dist/skills.js +33 -0
  108. package/dist/slash-completion.js +155 -0
  109. package/dist/support-dump.js +206 -0
  110. package/dist/tool-catalog.js +59 -0
  111. package/dist/tools/edit.js +45 -15
  112. package/dist/tools/git.js +10 -5
  113. package/dist/tools/homeassistant.js +106 -0
  114. package/dist/tools/index.js +10 -0
  115. package/dist/tools/list.js +19 -6
  116. package/dist/tools/permission.js +992 -12
  117. package/dist/tools/polyglot.js +126 -0
  118. package/dist/tools/read.js +16 -4
  119. package/dist/tools/sandbox.js +38 -13
  120. package/dist/tools/schedule.js +19 -3
  121. package/dist/tools/search.js +226 -15
  122. package/dist/tools/task.js +40 -9
  123. package/dist/tools/timeout.js +23 -3
  124. package/dist/tools/web-fetch-tool.js +33 -0
  125. package/dist/trust.js +11 -1
  126. package/dist/turn-retrieval.js +83 -0
  127. package/dist/ui/app.js +878 -32
  128. package/dist/ui/banner.js +78 -4
  129. package/dist/ui/history.js +37 -5
  130. package/dist/ui/markdown.js +122 -0
  131. package/dist/ui/mentions.js +3 -2
  132. package/dist/ui/overlay.js +496 -0
  133. package/dist/ui/queue.js +23 -0
  134. package/dist/ui/render.js +20 -1
  135. package/dist/ui/session-panel.js +115 -0
  136. package/dist/ui/setup-providers.js +40 -0
  137. package/dist/ui/setup.js +172 -46
  138. package/dist/ui/status.js +142 -0
  139. package/dist/ui/thinking-panel.js +36 -0
  140. package/dist/ui/tool-trail.js +97 -0
  141. package/dist/ui/transcript.js +26 -0
  142. package/dist/ui/useBusyElapsed.js +19 -0
  143. package/dist/ui/useEditor.js +144 -5
  144. package/dist/ui/useGitBranch.js +57 -0
  145. package/dist/update.js +56 -17
  146. package/dist/web-fetch.js +637 -0
  147. package/dist/web-surface.js +190 -0
  148. package/dist/worktree.js +175 -4
  149. package/package.json +5 -5
  150. package/second-brain/AGENTS.md +6 -4
  151. package/second-brain/CLAUDE.md +7 -1
  152. package/second-brain/Evals/_Index.md +10 -2
  153. package/second-brain/Evals/quality-ledger.md +9 -1
  154. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  155. package/second-brain/GEMINI.md +5 -4
  156. package/second-brain/Home.md +1 -1
  157. package/second-brain/Projects/_Index.md +19 -4
  158. package/second-brain/Projects/sanook-cli/_Index.md +30 -0
  159. package/second-brain/Projects/sanook-cli/context.md +35 -0
  160. package/second-brain/Projects/sanook-cli/current-state.md +32 -0
  161. package/second-brain/Projects/sanook-cli/overview.md +41 -0
  162. package/second-brain/Projects/sanook-cli/repo.md +34 -0
  163. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +197 -0
  164. package/second-brain/README.md +1 -1
  165. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  166. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  167. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  168. package/second-brain/Research/2026-06-18-hermes-tui-parity-map.md +129 -0
  169. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  170. package/second-brain/Research/2026-06-19-hermes-python-architecture-for-sanook.md +49 -0
  171. package/second-brain/Research/2026-06-19-terminal-ui-brand-research.md +52 -0
  172. package/second-brain/Research/_Index.md +8 -1
  173. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  174. package/second-brain/Reviews/_Index.md +1 -1
  175. package/second-brain/Runbooks/_Index.md +6 -1
  176. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  177. package/second-brain/SANOOK.md +45 -0
  178. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  179. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  180. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  181. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  182. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  183. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  184. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  185. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  186. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  187. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  188. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  189. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  190. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  191. package/second-brain/Sessions/_Index.md +15 -1
  192. package/second-brain/Shared/AI-Context-Index.md +22 -0
  193. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  194. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  195. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  196. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  197. package/second-brain/Shared/Operating-State/current-state.md +14 -4
  198. package/second-brain/Shared/Scripts/_Index.md +3 -1
  199. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  200. package/second-brain/Shared/Tech-Standards/_Index.md +6 -1
  201. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  202. package/second-brain/Shared/Tech-Standards/polyglot-runtime-strategy.md +46 -0
  203. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  204. package/second-brain/Shared/Tech-Standards/web-search-grounding-policy.md +70 -0
  205. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  206. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  207. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  208. package/second-brain/Templates/_Index.md +9 -0
  209. package/second-brain/Templates/final-lite.md +111 -0
  210. package/second-brain/Templates/final.md +231 -0
  211. package/second-brain/Templates/project-workspace/_Index.md +31 -0
  212. package/second-brain/Templates/project-workspace/context.md +28 -0
  213. package/second-brain/Templates/project-workspace/current-state.md +29 -0
  214. package/second-brain/Templates/project-workspace/overview.md +39 -0
  215. package/second-brain/Templates/project-workspace/repo.md +33 -0
  216. package/second-brain/Vault Structure Map.md +2 -1
  217. package/skills/structured-output-llm/SKILL.md +1 -1
@@ -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 mkdir(GATEWAY_DIR, { recursive: true });
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 (ถ้าตั้ง TELEGRAM_BOT_TOKEN) — long-polling, ไม่ต้อง public URL
46
+ // Telegram channel (env หรือ ~/.sanook/gateway/config.json) — long-polling, ไม่ต้อง public URL
40
47
  let stopTelegram;
41
- if (process.env.TELEGRAM_BOT_TOKEN) {
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: process.env.TELEGRAM_BOT_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
  }
@@ -1,6 +1,7 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { listTasks, enqueueTask } from './ledger.js';
3
3
  import { parseSchedule } from './schedule.js';
4
+ import { formatTarget, parseSendTarget } from './targets.js';
4
5
  import { tokenMatches } from './auth.js';
5
6
  import { runAgent } from '../loop.js';
6
7
  import { redactKey } from '../providers/keys.js';
@@ -9,21 +10,111 @@ function send(res, status, body) {
9
10
  res.writeHead(status, { 'content-type': 'application/json' });
10
11
  res.end(JSON.stringify(body));
11
12
  }
13
+ function sendRaw(res, status, contentType, body) {
14
+ res.writeHead(status, { 'content-type': contentType });
15
+ res.end(body);
16
+ }
17
+ function sendSse(res, body) {
18
+ res.write(`data: ${typeof body === 'string' ? body : JSON.stringify(body)}\n\n`);
19
+ }
20
+ export function optionalString(value) {
21
+ if (typeof value !== 'string')
22
+ return undefined;
23
+ const trimmed = value.trim();
24
+ return trimmed || undefined;
25
+ }
26
+ export function parseBearerToken(authorization) {
27
+ if (!authorization)
28
+ return undefined;
29
+ const tokenMatch = /^Bearer +(\S+)$/i.exec(authorization);
30
+ if (!tokenMatch)
31
+ return undefined;
32
+ return tokenMatch[1];
33
+ }
34
+ export function parseWebhookRouteName(pathname) {
35
+ if (!pathname.startsWith('/webhooks/'))
36
+ return undefined;
37
+ try {
38
+ const routeName = decodeURIComponent(pathname.slice('/webhooks/'.length)).replace(/^\/+|\/+$/g, '');
39
+ return /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(routeName) ? routeName : undefined;
40
+ }
41
+ catch {
42
+ return undefined;
43
+ }
44
+ }
45
+ export function parseOptionalSchedule(value, now) {
46
+ if (value == null)
47
+ return { schedule: null };
48
+ if (typeof value !== 'string')
49
+ return { schedule: null, invalid: 'ต้องเป็นข้อความ' };
50
+ const scheduleText = optionalString(value);
51
+ if (!scheduleText)
52
+ return { schedule: null };
53
+ const schedule = parseSchedule(scheduleText, now);
54
+ return schedule ? { schedule } : { schedule: null, invalid: scheduleText };
55
+ }
56
+ export function parseRequiredTaskSpec(value) {
57
+ if (typeof value !== 'string') {
58
+ return value == null ? { invalid: 'ต้องมี spec' } : { invalid: 'spec ต้องเป็นข้อความ' };
59
+ }
60
+ const spec = value.trim();
61
+ return spec ? { spec } : { invalid: 'ต้องมี spec' };
62
+ }
63
+ export function parseOptionalDeliverTarget(value) {
64
+ if (value == null)
65
+ return {};
66
+ if (typeof value !== 'string')
67
+ return { invalid: 'deliver ต้องเป็นข้อความ' };
68
+ const deliverText = optionalString(value);
69
+ if (!deliverText)
70
+ return {};
71
+ try {
72
+ return { deliver: formatTarget(parseSendTarget(deliverText)) };
73
+ }
74
+ catch (e) {
75
+ return { invalid: e.message };
76
+ }
77
+ }
78
+ export function parseOptionalTaskModel(value) {
79
+ if (value == null)
80
+ return {};
81
+ if (typeof value !== 'string')
82
+ return { invalid: 'model ต้องเป็นข้อความ' };
83
+ const model = optionalString(value);
84
+ return model ? { model } : {};
85
+ }
12
86
  const MAX_BODY = 1_000_000; // 1MB กัน memory blowup
87
+ /** error ที่พก HTTP status — ให้ client เห็น 400/413 (client error) แทน 500 (server error) */
88
+ class HttpError extends Error {
89
+ status;
90
+ constructor(status, message) {
91
+ super(message);
92
+ this.status = status;
93
+ }
94
+ }
13
95
  async function readBody(req) {
96
+ const raw = await readRawBody(req);
97
+ if (!raw)
98
+ return {};
99
+ let parsed;
100
+ try {
101
+ parsed = JSON.parse(raw);
102
+ }
103
+ catch {
104
+ throw new HttpError(400, 'invalid JSON body'); // Bad Request — ไม่ leak ข้อความ parser
105
+ }
106
+ return parsed && typeof parsed === 'object' ? parsed : {};
107
+ }
108
+ async function readRawBody(req) {
14
109
  const chunks = [];
15
110
  let size = 0;
16
111
  for await (const c of req) {
17
112
  size += c.length;
18
113
  if (size > MAX_BODY)
19
- throw new Error('request body ใหญ่เกิน');
114
+ throw new HttpError(413, 'request body ใหญ่เกิน'); // Payload Too Large
20
115
  chunks.push(c);
21
116
  }
22
- const raw = Buffer.concat(chunks).toString('utf8');
23
- if (!raw)
24
- return {};
25
- const parsed = JSON.parse(raw);
26
- return parsed && typeof parsed === 'object' ? parsed : {};
117
+ return Buffer.concat(chunks).toString('utf8');
27
118
  }
28
119
  /**
29
120
  * gateway HTTP — bind 127.0.0.1 เท่านั้น (loopback, ไม่ expose ออกเน็ต), ทุก endpoint ยกเว้น /health ต้อง bearer token
@@ -33,7 +124,7 @@ async function readBody(req) {
33
124
  export function startServer(opts) {
34
125
  const server = createServer((req, res) => {
35
126
  // redact กัน API key/secret รั่วใน error response (provider error อาจฝัง key)
36
- void handle(req, res, opts).catch((err) => send(res, 500, { error: redactKey(err.message ?? String(err)) }));
127
+ void handle(req, res, opts).catch((err) => send(res, err.status ?? 500, { error: redactKey(err.message ?? String(err)) }));
37
128
  });
38
129
  // '127.0.0.1' = loopback only — สำคัญ: ห้าม 0.0.0.0 (จะเปิดให้ทั้ง LAN)
39
130
  server.listen(opts.port, '127.0.0.1', () => opts.onLog?.(`http://127.0.0.1:${opts.port} (loopback)`));
@@ -45,9 +136,102 @@ async function handle(req, res, opts) {
45
136
  if (req.method === 'GET' && url.pathname === '/health') {
46
137
  return send(res, 200, { ok: true, service: BRAND.gatewayServiceName });
47
138
  }
139
+ if (req.method === 'GET' && url.pathname === '/line/webhook/health') {
140
+ return send(res, 200, { status: 'ok', platform: 'line' });
141
+ }
142
+ if (req.method === 'GET' && url.pathname === '/sms/webhook/health') {
143
+ return send(res, 200, { status: 'ok', platform: 'sms' });
144
+ }
145
+ if (req.method === 'GET' && url.pathname === '/whatsapp/webhook/health') {
146
+ const { readGatewayConfig, resolveWhatsAppConfig } = await import('./config.js');
147
+ const whatsapp = resolveWhatsAppConfig(await readGatewayConfig());
148
+ return send(res, 200, {
149
+ status: 'ok',
150
+ platform: 'whatsapp',
151
+ phone_number_id_configured: Boolean(whatsapp.phoneNumberId),
152
+ access_token_configured: Boolean(whatsapp.accessToken),
153
+ app_secret_configured: Boolean(whatsapp.appSecret),
154
+ verify_token_configured: Boolean(whatsapp.verifyToken),
155
+ });
156
+ }
157
+ if (req.method === 'GET' && url.pathname === '/webhooks/health') {
158
+ return send(res, 200, { status: 'ok', platform: 'webhook' });
159
+ }
160
+ if (req.method === 'POST' && url.pathname === '/line/webhook') {
161
+ const rawBody = await readRawBody(req);
162
+ const signature = Array.isArray(req.headers['x-line-signature']) ? req.headers['x-line-signature'][0] : req.headers['x-line-signature'];
163
+ const { readGatewayConfig, resolveLineConfig } = await import('./config.js');
164
+ const { handleLineWebhook } = await import('./line.js');
165
+ const result = await handleLineWebhook({
166
+ rawBody,
167
+ signature,
168
+ config: resolveLineConfig(await readGatewayConfig()),
169
+ model: opts.defaultModel,
170
+ budgetUsd: opts.budgetUsd,
171
+ permissionMode: opts.permissionMode ?? 'ask',
172
+ onLog: opts.onLog,
173
+ });
174
+ return send(res, result.status, result.body);
175
+ }
176
+ if (req.method === 'POST' && url.pathname === '/sms/webhook') {
177
+ const rawBody = await readRawBody(req);
178
+ const signature = Array.isArray(req.headers['x-twilio-signature']) ? req.headers['x-twilio-signature'][0] : req.headers['x-twilio-signature'];
179
+ const { readGatewayConfig, resolveSmsConfig } = await import('./config.js');
180
+ const { handleSmsWebhook } = await import('./sms.js');
181
+ const result = await handleSmsWebhook({
182
+ rawBody,
183
+ signature,
184
+ config: resolveSmsConfig(await readGatewayConfig()),
185
+ model: opts.defaultModel,
186
+ budgetUsd: opts.budgetUsd,
187
+ permissionMode: opts.permissionMode ?? 'ask',
188
+ onLog: opts.onLog,
189
+ });
190
+ return sendRaw(res, result.status, result.contentType, result.body);
191
+ }
192
+ if (req.method === 'GET' && url.pathname === '/whatsapp/webhook') {
193
+ const { readGatewayConfig, resolveWhatsAppConfig } = await import('./config.js');
194
+ const { handleWhatsAppChallenge } = await import('./whatsapp.js');
195
+ const result = handleWhatsAppChallenge(resolveWhatsAppConfig(await readGatewayConfig()), url.searchParams);
196
+ return sendRaw(res, result.status, result.contentType, result.body);
197
+ }
198
+ if (req.method === 'POST' && url.pathname === '/whatsapp/webhook') {
199
+ const rawBody = await readRawBody(req);
200
+ const signature = Array.isArray(req.headers['x-hub-signature-256']) ? req.headers['x-hub-signature-256'][0] : req.headers['x-hub-signature-256'];
201
+ const { readGatewayConfig, resolveWhatsAppConfig } = await import('./config.js');
202
+ const { handleWhatsAppWebhook } = await import('./whatsapp.js');
203
+ const result = await handleWhatsAppWebhook({
204
+ rawBody,
205
+ signature,
206
+ config: resolveWhatsAppConfig(await readGatewayConfig()),
207
+ model: opts.defaultModel,
208
+ budgetUsd: opts.budgetUsd,
209
+ permissionMode: opts.permissionMode ?? 'ask',
210
+ onLog: opts.onLog,
211
+ });
212
+ return send(res, result.status, result.body);
213
+ }
214
+ if (req.method === 'POST' && url.pathname.startsWith('/webhooks/')) {
215
+ const routeName = parseWebhookRouteName(url.pathname);
216
+ if (!routeName)
217
+ return send(res, 400, { error: 'invalid_webhook_route' });
218
+ const rawBody = await readRawBody(req);
219
+ const { readGatewayConfig, resolveWebhookConfig } = await import('./config.js');
220
+ const { handleWebhookRequest } = await import('./webhooks.js');
221
+ const result = await handleWebhookRequest({
222
+ routeName,
223
+ rawBody,
224
+ headers: req.headers,
225
+ config: resolveWebhookConfig(await readGatewayConfig()),
226
+ model: opts.defaultModel,
227
+ budgetUsd: opts.budgetUsd,
228
+ permissionMode: opts.permissionMode ?? 'ask',
229
+ onLog: opts.onLog,
230
+ });
231
+ return send(res, result.status, result.body);
232
+ }
48
233
  // ทุก endpoint อื่น → bearer token
49
- const auth = req.headers.authorization ?? '';
50
- const provided = auth.startsWith('Bearer ') ? auth.slice(7) : undefined;
234
+ const provided = parseBearerToken(req.headers.authorization);
51
235
  if (!tokenMatches(opts.token, provided)) {
52
236
  return send(res, 401, { error: 'unauthorized' });
53
237
  }
@@ -63,8 +247,51 @@ async function handle(req, res, opts) {
63
247
  const history = msgs
64
248
  .slice(0, lastUserIdx)
65
249
  .map((m) => ({ role: m.role, content: m.content }));
66
- const model = typeof body.model === 'string' && body.model ? body.model : opts.defaultModel;
67
- const { text } = await runAgent({
250
+ const { model: requestedModel, invalid: invalidModel } = parseOptionalTaskModel(body.model);
251
+ if (invalidModel)
252
+ return send(res, 400, { error: invalidModel });
253
+ const model = requestedModel ?? opts.defaultModel;
254
+ const runner = opts.runner ?? runAgent;
255
+ if (body.stream === true) {
256
+ res.writeHead(200, {
257
+ 'content-type': 'text/event-stream; charset=utf-8',
258
+ 'cache-control': 'no-cache, no-transform',
259
+ connection: 'keep-alive',
260
+ });
261
+ sendSse(res, {
262
+ object: 'chat.completion.chunk',
263
+ model,
264
+ choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }],
265
+ });
266
+ try {
267
+ await runner({
268
+ model,
269
+ prompt,
270
+ history,
271
+ maxSteps: 20,
272
+ budgetUsd: opts.budgetUsd,
273
+ permissionMode: opts.permissionMode ?? 'ask',
274
+ onEvent: (e) => {
275
+ if (e.type !== 'text' || !e.text)
276
+ return;
277
+ sendSse(res, {
278
+ object: 'chat.completion.chunk',
279
+ model,
280
+ choices: [{ index: 0, delta: { content: e.text }, finish_reason: null }],
281
+ });
282
+ },
283
+ });
284
+ sendSse(res, { object: 'chat.completion.chunk', model, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] });
285
+ sendSse(res, '[DONE]');
286
+ }
287
+ catch (e) {
288
+ sendSse(res, { error: redactKey(e.message ?? String(e)) });
289
+ sendSse(res, '[DONE]');
290
+ }
291
+ res.end();
292
+ return;
293
+ }
294
+ const { text } = await runner({
68
295
  model,
69
296
  prompt,
70
297
  history,
@@ -83,17 +310,24 @@ async function handle(req, res, opts) {
83
310
  }
84
311
  if (req.method === 'POST' && url.pathname === '/tasks') {
85
312
  const body = await readBody(req);
86
- const spec = String(body.spec ?? '').trim();
87
- if (!spec)
88
- return send(res, 400, { error: 'ต้องมี spec' });
89
- const sched = body.schedule ? parseSchedule(String(body.schedule), Date.now()) : null;
90
- if (body.schedule && !sched)
91
- return send(res, 400, { error: `schedule ไม่ถูกต้อง: ${String(body.schedule)}` });
313
+ const specInput = parseRequiredTaskSpec(body.spec);
314
+ if ('invalid' in specInput)
315
+ return send(res, 400, { error: specInput.invalid });
316
+ const { schedule: sched, invalid } = parseOptionalSchedule(body.schedule, Date.now());
317
+ if (invalid)
318
+ return send(res, 400, { error: `schedule ไม่ถูกต้อง: ${invalid}` });
319
+ const { model, invalid: invalidModel } = parseOptionalTaskModel(body.model);
320
+ if (invalidModel)
321
+ return send(res, 400, { error: invalidModel });
322
+ const { deliver, invalid: invalidDeliver } = parseOptionalDeliverTarget(body.deliver);
323
+ if (invalidDeliver)
324
+ return send(res, 400, { error: invalidDeliver });
92
325
  const task = await enqueueTask({
93
326
  kind: sched?.recurring ? 'cron' : 'once',
94
- spec,
327
+ spec: specInput.spec,
95
328
  schedule: sched?.recurring ? sched.normalized : undefined,
96
- model: typeof body.model === 'string' ? body.model : undefined,
329
+ model,
330
+ deliver,
97
331
  runAt: sched?.runAt ?? Date.now(),
98
332
  });
99
333
  return send(res, 201, { task });