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
@@ -0,0 +1,399 @@
1
+ import { BRAND } from '../brand.js';
2
+ import { readGatewayConfig, resolveBlueBubblesConfig, resolveDiscordConfig, resolveEmailConfig, resolveGoogleChatConfig, resolveHomeAssistantConfig, resolveLineConfig, resolveMattermostConfig, resolveMatrixConfig, resolveNtfyConfig, resolveSignalConfig, resolveSlackConfig, resolveSmsConfig, resolveTelegramConfig, resolveTeamsConfig, resolveWhatsAppConfig, } from './config.js';
3
+ import { parseBlueBubblesTarget, sendBlueBubblesMessage } from './bluebubbles.js';
4
+ import { sendDiscordMessage } from './discord.js';
5
+ import { sendEmailMessage } from './email.js';
6
+ import { parseGoogleChatTarget, sendGoogleChatMessage } from './googlechat.js';
7
+ import { sendHomeAssistantNotification } from './homeassistant.js';
8
+ import { sendLineMessage } from './line.js';
9
+ import { sendMattermostMessage } from './mattermost.js';
10
+ import { sendMatrixMessage } from './matrix.js';
11
+ import { sendNtfyMessage } from './ntfy.js';
12
+ import { normalizeSignalId, sendSignalMessage } from './signal.js';
13
+ import { sendSlackMessage } from './slack.js';
14
+ import { normalizeSmsPhone, sendSmsMessage } from './sms.js';
15
+ import { formatTarget, parseSendTarget } from './targets.js';
16
+ import { sendTelegramMessage } from './telegram.js';
17
+ import { sendTeamsMessage } from './teams.js';
18
+ import { normalizeWhatsAppId, redactWhatsAppId, sendWhatsAppMessage } from './whatsapp.js';
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) {
59
+ const trimmed = message.trim();
60
+ const base = trimmed || '(ไม่มีผลลัพธ์)';
61
+ if (platform && isMobileChatPlatform(platform))
62
+ return formatMobileChatReply(base);
63
+ return base;
64
+ }
65
+ function normalizeBlueBubblesAllowTarget(config, raw) {
66
+ const value = raw?.trim();
67
+ if (!value)
68
+ return undefined;
69
+ try {
70
+ return parseBlueBubblesTarget(config, value).value;
71
+ }
72
+ catch {
73
+ return value;
74
+ }
75
+ }
76
+ export async function deliverToTarget(rawTarget, message, options = {}) {
77
+ const target = parseSendTarget(rawTarget);
78
+ const config = options.config ?? (await readGatewayConfig());
79
+ const env = options.env ?? process.env;
80
+ const text = deliveryText(message, target.platform);
81
+ if (target.platform === 'telegram') {
82
+ const telegram = resolveTelegramConfig(config, env);
83
+ if (!telegram.token)
84
+ throw new Error(`ยังไม่ได้ตั้ง Telegram — รัน: ${BRAND.cliName} gateway setup telegram`);
85
+ const chatId = target.chatId ?? telegram.allowedChatIds[0];
86
+ if (!Number.isInteger(chatId))
87
+ throw new Error('ต้องระบุ chat id หรือมี allowed chat อย่างน้อย 1 ค่าใน gateway config');
88
+ if (telegram.allowedChatIds.length && !telegram.allowedChatIds.includes(chatId)) {
89
+ throw new Error(`chat ${chatId} ไม่อยู่ใน allowlist (${telegram.allowedChatIds.join(', ')})`);
90
+ }
91
+ const result = await sendTelegramMessage(telegram.token, chatId, text, target.threadId);
92
+ return {
93
+ platform: 'telegram',
94
+ target: formatTarget({ platform: 'telegram', chatId, threadId: target.threadId }),
95
+ ...result,
96
+ };
97
+ }
98
+ if (target.platform === 'discord') {
99
+ const discord = resolveDiscordConfig(config, env);
100
+ if (!discord.token)
101
+ throw new Error(`ยังไม่ได้ตั้ง Discord — รัน: ${BRAND.cliName} gateway setup discord`);
102
+ const channelId = target.thread ?? target.address ?? discord.defaultChannelId;
103
+ if (!channelId)
104
+ throw new Error('ต้องระบุ Discord channel id หรือ default channel ใน gateway config');
105
+ const baseChannel = target.address ?? channelId;
106
+ if (discord.allowedChannelIds.length && !discord.allowedChannelIds.includes(baseChannel) && !discord.allowedChannelIds.includes(channelId)) {
107
+ throw new Error(`channel ${baseChannel} ไม่อยู่ใน allowlist (${discord.allowedChannelIds.join(', ')})`);
108
+ }
109
+ const result = await sendDiscordMessage(discord.token, channelId, text);
110
+ return {
111
+ platform: 'discord',
112
+ target: formatTarget({ platform: 'discord', address: target.address ?? channelId, thread: target.thread }),
113
+ ...result,
114
+ };
115
+ }
116
+ if (target.platform === 'slack') {
117
+ const slack = resolveSlackConfig(config, env);
118
+ if (!slack.botToken)
119
+ throw new Error(`ยังไม่ได้ตั้ง Slack — รัน: ${BRAND.cliName} gateway setup slack`);
120
+ const channelId = target.address ?? slack.defaultChannelId;
121
+ if (!channelId)
122
+ throw new Error('ต้องระบุ Slack channel id หรือ default channel ใน gateway config');
123
+ if (slack.allowedChannelIds.length && !slack.allowedChannelIds.includes(channelId)) {
124
+ throw new Error(`channel ${channelId} ไม่อยู่ใน allowlist (${slack.allowedChannelIds.join(', ')})`);
125
+ }
126
+ const result = await sendSlackMessage(slack.botToken, channelId, text, target.thread);
127
+ return {
128
+ platform: 'slack',
129
+ target: formatTarget({ platform: 'slack', address: channelId, thread: target.thread }),
130
+ ...result,
131
+ };
132
+ }
133
+ if (target.platform === 'mattermost') {
134
+ const mattermost = resolveMattermostConfig(config, env);
135
+ if (!mattermost.serverUrl || !mattermost.token)
136
+ throw new Error(`ยังไม่ได้ตั้ง Mattermost — รัน: ${BRAND.cliName} gateway setup mattermost`);
137
+ const channelId = target.address ?? mattermost.homeChannel;
138
+ if (!channelId)
139
+ throw new Error('ต้องระบุ Mattermost channel id หรือ home channel ใน gateway config');
140
+ const allowed = new Set([mattermost.homeChannel, ...mattermost.allowedChannels].filter((v) => Boolean(v?.trim())));
141
+ if (!mattermost.allowAllUsers && !allowed.size) {
142
+ throw new Error('ต้องตั้ง Mattermost home channel หรือ allowed channels เพื่อ fail-closed');
143
+ }
144
+ if (!mattermost.allowAllUsers && allowed.size && !allowed.has(channelId)) {
145
+ throw new Error(`Mattermost channel ${channelId} ไม่อยู่ใน allowlist (${[...allowed].join(', ') || 'none'})`);
146
+ }
147
+ const result = await sendMattermostMessage(mattermost, channelId, text, target.thread);
148
+ return {
149
+ platform: 'mattermost',
150
+ target: formatTarget({ platform: 'mattermost', address: result.channelId, thread: target.thread }),
151
+ channelId: result.channelId,
152
+ messageIds: result.postIds,
153
+ messageCount: result.messageCount,
154
+ };
155
+ }
156
+ if (target.platform === 'homeassistant') {
157
+ const homeassistant = resolveHomeAssistantConfig(config, env);
158
+ if (!homeassistant.token)
159
+ throw new Error(`ยังไม่ได้ตั้ง Home Assistant — รัน: ${BRAND.cliName} gateway setup homeassistant`);
160
+ const notificationId = target.address ?? homeassistant.homeChannel;
161
+ const result = await sendHomeAssistantNotification(homeassistant, text, notificationId);
162
+ return {
163
+ platform: 'homeassistant',
164
+ target: formatTarget({ platform: 'homeassistant', address: result.notificationId }),
165
+ messageId: result.messageId,
166
+ messageCount: result.messageCount,
167
+ };
168
+ }
169
+ if (target.platform === 'email') {
170
+ const email = resolveEmailConfig(config, env);
171
+ if (!email.address || !email.password || !email.smtpHost) {
172
+ throw new Error(`ยังไม่ได้ตั้ง Email — รัน: ${BRAND.cliName} gateway setup email`);
173
+ }
174
+ const toAddress = target.address ?? email.homeAddress;
175
+ if (!toAddress)
176
+ throw new Error('ต้องระบุ email recipient หรือ home address ใน gateway config');
177
+ const lower = toAddress.toLowerCase();
178
+ if (!email.allowAllUsers && email.allowedUsers.length && !email.allowedUsers.includes(lower)) {
179
+ throw new Error(`email ${toAddress} ไม่อยู่ใน allowlist (${email.allowedUsers.join(', ')})`);
180
+ }
181
+ const result = await sendEmailMessage({ address: email.address, password: email.password, smtpHost: email.smtpHost, smtpPort: email.smtpPort, fromName: BRAND.productName }, toAddress, text, { subject: options.subject?.trim() || BRAND.productName });
182
+ return {
183
+ platform: 'email',
184
+ target: formatTarget({ platform: 'email', address: result.to }),
185
+ ...result,
186
+ };
187
+ }
188
+ if (target.platform === 'line') {
189
+ const line = resolveLineConfig(config, env);
190
+ if (!line.channelAccessToken)
191
+ throw new Error(`ยังไม่ได้ตั้ง LINE — รัน: ${BRAND.cliName} gateway setup line`);
192
+ const to = target.address ?? line.homeChannel;
193
+ if (!to)
194
+ throw new Error('ต้องระบุ LINE user/group/room id หรือ home channel ใน gateway config');
195
+ const allowed = new Set([line.homeChannel, ...line.allowedUsers, ...line.allowedGroups, ...line.allowedRooms].filter(Boolean));
196
+ if (!line.allowAllUsers && !allowed.has(to)) {
197
+ throw new Error(`LINE target ${to} ไม่อยู่ใน allowlist (${[...allowed].join(', ') || 'none'})`);
198
+ }
199
+ const result = await sendLineMessage(line.channelAccessToken, to, text);
200
+ return {
201
+ platform: 'line',
202
+ target: formatTarget({ platform: 'line', address: result.to }),
203
+ ...result,
204
+ };
205
+ }
206
+ if (target.platform === 'sms') {
207
+ const sms = resolveSmsConfig(config, env);
208
+ if (!sms.accountSid || !sms.authToken || !sms.phoneNumber)
209
+ throw new Error(`ยังไม่ได้ตั้ง SMS — รัน: ${BRAND.cliName} gateway setup sms`);
210
+ const to = normalizeSmsPhone(target.address ?? sms.homeChannel);
211
+ if (!to)
212
+ throw new Error('ต้องระบุ SMS phone number หรือ home channel ใน gateway config');
213
+ const allowed = new Set([sms.homeChannel, ...sms.allowedUsers].map(normalizeSmsPhone).filter(Boolean));
214
+ if (!sms.allowAllUsers && !allowed.has(to)) {
215
+ throw new Error(`SMS target ${to} ไม่อยู่ใน allowlist (${[...allowed].join(', ') || 'none'})`);
216
+ }
217
+ const result = await sendSmsMessage({ accountSid: sms.accountSid, authToken: sms.authToken, phoneNumber: sms.phoneNumber }, to, text);
218
+ return {
219
+ platform: 'sms',
220
+ target: formatTarget({ platform: 'sms', address: result.to }),
221
+ ...result,
222
+ };
223
+ }
224
+ if (target.platform === 'ntfy') {
225
+ const ntfy = resolveNtfyConfig(config, env);
226
+ const topic = target.address ?? ntfy.homeChannel ?? ntfy.publishTopic ?? ntfy.topic;
227
+ if (!topic)
228
+ throw new Error(`ยังไม่ได้ตั้ง ntfy topic — รัน: ${BRAND.cliName} gateway setup ntfy --topic <topic>`);
229
+ const allowed = new Set([ntfy.topic, ntfy.homeChannel, ntfy.publishTopic, ...ntfy.allowedUsers].filter((v) => Boolean(v?.trim())));
230
+ if (!ntfy.allowAllUsers && !allowed.has(topic)) {
231
+ throw new Error(`ntfy topic ${topic} ไม่อยู่ใน allowlist (${[...allowed].join(', ') || 'none'})`);
232
+ }
233
+ const result = await sendNtfyMessage(ntfy, topic, text, { title: options.subject?.trim() || BRAND.productName });
234
+ return {
235
+ platform: 'ntfy',
236
+ target: formatTarget({ platform: 'ntfy', address: result.topic }),
237
+ topic: result.topic,
238
+ messageId: result.messageId,
239
+ messageCount: result.messageCount,
240
+ };
241
+ }
242
+ if (target.platform === 'signal') {
243
+ const signal = resolveSignalConfig(config, env);
244
+ if (!signal.account)
245
+ throw new Error(`ยังไม่ได้ตั้ง Signal — รัน: ${BRAND.cliName} gateway setup signal`);
246
+ const to = normalizeSignalId(target.address ?? signal.homeChannel);
247
+ if (!to)
248
+ throw new Error('ต้องระบุ Signal recipient/group หรือ home channel ใน gateway config');
249
+ const allowedUsers = new Set([signal.homeChannel, ...signal.allowedUsers].map(normalizeSignalId).filter((v) => Boolean(v)));
250
+ const allowedGroups = new Set(signal.groupAllowedUsers
251
+ .map((id) => (id.trim() === '*' ? '*' : normalizeSignalId(id)?.replace(/^group:/, '') ?? id.trim()))
252
+ .filter(Boolean));
253
+ const isGroup = to.startsWith('group:');
254
+ const groupId = isGroup ? to.slice('group:'.length) : undefined;
255
+ const allowed = isGroup && groupId
256
+ ? allowedGroups.has('*') || allowedGroups.has(groupId) || allowedGroups.has(`group:${groupId}`)
257
+ : signal.allowAllUsers || allowedUsers.has(to);
258
+ if (!allowed) {
259
+ const allowedList = isGroup ? [...allowedGroups].join(', ') : [...allowedUsers].join(', ');
260
+ throw new Error(`Signal target ${to} ไม่อยู่ใน allowlist (${allowedList || 'none'})`);
261
+ }
262
+ const result = await sendSignalMessage(signal, to, text);
263
+ return {
264
+ platform: 'signal',
265
+ target: formatTarget({ platform: 'signal', address: result.to }),
266
+ to: result.to,
267
+ messageIds: result.messageIds,
268
+ messageCount: result.messageCount,
269
+ };
270
+ }
271
+ if (target.platform === 'whatsapp') {
272
+ const whatsapp = resolveWhatsAppConfig(config, env);
273
+ if (!whatsapp.phoneNumberId || !whatsapp.accessToken) {
274
+ throw new Error(`ยังไม่ได้ตั้ง WhatsApp Cloud — รัน: ${BRAND.cliName} gateway setup whatsapp`);
275
+ }
276
+ const to = normalizeWhatsAppId(target.address ?? whatsapp.homeChannel);
277
+ if (!to)
278
+ throw new Error('ต้องระบุ WhatsApp wa_id หรือ home channel ใน gateway config');
279
+ const allowed = new Set([whatsapp.homeChannel, ...whatsapp.allowedUsers].map(normalizeWhatsAppId).filter((v) => Boolean(v)));
280
+ if (!whatsapp.allowAllUsers && !allowed.has(to)) {
281
+ throw new Error(`WhatsApp target ${redactWhatsAppId(to)} ไม่อยู่ใน allowlist (${[...allowed].map(redactWhatsAppId).join(', ') || 'none'})`);
282
+ }
283
+ const result = await sendWhatsAppMessage(whatsapp, to, text);
284
+ return {
285
+ platform: 'whatsapp',
286
+ target: formatTarget({ platform: 'whatsapp', address: result.to }),
287
+ to: result.to,
288
+ messageIds: result.messageIds,
289
+ messageCount: result.messageCount,
290
+ };
291
+ }
292
+ if (target.platform === 'matrix') {
293
+ const matrix = resolveMatrixConfig(config, env);
294
+ if (!matrix.homeserver || (!matrix.accessToken && (!matrix.userId || !matrix.password))) {
295
+ throw new Error(`ยังไม่ได้ตั้ง Matrix — รัน: ${BRAND.cliName} gateway setup matrix`);
296
+ }
297
+ const roomId = target.address ?? matrix.homeRoom;
298
+ if (!roomId)
299
+ throw new Error('ต้องระบุ Matrix room id หรือ home room ใน gateway config');
300
+ const allowed = new Set([matrix.homeRoom, ...matrix.allowedRooms].filter((v) => Boolean(v?.trim())));
301
+ if (!matrix.allowAllUsers && !allowed.size) {
302
+ throw new Error('ต้องตั้ง Matrix home room หรือ allowed rooms เพื่อ fail-closed');
303
+ }
304
+ if (!matrix.allowAllUsers && allowed.size && !allowed.has(roomId)) {
305
+ throw new Error(`Matrix room ${roomId} ไม่อยู่ใน allowlist (${[...allowed].join(', ') || 'none'})`);
306
+ }
307
+ const result = await sendMatrixMessage(matrix, roomId, text);
308
+ return {
309
+ platform: 'matrix',
310
+ target: formatTarget({ platform: 'matrix', address: result.roomId }),
311
+ to: result.roomId,
312
+ messageIds: result.eventIds,
313
+ messageCount: result.messageCount,
314
+ };
315
+ }
316
+ if (target.platform === 'googlechat') {
317
+ const googleChat = resolveGoogleChatConfig(config, env);
318
+ const parsedTarget = parseGoogleChatTarget(googleChat, target.address);
319
+ const hasWebhook = parsedTarget.type === 'webhook' && (googleChat.incomingWebhookUrl || /^https:\/\//i.test(target.address ?? ''));
320
+ const hasChatApi = Boolean(googleChat.serviceAccountJson);
321
+ if (!hasWebhook && !hasChatApi)
322
+ throw new Error(`ยังไม่ได้ตั้ง Google Chat — รัน: ${BRAND.cliName} gateway setup googlechat`);
323
+ if (parsedTarget.type === 'space') {
324
+ const allowed = new Set([googleChat.homeChannel, ...googleChat.allowedSpaces]
325
+ .flatMap((id) => {
326
+ const value = id?.trim();
327
+ if (!value)
328
+ return [];
329
+ const space = /^spaces\/[^/\s]+/.exec(value)?.[0];
330
+ return space && space !== value ? [value, space] : [value];
331
+ })
332
+ .filter(Boolean));
333
+ const targetAllowed = allowed.has(parsedTarget.value) || Boolean(parsedTarget.space && allowed.has(parsedTarget.space));
334
+ if (!googleChat.allowAllSpaces && !allowed.size) {
335
+ throw new Error('ต้องตั้ง Google Chat home channel หรือ allowed spaces เพื่อ fail-closed');
336
+ }
337
+ if (!googleChat.allowAllSpaces && allowed.size && !targetAllowed) {
338
+ throw new Error(`Google Chat space ${parsedTarget.space ?? parsedTarget.value} ไม่อยู่ใน allowlist (${[...allowed].join(', ') || 'none'})`);
339
+ }
340
+ }
341
+ else {
342
+ const allowed = new Set([googleChat.incomingWebhookUrl, googleChat.homeChannel, ...googleChat.allowedSpaces].filter((v) => Boolean(v?.trim())));
343
+ if (!googleChat.allowAllSpaces && !allowed.size)
344
+ throw new Error('ต้องตั้ง Google Chat webhook/home/allowed spaces เพื่อ fail-closed');
345
+ if (!googleChat.allowAllSpaces && allowed.size && !allowed.has(parsedTarget.value)) {
346
+ throw new Error('Google Chat webhook target ไม่อยู่ใน allowlist');
347
+ }
348
+ }
349
+ const result = await sendGoogleChatMessage(googleChat, text, target.address);
350
+ return {
351
+ platform: 'googlechat',
352
+ target: formatTarget({ platform: 'googlechat', address: result.target === 'webhook' ? undefined : result.target }),
353
+ to: result.target,
354
+ messageIds: result.messageIds,
355
+ messageCount: result.messageCount,
356
+ };
357
+ }
358
+ if (target.platform === 'bluebubbles') {
359
+ const bluebubbles = resolveBlueBubblesConfig(config, env);
360
+ if (!bluebubbles.serverUrl || !bluebubbles.password) {
361
+ throw new Error(`ยังไม่ได้ตั้ง BlueBubbles — รัน: ${BRAND.cliName} gateway setup bluebubbles`);
362
+ }
363
+ const destination = parseBlueBubblesTarget(bluebubbles, target.address).value;
364
+ const allowed = new Set([bluebubbles.homeChannel, ...bluebubbles.allowedUsers]
365
+ .map((value) => normalizeBlueBubblesAllowTarget(bluebubbles, value))
366
+ .filter((v) => Boolean(v)));
367
+ if (!bluebubbles.allowAllUsers && !allowed.size) {
368
+ throw new Error('ต้องตั้ง BlueBubbles home channel หรือ allowed users เพื่อ fail-closed');
369
+ }
370
+ if (!bluebubbles.allowAllUsers && allowed.size && !allowed.has(destination)) {
371
+ throw new Error(`BlueBubbles target ${destination} ไม่อยู่ใน allowlist (${[...allowed].join(', ') || 'none'})`);
372
+ }
373
+ const result = await sendBlueBubblesMessage(bluebubbles, text, destination);
374
+ return {
375
+ platform: 'bluebubbles',
376
+ target: formatTarget({ platform: 'bluebubbles', address: result.target }),
377
+ to: result.target,
378
+ messageIds: result.messageIds,
379
+ messageCount: result.messageCount,
380
+ };
381
+ }
382
+ if (target.platform === 'teams') {
383
+ const teams = resolveTeamsConfig(config, env);
384
+ const graphReady = teams.graphAccessToken && (target.address || teams.chatId || teams.homeChannel || (teams.teamId && teams.channelId));
385
+ const webhookReady = teams.incomingWebhookUrl || target.address?.startsWith('https://');
386
+ if (!graphReady && !webhookReady) {
387
+ throw new Error(`ยังไม่ได้ตั้ง Microsoft Teams delivery — รัน: ${BRAND.cliName} gateway setup teams`);
388
+ }
389
+ const result = await sendTeamsMessage(teams, text, target.address);
390
+ return {
391
+ platform: 'teams',
392
+ target: formatTarget({ platform: 'teams', address: result.target === 'webhook' ? undefined : result.target }),
393
+ to: result.target,
394
+ messageId: result.messageId,
395
+ messageCount: result.messageCount,
396
+ };
397
+ }
398
+ throw new Error(`ยังไม่รองรับ platform "${target.platform}" — ตอนนี้รองรับ telegram / discord / slack / mattermost / homeassistant / email / line / sms / ntfy / signal / whatsapp / matrix / googlechat / bluebubbles / teams`);
399
+ }
@@ -0,0 +1,124 @@
1
+ import { redactKey } from '../providers/keys.js';
2
+ import { runGatewayAgent } from './session.js';
3
+ export async function sendDiscordMessage(botToken, channelId, text) {
4
+ const r = await fetch(`https://discord.com/api/v10/channels/${encodeURIComponent(channelId)}/messages`, {
5
+ method: 'POST',
6
+ headers: {
7
+ authorization: `Bot ${botToken}`,
8
+ 'content-type': 'application/json',
9
+ },
10
+ body: JSON.stringify({ content: text.slice(0, 2000) }),
11
+ });
12
+ if (!r.ok)
13
+ throw new Error(`Discord create message ${r.status}`);
14
+ const body = (await r.json().catch(() => ({})));
15
+ return { channelId: body.channel_id ?? channelId, messageId: body.id };
16
+ }
17
+ const DISCORD_GATEWAY_URL = 'wss://gateway.discord.gg/?v=10&encoding=json';
18
+ const DISCORD_INTENTS = 1 | 512 | 4096 | 32768; // guilds, guild messages, DMs, message content
19
+ function defaultWebSocketFactory(url) {
20
+ const WS = globalThis.WebSocket;
21
+ if (!WS)
22
+ throw new Error('WebSocket runtime ไม่พร้อมใช้งานใน Node นี้');
23
+ return new WS(url);
24
+ }
25
+ function allowed(channelId, allowedChannelIds, defaultChannelId) {
26
+ const allow = allowedChannelIds?.length ? allowedChannelIds : defaultChannelId ? [defaultChannelId] : [];
27
+ return allow.includes(channelId);
28
+ }
29
+ export function startDiscord(opts) {
30
+ const allowedChannelIds = opts.allowedChannelIds?.filter(Boolean) ?? [];
31
+ if (!allowedChannelIds.length && !opts.defaultChannelId) {
32
+ opts.onLog?.('⛔ Discord ไม่เริ่ม: ต้องตั้ง default channel หรือ allowed channels เพื่อ fail-closed');
33
+ return () => { };
34
+ }
35
+ const ws = (opts.webSocketFactory ?? defaultWebSocketFactory)(opts.gatewayUrl ?? DISCORD_GATEWAY_URL);
36
+ const running = new Set();
37
+ let heartbeat;
38
+ let lastSeq;
39
+ let stopped = false;
40
+ const sendJson = (payload) => ws.send(JSON.stringify(payload));
41
+ ws.addEventListener('open', () => {
42
+ opts.onLog?.(`Discord: gateway connecting (allowlist ${allowedChannelIds.length || 1} channel)`);
43
+ });
44
+ ws.addEventListener('message', (event) => {
45
+ let packet;
46
+ try {
47
+ packet = JSON.parse(String(event.data));
48
+ }
49
+ catch {
50
+ return;
51
+ }
52
+ if (typeof packet.s === 'number')
53
+ lastSeq = packet.s;
54
+ if (packet.op === 10) {
55
+ const interval = packet.d?.heartbeat_interval ?? 45_000;
56
+ heartbeat = setInterval(() => sendJson({ op: 1, d: lastSeq ?? null }), interval);
57
+ sendJson({
58
+ op: 2,
59
+ d: {
60
+ token: opts.token,
61
+ intents: DISCORD_INTENTS,
62
+ properties: { os: process.platform, browser: 'sanook-cli', device: 'sanook-cli' },
63
+ },
64
+ });
65
+ return;
66
+ }
67
+ if (packet.op === 11)
68
+ return;
69
+ if (packet.t === 'READY') {
70
+ opts.onLog?.('Discord: gateway ready');
71
+ return;
72
+ }
73
+ if (packet.t !== 'MESSAGE_CREATE')
74
+ return;
75
+ const channelId = packet.d?.channel_id;
76
+ const text = packet.d?.content?.trim();
77
+ if (!channelId || !text || packet.d?.author?.bot)
78
+ return;
79
+ if (!allowed(channelId, allowedChannelIds, opts.defaultChannelId)) {
80
+ opts.onLog?.(`Discord: ปฏิเสธ channel ${channelId} (ไม่อยู่ใน allowlist)`);
81
+ return;
82
+ }
83
+ const sessionTarget = `${channelId}:${packet.d?.author?.id ?? 'unknown'}`;
84
+ if (running.has(sessionTarget)) {
85
+ void sendDiscordMessage(opts.token, channelId, 'กำลังทำงานก่อนหน้าอยู่ รอสักครู่').catch(() => { });
86
+ return;
87
+ }
88
+ running.add(sessionTarget);
89
+ void (async () => {
90
+ try {
91
+ await sendDiscordMessage(opts.token, channelId, 'กำลังคิด...');
92
+ const out = await runGatewayAgent({
93
+ platform: 'discord',
94
+ target: sessionTarget,
95
+ model: opts.model,
96
+ prompt: text,
97
+ userText: text,
98
+ budgetUsd: opts.budgetUsd,
99
+ permissionMode: opts.allowWrite === true ? 'auto' : 'ask',
100
+ });
101
+ if (!out.suppressDelivery && out.text.trim())
102
+ await sendDiscordMessage(opts.token, channelId, out.text);
103
+ }
104
+ catch (e) {
105
+ opts.onLog?.(`Discord run error (${channelId}): ${redactKey(e.message)}`);
106
+ await sendDiscordMessage(opts.token, channelId, 'เกิดข้อผิดพลาดภายใน').catch(() => { });
107
+ }
108
+ finally {
109
+ running.delete(sessionTarget);
110
+ }
111
+ })();
112
+ });
113
+ ws.addEventListener('close', () => {
114
+ if (!stopped)
115
+ opts.onLog?.('Discord: gateway closed');
116
+ });
117
+ ws.addEventListener('error', () => opts.onLog?.('Discord: gateway error'));
118
+ return () => {
119
+ stopped = true;
120
+ if (heartbeat)
121
+ clearInterval(heartbeat);
122
+ ws.close();
123
+ };
124
+ }