sanook-cli 0.5.1 → 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 (144) hide show
  1. package/.env.example +161 -3
  2. package/CHANGELOG.md +57 -8
  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 +3026 -196
  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 +70 -36
  61. package/dist/providers/keys.js +1 -1
  62. package/dist/providers/models.js +1 -1
  63. package/dist/providers/registry.js +14 -47
  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 +48 -8
  85. package/dist/ui/history.js +37 -5
  86. package/dist/ui/mentions.js +3 -2
  87. package/dist/ui/setup.js +17 -4
  88. package/dist/update.js +24 -11
  89. package/dist/worktree.js +175 -4
  90. package/package.json +4 -4
  91. package/second-brain/AGENTS.md +6 -4
  92. package/second-brain/CLAUDE.md +7 -1
  93. package/second-brain/Evals/_Index.md +10 -2
  94. package/second-brain/Evals/quality-ledger.md +9 -1
  95. package/second-brain/Evals/second-brain-benchmarks.md +62 -0
  96. package/second-brain/GEMINI.md +5 -4
  97. package/second-brain/Home.md +1 -1
  98. package/second-brain/Projects/_Index.md +3 -1
  99. package/second-brain/Projects/sanook-cli/_Index.md +26 -0
  100. package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +156 -0
  101. package/second-brain/README.md +1 -1
  102. package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
  103. package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
  104. package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
  105. package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
  106. package/second-brain/Research/_Index.md +6 -1
  107. package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
  108. package/second-brain/Reviews/_Index.md +1 -1
  109. package/second-brain/Runbooks/_Index.md +6 -1
  110. package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
  111. package/second-brain/SANOOK.md +45 -0
  112. package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
  113. package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
  114. package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
  115. package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
  116. package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
  117. package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
  118. package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
  119. package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
  120. package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
  121. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
  122. package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
  123. package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
  124. package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
  125. package/second-brain/Sessions/_Index.md +15 -1
  126. package/second-brain/Shared/AI-Context-Index.md +22 -0
  127. package/second-brain/Shared/Context-Packs/_Index.md +9 -1
  128. package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
  129. package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
  130. package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
  131. package/second-brain/Shared/Operating-State/current-state.md +22 -3
  132. package/second-brain/Shared/Scripts/_Index.md +3 -1
  133. package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
  134. package/second-brain/Shared/Tech-Standards/_Index.md +4 -1
  135. package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
  136. package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
  137. package/second-brain/Shared/User-Memory/_Index.md +4 -1
  138. package/second-brain/Shared/User-Memory/response-examples.md +98 -0
  139. package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
  140. package/second-brain/Templates/_Index.md +9 -0
  141. package/second-brain/Templates/final-lite.md +111 -0
  142. package/second-brain/Templates/final.md +231 -0
  143. package/second-brain/Vault Structure Map.md +2 -1
  144. package/skills/structured-output-llm/SKILL.md +1 -1
