sanook-cli 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +83 -5
  3. package/README.md +240 -23
  4. package/README.th.md +87 -6
  5. package/dist/approval.js +6 -0
  6. package/dist/bin.js +3045 -210
  7. package/dist/brain-context.js +223 -0
  8. package/dist/brain-doctor.js +318 -0
  9. package/dist/brain-eval.js +186 -0
  10. package/dist/brain-final.js +371 -0
  11. package/dist/brain-review.js +382 -0
  12. package/dist/brain.js +12 -1
  13. package/dist/brand.js +1 -1
  14. package/dist/cli-args.js +152 -0
  15. package/dist/cli-option-values.js +16 -0
  16. package/dist/commands.js +172 -13
  17. package/dist/compaction.js +96 -11
  18. package/dist/config.js +118 -28
  19. package/dist/context-compression.js +191 -0
  20. package/dist/cost.js +49 -15
  21. package/dist/first-run.js +21 -0
  22. package/dist/gateway/auth.js +37 -8
  23. package/dist/gateway/bluebubbles.js +205 -0
  24. package/dist/gateway/config.js +929 -0
  25. package/dist/gateway/deliver.js +357 -0
  26. package/dist/gateway/discord.js +124 -0
  27. package/dist/gateway/email.js +472 -0
  28. package/dist/gateway/googlechat.js +207 -0
  29. package/dist/gateway/homeassistant.js +256 -0
  30. package/dist/gateway/ledger.js +18 -0
  31. package/dist/gateway/line.js +171 -0
  32. package/dist/gateway/lock.js +3 -1
  33. package/dist/gateway/matrix.js +366 -0
  34. package/dist/gateway/mattermost.js +322 -0
  35. package/dist/gateway/ntfy.js +218 -0
  36. package/dist/gateway/schedule.js +31 -4
  37. package/dist/gateway/serve.js +267 -7
  38. package/dist/gateway/server.js +253 -19
  39. package/dist/gateway/service.js +224 -0
  40. package/dist/gateway/session.js +343 -0
  41. package/dist/gateway/signal.js +351 -0
  42. package/dist/gateway/slack.js +124 -0
  43. package/dist/gateway/sms.js +169 -0
  44. package/dist/gateway/targets.js +576 -0
  45. package/dist/gateway/teams.js +106 -0
  46. package/dist/gateway/telegram.js +38 -15
  47. package/dist/gateway/webhooks.js +220 -0
  48. package/dist/gateway/whatsapp.js +230 -0
  49. package/dist/hooks.js +13 -2
  50. package/dist/insights-args.js +35 -0
  51. package/dist/insights.js +86 -0
  52. package/dist/loop.js +123 -24
  53. package/dist/lsp/index.js +23 -5
  54. package/dist/mcp-registry.js +350 -0
  55. package/dist/mcp-server.js +1 -1
  56. package/dist/mcp.js +44 -6
  57. package/dist/memory.js +100 -33
  58. package/dist/orchestrate.js +49 -19
  59. package/dist/personality.js +58 -0
  60. package/dist/providers/codex.js +86 -38
  61. package/dist/providers/keys.js +1 -1
  62. package/dist/providers/models.js +22 -6
  63. package/dist/providers/registry.js +38 -49
  64. package/dist/search/chunk.js +7 -8
  65. package/dist/search/cli.js +75 -0
  66. package/dist/search/embed-store.js +3 -0
  67. package/dist/search/indexer.js +44 -1
  68. package/dist/search/store.js +23 -1
  69. package/dist/session.js +93 -7
  70. package/dist/skill-install.js +29 -12
  71. package/dist/support-dump.js +175 -0
  72. package/dist/tools/edit.js +45 -15
  73. package/dist/tools/git.js +10 -5
  74. package/dist/tools/homeassistant.js +106 -0
  75. package/dist/tools/index.js +5 -0
  76. package/dist/tools/list.js +19 -6
  77. package/dist/tools/permission.js +923 -9
  78. package/dist/tools/read.js +16 -4
  79. package/dist/tools/schedule.js +19 -3
  80. package/dist/tools/search.js +217 -13
  81. package/dist/tools/task.js +18 -7
  82. package/dist/tools/timeout.js +21 -3
  83. package/dist/trust.js +11 -1
  84. package/dist/ui/app.js +57 -11
  85. package/dist/ui/brain-wizard.js +2 -2
  86. package/dist/ui/history.js +37 -5
  87. package/dist/ui/mentions.js +3 -2
  88. package/dist/ui/render.js +55 -15
  89. package/dist/ui/setup.js +107 -10
  90. package/dist/update.js +24 -11
  91. package/dist/worktree.js +175 -4
  92. package/package.json +4 -4
  93. package/second-brain/AGENTS.md +6 -4
  94. package/second-brain/CLAUDE.md +7 -1
  95. package/second-brain/Evals/_Index.md +10 -2
  96. package/second-brain/Evals/quality-ledger.md +9 -1
  97. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  98. package/second-brain/GEMINI.md +5 -4
  99. package/second-brain/Home.md +1 -1
  100. package/second-brain/Projects/_Index.md +3 -1
  101. package/second-brain/Projects/sanook-cli/_Index.md +26 -0
  102. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +156 -0
  103. package/second-brain/README.md +1 -1
  104. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  105. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  106. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  107. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  108. package/second-brain/Research/_Index.md +6 -1
  109. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  110. package/second-brain/Reviews/_Index.md +1 -1
  111. package/second-brain/Runbooks/_Index.md +6 -1
  112. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  113. package/second-brain/SANOOK.md +45 -0
  114. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  115. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  116. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  117. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  118. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  119. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  120. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  121. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  122. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  123. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  124. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  125. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  126. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  127. package/second-brain/Sessions/_Index.md +15 -1
  128. package/second-brain/Shared/AI-Context-Index.md +22 -0
  129. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  130. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  131. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  132. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  133. package/second-brain/Shared/Operating-State/current-state.md +22 -3
  134. package/second-brain/Shared/Scripts/_Index.md +3 -1
  135. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  136. package/second-brain/Shared/Tech-Standards/_Index.md +4 -1
  137. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  138. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  139. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  140. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  141. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  142. package/second-brain/Templates/_Index.md +9 -0
  143. package/second-brain/Templates/final-lite.md +111 -0
  144. package/second-brain/Templates/final.md +231 -0
  145. package/second-brain/Vault Structure Map.md +2 -1
  146. package/skills/structured-output-llm/SKILL.md +1 -1
