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.
- package/.env.example +161 -3
- package/CHANGELOG.md +83 -5
- package/README.md +240 -23
- package/README.th.md +87 -6
- package/dist/approval.js +6 -0
- package/dist/bin.js +3045 -210
- package/dist/brain-context.js +223 -0
- package/dist/brain-doctor.js +318 -0
- package/dist/brain-eval.js +186 -0
- package/dist/brain-final.js +371 -0
- package/dist/brain-review.js +382 -0
- package/dist/brain.js +12 -1
- package/dist/brand.js +1 -1
- package/dist/cli-args.js +152 -0
- package/dist/cli-option-values.js +16 -0
- package/dist/commands.js +172 -13
- package/dist/compaction.js +96 -11
- package/dist/config.js +118 -28
- package/dist/context-compression.js +191 -0
- package/dist/cost.js +49 -15
- package/dist/first-run.js +21 -0
- package/dist/gateway/auth.js +37 -8
- package/dist/gateway/bluebubbles.js +205 -0
- package/dist/gateway/config.js +929 -0
- package/dist/gateway/deliver.js +357 -0
- package/dist/gateway/discord.js +124 -0
- package/dist/gateway/email.js +472 -0
- package/dist/gateway/googlechat.js +207 -0
- package/dist/gateway/homeassistant.js +256 -0
- package/dist/gateway/ledger.js +18 -0
- package/dist/gateway/line.js +171 -0
- package/dist/gateway/lock.js +3 -1
- package/dist/gateway/matrix.js +366 -0
- package/dist/gateway/mattermost.js +322 -0
- package/dist/gateway/ntfy.js +218 -0
- package/dist/gateway/schedule.js +31 -4
- package/dist/gateway/serve.js +267 -7
- package/dist/gateway/server.js +253 -19
- package/dist/gateway/service.js +224 -0
- package/dist/gateway/session.js +343 -0
- package/dist/gateway/signal.js +351 -0
- package/dist/gateway/slack.js +124 -0
- package/dist/gateway/sms.js +169 -0
- package/dist/gateway/targets.js +576 -0
- package/dist/gateway/teams.js +106 -0
- package/dist/gateway/telegram.js +38 -15
- package/dist/gateway/webhooks.js +220 -0
- package/dist/gateway/whatsapp.js +230 -0
- package/dist/hooks.js +13 -2
- package/dist/insights-args.js +35 -0
- package/dist/insights.js +86 -0
- package/dist/loop.js +123 -24
- package/dist/lsp/index.js +23 -5
- package/dist/mcp-registry.js +350 -0
- package/dist/mcp-server.js +1 -1
- package/dist/mcp.js +44 -6
- package/dist/memory.js +100 -33
- package/dist/orchestrate.js +49 -19
- package/dist/personality.js +58 -0
- package/dist/providers/codex.js +86 -38
- package/dist/providers/keys.js +1 -1
- package/dist/providers/models.js +22 -6
- package/dist/providers/registry.js +38 -49
- package/dist/search/chunk.js +7 -8
- package/dist/search/cli.js +75 -0
- package/dist/search/embed-store.js +3 -0
- package/dist/search/indexer.js +44 -1
- package/dist/search/store.js +23 -1
- package/dist/session.js +93 -7
- package/dist/skill-install.js +29 -12
- package/dist/support-dump.js +175 -0
- package/dist/tools/edit.js +45 -15
- package/dist/tools/git.js +10 -5
- package/dist/tools/homeassistant.js +106 -0
- package/dist/tools/index.js +5 -0
- package/dist/tools/list.js +19 -6
- package/dist/tools/permission.js +923 -9
- package/dist/tools/read.js +16 -4
- package/dist/tools/schedule.js +19 -3
- package/dist/tools/search.js +217 -13
- package/dist/tools/task.js +18 -7
- package/dist/tools/timeout.js +21 -3
- package/dist/trust.js +11 -1
- package/dist/ui/app.js +57 -11
- package/dist/ui/brain-wizard.js +2 -2
- package/dist/ui/history.js +37 -5
- package/dist/ui/mentions.js +3 -2
- package/dist/ui/render.js +55 -15
- package/dist/ui/setup.js +107 -10
- package/dist/update.js +24 -11
- package/dist/worktree.js +175 -4
- package/package.json +4 -4
- package/second-brain/AGENTS.md +6 -4
- package/second-brain/CLAUDE.md +7 -1
- package/second-brain/Evals/_Index.md +10 -2
- package/second-brain/Evals/quality-ledger.md +9 -1
- package/second-brain/Evals/second-brain-benchmarks.md +62 -0
- package/second-brain/GEMINI.md +5 -4
- package/second-brain/Home.md +1 -1
- package/second-brain/Projects/_Index.md +3 -1
- package/second-brain/Projects/sanook-cli/_Index.md +26 -0
- package/second-brain/Projects/sanook-cli/second-brain-feature-roadmap.md +156 -0
- package/second-brain/README.md +1 -1
- package/second-brain/Research/2026-06-17-ai-second-brain-method-experiment.md +108 -0
- package/second-brain/Research/2026-06-18-ai-token-reduction-frameworks.md +55 -0
- package/second-brain/Research/2026-06-18-hermes-cli-second-brain-expansion-research.md +160 -0
- package/second-brain/Research/2026-06-18-sanook-mcp-ecosystem-and-ux-roadmap.md +181 -0
- package/second-brain/Research/_Index.md +6 -1
- package/second-brain/Reviews/2026-06-18-auto-improve-maintenance.md +54 -0
- package/second-brain/Reviews/_Index.md +1 -1
- package/second-brain/Runbooks/_Index.md +6 -1
- package/second-brain/Runbooks/ai-second-brain-operating-sequence.md +108 -0
- package/second-brain/SANOOK.md +45 -0
- package/second-brain/Sessions/2026-06-17-ai-framework-additional-zones.md +68 -0
- package/second-brain/Sessions/2026-06-17-ai-second-brain-sequence-experiment.md +63 -0
- package/second-brain/Sessions/2026-06-18-cli-args-release-readiness.md +59 -0
- package/second-brain/Sessions/2026-06-18-final-gate-template-final.md +192 -0
- package/second-brain/Sessions/2026-06-18-final-gate-template.md +71 -0
- package/second-brain/Sessions/2026-06-18-framework-dogfood-permission-and-memory.md +58 -0
- package/second-brain/Sessions/2026-06-18-hermes-second-brain-expansion-research.md +52 -0
- package/second-brain/Sessions/2026-06-18-mcp-ecosystem-and-sanook-ux-scan.md +81 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-cli-p0-implementation.md +86 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli-final.md +246 -0
- package/second-brain/Sessions/2026-06-18-sanook-brain-final-cli.md +78 -0
- package/second-brain/Sessions/2026-06-18-sanook-cli-second-brain-roadmap-correction.md +54 -0
- package/second-brain/Sessions/2026-06-18-token-reduction-framework-integration.md +69 -0
- package/second-brain/Sessions/_Index.md +15 -1
- package/second-brain/Shared/AI-Context-Index.md +22 -0
- package/second-brain/Shared/Context-Packs/_Index.md +9 -1
- package/second-brain/Shared/Context-Packs/coding-release.md +51 -0
- package/second-brain/Shared/Context-Packs/research-to-framework.md +51 -0
- package/second-brain/Shared/Context-Packs/second-brain-maintenance.md +41 -0
- package/second-brain/Shared/Operating-State/current-state.md +22 -3
- package/second-brain/Shared/Scripts/_Index.md +3 -1
- package/second-brain/Shared/Scripts/ai-second-brain-method-eval.mjs +198 -0
- package/second-brain/Shared/Tech-Standards/_Index.md +4 -1
- package/second-brain/Shared/Tech-Standards/mcp-integration-roadmap.md +86 -0
- package/second-brain/Shared/Tech-Standards/verification-standard.md +24 -0
- package/second-brain/Shared/User-Memory/_Index.md +4 -1
- package/second-brain/Shared/User-Memory/response-examples.md +98 -0
- package/second-brain/Shared/User-Memory/user-preferences.md +1 -0
- package/second-brain/Templates/_Index.md +9 -0
- package/second-brain/Templates/final-lite.md +111 -0
- package/second-brain/Templates/final.md +231 -0
- package/second-brain/Vault Structure Map.md +2 -1
- package/skills/structured-output-llm/SKILL.md +1 -1
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { BRAND } from '../brand.js';
|
|
2
|
+
import { redactKey } from '../providers/keys.js';
|
|
3
|
+
import { runGatewayAgent } from './session.js';
|
|
4
|
+
const MATRIX_TEXT_LIMIT = 4000;
|
|
5
|
+
const MATRIX_STARTUP_GRACE_MS = 5000;
|
|
6
|
+
const runningTargets = new Set();
|
|
7
|
+
export function normalizeMatrixHomeserver(raw) {
|
|
8
|
+
const trimmed = raw?.trim().replace(/\/+$/, '');
|
|
9
|
+
if (!trimmed)
|
|
10
|
+
return undefined;
|
|
11
|
+
if (!/^https?:\/\//i.test(trimmed))
|
|
12
|
+
return undefined;
|
|
13
|
+
return trimmed;
|
|
14
|
+
}
|
|
15
|
+
export function normalizeMatrixUserId(raw) {
|
|
16
|
+
const trimmed = raw?.trim();
|
|
17
|
+
if (!trimmed)
|
|
18
|
+
return undefined;
|
|
19
|
+
return /^@[^:\s]+:[^:\s]+$/.test(trimmed) ? trimmed : undefined;
|
|
20
|
+
}
|
|
21
|
+
export function normalizeMatrixRoomId(raw) {
|
|
22
|
+
const trimmed = raw?.trim();
|
|
23
|
+
if (!trimmed)
|
|
24
|
+
return undefined;
|
|
25
|
+
return /^[!#][^:\s]+:[^:\s]+(?::\d+)?$/.test(trimmed) ? trimmed : undefined;
|
|
26
|
+
}
|
|
27
|
+
export function redactMatrixToken(raw) {
|
|
28
|
+
const token = raw?.trim();
|
|
29
|
+
if (!token)
|
|
30
|
+
return '(not set)';
|
|
31
|
+
if (token.length <= 10)
|
|
32
|
+
return '<redacted>';
|
|
33
|
+
return `${token.slice(0, 4)}…${token.slice(-4)}`;
|
|
34
|
+
}
|
|
35
|
+
export function matrixClientUrl(config, path, params) {
|
|
36
|
+
const homeserver = normalizeMatrixHomeserver(config.homeserver);
|
|
37
|
+
if (!homeserver)
|
|
38
|
+
throw new Error('Matrix homeserver ต้องเป็น URL เช่น https://matrix.org');
|
|
39
|
+
const url = new URL(`${homeserver}/_matrix/client/v3/${path.replace(/^\/+/, '')}`);
|
|
40
|
+
for (const [key, value] of Object.entries(params ?? {})) {
|
|
41
|
+
if (value != null && String(value).trim())
|
|
42
|
+
url.searchParams.set(key, String(value));
|
|
43
|
+
}
|
|
44
|
+
return url.toString();
|
|
45
|
+
}
|
|
46
|
+
export function matrixAuthHeaders(accessToken, extra = {}) {
|
|
47
|
+
const token = accessToken?.trim();
|
|
48
|
+
if (!token)
|
|
49
|
+
throw new Error('Matrix access token ว่าง');
|
|
50
|
+
return { authorization: `Bearer ${token}`, ...extra };
|
|
51
|
+
}
|
|
52
|
+
export function splitMatrixText(raw, limit = MATRIX_TEXT_LIMIT) {
|
|
53
|
+
let remaining = raw.trim() || '(ไม่มีผลลัพธ์)';
|
|
54
|
+
const chunks = [];
|
|
55
|
+
while (remaining.length > limit) {
|
|
56
|
+
const window = remaining.slice(0, limit + 1);
|
|
57
|
+
let cut = window.lastIndexOf('\n');
|
|
58
|
+
if (cut < Math.floor(limit * 0.4))
|
|
59
|
+
cut = window.lastIndexOf(' ');
|
|
60
|
+
if (cut < Math.floor(limit * 0.4))
|
|
61
|
+
cut = limit;
|
|
62
|
+
chunks.push(remaining.slice(0, cut).trim());
|
|
63
|
+
remaining = remaining.slice(cut).trimStart();
|
|
64
|
+
}
|
|
65
|
+
if (remaining)
|
|
66
|
+
chunks.push(remaining);
|
|
67
|
+
return chunks.length ? chunks : ['(ไม่มีผลลัพธ์)'];
|
|
68
|
+
}
|
|
69
|
+
async function readJsonOrThrow(response, label) {
|
|
70
|
+
const text = await response.text().catch(() => '');
|
|
71
|
+
if (!response.ok)
|
|
72
|
+
throw new Error(`${label} ${response.status}${text ? `: ${redactKey(text).slice(0, 200)}` : ''}`);
|
|
73
|
+
if (!text)
|
|
74
|
+
return {};
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(text);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw new Error(`${label} ${response.status}: response ไม่ใช่ JSON: ${redactKey(text).slice(0, 200)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export async function loginMatrix(config) {
|
|
83
|
+
if (config.accessToken?.trim())
|
|
84
|
+
return { accessToken: config.accessToken.trim(), userId: config.userId };
|
|
85
|
+
const user = config.userId?.trim();
|
|
86
|
+
const password = config.password?.trim();
|
|
87
|
+
if (!user || !password)
|
|
88
|
+
throw new Error('Matrix config ต้องมี accessToken หรือ userId/password');
|
|
89
|
+
const r = await fetch(matrixClientUrl(config, '/login'), {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'content-type': 'application/json' },
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
type: 'm.login.password',
|
|
94
|
+
identifier: { type: 'm.id.user', user },
|
|
95
|
+
password,
|
|
96
|
+
initial_device_display_name: `${BRAND.productName} Gateway`,
|
|
97
|
+
}),
|
|
98
|
+
});
|
|
99
|
+
const parsed = await readJsonOrThrow(r, 'Matrix login');
|
|
100
|
+
if (!parsed.access_token)
|
|
101
|
+
throw new Error('Matrix login response ไม่มี access_token');
|
|
102
|
+
return { accessToken: parsed.access_token, userId: parsed.user_id ?? user, deviceId: parsed.device_id };
|
|
103
|
+
}
|
|
104
|
+
export async function matrixWhoami(config) {
|
|
105
|
+
const auth = await loginMatrix(config);
|
|
106
|
+
const r = await fetch(matrixClientUrl(config, '/account/whoami'), {
|
|
107
|
+
method: 'GET',
|
|
108
|
+
headers: matrixAuthHeaders(auth.accessToken),
|
|
109
|
+
});
|
|
110
|
+
const parsed = await readJsonOrThrow(r, 'Matrix whoami');
|
|
111
|
+
return { userId: parsed.user_id ?? auth.userId, deviceId: parsed.device_id ?? auth.deviceId };
|
|
112
|
+
}
|
|
113
|
+
export async function sendMatrixMessage(config, roomId, text) {
|
|
114
|
+
const room = normalizeMatrixRoomId(roomId);
|
|
115
|
+
if (!room)
|
|
116
|
+
throw new Error('Matrix room id ต้องขึ้นต้นด้วย ! หรือ # และมี homeserver เช่น !abc:matrix.org');
|
|
117
|
+
const auth = await loginMatrix(config);
|
|
118
|
+
const chunks = splitMatrixText(text);
|
|
119
|
+
const eventIds = [];
|
|
120
|
+
for (const body of chunks) {
|
|
121
|
+
const txnId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
|
122
|
+
const r = await fetch(matrixClientUrl(config, `/rooms/${encodeURIComponent(room)}/send/m.room.message/${encodeURIComponent(txnId)}`), {
|
|
123
|
+
method: 'PUT',
|
|
124
|
+
headers: matrixAuthHeaders(auth.accessToken, { 'content-type': 'application/json' }),
|
|
125
|
+
body: JSON.stringify({ msgtype: 'm.text', body }),
|
|
126
|
+
});
|
|
127
|
+
const parsed = await readJsonOrThrow(r, 'Matrix send');
|
|
128
|
+
if (parsed.event_id)
|
|
129
|
+
eventIds.push(parsed.event_id);
|
|
130
|
+
}
|
|
131
|
+
return { roomId: room, eventIds, messageCount: chunks.length };
|
|
132
|
+
}
|
|
133
|
+
export async function joinMatrixRoom(config, roomId) {
|
|
134
|
+
const room = normalizeMatrixRoomId(roomId);
|
|
135
|
+
if (!room)
|
|
136
|
+
return;
|
|
137
|
+
const auth = await loginMatrix(config);
|
|
138
|
+
const r = await fetch(matrixClientUrl(config, `/join/${encodeURIComponent(room)}`), {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
headers: matrixAuthHeaders(auth.accessToken, { 'content-type': 'application/json' }),
|
|
141
|
+
body: '{}',
|
|
142
|
+
});
|
|
143
|
+
await readJsonOrThrow(r, 'Matrix join');
|
|
144
|
+
}
|
|
145
|
+
export function matrixSyncUrl(config, since) {
|
|
146
|
+
return matrixClientUrl(config, '/sync', {
|
|
147
|
+
since,
|
|
148
|
+
timeout: config.pollTimeoutMs,
|
|
149
|
+
set_presence: 'online',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
export function extractMatrixDirectRooms(sync) {
|
|
153
|
+
const rooms = new Set();
|
|
154
|
+
for (const event of sync.account_data?.events ?? []) {
|
|
155
|
+
if (event.type !== 'm.direct' || !event.content)
|
|
156
|
+
continue;
|
|
157
|
+
for (const ids of Object.values(event.content)) {
|
|
158
|
+
if (Array.isArray(ids)) {
|
|
159
|
+
for (const room of ids)
|
|
160
|
+
if (normalizeMatrixRoomId(room))
|
|
161
|
+
rooms.add(room);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return rooms;
|
|
166
|
+
}
|
|
167
|
+
export function extractMatrixTextEvents(sync, config, nowMs = Date.now()) {
|
|
168
|
+
const directRooms = extractMatrixDirectRooms(sync);
|
|
169
|
+
const out = [];
|
|
170
|
+
const botUserId = normalizeMatrixUserId(config.userId);
|
|
171
|
+
const staleBefore = nowMs - MATRIX_STARTUP_GRACE_MS;
|
|
172
|
+
for (const [roomId, room] of Object.entries(sync.rooms?.join ?? {})) {
|
|
173
|
+
const normalizedRoom = normalizeMatrixRoomId(roomId);
|
|
174
|
+
if (!normalizedRoom)
|
|
175
|
+
continue;
|
|
176
|
+
const isDirect = directRooms.has(normalizedRoom) || (room.summary?.['m.joined_member_count'] ?? 0) <= 2;
|
|
177
|
+
for (const event of room.timeline?.events ?? []) {
|
|
178
|
+
if (event.type !== 'm.room.message')
|
|
179
|
+
continue;
|
|
180
|
+
const sender = normalizeMatrixUserId(event.sender);
|
|
181
|
+
const text = event.content?.body?.trim();
|
|
182
|
+
const msgtype = event.content?.msgtype;
|
|
183
|
+
if (!sender || !text || !['m.text', 'm.notice'].includes(String(msgtype)))
|
|
184
|
+
continue;
|
|
185
|
+
if (botUserId && sender === botUserId)
|
|
186
|
+
continue;
|
|
187
|
+
if (event.origin_server_ts != null && event.origin_server_ts < staleBefore)
|
|
188
|
+
continue;
|
|
189
|
+
out.push({
|
|
190
|
+
roomId: normalizedRoom,
|
|
191
|
+
sender,
|
|
192
|
+
text,
|
|
193
|
+
eventId: event.event_id,
|
|
194
|
+
originServerTs: event.origin_server_ts,
|
|
195
|
+
isDirect,
|
|
196
|
+
mentionsBot: matrixMentionsBot(text, event, botUserId),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
export function matrixMentionsBot(text, event, botUserId) {
|
|
203
|
+
if (!botUserId)
|
|
204
|
+
return false;
|
|
205
|
+
if (event.content?.['m.mentions']?.user_ids?.includes(botUserId))
|
|
206
|
+
return true;
|
|
207
|
+
const localpart = botUserId.slice(1).split(':')[0];
|
|
208
|
+
return text.includes(botUserId) || Boolean(localpart && new RegExp(`(^|\\s)@?${escapeRegExp(localpart)}(\\b|\\s|:)`, 'i').test(text));
|
|
209
|
+
}
|
|
210
|
+
function escapeRegExp(value) {
|
|
211
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
212
|
+
}
|
|
213
|
+
export function isAllowedMatrixEvent(config, event) {
|
|
214
|
+
if (config.allowedRooms.length && !event.isDirect && !config.allowedRooms.includes(event.roomId))
|
|
215
|
+
return false;
|
|
216
|
+
if (config.allowAllUsers)
|
|
217
|
+
return true;
|
|
218
|
+
return config.allowedUsers.includes(event.sender);
|
|
219
|
+
}
|
|
220
|
+
export function matrixShouldRespond(config, event) {
|
|
221
|
+
if (event.isDirect)
|
|
222
|
+
return true;
|
|
223
|
+
if (config.freeResponseRooms.includes(event.roomId))
|
|
224
|
+
return true;
|
|
225
|
+
if (!config.requireMention)
|
|
226
|
+
return true;
|
|
227
|
+
return event.mentionsBot;
|
|
228
|
+
}
|
|
229
|
+
function matrixSessionTarget(config, event) {
|
|
230
|
+
if (event.isDirect || !config.groupSessionsPerUser)
|
|
231
|
+
return event.roomId;
|
|
232
|
+
return `${event.roomId}:${event.sender}`;
|
|
233
|
+
}
|
|
234
|
+
function matrixUserText(event) {
|
|
235
|
+
return event.text.replace(/^!(new|reset|status|help)\b/i, '/$1').trim();
|
|
236
|
+
}
|
|
237
|
+
function matrixPrompt(event) {
|
|
238
|
+
return [`Matrix ${event.isDirect ? 'DM' : 'room'} ${event.roomId} from ${event.sender}:`, event.text].join('\n');
|
|
239
|
+
}
|
|
240
|
+
export async function handleMatrixEvent(opts) {
|
|
241
|
+
const event = opts.event;
|
|
242
|
+
if (!isAllowedMatrixEvent(opts.config, event)) {
|
|
243
|
+
opts.onLog?.(`Matrix: ปฏิเสธ ${event.sender} ใน ${event.roomId} (ไม่อยู่ใน allowlist)`);
|
|
244
|
+
return { handled: false, reason: 'not_allowed' };
|
|
245
|
+
}
|
|
246
|
+
if (!matrixShouldRespond(opts.config, event))
|
|
247
|
+
return { handled: false, reason: 'not_mentioned' };
|
|
248
|
+
const target = matrixSessionTarget(opts.config, event);
|
|
249
|
+
const running = opts.runningTargets ?? runningTargets;
|
|
250
|
+
if (running.has(target))
|
|
251
|
+
return { handled: false, reason: 'busy' };
|
|
252
|
+
running.add(target);
|
|
253
|
+
try {
|
|
254
|
+
const result = await runGatewayAgent({
|
|
255
|
+
platform: 'matrix',
|
|
256
|
+
target,
|
|
257
|
+
model: opts.model,
|
|
258
|
+
prompt: matrixPrompt(event),
|
|
259
|
+
userText: matrixUserText(event),
|
|
260
|
+
budgetUsd: opts.budgetUsd,
|
|
261
|
+
permissionMode: opts.permissionMode ?? 'ask',
|
|
262
|
+
});
|
|
263
|
+
if (!result.suppressDelivery)
|
|
264
|
+
await sendMatrixMessage(opts.config, event.roomId, result.text || '(ไม่มีผลลัพธ์)');
|
|
265
|
+
return { handled: true };
|
|
266
|
+
}
|
|
267
|
+
catch (e) {
|
|
268
|
+
opts.onLog?.(`Matrix run error (${event.roomId}): ${redactKey(e.message)}`);
|
|
269
|
+
await sendMatrixMessage(opts.config, event.roomId, 'เกิดข้อผิดพลาดภายใน').catch(() => { });
|
|
270
|
+
return { handled: false, reason: 'error' };
|
|
271
|
+
}
|
|
272
|
+
finally {
|
|
273
|
+
running.delete(target);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
export async function handleMatrixSync(opts) {
|
|
277
|
+
let handled = 0;
|
|
278
|
+
let ignored = 0;
|
|
279
|
+
let joined = 0;
|
|
280
|
+
if (opts.config.autoJoin) {
|
|
281
|
+
for (const roomId of Object.keys(opts.sync.rooms?.invite ?? {})) {
|
|
282
|
+
try {
|
|
283
|
+
await joinMatrixRoom(opts.config, roomId);
|
|
284
|
+
joined += 1;
|
|
285
|
+
}
|
|
286
|
+
catch (e) {
|
|
287
|
+
opts.onLog?.(`Matrix join error (${roomId}): ${redactKey(e.message)}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
for (const event of extractMatrixTextEvents(opts.sync, opts.config, opts.startupMs ?? Date.now())) {
|
|
292
|
+
const result = await handleMatrixEvent({ ...opts, event });
|
|
293
|
+
if (result.handled)
|
|
294
|
+
handled += 1;
|
|
295
|
+
else
|
|
296
|
+
ignored += 1;
|
|
297
|
+
}
|
|
298
|
+
return { handled, ignored, joined };
|
|
299
|
+
}
|
|
300
|
+
async function delay(ms, signal) {
|
|
301
|
+
if (signal.aborted)
|
|
302
|
+
return;
|
|
303
|
+
await new Promise((resolve) => {
|
|
304
|
+
const timer = setTimeout(resolve, ms);
|
|
305
|
+
signal.addEventListener('abort', () => {
|
|
306
|
+
clearTimeout(timer);
|
|
307
|
+
resolve();
|
|
308
|
+
}, { once: true });
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
export function startMatrix(opts) {
|
|
312
|
+
if (!normalizeMatrixHomeserver(opts.config.homeserver)) {
|
|
313
|
+
opts.onLog?.('Matrix ไม่เริ่ม: ต้องตั้ง MATRIX_HOMESERVER เช่น https://matrix.org');
|
|
314
|
+
return () => { };
|
|
315
|
+
}
|
|
316
|
+
if (!opts.config.accessToken && (!opts.config.userId || !opts.config.password)) {
|
|
317
|
+
opts.onLog?.('Matrix ไม่เริ่ม: ต้องตั้ง MATRIX_ACCESS_TOKEN หรือ MATRIX_USER_ID/MATRIX_PASSWORD');
|
|
318
|
+
return () => { };
|
|
319
|
+
}
|
|
320
|
+
if (!opts.config.allowAllUsers && !opts.config.allowedUsers.length) {
|
|
321
|
+
opts.onLog?.('Matrix ไม่เริ่ม: ต้องตั้ง MATRIX_ALLOWED_USERS เพื่อ fail-closed');
|
|
322
|
+
return () => { };
|
|
323
|
+
}
|
|
324
|
+
const controller = new AbortController();
|
|
325
|
+
const reconnectMs = opts.reconnectMs ?? 5000;
|
|
326
|
+
const startupMs = opts.startupMs ?? Date.now();
|
|
327
|
+
const loop = async () => {
|
|
328
|
+
let since;
|
|
329
|
+
let runtimeConfig = opts.config;
|
|
330
|
+
opts.onLog?.(`Matrix: syncing ${opts.config.homeserver}`);
|
|
331
|
+
while (!controller.signal.aborted) {
|
|
332
|
+
try {
|
|
333
|
+
if (!runtimeConfig.userId) {
|
|
334
|
+
const whoami = await matrixWhoami(runtimeConfig);
|
|
335
|
+
runtimeConfig = { ...runtimeConfig, userId: whoami.userId };
|
|
336
|
+
}
|
|
337
|
+
const auth = await loginMatrix(runtimeConfig);
|
|
338
|
+
runtimeConfig = { ...runtimeConfig, accessToken: auth.accessToken, userId: runtimeConfig.userId ?? auth.userId };
|
|
339
|
+
const r = await fetch(matrixSyncUrl(runtimeConfig, since), {
|
|
340
|
+
method: 'GET',
|
|
341
|
+
headers: matrixAuthHeaders(auth.accessToken),
|
|
342
|
+
signal: controller.signal,
|
|
343
|
+
});
|
|
344
|
+
const sync = await readJsonOrThrow(r, 'Matrix sync');
|
|
345
|
+
since = sync.next_batch || since;
|
|
346
|
+
await handleMatrixSync({
|
|
347
|
+
config: runtimeConfig,
|
|
348
|
+
sync,
|
|
349
|
+
model: opts.model,
|
|
350
|
+
budgetUsd: opts.budgetUsd,
|
|
351
|
+
permissionMode: opts.permissionMode,
|
|
352
|
+
startupMs,
|
|
353
|
+
runningTargets,
|
|
354
|
+
onLog: opts.onLog,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
catch (e) {
|
|
358
|
+
if (!controller.signal.aborted)
|
|
359
|
+
opts.onLog?.(`Matrix sync error: ${redactKey(e.message)}; reconnecting`);
|
|
360
|
+
await delay(reconnectMs, controller.signal);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
void loop();
|
|
365
|
+
return () => controller.abort();
|
|
366
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { BRAND } from '../brand.js';
|
|
2
|
+
import { redactKey } from '../providers/keys.js';
|
|
3
|
+
import { runGatewayAgent } from './session.js';
|
|
4
|
+
const MATTERMOST_TEXT_LIMIT = 4000;
|
|
5
|
+
const runningTargets = new Set();
|
|
6
|
+
export function normalizeMattermostUrl(raw) {
|
|
7
|
+
const trimmed = raw?.trim().replace(/\/+$/, '');
|
|
8
|
+
if (!trimmed)
|
|
9
|
+
return undefined;
|
|
10
|
+
if (!/^https?:\/\//i.test(trimmed))
|
|
11
|
+
return undefined;
|
|
12
|
+
return trimmed;
|
|
13
|
+
}
|
|
14
|
+
export function mattermostApiUrl(config, path, params) {
|
|
15
|
+
const base = normalizeMattermostUrl(config.serverUrl);
|
|
16
|
+
if (!base)
|
|
17
|
+
throw new Error('Mattermost URL ต้องเป็น URL เช่น https://mm.example.com');
|
|
18
|
+
const url = new URL(`${base}/api/v4/${path.replace(/^\/+/, '')}`);
|
|
19
|
+
for (const [key, value] of Object.entries(params ?? {})) {
|
|
20
|
+
if (value != null && String(value).trim())
|
|
21
|
+
url.searchParams.set(key, String(value));
|
|
22
|
+
}
|
|
23
|
+
return url.toString();
|
|
24
|
+
}
|
|
25
|
+
export function mattermostWebSocketUrl(serverUrl) {
|
|
26
|
+
const base = normalizeMattermostUrl(serverUrl);
|
|
27
|
+
if (!base)
|
|
28
|
+
throw new Error('Mattermost URL ต้องเป็น URL เช่น https://mm.example.com');
|
|
29
|
+
const url = new URL(base);
|
|
30
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
31
|
+
const pathBase = url.pathname.replace(/\/+$/, '');
|
|
32
|
+
url.pathname = `${pathBase}/api/v4/websocket`;
|
|
33
|
+
url.search = '';
|
|
34
|
+
return url.toString();
|
|
35
|
+
}
|
|
36
|
+
export function mattermostAuthHeaders(token, extra = {}) {
|
|
37
|
+
const clean = token?.trim();
|
|
38
|
+
if (!clean)
|
|
39
|
+
throw new Error('Mattermost token ว่าง');
|
|
40
|
+
return { authorization: `Bearer ${clean}`, ...extra };
|
|
41
|
+
}
|
|
42
|
+
export function splitMattermostText(raw, limit = MATTERMOST_TEXT_LIMIT) {
|
|
43
|
+
let remaining = raw.trim() || '(ไม่มีผลลัพธ์)';
|
|
44
|
+
const chunks = [];
|
|
45
|
+
while (remaining.length > limit) {
|
|
46
|
+
const window = remaining.slice(0, limit + 1);
|
|
47
|
+
let cut = window.lastIndexOf('\n');
|
|
48
|
+
if (cut < Math.floor(limit * 0.4))
|
|
49
|
+
cut = window.lastIndexOf(' ');
|
|
50
|
+
if (cut < Math.floor(limit * 0.4))
|
|
51
|
+
cut = limit;
|
|
52
|
+
chunks.push(remaining.slice(0, cut).trim());
|
|
53
|
+
remaining = remaining.slice(cut).trimStart();
|
|
54
|
+
}
|
|
55
|
+
if (remaining)
|
|
56
|
+
chunks.push(remaining);
|
|
57
|
+
return chunks.length ? chunks : ['(ไม่มีผลลัพธ์)'];
|
|
58
|
+
}
|
|
59
|
+
async function readJsonOrThrow(response, label) {
|
|
60
|
+
const text = await response.text().catch(() => '');
|
|
61
|
+
if (!response.ok)
|
|
62
|
+
throw new Error(`${label} ${response.status}${text ? `: ${redactKey(text).slice(0, 200)}` : ''}`);
|
|
63
|
+
if (!text)
|
|
64
|
+
return {};
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(text);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new Error(`${label} ${response.status}: response ไม่ใช่ JSON: ${redactKey(text).slice(0, 200)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
export async function mattermostMe(config) {
|
|
73
|
+
const r = await fetch(mattermostApiUrl(config, '/users/me'), {
|
|
74
|
+
method: 'GET',
|
|
75
|
+
headers: mattermostAuthHeaders(config.token),
|
|
76
|
+
});
|
|
77
|
+
return readJsonOrThrow(r, 'Mattermost users/me');
|
|
78
|
+
}
|
|
79
|
+
export async function sendMattermostMessage(config, channelId, text, rootId) {
|
|
80
|
+
const channel = channelId.trim();
|
|
81
|
+
if (!channel)
|
|
82
|
+
throw new Error('Mattermost channel id ว่าง');
|
|
83
|
+
const chunks = splitMattermostText(text);
|
|
84
|
+
const postIds = [];
|
|
85
|
+
for (const chunk of chunks) {
|
|
86
|
+
const r = await fetch(mattermostApiUrl(config, '/posts'), {
|
|
87
|
+
method: 'POST',
|
|
88
|
+
headers: mattermostAuthHeaders(config.token, { 'content-type': 'application/json' }),
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
channel_id: channel,
|
|
91
|
+
message: chunk,
|
|
92
|
+
...(rootId?.trim() ? { root_id: rootId.trim() } : {}),
|
|
93
|
+
}),
|
|
94
|
+
});
|
|
95
|
+
const parsed = await readJsonOrThrow(r, 'Mattermost create post');
|
|
96
|
+
if (parsed.id)
|
|
97
|
+
postIds.push(parsed.id);
|
|
98
|
+
}
|
|
99
|
+
return { channelId: channel, postIds, messageCount: chunks.length };
|
|
100
|
+
}
|
|
101
|
+
function parsePost(raw) {
|
|
102
|
+
if (!raw)
|
|
103
|
+
return undefined;
|
|
104
|
+
if (typeof raw === 'string') {
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(raw);
|
|
107
|
+
return parsed && typeof parsed === 'object' ? parsed : undefined;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return typeof raw === 'object' ? raw : undefined;
|
|
114
|
+
}
|
|
115
|
+
function parseMentions(raw) {
|
|
116
|
+
if (!raw)
|
|
117
|
+
return [];
|
|
118
|
+
if (Array.isArray(raw))
|
|
119
|
+
return raw.filter((v) => typeof v === 'string');
|
|
120
|
+
if (typeof raw !== 'string')
|
|
121
|
+
return [];
|
|
122
|
+
try {
|
|
123
|
+
const parsed = JSON.parse(raw);
|
|
124
|
+
if (Array.isArray(parsed))
|
|
125
|
+
return parsed.filter((v) => typeof v === 'string');
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Some Mattermost-compatible gateways emit comma-separated mention IDs.
|
|
129
|
+
}
|
|
130
|
+
return raw
|
|
131
|
+
.split(',')
|
|
132
|
+
.map((s) => s.trim())
|
|
133
|
+
.filter(Boolean);
|
|
134
|
+
}
|
|
135
|
+
export function mattermostMentionsBot(text, envelope, botUsername, botUserId) {
|
|
136
|
+
const mentions = parseMentions(envelope.data?.mentions);
|
|
137
|
+
if (botUserId && mentions.includes(botUserId))
|
|
138
|
+
return true;
|
|
139
|
+
const username = botUsername?.trim().replace(/^@/, '');
|
|
140
|
+
if (!username)
|
|
141
|
+
return false;
|
|
142
|
+
return new RegExp(`(^|\\s)@${escapeRegExp(username)}(\\b|\\s|:)`, 'i').test(text);
|
|
143
|
+
}
|
|
144
|
+
export function parseMattermostPostedEvent(raw, bot = {}) {
|
|
145
|
+
let envelope;
|
|
146
|
+
try {
|
|
147
|
+
envelope = JSON.parse(raw);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
if (envelope.event !== 'posted')
|
|
153
|
+
return null;
|
|
154
|
+
const post = parsePost(envelope.data?.post);
|
|
155
|
+
const userId = post?.user_id?.trim();
|
|
156
|
+
const channelId = (post?.channel_id ?? envelope.broadcast?.channel_id)?.trim();
|
|
157
|
+
const text = post?.message?.trim();
|
|
158
|
+
if (!userId || !channelId || !text)
|
|
159
|
+
return null;
|
|
160
|
+
if (bot.userId && userId === bot.userId)
|
|
161
|
+
return null;
|
|
162
|
+
const channelType = envelope.data?.channel_type?.trim();
|
|
163
|
+
return {
|
|
164
|
+
channelId,
|
|
165
|
+
userId,
|
|
166
|
+
text,
|
|
167
|
+
postId: post?.id,
|
|
168
|
+
rootId: post?.root_id || undefined,
|
|
169
|
+
channelType,
|
|
170
|
+
createAt: post?.create_at,
|
|
171
|
+
isDirect: channelType === 'D',
|
|
172
|
+
mentionsBot: mattermostMentionsBot(text, envelope, bot.username, bot.userId),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
export function isAllowedMattermostEvent(config, event) {
|
|
176
|
+
if (!event.isDirect && config.allowedChannels.length && !config.allowedChannels.includes(event.channelId))
|
|
177
|
+
return false;
|
|
178
|
+
if (config.allowAllUsers)
|
|
179
|
+
return true;
|
|
180
|
+
return config.allowedUsers.includes(event.userId);
|
|
181
|
+
}
|
|
182
|
+
export function mattermostShouldRespond(config, event) {
|
|
183
|
+
if (event.isDirect)
|
|
184
|
+
return true;
|
|
185
|
+
if (config.freeResponseChannels.includes(event.channelId))
|
|
186
|
+
return true;
|
|
187
|
+
if (!config.requireMention)
|
|
188
|
+
return true;
|
|
189
|
+
return event.mentionsBot;
|
|
190
|
+
}
|
|
191
|
+
export function mattermostUserText(event, botUsername) {
|
|
192
|
+
let text = event.text.replace(/^!(new|reset|status|help)\b/i, '/$1').trim();
|
|
193
|
+
const username = botUsername?.trim().replace(/^@/, '');
|
|
194
|
+
if (username)
|
|
195
|
+
text = text.replace(new RegExp(`(^|\\s)@${escapeRegExp(username)}(\\b|\\s|:)`, 'gi'), ' ').trim();
|
|
196
|
+
return text || event.text;
|
|
197
|
+
}
|
|
198
|
+
function mattermostPrompt(event) {
|
|
199
|
+
return [`Mattermost ${event.isDirect ? 'DM' : 'channel'} ${event.channelId} from ${event.userId}:`, event.text].join('\n');
|
|
200
|
+
}
|
|
201
|
+
function mattermostReplyRootId(config, event) {
|
|
202
|
+
return event.rootId || (config.replyMode === 'thread' ? event.postId : undefined);
|
|
203
|
+
}
|
|
204
|
+
function mattermostSessionTarget(config, event) {
|
|
205
|
+
if (event.isDirect)
|
|
206
|
+
return event.channelId;
|
|
207
|
+
const threadId = event.rootId || (config.replyMode === 'thread' ? event.postId : undefined);
|
|
208
|
+
if (threadId)
|
|
209
|
+
return config.groupSessionsPerUser ? `${event.channelId}:${threadId}:${event.userId}` : `${event.channelId}:${threadId}`;
|
|
210
|
+
return config.groupSessionsPerUser ? `${event.channelId}:${event.userId}` : event.channelId;
|
|
211
|
+
}
|
|
212
|
+
export async function handleMattermostEvent(opts) {
|
|
213
|
+
const event = opts.event;
|
|
214
|
+
if (!isAllowedMattermostEvent(opts.config, event)) {
|
|
215
|
+
opts.onLog?.(`Mattermost: ปฏิเสธ ${event.userId} ใน ${event.channelId} (ไม่อยู่ใน allowlist)`);
|
|
216
|
+
return { handled: false, reason: 'not_allowed' };
|
|
217
|
+
}
|
|
218
|
+
if (!mattermostShouldRespond(opts.config, event))
|
|
219
|
+
return { handled: false, reason: 'not_mentioned' };
|
|
220
|
+
const target = mattermostSessionTarget(opts.config, event);
|
|
221
|
+
const running = opts.runningTargets ?? runningTargets;
|
|
222
|
+
if (running.has(target))
|
|
223
|
+
return { handled: false, reason: 'busy' };
|
|
224
|
+
running.add(target);
|
|
225
|
+
try {
|
|
226
|
+
const result = await runGatewayAgent({
|
|
227
|
+
platform: 'mattermost',
|
|
228
|
+
target,
|
|
229
|
+
model: opts.model,
|
|
230
|
+
prompt: mattermostPrompt(event),
|
|
231
|
+
userText: mattermostUserText(event, opts.botUsername),
|
|
232
|
+
budgetUsd: opts.budgetUsd,
|
|
233
|
+
permissionMode: opts.permissionMode ?? 'ask',
|
|
234
|
+
});
|
|
235
|
+
if (!result.suppressDelivery) {
|
|
236
|
+
await sendMattermostMessage(opts.config, event.channelId, result.text || '(ไม่มีผลลัพธ์)', mattermostReplyRootId(opts.config, event));
|
|
237
|
+
}
|
|
238
|
+
return { handled: true };
|
|
239
|
+
}
|
|
240
|
+
catch (e) {
|
|
241
|
+
opts.onLog?.(`Mattermost run error (${event.channelId}): ${redactKey(e.message)}`);
|
|
242
|
+
await sendMattermostMessage(opts.config, event.channelId, 'เกิดข้อผิดพลาดภายใน', mattermostReplyRootId(opts.config, event)).catch(() => { });
|
|
243
|
+
return { handled: false, reason: 'error' };
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
running.delete(target);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
function defaultWebSocketFactory(url) {
|
|
250
|
+
const WS = globalThis.WebSocket;
|
|
251
|
+
if (!WS)
|
|
252
|
+
throw new Error('WebSocket runtime ไม่พร้อมใช้งานใน Node นี้');
|
|
253
|
+
return new WS(url);
|
|
254
|
+
}
|
|
255
|
+
export async function startMattermost(opts) {
|
|
256
|
+
if (!normalizeMattermostUrl(opts.config.serverUrl)) {
|
|
257
|
+
opts.onLog?.('Mattermost ไม่เริ่ม: ต้องตั้ง MATTERMOST_URL เช่น https://mm.example.com');
|
|
258
|
+
return () => { };
|
|
259
|
+
}
|
|
260
|
+
if (!opts.config.token?.trim()) {
|
|
261
|
+
opts.onLog?.('Mattermost ไม่เริ่ม: ต้องตั้ง MATTERMOST_TOKEN');
|
|
262
|
+
return () => { };
|
|
263
|
+
}
|
|
264
|
+
if (!opts.config.allowAllUsers && !opts.config.allowedUsers.length) {
|
|
265
|
+
opts.onLog?.('Mattermost ไม่เริ่ม: ต้องตั้ง MATTERMOST_ALLOWED_USERS เพื่อ fail-closed');
|
|
266
|
+
return () => { };
|
|
267
|
+
}
|
|
268
|
+
const me = await mattermostMe(opts.config);
|
|
269
|
+
if (!me.id)
|
|
270
|
+
throw new Error('Mattermost users/me response ไม่มี id');
|
|
271
|
+
const ws = (opts.webSocketFactory ?? defaultWebSocketFactory)(mattermostWebSocketUrl(opts.config.serverUrl));
|
|
272
|
+
const bot = { userId: me.id, username: me.username };
|
|
273
|
+
let stopped = false;
|
|
274
|
+
ws.addEventListener('open', () => {
|
|
275
|
+
opts.onLog?.(`Mattermost: websocket connecting as @${me.username ?? me.id}`);
|
|
276
|
+
ws.send(JSON.stringify({
|
|
277
|
+
seq: 1,
|
|
278
|
+
action: 'authentication_challenge',
|
|
279
|
+
data: { token: opts.config.token },
|
|
280
|
+
}));
|
|
281
|
+
});
|
|
282
|
+
ws.addEventListener('message', (event) => {
|
|
283
|
+
const raw = String(event.data ?? '');
|
|
284
|
+
if (!raw)
|
|
285
|
+
return;
|
|
286
|
+
try {
|
|
287
|
+
const envelope = JSON.parse(raw);
|
|
288
|
+
if (envelope.status === 'OK' && envelope.seq_reply === 1) {
|
|
289
|
+
opts.onLog?.('Mattermost: websocket authenticated');
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const inbound = parseMattermostPostedEvent(raw, bot);
|
|
297
|
+
if (!inbound)
|
|
298
|
+
return;
|
|
299
|
+
void handleMattermostEvent({
|
|
300
|
+
config: opts.config,
|
|
301
|
+
event: inbound,
|
|
302
|
+
model: opts.model,
|
|
303
|
+
budgetUsd: opts.budgetUsd,
|
|
304
|
+
permissionMode: opts.permissionMode,
|
|
305
|
+
botUsername: bot.username,
|
|
306
|
+
runningTargets,
|
|
307
|
+
onLog: opts.onLog,
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
ws.addEventListener('close', () => {
|
|
311
|
+
if (!stopped)
|
|
312
|
+
opts.onLog?.('Mattermost: websocket closed');
|
|
313
|
+
});
|
|
314
|
+
ws.addEventListener('error', () => opts.onLog?.('Mattermost: websocket error'));
|
|
315
|
+
return () => {
|
|
316
|
+
stopped = true;
|
|
317
|
+
ws.close();
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
function escapeRegExp(value) {
|
|
321
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
322
|
+
}
|