@@ -0,0 +1,929 @@
1
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { appHomePath } from '../brand.js';
4
+ import { parseAllowedChats } from './telegram.js';
5
+ const CONFIG_PATH = appHomePath('gateway', 'config.json');
6
+ export function gatewayConfigPath() {
7
+ return CONFIG_PATH;
8
+ }
9
+ export async function readGatewayConfig() {
10
+ try {
11
+ const parsed = JSON.parse(await readFile(CONFIG_PATH, 'utf8'));
12
+ if (!parsed || typeof parsed !== 'object')
13
+ return {};
14
+ const raw = parsed;
15
+ const telegram = raw.telegram;
16
+ const discord = raw.discord;
17
+ const slack = raw.slack;
18
+ const mattermost = raw.mattermost;
19
+ const homeassistant = raw.homeassistant;
20
+ const email = raw.email;
21
+ const line = raw.line;
22
+ const sms = raw.sms;
23
+ const ntfy = raw.ntfy;
24
+ const signal = raw.signal;
25
+ const whatsapp = raw.whatsapp;
26
+ const matrix = raw.matrix;
27
+ const googleChat = raw.googleChat;
28
+ const bluebubbles = raw.bluebubbles;
29
+ const teams = raw.teams;
30
+ const webhooks = raw.webhooks;
31
+ return {
32
+ telegram: telegram
33
+ ? {
34
+ enabled: telegram.enabled !== false,
35
+ botToken: typeof telegram.botToken === 'string' ? telegram.botToken : undefined,
36
+ allowedChatIds: Array.isArray(telegram.allowedChatIds)
37
+ ? telegram.allowedChatIds.filter((n) => Number.isInteger(n))
38
+ : undefined,
39
+ allowWrite: telegram.allowWrite === true,
40
+ }
41
+ : undefined,
42
+ discord: discord
43
+ ? {
44
+ enabled: discord.enabled !== false,
45
+ botToken: typeof discord.botToken === 'string' ? discord.botToken : undefined,
46
+ defaultChannelId: typeof discord.defaultChannelId === 'string' ? discord.defaultChannelId : undefined,
47
+ allowedChannelIds: Array.isArray(discord.allowedChannelIds)
48
+ ? discord.allowedChannelIds.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
49
+ : undefined,
50
+ allowWrite: discord.allowWrite === true,
51
+ }
52
+ : undefined,
53
+ slack: slack
54
+ ? {
55
+ enabled: slack.enabled !== false,
56
+ botToken: typeof slack.botToken === 'string' ? slack.botToken : undefined,
57
+ appToken: typeof slack.appToken === 'string' ? slack.appToken : undefined,
58
+ defaultChannelId: typeof slack.defaultChannelId === 'string' ? slack.defaultChannelId : undefined,
59
+ allowedChannelIds: Array.isArray(slack.allowedChannelIds)
60
+ ? slack.allowedChannelIds.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
61
+ : undefined,
62
+ allowWrite: slack.allowWrite === true,
63
+ }
64
+ : undefined,
65
+ mattermost: mattermost
66
+ ? {
67
+ enabled: mattermost.enabled !== false,
68
+ serverUrl: typeof mattermost.serverUrl === 'string' ? mattermost.serverUrl.trim() : undefined,
69
+ token: typeof mattermost.token === 'string' ? mattermost.token : undefined,
70
+ homeChannel: typeof mattermost.homeChannel === 'string' ? mattermost.homeChannel.trim() : undefined,
71
+ homeChannelName: typeof mattermost.homeChannelName === 'string' ? mattermost.homeChannelName : undefined,
72
+ allowedUsers: Array.isArray(mattermost.allowedUsers)
73
+ ? mattermost.allowedUsers.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
74
+ : undefined,
75
+ allowedChannels: Array.isArray(mattermost.allowedChannels)
76
+ ? mattermost.allowedChannels.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
77
+ : undefined,
78
+ freeResponseChannels: Array.isArray(mattermost.freeResponseChannels)
79
+ ? mattermost.freeResponseChannels.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
80
+ : undefined,
81
+ allowAllUsers: mattermost.allowAllUsers === true,
82
+ requireMention: mattermost.requireMention !== false,
83
+ groupSessionsPerUser: mattermost.groupSessionsPerUser !== false,
84
+ replyMode: mattermost.replyMode === 'thread' ? 'thread' : 'off',
85
+ }
86
+ : undefined,
87
+ homeassistant: homeassistant
88
+ ? {
89
+ enabled: homeassistant.enabled !== false,
90
+ url: typeof homeassistant.url === 'string' ? homeassistant.url.trim() : undefined,
91
+ token: typeof homeassistant.token === 'string' ? homeassistant.token : undefined,
92
+ homeChannel: typeof homeassistant.homeChannel === 'string' ? homeassistant.homeChannel.trim() : undefined,
93
+ homeChannelName: typeof homeassistant.homeChannelName === 'string' ? homeassistant.homeChannelName : undefined,
94
+ watchDomains: Array.isArray(homeassistant.watchDomains)
95
+ ? homeassistant.watchDomains.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
96
+ : undefined,
97
+ watchEntities: Array.isArray(homeassistant.watchEntities)
98
+ ? homeassistant.watchEntities.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
99
+ : undefined,
100
+ ignoreEntities: Array.isArray(homeassistant.ignoreEntities)
101
+ ? homeassistant.ignoreEntities.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
102
+ : undefined,
103
+ watchAll: homeassistant.watchAll === true,
104
+ cooldownSeconds: Number.isInteger(homeassistant.cooldownSeconds) ? homeassistant.cooldownSeconds : undefined,
105
+ }
106
+ : undefined,
107
+ email: email
108
+ ? {
109
+ enabled: email.enabled !== false,
110
+ address: typeof email.address === 'string' ? email.address : undefined,
111
+ password: typeof email.password === 'string' ? email.password : undefined,
112
+ imapHost: typeof email.imapHost === 'string' ? email.imapHost : undefined,
113
+ imapPort: Number.isInteger(email.imapPort) ? email.imapPort : undefined,
114
+ smtpHost: typeof email.smtpHost === 'string' ? email.smtpHost : undefined,
115
+ smtpPort: Number.isInteger(email.smtpPort) ? email.smtpPort : undefined,
116
+ homeAddress: typeof email.homeAddress === 'string' ? email.homeAddress : undefined,
117
+ allowedUsers: Array.isArray(email.allowedUsers)
118
+ ? email.allowedUsers.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim().toLowerCase())
119
+ : undefined,
120
+ allowAllUsers: email.allowAllUsers === true,
121
+ pollIntervalSeconds: Number.isInteger(email.pollIntervalSeconds) ? email.pollIntervalSeconds : undefined,
122
+ }
123
+ : undefined,
124
+ line: line
125
+ ? {
126
+ enabled: line.enabled !== false,
127
+ channelAccessToken: typeof line.channelAccessToken === 'string' ? line.channelAccessToken : undefined,
128
+ channelSecret: typeof line.channelSecret === 'string' ? line.channelSecret : undefined,
129
+ homeChannel: typeof line.homeChannel === 'string' ? line.homeChannel : undefined,
130
+ allowedUsers: Array.isArray(line.allowedUsers)
131
+ ? line.allowedUsers.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
132
+ : undefined,
133
+ allowedGroups: Array.isArray(line.allowedGroups)
134
+ ? line.allowedGroups.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
135
+ : undefined,
136
+ allowedRooms: Array.isArray(line.allowedRooms)
137
+ ? line.allowedRooms.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
138
+ : undefined,
139
+ allowAllUsers: line.allowAllUsers === true,
140
+ publicUrl: typeof line.publicUrl === 'string' ? line.publicUrl : undefined,
141
+ }
142
+ : undefined,
143
+ sms: sms
144
+ ? {
145
+ enabled: sms.enabled !== false,
146
+ accountSid: typeof sms.accountSid === 'string' ? sms.accountSid : undefined,
147
+ authToken: typeof sms.authToken === 'string' ? sms.authToken : undefined,
148
+ phoneNumber: typeof sms.phoneNumber === 'string' ? sms.phoneNumber : undefined,
149
+ homeChannel: typeof sms.homeChannel === 'string' ? sms.homeChannel : undefined,
150
+ homeChannelName: typeof sms.homeChannelName === 'string' ? sms.homeChannelName : undefined,
151
+ allowedUsers: Array.isArray(sms.allowedUsers)
152
+ ? sms.allowedUsers.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
153
+ : undefined,
154
+ allowAllUsers: sms.allowAllUsers === true,
155
+ webhookUrl: typeof sms.webhookUrl === 'string' ? sms.webhookUrl : undefined,
156
+ insecureNoSignature: sms.insecureNoSignature === true,
157
+ }
158
+ : undefined,
159
+ ntfy: ntfy
160
+ ? {
161
+ enabled: ntfy.enabled !== false,
162
+ serverUrl: typeof ntfy.serverUrl === 'string' ? ntfy.serverUrl : undefined,
163
+ topic: typeof ntfy.topic === 'string' ? ntfy.topic : undefined,
164
+ publishTopic: typeof ntfy.publishTopic === 'string' ? ntfy.publishTopic : undefined,
165
+ token: typeof ntfy.token === 'string' ? ntfy.token : undefined,
166
+ homeChannel: typeof ntfy.homeChannel === 'string' ? ntfy.homeChannel : undefined,
167
+ homeChannelName: typeof ntfy.homeChannelName === 'string' ? ntfy.homeChannelName : undefined,
168
+ allowedUsers: Array.isArray(ntfy.allowedUsers)
169
+ ? ntfy.allowedUsers.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
170
+ : undefined,
171
+ allowAllUsers: ntfy.allowAllUsers === true,
172
+ markdown: ntfy.markdown === true,
173
+ }
174
+ : undefined,
175
+ signal: signal
176
+ ? {
177
+ enabled: signal.enabled !== false,
178
+ httpUrl: typeof signal.httpUrl === 'string' ? signal.httpUrl : undefined,
179
+ account: typeof signal.account === 'string' ? signal.account.trim() : undefined,
180
+ homeChannel: typeof signal.homeChannel === 'string' ? signal.homeChannel.trim() : undefined,
181
+ homeChannelName: typeof signal.homeChannelName === 'string' ? signal.homeChannelName : undefined,
182
+ allowedUsers: Array.isArray(signal.allowedUsers)
183
+ ? signal.allowedUsers.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
184
+ : undefined,
185
+ groupAllowedUsers: Array.isArray(signal.groupAllowedUsers)
186
+ ? signal.groupAllowedUsers.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
187
+ : undefined,
188
+ allowAllUsers: signal.allowAllUsers === true,
189
+ requireMention: signal.requireMention === true,
190
+ }
191
+ : undefined,
192
+ whatsapp: whatsapp
193
+ ? {
194
+ enabled: whatsapp.enabled !== false,
195
+ phoneNumberId: typeof whatsapp.phoneNumberId === 'string' ? whatsapp.phoneNumberId.trim() : undefined,
196
+ accessToken: typeof whatsapp.accessToken === 'string' ? whatsapp.accessToken : undefined,
197
+ appSecret: typeof whatsapp.appSecret === 'string' ? whatsapp.appSecret : undefined,
198
+ verifyToken: typeof whatsapp.verifyToken === 'string' ? whatsapp.verifyToken : undefined,
199
+ homeChannel: typeof whatsapp.homeChannel === 'string' ? whatsapp.homeChannel.trim() : undefined,
200
+ homeChannelName: typeof whatsapp.homeChannelName === 'string' ? whatsapp.homeChannelName : undefined,
201
+ allowedUsers: Array.isArray(whatsapp.allowedUsers)
202
+ ? whatsapp.allowedUsers.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
203
+ : undefined,
204
+ allowAllUsers: whatsapp.allowAllUsers === true,
205
+ publicUrl: typeof whatsapp.publicUrl === 'string' ? whatsapp.publicUrl : undefined,
206
+ apiVersion: typeof whatsapp.apiVersion === 'string' ? whatsapp.apiVersion.trim() : undefined,
207
+ }
208
+ : undefined,
209
+ matrix: matrix
210
+ ? {
211
+ enabled: matrix.enabled !== false,
212
+ homeserver: typeof matrix.homeserver === 'string' ? matrix.homeserver.trim() : undefined,
213
+ accessToken: typeof matrix.accessToken === 'string' ? matrix.accessToken : undefined,
214
+ userId: typeof matrix.userId === 'string' ? matrix.userId.trim() : undefined,
215
+ password: typeof matrix.password === 'string' ? matrix.password : undefined,
216
+ homeRoom: typeof matrix.homeRoom === 'string' ? matrix.homeRoom.trim() : undefined,
217
+ homeRoomName: typeof matrix.homeRoomName === 'string' ? matrix.homeRoomName : undefined,
218
+ allowedUsers: Array.isArray(matrix.allowedUsers)
219
+ ? matrix.allowedUsers.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
220
+ : undefined,
221
+ allowedRooms: Array.isArray(matrix.allowedRooms)
222
+ ? matrix.allowedRooms.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
223
+ : undefined,
224
+ freeResponseRooms: Array.isArray(matrix.freeResponseRooms)
225
+ ? matrix.freeResponseRooms.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
226
+ : undefined,
227
+ allowAllUsers: matrix.allowAllUsers === true,
228
+ requireMention: matrix.requireMention !== false,
229
+ groupSessionsPerUser: matrix.groupSessionsPerUser !== false,
230
+ autoJoin: matrix.autoJoin !== false,
231
+ pollTimeoutMs: Number.isInteger(matrix.pollTimeoutMs) ? matrix.pollTimeoutMs : undefined,
232
+ }
233
+ : undefined,
234
+ googleChat: googleChat
235
+ ? {
236
+ enabled: googleChat.enabled !== false,
237
+ projectId: typeof googleChat.projectId === 'string' ? googleChat.projectId.trim() : undefined,
238
+ subscriptionName: typeof googleChat.subscriptionName === 'string' ? googleChat.subscriptionName.trim() : undefined,
239
+ serviceAccountJson: typeof googleChat.serviceAccountJson === 'string' ? googleChat.serviceAccountJson.trim() : undefined,
240
+ apiBaseUrl: typeof googleChat.apiBaseUrl === 'string' ? googleChat.apiBaseUrl.trim() : undefined,
241
+ incomingWebhookUrl: typeof googleChat.incomingWebhookUrl === 'string' ? googleChat.incomingWebhookUrl.trim() : undefined,
242
+ homeChannel: typeof googleChat.homeChannel === 'string' ? googleChat.homeChannel.trim() : undefined,
243
+ homeChannelName: typeof googleChat.homeChannelName === 'string' ? googleChat.homeChannelName : undefined,
244
+ allowedUsers: Array.isArray(googleChat.allowedUsers)
245
+ ? googleChat.allowedUsers.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
246
+ : undefined,
247
+ allowedSpaces: Array.isArray(googleChat.allowedSpaces)
248
+ ? googleChat.allowedSpaces.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
249
+ : undefined,
250
+ freeResponseSpaces: Array.isArray(googleChat.freeResponseSpaces)
251
+ ? googleChat.freeResponseSpaces.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
252
+ : undefined,
253
+ allowAllUsers: googleChat.allowAllUsers === true,
254
+ allowAllSpaces: googleChat.allowAllSpaces === true,
255
+ maxMessages: Number.isInteger(googleChat.maxMessages) ? googleChat.maxMessages : undefined,
256
+ maxBytes: Number.isInteger(googleChat.maxBytes) ? googleChat.maxBytes : undefined,
257
+ }
258
+ : undefined,
259
+ bluebubbles: bluebubbles
260
+ ? {
261
+ enabled: bluebubbles.enabled !== false,
262
+ serverUrl: typeof bluebubbles.serverUrl === 'string' ? bluebubbles.serverUrl.trim() : undefined,
263
+ password: typeof bluebubbles.password === 'string' ? bluebubbles.password : undefined,
264
+ webhookHost: typeof bluebubbles.webhookHost === 'string' ? bluebubbles.webhookHost.trim() : undefined,
265
+ webhookPort: Number.isInteger(bluebubbles.webhookPort) ? bluebubbles.webhookPort : undefined,
266
+ webhookPath: typeof bluebubbles.webhookPath === 'string' ? bluebubbles.webhookPath.trim() : undefined,
267
+ homeChannel: typeof bluebubbles.homeChannel === 'string' ? bluebubbles.homeChannel.trim() : undefined,
268
+ homeChannelName: typeof bluebubbles.homeChannelName === 'string' ? bluebubbles.homeChannelName : undefined,
269
+ allowedUsers: Array.isArray(bluebubbles.allowedUsers)
270
+ ? bluebubbles.allowedUsers.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
271
+ : undefined,
272
+ allowAllUsers: bluebubbles.allowAllUsers === true,
273
+ requireMention: bluebubbles.requireMention === true,
274
+ mentionPatterns: Array.isArray(bluebubbles.mentionPatterns)
275
+ ? bluebubbles.mentionPatterns.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
276
+ : undefined,
277
+ sendReadReceipts: bluebubbles.sendReadReceipts !== false,
278
+ }
279
+ : undefined,
280
+ teams: teams
281
+ ? {
282
+ enabled: teams.enabled !== false,
283
+ deliveryMode: teams.deliveryMode === 'graph' ? 'graph' : 'incoming_webhook',
284
+ incomingWebhookUrl: typeof teams.incomingWebhookUrl === 'string' ? teams.incomingWebhookUrl.trim() : undefined,
285
+ graphAccessToken: typeof teams.graphAccessToken === 'string' ? teams.graphAccessToken : undefined,
286
+ teamId: typeof teams.teamId === 'string' ? teams.teamId.trim() : undefined,
287
+ channelId: typeof teams.channelId === 'string' ? teams.channelId.trim() : undefined,
288
+ chatId: typeof teams.chatId === 'string' ? teams.chatId.trim() : undefined,
289
+ homeChannel: typeof teams.homeChannel === 'string' ? teams.homeChannel.trim() : undefined,
290
+ homeChannelName: typeof teams.homeChannelName === 'string' ? teams.homeChannelName : undefined,
291
+ clientId: typeof teams.clientId === 'string' ? teams.clientId.trim() : undefined,
292
+ clientSecret: typeof teams.clientSecret === 'string' ? teams.clientSecret : undefined,
293
+ tenantId: typeof teams.tenantId === 'string' ? teams.tenantId.trim() : undefined,
294
+ allowedUsers: Array.isArray(teams.allowedUsers)
295
+ ? teams.allowedUsers.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim())
296
+ : undefined,
297
+ allowAllUsers: teams.allowAllUsers === true,
298
+ port: Number.isInteger(teams.port) ? teams.port : undefined,
299
+ }
300
+ : undefined,
301
+ webhooks: webhooks
302
+ ? {
303
+ enabled: webhooks.enabled !== false,
304
+ secret: typeof webhooks.secret === 'string' ? webhooks.secret : undefined,
305
+ publicUrl: typeof webhooks.publicUrl === 'string' ? webhooks.publicUrl : undefined,
306
+ rateLimitPerMinute: Number.isInteger(webhooks.rateLimitPerMinute) ? webhooks.rateLimitPerMinute : undefined,
307
+ routes: webhooks.routes && typeof webhooks.routes === 'object'
308
+ ? Object.fromEntries(Object.entries(webhooks.routes)
309
+ .filter(([name, route]) => typeof name === 'string' && Boolean(route) && typeof route === 'object')
310
+ .map(([name, route]) => {
311
+ const r = route;
312
+ return [
313
+ name.trim(),
314
+ {
315
+ events: Array.isArray(r.events)
316
+ ? r.events.filter((event) => typeof event === 'string' && event.trim()).map((event) => event.trim())
317
+ : undefined,
318
+ secret: typeof r.secret === 'string' ? r.secret : undefined,
319
+ prompt: typeof r.prompt === 'string' ? r.prompt : undefined,
320
+ deliver: typeof r.deliver === 'string' ? r.deliver : undefined,
321
+ deliverOnly: r.deliverOnly === true,
322
+ description: typeof r.description === 'string' ? r.description : undefined,
323
+ rateLimitPerMinute: Number.isInteger(r.rateLimitPerMinute) ? r.rateLimitPerMinute : undefined,
324
+ },
325
+ ];
326
+ })
327
+ .filter(([name]) => Boolean(name)))
328
+ : undefined,
329
+ }
330
+ : undefined,
331
+ };
332
+ }
333
+ catch {
334
+ return {};
335
+ }
336
+ }
337
+ export async function writeGatewayConfig(config) {
338
+ await mkdir(dirname(CONFIG_PATH), { recursive: true });
339
+ await writeFile(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
340
+ await chmod(CONFIG_PATH, 0o600).catch(() => { });
341
+ }
342
+ export async function patchGatewayConfig(patch) {
343
+ const current = await readGatewayConfig();
344
+ const next = {
345
+ ...current,
346
+ ...patch,
347
+ telegram: patch.telegram ? { ...current.telegram, ...patch.telegram } : current.telegram,
348
+ discord: patch.discord ? { ...current.discord, ...patch.discord } : current.discord,
349
+ slack: patch.slack ? { ...current.slack, ...patch.slack } : current.slack,
350
+ mattermost: patch.mattermost ? { ...current.mattermost, ...patch.mattermost } : current.mattermost,
351
+ homeassistant: patch.homeassistant ? { ...current.homeassistant, ...patch.homeassistant } : current.homeassistant,
352
+ email: patch.email ? { ...current.email, ...patch.email } : current.email,
353
+ line: patch.line ? { ...current.line, ...patch.line } : current.line,
354
+ sms: patch.sms ? { ...current.sms, ...patch.sms } : current.sms,
355
+ ntfy: patch.ntfy ? { ...current.ntfy, ...patch.ntfy } : current.ntfy,
356
+ signal: patch.signal ? { ...current.signal, ...patch.signal } : current.signal,
357
+ whatsapp: patch.whatsapp ? { ...current.whatsapp, ...patch.whatsapp } : current.whatsapp,
358
+ matrix: patch.matrix ? { ...current.matrix, ...patch.matrix } : current.matrix,
359
+ googleChat: patch.googleChat ? { ...current.googleChat, ...patch.googleChat } : current.googleChat,
360
+ bluebubbles: patch.bluebubbles ? { ...current.bluebubbles, ...patch.bluebubbles } : current.bluebubbles,
361
+ teams: patch.teams ? { ...current.teams, ...patch.teams } : current.teams,
362
+ webhooks: patch.webhooks
363
+ ? {
364
+ ...current.webhooks,
365
+ ...patch.webhooks,
366
+ routes: patch.webhooks.routes ? { ...current.webhooks?.routes, ...patch.webhooks.routes } : current.webhooks?.routes,
367
+ }
368
+ : current.webhooks,
369
+ };
370
+ await writeGatewayConfig(next);
371
+ return next;
372
+ }
373
+ function parseStringList(raw) {
374
+ if (!raw)
375
+ return [];
376
+ return raw
377
+ .split(',')
378
+ .map((s) => s.trim())
379
+ .filter(Boolean);
380
+ }
381
+ function parseFlexibleStringList(raw) {
382
+ const text = raw?.trim();
383
+ if (!text)
384
+ return [];
385
+ if (text.startsWith('[')) {
386
+ try {
387
+ const parsed = JSON.parse(text);
388
+ if (Array.isArray(parsed))
389
+ return parsed.map((item) => String(item).trim()).filter(Boolean);
390
+ }
391
+ catch {
392
+ // Fall back to CSV/newline parsing below.
393
+ }
394
+ }
395
+ return text
396
+ .split(/\r?\n|,/)
397
+ .map((s) => s.trim())
398
+ .filter(Boolean);
399
+ }
400
+ function optionalTrim(raw) {
401
+ const trimmed = raw?.trim();
402
+ return trimmed || undefined;
403
+ }
404
+ export function resolveTelegramConfig(config, env = process.env) {
405
+ const envToken = env.TELEGRAM_BOT_TOKEN;
406
+ const cfg = config.telegram;
407
+ const token = envToken || cfg?.botToken;
408
+ const allowedChatIds = env.TELEGRAM_ALLOWED_CHATS
409
+ ? parseAllowedChats(env.TELEGRAM_ALLOWED_CHATS)
410
+ : (cfg?.allowedChatIds ?? []);
411
+ return {
412
+ token,
413
+ allowedChatIds,
414
+ allowWrite: env.TELEGRAM_ALLOW_WRITE === '1' || cfg?.allowWrite === true,
415
+ enabled: cfg?.enabled !== false,
416
+ source: envToken ? 'env' : token ? 'config' : 'none',
417
+ };
418
+ }
419
+ export function resolveDiscordConfig(config, env = process.env) {
420
+ const envToken = env.DISCORD_BOT_TOKEN;
421
+ const cfg = config.discord;
422
+ const token = envToken || cfg?.botToken;
423
+ return {
424
+ token,
425
+ defaultChannelId: env.DISCORD_DEFAULT_CHANNEL || cfg?.defaultChannelId,
426
+ allowedChannelIds: env.DISCORD_ALLOWED_CHANNELS ? parseStringList(env.DISCORD_ALLOWED_CHANNELS) : (cfg?.allowedChannelIds ?? []),
427
+ allowWrite: env.DISCORD_ALLOW_WRITE === '1' || cfg?.allowWrite === true,
428
+ enabled: cfg?.enabled !== false,
429
+ source: envToken ? 'env' : token ? 'config' : 'none',
430
+ };
431
+ }
432
+ export function resolveSlackConfig(config, env = process.env) {
433
+ const envBotToken = env.SLACK_BOT_TOKEN;
434
+ const cfg = config.slack;
435
+ const botToken = envBotToken || cfg?.botToken;
436
+ return {
437
+ botToken,
438
+ appToken: env.SLACK_APP_TOKEN || cfg?.appToken,
439
+ defaultChannelId: env.SLACK_DEFAULT_CHANNEL || cfg?.defaultChannelId,
440
+ allowedChannelIds: env.SLACK_ALLOWED_CHANNELS ? parseStringList(env.SLACK_ALLOWED_CHANNELS) : (cfg?.allowedChannelIds ?? []),
441
+ allowWrite: env.SLACK_ALLOW_WRITE === '1' || cfg?.allowWrite === true,
442
+ enabled: cfg?.enabled !== false,
443
+ source: envBotToken ? 'env' : botToken ? 'config' : 'none',
444
+ };
445
+ }
446
+ export function resolveMattermostConfig(config, env = process.env) {
447
+ const cfg = config.mattermost;
448
+ const envServerUrl = optionalTrim(env.MATTERMOST_URL) || optionalTrim(env.MATTERMOST_SERVER_URL);
449
+ const envToken = optionalTrim(env.MATTERMOST_TOKEN);
450
+ const serverUrl = (envServerUrl || optionalTrim(cfg?.serverUrl))?.replace(/\/+$/, '');
451
+ const token = envToken || optionalTrim(cfg?.token);
452
+ const requireMentionEnv = env.MATTERMOST_REQUIRE_MENTION;
453
+ const groupSessionsEnv = env.MATTERMOST_GROUP_SESSIONS_PER_USER;
454
+ const replyModeRaw = optionalTrim(env.MATTERMOST_REPLY_MODE) || optionalTrim(cfg?.replyMode);
455
+ const replyMode = replyModeRaw === 'thread' ? 'thread' : 'off';
456
+ return {
457
+ serverUrl,
458
+ token,
459
+ homeChannel: optionalTrim(env.MATTERMOST_HOME_CHANNEL) || optionalTrim(cfg?.homeChannel),
460
+ homeChannelName: optionalTrim(env.MATTERMOST_HOME_CHANNEL_NAME) || optionalTrim(cfg?.homeChannelName),
461
+ allowedUsers: env.MATTERMOST_ALLOWED_USERS ? parseStringList(env.MATTERMOST_ALLOWED_USERS) : (cfg?.allowedUsers ?? []),
462
+ allowedChannels: env.MATTERMOST_ALLOWED_CHANNELS ? parseStringList(env.MATTERMOST_ALLOWED_CHANNELS) : (cfg?.allowedChannels ?? []),
463
+ freeResponseChannels: env.MATTERMOST_FREE_RESPONSE_CHANNELS
464
+ ? parseStringList(env.MATTERMOST_FREE_RESPONSE_CHANNELS)
465
+ : (cfg?.freeResponseChannels ?? []),
466
+ allowAllUsers: env.MATTERMOST_ALLOW_ALL_USERS === '1' || env.MATTERMOST_ALLOW_ALL_USERS === 'true' || cfg?.allowAllUsers === true,
467
+ requireMention: requireMentionEnv == null ? cfg?.requireMention !== false : requireMentionEnv === '1' || requireMentionEnv === 'true',
468
+ groupSessionsPerUser: groupSessionsEnv == null ? cfg?.groupSessionsPerUser !== false : groupSessionsEnv === '1' || groupSessionsEnv === 'true',
469
+ replyMode,
470
+ enabled: cfg?.enabled !== false,
471
+ source: envServerUrl || envToken ? 'env' : serverUrl || token ? 'config' : 'none',
472
+ };
473
+ }
474
+ export function resolveHomeAssistantConfig(config, env = process.env) {
475
+ const cfg = config.homeassistant;
476
+ const envToken = optionalTrim(env.HASS_TOKEN);
477
+ const envUrl = optionalTrim(env.HASS_URL);
478
+ const url = (envUrl || optionalTrim(cfg?.url) || 'http://homeassistant.local:8123').replace(/\/+$/, '');
479
+ const watchAllEnv = env.HASS_WATCH_ALL;
480
+ return {
481
+ url,
482
+ token: envToken || optionalTrim(cfg?.token),
483
+ homeChannel: optionalTrim(env.HASS_HOME_CHANNEL) || optionalTrim(cfg?.homeChannel),
484
+ homeChannelName: optionalTrim(env.HASS_HOME_CHANNEL_NAME) || optionalTrim(cfg?.homeChannelName),
485
+ watchDomains: env.HASS_WATCH_DOMAINS ? parseStringList(env.HASS_WATCH_DOMAINS) : (cfg?.watchDomains ?? []),
486
+ watchEntities: env.HASS_WATCH_ENTITIES ? parseStringList(env.HASS_WATCH_ENTITIES) : (cfg?.watchEntities ?? []),
487
+ ignoreEntities: env.HASS_IGNORE_ENTITIES ? parseStringList(env.HASS_IGNORE_ENTITIES) : (cfg?.ignoreEntities ?? []),
488
+ watchAll: watchAllEnv == null ? cfg?.watchAll === true : watchAllEnv === '1' || watchAllEnv === 'true',
489
+ cooldownSeconds: parsePositiveInt(env.HASS_COOLDOWN_SECONDS, cfg?.cooldownSeconds ?? 30),
490
+ enabled: cfg?.enabled !== false,
491
+ source: envToken || envUrl ? 'env' : cfg?.token || cfg?.url ? 'config' : 'none',
492
+ };
493
+ }
494
+ function parsePositiveInt(raw, fallback) {
495
+ if (!raw)
496
+ return fallback;
497
+ const n = Number(raw);
498
+ return Number.isInteger(n) && n > 0 ? n : fallback;
499
+ }
500
+ export function resolveEmailConfig(config, env = process.env) {
501
+ const cfg = config.email;
502
+ const envAddress = env.EMAIL_ADDRESS;
503
+ const address = envAddress || cfg?.address;
504
+ const password = env.EMAIL_PASSWORD || cfg?.password;
505
+ const imapHost = env.EMAIL_IMAP_HOST || cfg?.imapHost;
506
+ const smtpHost = env.EMAIL_SMTP_HOST || cfg?.smtpHost;
507
+ return {
508
+ address,
509
+ password,
510
+ imapHost,
511
+ imapPort: parsePositiveInt(env.EMAIL_IMAP_PORT, cfg?.imapPort ?? 993),
512
+ smtpHost,
513
+ smtpPort: parsePositiveInt(env.EMAIL_SMTP_PORT, cfg?.smtpPort ?? 587),
514
+ homeAddress: env.EMAIL_HOME_ADDRESS || cfg?.homeAddress,
515
+ allowedUsers: env.EMAIL_ALLOWED_USERS ? parseStringList(env.EMAIL_ALLOWED_USERS).map((s) => s.toLowerCase()) : (cfg?.allowedUsers ?? []),
516
+ allowAllUsers: env.EMAIL_ALLOW_ALL_USERS === '1' || env.EMAIL_ALLOW_ALL_USERS === 'true' || cfg?.allowAllUsers === true,
517
+ pollIntervalSeconds: parsePositiveInt(env.EMAIL_POLL_INTERVAL, cfg?.pollIntervalSeconds ?? 15),
518
+ enabled: cfg?.enabled !== false,
519
+ source: envAddress ? 'env' : address ? 'config' : 'none',
520
+ };
521
+ }
522
+ export function resolveLineConfig(config, env = process.env) {
523
+ const cfg = config.line;
524
+ const envToken = env.LINE_CHANNEL_ACCESS_TOKEN;
525
+ const channelAccessToken = envToken || cfg?.channelAccessToken;
526
+ return {
527
+ channelAccessToken,
528
+ channelSecret: env.LINE_CHANNEL_SECRET || cfg?.channelSecret,
529
+ homeChannel: env.LINE_HOME_CHANNEL || cfg?.homeChannel,
530
+ allowedUsers: env.LINE_ALLOWED_USERS ? parseStringList(env.LINE_ALLOWED_USERS) : (cfg?.allowedUsers ?? []),
531
+ allowedGroups: env.LINE_ALLOWED_GROUPS ? parseStringList(env.LINE_ALLOWED_GROUPS) : (cfg?.allowedGroups ?? []),
532
+ allowedRooms: env.LINE_ALLOWED_ROOMS ? parseStringList(env.LINE_ALLOWED_ROOMS) : (cfg?.allowedRooms ?? []),
533
+ allowAllUsers: env.LINE_ALLOW_ALL_USERS === '1' || env.LINE_ALLOW_ALL_USERS === 'true' || cfg?.allowAllUsers === true,
534
+ publicUrl: env.LINE_PUBLIC_URL || cfg?.publicUrl,
535
+ enabled: cfg?.enabled !== false,
536
+ source: envToken ? 'env' : channelAccessToken ? 'config' : 'none',
537
+ };
538
+ }
539
+ export function resolveSmsConfig(config, env = process.env) {
540
+ const cfg = config.sms;
541
+ const envAccountSid = env.TWILIO_ACCOUNT_SID;
542
+ const envAuthToken = env.TWILIO_AUTH_TOKEN;
543
+ const envPhoneNumber = env.TWILIO_PHONE_NUMBER;
544
+ const accountSid = envAccountSid || cfg?.accountSid;
545
+ const authToken = envAuthToken || cfg?.authToken;
546
+ const phoneNumber = envPhoneNumber || cfg?.phoneNumber;
547
+ return {
548
+ accountSid,
549
+ authToken,
550
+ phoneNumber,
551
+ homeChannel: env.SMS_HOME_CHANNEL || cfg?.homeChannel,
552
+ homeChannelName: env.SMS_HOME_CHANNEL_NAME || cfg?.homeChannelName,
553
+ allowedUsers: env.SMS_ALLOWED_USERS ? parseStringList(env.SMS_ALLOWED_USERS) : (cfg?.allowedUsers ?? []),
554
+ allowAllUsers: env.SMS_ALLOW_ALL_USERS === '1' || env.SMS_ALLOW_ALL_USERS === 'true' || cfg?.allowAllUsers === true,
555
+ webhookUrl: env.SMS_WEBHOOK_URL || cfg?.webhookUrl,
556
+ insecureNoSignature: env.SMS_INSECURE_NO_SIGNATURE === '1' || env.SMS_INSECURE_NO_SIGNATURE === 'true' || cfg?.insecureNoSignature === true,
557
+ enabled: cfg?.enabled !== false,
558
+ source: envAccountSid || envAuthToken || envPhoneNumber ? 'env' : accountSid || authToken || phoneNumber ? 'config' : 'none',
559
+ };
560
+ }
561
+ export function resolveNtfyConfig(config, env = process.env) {
562
+ const cfg = config.ntfy;
563
+ const envServerUrl = optionalTrim(env.NTFY_SERVER_URL);
564
+ const envTopic = optionalTrim(env.NTFY_TOPIC);
565
+ const envToken = optionalTrim(env.NTFY_TOKEN);
566
+ const topic = envTopic || optionalTrim(cfg?.topic);
567
+ const token = envToken || optionalTrim(cfg?.token);
568
+ const serverUrl = (envServerUrl || optionalTrim(cfg?.serverUrl) || 'https://ntfy.sh').replace(/\/+$/, '');
569
+ return {
570
+ serverUrl,
571
+ topic,
572
+ publishTopic: optionalTrim(env.NTFY_PUBLISH_TOPIC) || optionalTrim(cfg?.publishTopic),
573
+ token,
574
+ homeChannel: optionalTrim(env.NTFY_HOME_CHANNEL) || optionalTrim(cfg?.homeChannel),
575
+ homeChannelName: optionalTrim(env.NTFY_HOME_CHANNEL_NAME) || optionalTrim(cfg?.homeChannelName),
576
+ allowedUsers: env.NTFY_ALLOWED_USERS ? parseStringList(env.NTFY_ALLOWED_USERS) : (cfg?.allowedUsers ?? []),
577
+ allowAllUsers: env.NTFY_ALLOW_ALL_USERS === '1' || env.NTFY_ALLOW_ALL_USERS === 'true' || cfg?.allowAllUsers === true,
578
+ markdown: env.NTFY_MARKDOWN === '1' || env.NTFY_MARKDOWN === 'true' || cfg?.markdown === true,
579
+ enabled: cfg?.enabled !== false,
580
+ source: envTopic || envToken || envServerUrl ? 'env' : topic || token || optionalTrim(cfg?.serverUrl) ? 'config' : 'none',
581
+ };
582
+ }
583
+ export function resolveSignalConfig(config, env = process.env) {
584
+ const cfg = config.signal;
585
+ const envHttpUrl = env.SIGNAL_HTTP_URL;
586
+ const envAccount = env.SIGNAL_ACCOUNT;
587
+ const account = envAccount || cfg?.account;
588
+ const httpUrl = (envHttpUrl || cfg?.httpUrl || 'http://127.0.0.1:8080').replace(/\/+$/, '');
589
+ return {
590
+ httpUrl,
591
+ account,
592
+ homeChannel: env.SIGNAL_HOME_CHANNEL || cfg?.homeChannel,
593
+ homeChannelName: env.SIGNAL_HOME_CHANNEL_NAME || cfg?.homeChannelName,
594
+ allowedUsers: env.SIGNAL_ALLOWED_USERS ? parseStringList(env.SIGNAL_ALLOWED_USERS) : (cfg?.allowedUsers ?? []),
595
+ groupAllowedUsers: env.SIGNAL_GROUP_ALLOWED_USERS ? parseStringList(env.SIGNAL_GROUP_ALLOWED_USERS) : (cfg?.groupAllowedUsers ?? []),
596
+ allowAllUsers: env.SIGNAL_ALLOW_ALL_USERS === '1' || env.SIGNAL_ALLOW_ALL_USERS === 'true' || cfg?.allowAllUsers === true,
597
+ requireMention: env.SIGNAL_REQUIRE_MENTION === '1' || env.SIGNAL_REQUIRE_MENTION === 'true' || cfg?.requireMention === true,
598
+ enabled: cfg?.enabled !== false,
599
+ source: envHttpUrl || envAccount ? 'env' : account || cfg?.httpUrl ? 'config' : 'none',
600
+ };
601
+ }
602
+ export function resolveWhatsAppConfig(config, env = process.env) {
603
+ const cfg = config.whatsapp;
604
+ const envPhoneNumberId = optionalTrim(env.WHATSAPP_CLOUD_PHONE_NUMBER_ID);
605
+ const envAccessToken = optionalTrim(env.WHATSAPP_CLOUD_ACCESS_TOKEN);
606
+ const envAppSecret = optionalTrim(env.WHATSAPP_CLOUD_APP_SECRET);
607
+ const phoneNumberId = envPhoneNumberId || optionalTrim(cfg?.phoneNumberId);
608
+ const accessToken = envAccessToken || optionalTrim(cfg?.accessToken);
609
+ const appSecret = envAppSecret || optionalTrim(cfg?.appSecret);
610
+ return {
611
+ phoneNumberId,
612
+ accessToken,
613
+ appSecret,
614
+ verifyToken: optionalTrim(env.WHATSAPP_CLOUD_VERIFY_TOKEN) || optionalTrim(cfg?.verifyToken),
615
+ homeChannel: optionalTrim(env.WHATSAPP_CLOUD_HOME_CHANNEL) || optionalTrim(cfg?.homeChannel),
616
+ homeChannelName: optionalTrim(env.WHATSAPP_CLOUD_HOME_CHANNEL_NAME) || optionalTrim(cfg?.homeChannelName),
617
+ allowedUsers: env.WHATSAPP_CLOUD_ALLOWED_USERS ? parseStringList(env.WHATSAPP_CLOUD_ALLOWED_USERS) : (cfg?.allowedUsers ?? []),
618
+ allowAllUsers: env.WHATSAPP_CLOUD_ALLOW_ALL_USERS === '1' || env.WHATSAPP_CLOUD_ALLOW_ALL_USERS === 'true' || cfg?.allowAllUsers === true,
619
+ publicUrl: optionalTrim(env.WHATSAPP_CLOUD_PUBLIC_URL) || optionalTrim(cfg?.publicUrl),
620
+ apiVersion: optionalTrim(env.WHATSAPP_CLOUD_API_VERSION) || optionalTrim(cfg?.apiVersion) || 'v20.0',
621
+ enabled: cfg?.enabled !== false,
622
+ source: envPhoneNumberId || envAccessToken || envAppSecret ? 'env' : phoneNumberId || accessToken || appSecret ? 'config' : 'none',
623
+ };
624
+ }
625
+ export function resolveMatrixConfig(config, env = process.env) {
626
+ const cfg = config.matrix;
627
+ const envHomeserver = optionalTrim(env.MATRIX_HOMESERVER);
628
+ const envAccessToken = optionalTrim(env.MATRIX_ACCESS_TOKEN);
629
+ const envUserId = optionalTrim(env.MATRIX_USER_ID);
630
+ const envPassword = optionalTrim(env.MATRIX_PASSWORD);
631
+ const homeserver = (envHomeserver || optionalTrim(cfg?.homeserver))?.replace(/\/+$/, '');
632
+ const accessToken = envAccessToken || optionalTrim(cfg?.accessToken);
633
+ const userId = envUserId || optionalTrim(cfg?.userId);
634
+ const password = envPassword || optionalTrim(cfg?.password);
635
+ const requireMentionEnv = env.MATRIX_REQUIRE_MENTION;
636
+ const groupSessionsEnv = env.MATRIX_GROUP_SESSIONS_PER_USER;
637
+ const autoJoinEnv = env.MATRIX_AUTO_JOIN;
638
+ return {
639
+ homeserver,
640
+ accessToken,
641
+ userId,
642
+ password,
643
+ homeRoom: optionalTrim(env.MATRIX_HOME_ROOM) || optionalTrim(cfg?.homeRoom),
644
+ homeRoomName: optionalTrim(env.MATRIX_HOME_ROOM_NAME) || optionalTrim(cfg?.homeRoomName),
645
+ allowedUsers: env.MATRIX_ALLOWED_USERS ? parseStringList(env.MATRIX_ALLOWED_USERS) : (cfg?.allowedUsers ?? []),
646
+ allowedRooms: env.MATRIX_ALLOWED_ROOMS ? parseStringList(env.MATRIX_ALLOWED_ROOMS) : (cfg?.allowedRooms ?? []),
647
+ freeResponseRooms: env.MATRIX_FREE_RESPONSE_ROOMS ? parseStringList(env.MATRIX_FREE_RESPONSE_ROOMS) : (cfg?.freeResponseRooms ?? []),
648
+ allowAllUsers: env.MATRIX_ALLOW_ALL_USERS === '1' || env.MATRIX_ALLOW_ALL_USERS === 'true' || cfg?.allowAllUsers === true,
649
+ requireMention: requireMentionEnv == null ? cfg?.requireMention !== false : requireMentionEnv === '1' || requireMentionEnv === 'true',
650
+ groupSessionsPerUser: groupSessionsEnv == null ? cfg?.groupSessionsPerUser !== false : groupSessionsEnv === '1' || groupSessionsEnv === 'true',
651
+ autoJoin: autoJoinEnv == null ? cfg?.autoJoin !== false : autoJoinEnv === '1' || autoJoinEnv === 'true',
652
+ pollTimeoutMs: parsePositiveInt(env.MATRIX_POLL_TIMEOUT_MS, cfg?.pollTimeoutMs ?? 30_000),
653
+ enabled: cfg?.enabled !== false,
654
+ source: envHomeserver || envAccessToken || envUserId || envPassword
655
+ ? 'env'
656
+ : homeserver || accessToken || userId || password
657
+ ? 'config'
658
+ : 'none',
659
+ };
660
+ }
661
+ export function resolveGoogleChatConfig(config, env = process.env) {
662
+ const cfg = config.googleChat;
663
+ const envProjectId = optionalTrim(env.GOOGLE_CHAT_PROJECT_ID) || optionalTrim(env.GOOGLE_CLOUD_PROJECT);
664
+ const envSubscriptionName = optionalTrim(env.GOOGLE_CHAT_SUBSCRIPTION_NAME);
665
+ const envServiceAccountJson = optionalTrim(env.GOOGLE_CHAT_SERVICE_ACCOUNT_JSON) || optionalTrim(env.GOOGLE_APPLICATION_CREDENTIALS);
666
+ const envApiBaseUrl = optionalTrim(env.GOOGLE_CHAT_API_BASE_URL);
667
+ const envIncomingWebhookUrl = optionalTrim(env.GOOGLE_CHAT_INCOMING_WEBHOOK_URL) || optionalTrim(env.GOOGLE_CHAT_WEBHOOK_URL);
668
+ return {
669
+ projectId: envProjectId || optionalTrim(cfg?.projectId),
670
+ subscriptionName: envSubscriptionName || optionalTrim(cfg?.subscriptionName),
671
+ serviceAccountJson: envServiceAccountJson || optionalTrim(cfg?.serviceAccountJson),
672
+ apiBaseUrl: (envApiBaseUrl || optionalTrim(cfg?.apiBaseUrl) || 'https://chat.googleapis.com').replace(/\/+$/, ''),
673
+ incomingWebhookUrl: envIncomingWebhookUrl || optionalTrim(cfg?.incomingWebhookUrl),
674
+ homeChannel: optionalTrim(env.GOOGLE_CHAT_HOME_CHANNEL) || optionalTrim(cfg?.homeChannel),
675
+ homeChannelName: optionalTrim(env.GOOGLE_CHAT_HOME_CHANNEL_NAME) || optionalTrim(cfg?.homeChannelName),
676
+ allowedUsers: env.GOOGLE_CHAT_ALLOWED_USERS ? parseStringList(env.GOOGLE_CHAT_ALLOWED_USERS) : (cfg?.allowedUsers ?? []),
677
+ allowedSpaces: env.GOOGLE_CHAT_ALLOWED_SPACES ? parseStringList(env.GOOGLE_CHAT_ALLOWED_SPACES) : (cfg?.allowedSpaces ?? []),
678
+ freeResponseSpaces: env.GOOGLE_CHAT_FREE_RESPONSE_SPACES
679
+ ? parseStringList(env.GOOGLE_CHAT_FREE_RESPONSE_SPACES)
680
+ : (cfg?.freeResponseSpaces ?? []),
681
+ allowAllUsers: env.GOOGLE_CHAT_ALLOW_ALL_USERS === '1' || env.GOOGLE_CHAT_ALLOW_ALL_USERS === 'true' || cfg?.allowAllUsers === true,
682
+ allowAllSpaces: env.GOOGLE_CHAT_ALLOW_ALL_SPACES === '1' || env.GOOGLE_CHAT_ALLOW_ALL_SPACES === 'true' || cfg?.allowAllSpaces === true,
683
+ maxMessages: parsePositiveInt(env.GOOGLE_CHAT_MAX_MESSAGES, cfg?.maxMessages ?? 1),
684
+ maxBytes: parsePositiveInt(env.GOOGLE_CHAT_MAX_BYTES, cfg?.maxBytes ?? 16_777_216),
685
+ enabled: cfg?.enabled !== false,
686
+ source: envProjectId || envSubscriptionName || envServiceAccountJson || envApiBaseUrl || envIncomingWebhookUrl
687
+ ? 'env'
688
+ : cfg?.projectId || cfg?.subscriptionName || cfg?.serviceAccountJson || cfg?.apiBaseUrl || cfg?.incomingWebhookUrl
689
+ ? 'config'
690
+ : 'none',
691
+ };
692
+ }
693
+ export function resolveBlueBubblesConfig(config, env = process.env) {
694
+ const cfg = config.bluebubbles;
695
+ const envServerUrl = optionalTrim(env.BLUEBUBBLES_SERVER_URL);
696
+ const envPassword = optionalTrim(env.BLUEBUBBLES_PASSWORD);
697
+ const envWebhookHost = optionalTrim(env.BLUEBUBBLES_WEBHOOK_HOST);
698
+ const envWebhookPath = optionalTrim(env.BLUEBUBBLES_WEBHOOK_PATH);
699
+ const envRequireMention = env.BLUEBUBBLES_REQUIRE_MENTION;
700
+ const envSendReadReceipts = env.BLUEBUBBLES_SEND_READ_RECEIPTS;
701
+ return {
702
+ serverUrl: envServerUrl || optionalTrim(cfg?.serverUrl),
703
+ password: envPassword || optionalTrim(cfg?.password),
704
+ webhookHost: envWebhookHost || optionalTrim(cfg?.webhookHost) || '127.0.0.1',
705
+ webhookPort: parsePositiveInt(env.BLUEBUBBLES_WEBHOOK_PORT, cfg?.webhookPort ?? 8645),
706
+ webhookPath: envWebhookPath || optionalTrim(cfg?.webhookPath) || '/bluebubbles-webhook',
707
+ homeChannel: optionalTrim(env.BLUEBUBBLES_HOME_CHANNEL) || optionalTrim(cfg?.homeChannel),
708
+ homeChannelName: optionalTrim(env.BLUEBUBBLES_HOME_CHANNEL_NAME) || optionalTrim(cfg?.homeChannelName),
709
+ allowedUsers: env.BLUEBUBBLES_ALLOWED_USERS ? parseStringList(env.BLUEBUBBLES_ALLOWED_USERS) : (cfg?.allowedUsers ?? []),
710
+ allowAllUsers: env.BLUEBUBBLES_ALLOW_ALL_USERS === '1' || env.BLUEBUBBLES_ALLOW_ALL_USERS === 'true' || cfg?.allowAllUsers === true,
711
+ requireMention: envRequireMention == null ? cfg?.requireMention === true : envRequireMention === '1' || envRequireMention === 'true',
712
+ mentionPatterns: env.BLUEBUBBLES_MENTION_PATTERNS
713
+ ? parseFlexibleStringList(env.BLUEBUBBLES_MENTION_PATTERNS)
714
+ : (cfg?.mentionPatterns ?? []),
715
+ sendReadReceipts: envSendReadReceipts == null
716
+ ? cfg?.sendReadReceipts !== false
717
+ : envSendReadReceipts === '1' || envSendReadReceipts === 'true',
718
+ enabled: cfg?.enabled !== false,
719
+ source: envServerUrl || envPassword ? 'env' : cfg?.serverUrl || cfg?.password ? 'config' : 'none',
720
+ };
721
+ }
722
+ export function resolveTeamsConfig(config, env = process.env) {
723
+ const cfg = config.teams;
724
+ const envIncomingWebhookUrl = optionalTrim(env.TEAMS_INCOMING_WEBHOOK_URL);
725
+ const envGraphAccessToken = optionalTrim(env.TEAMS_GRAPH_ACCESS_TOKEN);
726
+ const envClientId = optionalTrim(env.TEAMS_CLIENT_ID);
727
+ const envClientSecret = optionalTrim(env.TEAMS_CLIENT_SECRET);
728
+ const envTenantId = optionalTrim(env.TEAMS_TENANT_ID);
729
+ const deliveryModeRaw = optionalTrim(env.TEAMS_DELIVERY_MODE) || optionalTrim(cfg?.deliveryMode);
730
+ const deliveryMode = deliveryModeRaw === 'graph' ? 'graph' : 'incoming_webhook';
731
+ const incomingWebhookUrl = envIncomingWebhookUrl || optionalTrim(cfg?.incomingWebhookUrl);
732
+ const graphAccessToken = envGraphAccessToken || optionalTrim(cfg?.graphAccessToken);
733
+ const teamId = optionalTrim(env.TEAMS_TEAM_ID) || optionalTrim(cfg?.teamId);
734
+ const channelId = optionalTrim(env.TEAMS_CHANNEL_ID) || optionalTrim(cfg?.channelId);
735
+ const chatId = optionalTrim(env.TEAMS_CHAT_ID) || optionalTrim(cfg?.chatId);
736
+ const clientId = envClientId || optionalTrim(cfg?.clientId);
737
+ const clientSecret = envClientSecret || optionalTrim(cfg?.clientSecret);
738
+ const tenantId = envTenantId || optionalTrim(cfg?.tenantId);
739
+ return {
740
+ deliveryMode,
741
+ incomingWebhookUrl,
742
+ graphAccessToken,
743
+ teamId,
744
+ channelId,
745
+ chatId,
746
+ homeChannel: optionalTrim(env.TEAMS_HOME_CHANNEL) || optionalTrim(cfg?.homeChannel),
747
+ homeChannelName: optionalTrim(env.TEAMS_HOME_CHANNEL_NAME) || optionalTrim(cfg?.homeChannelName),
748
+ clientId,
749
+ clientSecret,
750
+ tenantId,
751
+ allowedUsers: env.TEAMS_ALLOWED_USERS ? parseStringList(env.TEAMS_ALLOWED_USERS) : (cfg?.allowedUsers ?? []),
752
+ allowAllUsers: env.TEAMS_ALLOW_ALL_USERS === '1' || env.TEAMS_ALLOW_ALL_USERS === 'true' || cfg?.allowAllUsers === true,
753
+ port: parsePositiveInt(env.TEAMS_PORT, cfg?.port ?? 3978),
754
+ enabled: cfg?.enabled !== false,
755
+ source: envIncomingWebhookUrl || envGraphAccessToken || envClientId || envClientSecret || envTenantId
756
+ ? 'env'
757
+ : incomingWebhookUrl || graphAccessToken || clientId || clientSecret || tenantId
758
+ ? 'config'
759
+ : 'none',
760
+ };
761
+ }
762
+ export function resolveWebhookConfig(config, env = process.env) {
763
+ const cfg = config.webhooks;
764
+ const envSecret = env.WEBHOOK_SECRET;
765
+ const secret = envSecret || cfg?.secret;
766
+ const routes = Object.fromEntries(Object.entries(cfg?.routes ?? {}).map(([name, route]) => [
767
+ name,
768
+ {
769
+ name,
770
+ events: route.events ?? [],
771
+ secret: route.secret,
772
+ prompt: route.prompt,
773
+ deliver: route.deliver?.trim() || 'log',
774
+ deliverOnly: route.deliverOnly === true,
775
+ description: route.description,
776
+ rateLimitPerMinute: route.rateLimitPerMinute,
777
+ },
778
+ ]));
779
+ const envEnabled = env.WEBHOOK_ENABLED;
780
+ return {
781
+ enabled: envEnabled === '1' || envEnabled === 'true' || cfg?.enabled === true,
782
+ secret,
783
+ publicUrl: env.WEBHOOK_PUBLIC_URL || cfg?.publicUrl,
784
+ routes,
785
+ rateLimitPerMinute: parsePositiveInt(env.WEBHOOK_RATE_LIMIT_PER_MINUTE, cfg?.rateLimitPerMinute ?? 30),
786
+ source: envSecret || envEnabled ? 'env' : cfg ? 'config' : 'none',
787
+ };
788
+ }
789
+ export function redactGatewayConfig(config) {
790
+ return {
791
+ ...config,
792
+ telegram: config.telegram
793
+ ? {
794
+ ...config.telegram,
795
+ botToken: config.telegram.botToken ? '<secret:TELEGRAM_BOT_TOKEN>' : undefined,
796
+ }
797
+ : undefined,
798
+ discord: config.discord
799
+ ? {
800
+ ...config.discord,
801
+ botToken: config.discord.botToken ? '<secret:DISCORD_BOT_TOKEN>' : undefined,
802
+ }
803
+ : undefined,
804
+ slack: config.slack
805
+ ? {
806
+ ...config.slack,
807
+ botToken: config.slack.botToken ? '<secret:SLACK_BOT_TOKEN>' : undefined,
808
+ appToken: config.slack.appToken ? '<secret:SLACK_APP_TOKEN>' : undefined,
809
+ }
810
+ : undefined,
811
+ mattermost: config.mattermost
812
+ ? {
813
+ ...config.mattermost,
814
+ token: config.mattermost.token ? '<secret:MATTERMOST_TOKEN>' : undefined,
815
+ }
816
+ : undefined,
817
+ homeassistant: config.homeassistant
818
+ ? {
819
+ ...config.homeassistant,
820
+ token: config.homeassistant.token ? '<secret:HASS_TOKEN>' : undefined,
821
+ }
822
+ : undefined,
823
+ email: config.email
824
+ ? {
825
+ ...config.email,
826
+ password: config.email.password ? '<secret:EMAIL_PASSWORD>' : undefined,
827
+ }
828
+ : undefined,
829
+ line: config.line
830
+ ? {
831
+ ...config.line,
832
+ channelAccessToken: config.line.channelAccessToken ? '<secret:LINE_CHANNEL_ACCESS_TOKEN>' : undefined,
833
+ channelSecret: config.line.channelSecret ? '<secret:LINE_CHANNEL_SECRET>' : undefined,
834
+ }
835
+ : undefined,
836
+ sms: config.sms
837
+ ? {
838
+ ...config.sms,
839
+ authToken: config.sms.authToken ? '<secret:TWILIO_AUTH_TOKEN>' : undefined,
840
+ }
841
+ : undefined,
842
+ ntfy: config.ntfy
843
+ ? {
844
+ ...config.ntfy,
845
+ token: config.ntfy.token ? '<secret:NTFY_TOKEN>' : undefined,
846
+ }
847
+ : undefined,
848
+ signal: config.signal
849
+ ? {
850
+ ...config.signal,
851
+ account: redactSignalIdentifier(config.signal.account),
852
+ homeChannel: redactSignalIdentifier(config.signal.homeChannel),
853
+ allowedUsers: config.signal.allowedUsers?.map(redactSignalIdentifier).filter((id) => Boolean(id)),
854
+ groupAllowedUsers: config.signal.groupAllowedUsers?.map(redactSignalIdentifier).filter((id) => Boolean(id)),
855
+ }
856
+ : undefined,
857
+ whatsapp: config.whatsapp
858
+ ? {
859
+ ...config.whatsapp,
860
+ accessToken: config.whatsapp.accessToken ? '<secret:WHATSAPP_CLOUD_ACCESS_TOKEN>' : undefined,
861
+ appSecret: config.whatsapp.appSecret ? '<secret:WHATSAPP_CLOUD_APP_SECRET>' : undefined,
862
+ verifyToken: config.whatsapp.verifyToken ? '<secret:WHATSAPP_CLOUD_VERIFY_TOKEN>' : undefined,
863
+ homeChannel: redactPhoneLike(config.whatsapp.homeChannel),
864
+ allowedUsers: config.whatsapp.allowedUsers?.map(redactPhoneLike).filter((id) => Boolean(id)),
865
+ }
866
+ : undefined,
867
+ matrix: config.matrix
868
+ ? {
869
+ ...config.matrix,
870
+ accessToken: config.matrix.accessToken ? '<secret:MATRIX_ACCESS_TOKEN>' : undefined,
871
+ password: config.matrix.password ? '<secret:MATRIX_PASSWORD>' : undefined,
872
+ }
873
+ : undefined,
874
+ googleChat: config.googleChat
875
+ ? {
876
+ ...config.googleChat,
877
+ serviceAccountJson: config.googleChat.serviceAccountJson ? '<secret:GOOGLE_CHAT_SERVICE_ACCOUNT_JSON>' : undefined,
878
+ incomingWebhookUrl: config.googleChat.incomingWebhookUrl ? '<secret:GOOGLE_CHAT_INCOMING_WEBHOOK_URL>' : undefined,
879
+ }
880
+ : undefined,
881
+ bluebubbles: config.bluebubbles
882
+ ? {
883
+ ...config.bluebubbles,
884
+ password: config.bluebubbles.password ? '<secret:BLUEBUBBLES_PASSWORD>' : undefined,
885
+ }
886
+ : undefined,
887
+ teams: config.teams
888
+ ? {
889
+ ...config.teams,
890
+ incomingWebhookUrl: config.teams.incomingWebhookUrl ? '<secret:TEAMS_INCOMING_WEBHOOK_URL>' : undefined,
891
+ graphAccessToken: config.teams.graphAccessToken ? '<secret:TEAMS_GRAPH_ACCESS_TOKEN>' : undefined,
892
+ clientSecret: config.teams.clientSecret ? '<secret:TEAMS_CLIENT_SECRET>' : undefined,
893
+ }
894
+ : undefined,
895
+ webhooks: config.webhooks
896
+ ? {
897
+ ...config.webhooks,
898
+ secret: config.webhooks.secret ? '<secret:WEBHOOK_SECRET>' : undefined,
899
+ routes: config.webhooks.routes
900
+ ? Object.fromEntries(Object.entries(config.webhooks.routes).map(([name, route]) => [
901
+ name,
902
+ { ...route, secret: route.secret ? '<secret:WEBHOOK_ROUTE_SECRET>' : undefined },
903
+ ]))
904
+ : undefined,
905
+ }
906
+ : undefined,
907
+ };
908
+ }
909
+ function redactPhoneLike(value) {
910
+ const trimmed = value?.trim();
911
+ if (!trimmed)
912
+ return undefined;
913
+ const visible = trimmed.replace(/[\s()+.-]+/g, '');
914
+ if (visible.length <= 6)
915
+ return '<redacted>';
916
+ return `${visible.slice(0, 4)}…${visible.slice(-4)}`;
917
+ }
918
+ function redactSignalIdentifier(value) {
919
+ const trimmed = value?.trim();
920
+ if (!trimmed)
921
+ return undefined;
922
+ if (trimmed === '*')
923
+ return '*';
924
+ const [prefix, body] = trimmed.startsWith('group:') ? ['group:', trimmed.slice('group:'.length)] : ['', trimmed];
925
+ const visible = body.replace(/\s+/g, '');
926
+ if (visible.length <= 6)
927
+ return `${prefix}<redacted>`;
928
+ return `${prefix}${visible.slice(0, 4)}…${visible.slice(-4)}`;
929
+ }