@@ -0,0 +1,576 @@
1
+ import { resolveBlueBubblesConfig, resolveDiscordConfig, resolveEmailConfig, resolveGoogleChatConfig, resolveHomeAssistantConfig, resolveLineConfig, resolveMattermostConfig, resolveMatrixConfig, resolveNtfyConfig, resolveSignalConfig, resolveSlackConfig, resolveSmsConfig, resolveTelegramConfig, resolveTeamsConfig, resolveWhatsAppConfig, } from './config.js';
2
+ import { normalizeSignalId, redactSignalId } from './signal.js';
3
+ import { normalizeWhatsAppId, redactWhatsAppId } from './whatsapp.js';
4
+ export function parseNumericId(raw, label) {
5
+ if (!/^-?\d+$/.test(raw))
6
+ throw new Error(`${label} ต้องเป็น integer`);
7
+ const n = Number(raw);
8
+ if (!Number.isSafeInteger(n))
9
+ throw new Error(`${label} ใหญ่เกินไป`);
10
+ return n;
11
+ }
12
+ export function parseSendTarget(raw) {
13
+ const trimmed = raw.trim();
14
+ const firstColon = trimmed.indexOf(':');
15
+ const rawPlatform = firstColon === -1 ? trimmed : trimmed.slice(0, firstColon);
16
+ let platform = rawPlatform?.trim().toLowerCase();
17
+ if (platform === 'google-chat' || platform === 'google_chat' || platform === 'gchat')
18
+ platform = 'googlechat';
19
+ if (platform === 'blue-bubbles' || platform === 'blue_bubbles' || platform === 'imessage')
20
+ platform = 'bluebubbles';
21
+ const remainder = firstColon === -1 ? undefined : trimmed.slice(firstColon + 1);
22
+ let address;
23
+ let thread;
24
+ let extra = [];
25
+ if (platform === 'matrix' || platform === 'googlechat' || platform === 'bluebubbles' || platform === 'teams') {
26
+ address = remainder?.trim();
27
+ }
28
+ else if (remainder != null) {
29
+ const parts = remainder.split(':');
30
+ address = parts[0]?.trim();
31
+ thread = parts[1]?.trim();
32
+ extra = parts.slice(2);
33
+ }
34
+ if (!platform || extra.length) {
35
+ throw new Error('target ต้องเป็น platform, platform:chat_id, หรือ platform:chat_id:thread_id');
36
+ }
37
+ if (platform === 'signal' && address === 'group' && thread) {
38
+ address = `group:${thread}`;
39
+ thread = undefined;
40
+ }
41
+ if (![
42
+ 'telegram',
43
+ 'discord',
44
+ 'slack',
45
+ 'mattermost',
46
+ 'homeassistant',
47
+ 'email',
48
+ 'line',
49
+ 'sms',
50
+ 'ntfy',
51
+ 'signal',
52
+ 'whatsapp',
53
+ 'matrix',
54
+ 'googlechat',
55
+ 'bluebubbles',
56
+ 'teams',
57
+ ].includes(platform)) {
58
+ throw new Error('platform ต้องเป็น telegram, discord, slack, mattermost, homeassistant, email, line, sms, ntfy, signal, whatsapp, matrix, googlechat, bluebubbles, หรือ teams');
59
+ }
60
+ if (address === '' || thread === '') {
61
+ throw new Error('target ต้องเป็น platform, platform:chat_id, หรือ platform:chat_id:thread_id');
62
+ }
63
+ if (thread && !['telegram', 'discord', 'slack', 'mattermost'].includes(platform)) {
64
+ throw new Error(`${platform} target ไม่รองรับ thread segment`);
65
+ }
66
+ const target = { platform, address, thread };
67
+ if (platform === 'telegram') {
68
+ if (address)
69
+ target.chatId = parseNumericId(address, 'chat_id');
70
+ if (thread) {
71
+ target.threadId = parseNumericId(thread, 'thread_id');
72
+ if (target.threadId <= 0)
73
+ throw new Error('thread_id ต้องเป็น integer มากกว่า 0');
74
+ }
75
+ }
76
+ if (platform === 'signal' && address)
77
+ target.address = normalizeSignalId(address);
78
+ if (platform === 'whatsapp' && address) {
79
+ const waId = normalizeWhatsAppId(address);
80
+ if (!waId)
81
+ throw new Error('WhatsApp target ต้องเป็น wa_id ตัวเลขพร้อม country code เช่น whatsapp:15551234567');
82
+ target.address = waId;
83
+ }
84
+ if (platform === 'matrix' && address && !/^[!#][^:\s]+:[^:\s]+(?::\d+)?$/.test(address)) {
85
+ throw new Error('Matrix target ต้องเป็น room id/alias เช่น matrix:!abc123:matrix.org');
86
+ }
87
+ if (platform === 'googlechat' &&
88
+ address &&
89
+ !/^https:\/\//i.test(address) &&
90
+ !/^(?:spaces\/[^/\s]+|space[:/][^/\s]+)(?:\/threads\/.+)?$/i.test(address)) {
91
+ throw new Error('Google Chat target ต้องเป็น spaces/<space-id>, spaces/<space-id>/threads/<thread-id>, หรือ HTTPS webhook URL');
92
+ }
93
+ if (platform === 'bluebubbles' && address && /\s/.test(address)) {
94
+ throw new Error('BlueBubbles target ต้องเป็น chat GUID, email, หรือเบอร์โทรที่ไม่มีช่องว่าง');
95
+ }
96
+ return target;
97
+ }
98
+ export function formatTarget(target) {
99
+ const address = target.address ?? (target.chatId == null ? undefined : String(target.chatId));
100
+ const thread = target.thread ?? (target.threadId == null ? undefined : String(target.threadId));
101
+ return `${target.platform}${address == null ? '' : `:${address}`}${thread == null ? '' : `:${thread}`}`;
102
+ }
103
+ export function listConfiguredTargets(config, env = process.env) {
104
+ const out = [];
105
+ const telegram = resolveTelegramConfig(config, env);
106
+ if (telegram.token) {
107
+ const chats = telegram.allowedChatIds;
108
+ if (chats.length) {
109
+ out.push({
110
+ platform: 'telegram',
111
+ chatId: chats[0],
112
+ target: 'telegram',
113
+ label: `Telegram home (${chats[0]})`,
114
+ configured: true,
115
+ });
116
+ for (const chatId of chats) {
117
+ out.push({
118
+ platform: 'telegram',
119
+ chatId,
120
+ target: `telegram:${chatId}`,
121
+ label: `Telegram chat ${chatId}`,
122
+ configured: true,
123
+ });
124
+ }
125
+ }
126
+ else {
127
+ out.push({
128
+ platform: 'telegram',
129
+ target: 'telegram',
130
+ label: 'Telegram configured but no allowed chat (fail-closed)',
131
+ configured: false,
132
+ });
133
+ }
134
+ }
135
+ const discord = resolveDiscordConfig(config, env);
136
+ if (discord.token) {
137
+ if (discord.defaultChannelId) {
138
+ out.push({
139
+ platform: 'discord',
140
+ address: discord.defaultChannelId,
141
+ target: 'discord',
142
+ label: `Discord home (${discord.defaultChannelId})`,
143
+ configured: true,
144
+ });
145
+ }
146
+ for (const channelId of discord.allowedChannelIds) {
147
+ out.push({
148
+ platform: 'discord',
149
+ address: channelId,
150
+ target: `discord:${channelId}`,
151
+ label: `Discord channel ${channelId}`,
152
+ configured: true,
153
+ });
154
+ }
155
+ if (!discord.defaultChannelId && !discord.allowedChannelIds.length) {
156
+ out.push({
157
+ platform: 'discord',
158
+ target: 'discord',
159
+ label: 'Discord configured but no default/allowed channel',
160
+ configured: false,
161
+ });
162
+ }
163
+ }
164
+ const slack = resolveSlackConfig(config, env);
165
+ if (slack.botToken) {
166
+ if (slack.defaultChannelId) {
167
+ out.push({
168
+ platform: 'slack',
169
+ address: slack.defaultChannelId,
170
+ target: 'slack',
171
+ label: `Slack home (${slack.defaultChannelId})`,
172
+ configured: true,
173
+ });
174
+ }
175
+ for (const channelId of slack.allowedChannelIds) {
176
+ out.push({
177
+ platform: 'slack',
178
+ address: channelId,
179
+ target: `slack:${channelId}`,
180
+ label: `Slack channel ${channelId}`,
181
+ configured: true,
182
+ });
183
+ }
184
+ if (!slack.defaultChannelId && !slack.allowedChannelIds.length) {
185
+ out.push({
186
+ platform: 'slack',
187
+ target: 'slack',
188
+ label: 'Slack configured but no default/allowed channel',
189
+ configured: false,
190
+ });
191
+ }
192
+ }
193
+ const mattermost = resolveMattermostConfig(config, env);
194
+ if (mattermost.serverUrl || mattermost.token || mattermost.homeChannel || mattermost.allowedChannels.length) {
195
+ if (mattermost.homeChannel) {
196
+ out.push({
197
+ platform: 'mattermost',
198
+ address: mattermost.homeChannel,
199
+ target: 'mattermost',
200
+ label: `Mattermost ${mattermost.homeChannelName ?? 'home'} (${mattermost.homeChannel})`,
201
+ configured: Boolean(mattermost.serverUrl && mattermost.token),
202
+ });
203
+ }
204
+ const seen = new Set([mattermost.homeChannel].filter((v) => Boolean(v)));
205
+ for (const channel of mattermost.allowedChannels) {
206
+ if (seen.has(channel))
207
+ continue;
208
+ seen.add(channel);
209
+ out.push({
210
+ platform: 'mattermost',
211
+ address: channel,
212
+ target: `mattermost:${channel}`,
213
+ label: `Mattermost channel ${channel}`,
214
+ configured: Boolean(mattermost.serverUrl && mattermost.token),
215
+ });
216
+ }
217
+ if (!mattermost.homeChannel && !mattermost.allowedChannels.length) {
218
+ out.push({
219
+ platform: 'mattermost',
220
+ target: 'mattermost',
221
+ label: 'Mattermost configured but no home/allowed channel',
222
+ configured: false,
223
+ });
224
+ }
225
+ }
226
+ const homeassistant = resolveHomeAssistantConfig(config, env);
227
+ if (homeassistant.token || homeassistant.homeChannel || homeassistant.url !== 'http://homeassistant.local:8123') {
228
+ if (homeassistant.homeChannel) {
229
+ out.push({
230
+ platform: 'homeassistant',
231
+ address: homeassistant.homeChannel,
232
+ target: 'homeassistant',
233
+ label: `Home Assistant ${homeassistant.homeChannelName ?? 'notification'} (${homeassistant.homeChannel})`,
234
+ configured: Boolean(homeassistant.token),
235
+ });
236
+ }
237
+ else {
238
+ out.push({
239
+ platform: 'homeassistant',
240
+ target: 'homeassistant',
241
+ label: 'Home Assistant configured but no home notification id',
242
+ configured: Boolean(homeassistant.token),
243
+ });
244
+ }
245
+ }
246
+ const email = resolveEmailConfig(config, env);
247
+ if (email.address && email.smtpHost && email.password) {
248
+ if (email.homeAddress) {
249
+ out.push({
250
+ platform: 'email',
251
+ address: email.homeAddress,
252
+ target: 'email',
253
+ label: `Email home (${email.homeAddress})`,
254
+ configured: true,
255
+ });
256
+ }
257
+ for (const address of email.allowedUsers) {
258
+ out.push({
259
+ platform: 'email',
260
+ address,
261
+ target: `email:${address}`,
262
+ label: `Email ${address}`,
263
+ configured: true,
264
+ });
265
+ }
266
+ if (!email.homeAddress && !email.allowedUsers.length) {
267
+ out.push({
268
+ platform: 'email',
269
+ target: 'email',
270
+ label: 'Email configured but no home/allowed address',
271
+ configured: false,
272
+ });
273
+ }
274
+ }
275
+ const line = resolveLineConfig(config, env);
276
+ if (line.channelAccessToken) {
277
+ if (line.homeChannel) {
278
+ out.push({
279
+ platform: 'line',
280
+ address: line.homeChannel,
281
+ target: 'line',
282
+ label: `LINE home (${line.homeChannel})`,
283
+ configured: true,
284
+ });
285
+ }
286
+ for (const id of [...line.allowedUsers, ...line.allowedGroups, ...line.allowedRooms]) {
287
+ out.push({
288
+ platform: 'line',
289
+ address: id,
290
+ target: `line:${id}`,
291
+ label: `LINE ${id}`,
292
+ configured: true,
293
+ });
294
+ }
295
+ if (!line.homeChannel && !line.allowedUsers.length && !line.allowedGroups.length && !line.allowedRooms.length) {
296
+ out.push({
297
+ platform: 'line',
298
+ target: 'line',
299
+ label: 'LINE configured but no home/allowed channel',
300
+ configured: false,
301
+ });
302
+ }
303
+ }
304
+ const sms = resolveSmsConfig(config, env);
305
+ if (sms.accountSid && sms.authToken && sms.phoneNumber) {
306
+ if (sms.homeChannel) {
307
+ out.push({
308
+ platform: 'sms',
309
+ address: sms.homeChannel,
310
+ target: 'sms',
311
+ label: `SMS ${sms.homeChannelName ?? 'home'} (${sms.homeChannel})`,
312
+ configured: true,
313
+ });
314
+ }
315
+ for (const phone of sms.allowedUsers) {
316
+ out.push({
317
+ platform: 'sms',
318
+ address: phone,
319
+ target: `sms:${phone}`,
320
+ label: `SMS ${phone}`,
321
+ configured: true,
322
+ });
323
+ }
324
+ if (!sms.homeChannel && !sms.allowedUsers.length) {
325
+ out.push({
326
+ platform: 'sms',
327
+ target: 'sms',
328
+ label: 'SMS configured but no home/allowed phone',
329
+ configured: false,
330
+ });
331
+ }
332
+ }
333
+ const ntfy = resolveNtfyConfig(config, env);
334
+ if (ntfy.topic || ntfy.publishTopic || ntfy.token || ntfy.homeChannel) {
335
+ if (ntfy.homeChannel) {
336
+ out.push({
337
+ platform: 'ntfy',
338
+ address: ntfy.homeChannel,
339
+ target: 'ntfy',
340
+ label: `ntfy ${ntfy.homeChannelName ?? 'home'} (${ntfy.homeChannel})`,
341
+ configured: true,
342
+ });
343
+ }
344
+ const seen = new Set([ntfy.homeChannel].filter((v) => Boolean(v)));
345
+ for (const topic of [ntfy.topic, ntfy.publishTopic, ...ntfy.allowedUsers].filter((v) => Boolean(v?.trim()))) {
346
+ if (seen.has(topic))
347
+ continue;
348
+ seen.add(topic);
349
+ out.push({
350
+ platform: 'ntfy',
351
+ address: topic,
352
+ target: `ntfy:${topic}`,
353
+ label: `ntfy topic ${topic}`,
354
+ configured: true,
355
+ });
356
+ }
357
+ if (!ntfy.homeChannel && !ntfy.topic && !ntfy.publishTopic && !ntfy.allowedUsers.length) {
358
+ out.push({
359
+ platform: 'ntfy',
360
+ target: 'ntfy',
361
+ label: 'ntfy configured but no topic/home channel',
362
+ configured: false,
363
+ });
364
+ }
365
+ }
366
+ const signal = resolveSignalConfig(config, env);
367
+ if (signal.account || signal.httpUrl !== 'http://127.0.0.1:8080' || signal.homeChannel || signal.allowedUsers.length || signal.groupAllowedUsers.length) {
368
+ if (signal.homeChannel) {
369
+ const home = normalizeSignalId(signal.homeChannel) ?? signal.homeChannel;
370
+ out.push({
371
+ platform: 'signal',
372
+ address: home,
373
+ target: 'signal',
374
+ label: `Signal ${signal.homeChannelName ?? 'home'} (${redactSignalId(home)})`,
375
+ configured: Boolean(signal.account),
376
+ });
377
+ }
378
+ const seen = new Set([normalizeSignalId(signal.homeChannel)].filter((v) => Boolean(v)));
379
+ for (const id of signal.allowedUsers.map(normalizeSignalId).filter((v) => Boolean(v))) {
380
+ if (seen.has(id))
381
+ continue;
382
+ seen.add(id);
383
+ out.push({
384
+ platform: 'signal',
385
+ address: id,
386
+ target: `signal:${id}`,
387
+ label: `Signal ${redactSignalId(id)}`,
388
+ configured: Boolean(signal.account),
389
+ });
390
+ }
391
+ for (const id of signal.groupAllowedUsers) {
392
+ const targetId = id === '*' ? '*' : (normalizeSignalId(id)?.startsWith('group:') ? normalizeSignalId(id) : `group:${id.trim()}`);
393
+ if (!targetId || targetId === '*' || seen.has(targetId))
394
+ continue;
395
+ seen.add(targetId);
396
+ out.push({
397
+ platform: 'signal',
398
+ address: targetId,
399
+ target: `signal:${targetId}`,
400
+ label: `Signal group ${redactSignalId(targetId)}`,
401
+ configured: Boolean(signal.account),
402
+ });
403
+ }
404
+ if (!signal.homeChannel && !signal.allowedUsers.length && !signal.groupAllowedUsers.length) {
405
+ out.push({
406
+ platform: 'signal',
407
+ target: 'signal',
408
+ label: 'Signal configured but no home/allowed user',
409
+ configured: false,
410
+ });
411
+ }
412
+ }
413
+ const whatsapp = resolveWhatsAppConfig(config, env);
414
+ if (whatsapp.phoneNumberId || whatsapp.accessToken || whatsapp.homeChannel || whatsapp.allowedUsers.length) {
415
+ if (whatsapp.homeChannel) {
416
+ const home = normalizeWhatsAppId(whatsapp.homeChannel) ?? whatsapp.homeChannel;
417
+ out.push({
418
+ platform: 'whatsapp',
419
+ address: home,
420
+ target: 'whatsapp',
421
+ label: `WhatsApp ${whatsapp.homeChannelName ?? 'home'} (${redactWhatsAppId(home)})`,
422
+ configured: Boolean(whatsapp.phoneNumberId && whatsapp.accessToken),
423
+ });
424
+ }
425
+ const seen = new Set([normalizeWhatsAppId(whatsapp.homeChannel)].filter((v) => Boolean(v)));
426
+ for (const id of whatsapp.allowedUsers.map(normalizeWhatsAppId).filter((v) => Boolean(v))) {
427
+ if (seen.has(id))
428
+ continue;
429
+ seen.add(id);
430
+ out.push({
431
+ platform: 'whatsapp',
432
+ address: id,
433
+ target: `whatsapp:${id}`,
434
+ label: `WhatsApp ${redactWhatsAppId(id)}`,
435
+ configured: Boolean(whatsapp.phoneNumberId && whatsapp.accessToken),
436
+ });
437
+ }
438
+ if (!whatsapp.homeChannel && !whatsapp.allowedUsers.length) {
439
+ out.push({
440
+ platform: 'whatsapp',
441
+ target: 'whatsapp',
442
+ label: 'WhatsApp configured but no home/allowed user',
443
+ configured: false,
444
+ });
445
+ }
446
+ }
447
+ const matrix = resolveMatrixConfig(config, env);
448
+ if (matrix.homeserver || matrix.accessToken || matrix.userId || matrix.homeRoom || matrix.allowedRooms.length) {
449
+ if (matrix.homeRoom) {
450
+ out.push({
451
+ platform: 'matrix',
452
+ address: matrix.homeRoom,
453
+ target: 'matrix',
454
+ label: `Matrix ${matrix.homeRoomName ?? 'home'} (${matrix.homeRoom})`,
455
+ configured: Boolean(matrix.homeserver && (matrix.accessToken || (matrix.userId && matrix.password))),
456
+ });
457
+ }
458
+ const seen = new Set([matrix.homeRoom].filter((v) => Boolean(v)));
459
+ for (const room of matrix.allowedRooms) {
460
+ if (seen.has(room))
461
+ continue;
462
+ seen.add(room);
463
+ out.push({
464
+ platform: 'matrix',
465
+ address: room,
466
+ target: `matrix:${room}`,
467
+ label: `Matrix room ${room}`,
468
+ configured: Boolean(matrix.homeserver && (matrix.accessToken || (matrix.userId && matrix.password))),
469
+ });
470
+ }
471
+ if (!matrix.homeRoom && !matrix.allowedRooms.length) {
472
+ out.push({
473
+ platform: 'matrix',
474
+ target: 'matrix',
475
+ label: 'Matrix configured but no home/allowed room',
476
+ configured: false,
477
+ });
478
+ }
479
+ }
480
+ const googleChat = resolveGoogleChatConfig(config, env);
481
+ if (googleChat.serviceAccountJson ||
482
+ googleChat.incomingWebhookUrl ||
483
+ googleChat.homeChannel ||
484
+ googleChat.allowedSpaces.length ||
485
+ googleChat.subscriptionName) {
486
+ const chatApiReady = Boolean(googleChat.serviceAccountJson);
487
+ const webhookReady = Boolean(googleChat.incomingWebhookUrl);
488
+ if (googleChat.homeChannel || googleChat.incomingWebhookUrl) {
489
+ const address = googleChat.homeChannel || 'webhook';
490
+ out.push({
491
+ platform: 'googlechat',
492
+ address,
493
+ target: 'googlechat',
494
+ label: `Google Chat ${googleChat.homeChannelName ?? (googleChat.homeChannel ? `home (${googleChat.homeChannel})` : 'webhook')}`,
495
+ configured: address === 'webhook' ? webhookReady : chatApiReady,
496
+ });
497
+ }
498
+ const seen = new Set([googleChat.homeChannel].filter((v) => Boolean(v)));
499
+ for (const space of googleChat.allowedSpaces) {
500
+ if (seen.has(space))
501
+ continue;
502
+ seen.add(space);
503
+ out.push({
504
+ platform: 'googlechat',
505
+ address: space,
506
+ target: `googlechat:${space}`,
507
+ label: `Google Chat space ${space}`,
508
+ configured: chatApiReady,
509
+ });
510
+ }
511
+ if (!googleChat.homeChannel && !googleChat.incomingWebhookUrl && !googleChat.allowedSpaces.length) {
512
+ out.push({
513
+ platform: 'googlechat',
514
+ target: 'googlechat',
515
+ label: 'Google Chat configured but no home/allowed target',
516
+ configured: false,
517
+ });
518
+ }
519
+ }
520
+ const bluebubbles = resolveBlueBubblesConfig(config, env);
521
+ if (bluebubbles.serverUrl || bluebubbles.password || bluebubbles.homeChannel || bluebubbles.allowedUsers.length) {
522
+ const configured = Boolean(bluebubbles.serverUrl && bluebubbles.password);
523
+ if (bluebubbles.homeChannel) {
524
+ out.push({
525
+ platform: 'bluebubbles',
526
+ address: bluebubbles.homeChannel,
527
+ target: 'bluebubbles',
528
+ label: `BlueBubbles ${bluebubbles.homeChannelName ?? 'home'} (${bluebubbles.homeChannel})`,
529
+ configured,
530
+ });
531
+ }
532
+ const seen = new Set([bluebubbles.homeChannel].filter((v) => Boolean(v)));
533
+ for (const user of bluebubbles.allowedUsers) {
534
+ if (seen.has(user))
535
+ continue;
536
+ seen.add(user);
537
+ out.push({
538
+ platform: 'bluebubbles',
539
+ address: user,
540
+ target: `bluebubbles:${user}`,
541
+ label: `BlueBubbles target ${user}`,
542
+ configured,
543
+ });
544
+ }
545
+ if (!bluebubbles.homeChannel && !bluebubbles.allowedUsers.length) {
546
+ out.push({
547
+ platform: 'bluebubbles',
548
+ target: 'bluebubbles',
549
+ label: 'BlueBubbles configured but no home/allowed target',
550
+ configured: false,
551
+ });
552
+ }
553
+ }
554
+ const teams = resolveTeamsConfig(config, env);
555
+ if (teams.incomingWebhookUrl ||
556
+ teams.graphAccessToken ||
557
+ teams.chatId ||
558
+ teams.homeChannel ||
559
+ (teams.teamId && teams.channelId) ||
560
+ teams.clientId) {
561
+ const configured = teams.deliveryMode === 'graph'
562
+ ? Boolean(teams.graphAccessToken && (teams.chatId || teams.homeChannel || (teams.teamId && teams.channelId)))
563
+ : Boolean(teams.incomingWebhookUrl);
564
+ const address = teams.homeChannel ||
565
+ teams.chatId ||
566
+ (teams.teamId && teams.channelId ? `team/${teams.teamId}/channel/${teams.channelId}` : undefined);
567
+ out.push({
568
+ platform: 'teams',
569
+ address,
570
+ target: 'teams',
571
+ label: `Microsoft Teams ${teams.homeChannelName ?? (address ? `target (${address})` : 'configured')}`,
572
+ configured,
573
+ });
574
+ }
575
+ return out;
576
+ }
@@ -0,0 +1,106 @@
1
+ import { redactKey } from '../providers/keys.js';
2
+ const TEAMS_TEXT_LIMIT = 28_000;
3
+ const GRAPH_ROOT = 'https://graph.microsoft.com/v1.0';
4
+ export function normalizeTeamsWebhookUrl(raw) {
5
+ const trimmed = raw?.trim();
6
+ if (!trimmed)
7
+ return undefined;
8
+ try {
9
+ const url = new URL(trimmed);
10
+ if (url.protocol !== 'https:')
11
+ return undefined;
12
+ return url.toString();
13
+ }
14
+ catch {
15
+ return undefined;
16
+ }
17
+ }
18
+ export function truncateTeamsText(raw, limit = TEAMS_TEXT_LIMIT) {
19
+ const text = raw.trim() || '(ไม่มีผลลัพธ์)';
20
+ return text.length <= limit ? text : `${text.slice(0, Math.max(1, limit - 3)).trimEnd()}...`;
21
+ }
22
+ export function teamsGraphAuthHeaders(token, extra = {}) {
23
+ const clean = token?.trim();
24
+ if (!clean)
25
+ throw new Error('Microsoft Teams Graph access token ว่าง');
26
+ return { authorization: `Bearer ${clean}`, ...extra };
27
+ }
28
+ function escapeHtml(raw) {
29
+ return raw
30
+ .replace(/&/g, '&amp;')
31
+ .replace(/</g, '&lt;')
32
+ .replace(/>/g, '&gt;')
33
+ .replace(/"/g, '&quot;')
34
+ .replace(/'/g, '&#39;');
35
+ }
36
+ export function teamsGraphHtml(raw) {
37
+ return escapeHtml(truncateTeamsText(raw)).replace(/\n/g, '<br>');
38
+ }
39
+ export function teamsGraphMessageUrl(config, explicitTarget) {
40
+ const target = explicitTarget?.trim();
41
+ if (target?.startsWith('team/')) {
42
+ const match = /^team\/([^/]+)\/channel\/(.+)$/.exec(target);
43
+ if (!match)
44
+ throw new Error('Teams target ต้องเป็น teams:team/<team-id>/channel/<channel-id>');
45
+ return {
46
+ url: `${GRAPH_ROOT}/teams/${encodeURIComponent(match[1])}/channels/${encodeURIComponent(match[2])}/messages`,
47
+ target,
48
+ };
49
+ }
50
+ const chatId = target || config.chatId || config.homeChannel;
51
+ if (chatId)
52
+ return { url: `${GRAPH_ROOT}/chats/${encodeURIComponent(chatId)}/messages`, target: chatId };
53
+ if (config.teamId && config.channelId) {
54
+ return {
55
+ url: `${GRAPH_ROOT}/teams/${encodeURIComponent(config.teamId)}/channels/${encodeURIComponent(config.channelId)}/messages`,
56
+ target: `team/${config.teamId}/channel/${config.channelId}`,
57
+ };
58
+ }
59
+ throw new Error('Teams Graph delivery ต้องมี TEAMS_CHAT_ID หรือ TEAMS_TEAM_ID + TEAMS_CHANNEL_ID');
60
+ }
61
+ async function readTeamsJsonOrThrow(response, label) {
62
+ const text = await response.text().catch(() => '');
63
+ if (!response.ok)
64
+ throw new Error(`${label} ${response.status}${text ? `: ${redactKey(text).slice(0, 240)}` : ''}`);
65
+ if (!text)
66
+ return {};
67
+ try {
68
+ return JSON.parse(text);
69
+ }
70
+ catch {
71
+ throw new Error(`${label} ${response.status}: response ไม่ใช่ JSON: ${redactKey(text).slice(0, 240)}`);
72
+ }
73
+ }
74
+ export async function sendTeamsMessage(config, text, explicitTarget) {
75
+ const target = explicitTarget?.trim();
76
+ const targetLooksLikeWebhook = /^https:\/\//i.test(target ?? '');
77
+ const targetWebhookUrl = targetLooksLikeWebhook ? normalizeTeamsWebhookUrl(target) : undefined;
78
+ if (targetLooksLikeWebhook && !targetWebhookUrl)
79
+ throw new Error('Teams incoming webhook target ต้องเป็น HTTPS URL ที่ถูกต้อง');
80
+ const useWebhook = Boolean(targetWebhookUrl) || (!target && config.deliveryMode === 'incoming_webhook');
81
+ if (useWebhook) {
82
+ const webhookUrl = targetWebhookUrl ?? config.incomingWebhookUrl;
83
+ if (!webhookUrl)
84
+ throw new Error('ยังไม่ได้ตั้ง Microsoft Teams incoming webhook URL');
85
+ const r = await fetch(webhookUrl, {
86
+ method: 'POST',
87
+ headers: { 'content-type': 'application/json' },
88
+ body: JSON.stringify({ text: truncateTeamsText(text) }),
89
+ });
90
+ await readTeamsJsonOrThrow(r, 'Microsoft Teams incoming webhook');
91
+ return { mode: 'incoming_webhook', target: targetWebhookUrl ? 'webhook' : config.homeChannel || 'webhook', messageCount: 1 };
92
+ }
93
+ const graph = teamsGraphMessageUrl(config, target);
94
+ const r = await fetch(graph.url, {
95
+ method: 'POST',
96
+ headers: teamsGraphAuthHeaders(config.graphAccessToken, { 'content-type': 'application/json' }),
97
+ body: JSON.stringify({
98
+ body: {
99
+ contentType: 'html',
100
+ content: teamsGraphHtml(text),
101
+ },
102
+ }),
103
+ });
104
+ const json = await readTeamsJsonOrThrow(r, 'Microsoft Teams Graph message');
105
+ return { mode: 'graph', target: graph.target, messageId: json.id, messageCount: 1 };
106
+ }