@openclaw/mattermost 2026.2.21
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/index.ts +17 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +28 -0
- package/src/channel.test.ts +235 -0
- package/src/channel.ts +433 -0
- package/src/config-schema.ts +61 -0
- package/src/group-mentions.ts +15 -0
- package/src/mattermost/accounts.ts +128 -0
- package/src/mattermost/client.test.ts +19 -0
- package/src/mattermost/client.ts +230 -0
- package/src/mattermost/index.ts +9 -0
- package/src/mattermost/monitor-helpers.ts +93 -0
- package/src/mattermost/monitor-onchar.ts +25 -0
- package/src/mattermost/monitor-websocket.test.ts +232 -0
- package/src/mattermost/monitor-websocket.ts +221 -0
- package/src/mattermost/monitor.ts +1030 -0
- package/src/mattermost/probe.ts +73 -0
- package/src/mattermost/reactions.test-helpers.ts +83 -0
- package/src/mattermost/reactions.test.ts +103 -0
- package/src/mattermost/reactions.ts +124 -0
- package/src/mattermost/reconnect.test.ts +192 -0
- package/src/mattermost/reconnect.ts +103 -0
- package/src/mattermost/send.ts +231 -0
- package/src/normalize.ts +46 -0
- package/src/onboarding-helpers.ts +1 -0
- package/src/onboarding.ts +186 -0
- package/src/runtime.ts +14 -0
- package/src/types.ts +57 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
export type MattermostClient = {
|
|
2
|
+
baseUrl: string;
|
|
3
|
+
apiBaseUrl: string;
|
|
4
|
+
token: string;
|
|
5
|
+
request: <T>(path: string, init?: RequestInit) => Promise<T>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type MattermostUser = {
|
|
9
|
+
id: string;
|
|
10
|
+
username?: string | null;
|
|
11
|
+
nickname?: string | null;
|
|
12
|
+
first_name?: string | null;
|
|
13
|
+
last_name?: string | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type MattermostChannel = {
|
|
17
|
+
id: string;
|
|
18
|
+
name?: string | null;
|
|
19
|
+
display_name?: string | null;
|
|
20
|
+
type?: string | null;
|
|
21
|
+
team_id?: string | null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type MattermostPost = {
|
|
25
|
+
id: string;
|
|
26
|
+
user_id?: string | null;
|
|
27
|
+
channel_id?: string | null;
|
|
28
|
+
message?: string | null;
|
|
29
|
+
file_ids?: string[] | null;
|
|
30
|
+
type?: string | null;
|
|
31
|
+
root_id?: string | null;
|
|
32
|
+
create_at?: number | null;
|
|
33
|
+
props?: Record<string, unknown> | null;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type MattermostFileInfo = {
|
|
37
|
+
id: string;
|
|
38
|
+
name?: string | null;
|
|
39
|
+
mime_type?: string | null;
|
|
40
|
+
size?: number | null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function normalizeMattermostBaseUrl(raw?: string | null): string | undefined {
|
|
44
|
+
const trimmed = raw?.trim();
|
|
45
|
+
if (!trimmed) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
const withoutTrailing = trimmed.replace(/\/+$/, "");
|
|
49
|
+
return withoutTrailing.replace(/\/api\/v4$/i, "");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildMattermostApiUrl(baseUrl: string, path: string): string {
|
|
53
|
+
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
|
54
|
+
if (!normalized) {
|
|
55
|
+
throw new Error("Mattermost baseUrl is required");
|
|
56
|
+
}
|
|
57
|
+
const suffix = path.startsWith("/") ? path : `/${path}`;
|
|
58
|
+
return `${normalized}/api/v4${suffix}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readMattermostError(res: Response): Promise<string> {
|
|
62
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
63
|
+
if (contentType.includes("application/json")) {
|
|
64
|
+
const data = (await res.json()) as { message?: string } | undefined;
|
|
65
|
+
if (data?.message) {
|
|
66
|
+
return data.message;
|
|
67
|
+
}
|
|
68
|
+
return JSON.stringify(data);
|
|
69
|
+
}
|
|
70
|
+
return await res.text();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createMattermostClient(params: {
|
|
74
|
+
baseUrl: string;
|
|
75
|
+
botToken: string;
|
|
76
|
+
fetchImpl?: typeof fetch;
|
|
77
|
+
}): MattermostClient {
|
|
78
|
+
const baseUrl = normalizeMattermostBaseUrl(params.baseUrl);
|
|
79
|
+
if (!baseUrl) {
|
|
80
|
+
throw new Error("Mattermost baseUrl is required");
|
|
81
|
+
}
|
|
82
|
+
const apiBaseUrl = `${baseUrl}/api/v4`;
|
|
83
|
+
const token = params.botToken.trim();
|
|
84
|
+
const fetchImpl = params.fetchImpl ?? fetch;
|
|
85
|
+
|
|
86
|
+
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
|
87
|
+
const url = buildMattermostApiUrl(baseUrl, path);
|
|
88
|
+
const headers = new Headers(init?.headers);
|
|
89
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
90
|
+
if (typeof init?.body === "string" && !headers.has("Content-Type")) {
|
|
91
|
+
headers.set("Content-Type", "application/json");
|
|
92
|
+
}
|
|
93
|
+
const res = await fetchImpl(url, { ...init, headers });
|
|
94
|
+
if (!res.ok) {
|
|
95
|
+
const detail = await readMattermostError(res);
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (res.status === 204) {
|
|
102
|
+
return undefined as T;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
106
|
+
if (contentType.includes("application/json")) {
|
|
107
|
+
return (await res.json()) as T;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return (await res.text()) as T;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return { baseUrl, apiBaseUrl, token, request };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function fetchMattermostMe(client: MattermostClient): Promise<MattermostUser> {
|
|
117
|
+
return await client.request<MattermostUser>("/users/me");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function fetchMattermostUser(
|
|
121
|
+
client: MattermostClient,
|
|
122
|
+
userId: string,
|
|
123
|
+
): Promise<MattermostUser> {
|
|
124
|
+
return await client.request<MattermostUser>(`/users/${userId}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function fetchMattermostUserByUsername(
|
|
128
|
+
client: MattermostClient,
|
|
129
|
+
username: string,
|
|
130
|
+
): Promise<MattermostUser> {
|
|
131
|
+
return await client.request<MattermostUser>(`/users/username/${encodeURIComponent(username)}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function fetchMattermostChannel(
|
|
135
|
+
client: MattermostClient,
|
|
136
|
+
channelId: string,
|
|
137
|
+
): Promise<MattermostChannel> {
|
|
138
|
+
return await client.request<MattermostChannel>(`/channels/${channelId}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function sendMattermostTyping(
|
|
142
|
+
client: MattermostClient,
|
|
143
|
+
params: { channelId: string; parentId?: string },
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
const payload: Record<string, string> = {
|
|
146
|
+
channel_id: params.channelId,
|
|
147
|
+
};
|
|
148
|
+
const parentId = params.parentId?.trim();
|
|
149
|
+
if (parentId) {
|
|
150
|
+
payload.parent_id = parentId;
|
|
151
|
+
}
|
|
152
|
+
await client.request<Record<string, unknown>>("/users/me/typing", {
|
|
153
|
+
method: "POST",
|
|
154
|
+
body: JSON.stringify(payload),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function createMattermostDirectChannel(
|
|
159
|
+
client: MattermostClient,
|
|
160
|
+
userIds: string[],
|
|
161
|
+
): Promise<MattermostChannel> {
|
|
162
|
+
return await client.request<MattermostChannel>("/channels/direct", {
|
|
163
|
+
method: "POST",
|
|
164
|
+
body: JSON.stringify(userIds),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function createMattermostPost(
|
|
169
|
+
client: MattermostClient,
|
|
170
|
+
params: {
|
|
171
|
+
channelId: string;
|
|
172
|
+
message: string;
|
|
173
|
+
rootId?: string;
|
|
174
|
+
fileIds?: string[];
|
|
175
|
+
},
|
|
176
|
+
): Promise<MattermostPost> {
|
|
177
|
+
const payload: Record<string, string> = {
|
|
178
|
+
channel_id: params.channelId,
|
|
179
|
+
message: params.message,
|
|
180
|
+
};
|
|
181
|
+
if (params.rootId) {
|
|
182
|
+
payload.root_id = params.rootId;
|
|
183
|
+
}
|
|
184
|
+
if (params.fileIds?.length) {
|
|
185
|
+
(payload as Record<string, unknown>).file_ids = params.fileIds;
|
|
186
|
+
}
|
|
187
|
+
return await client.request<MattermostPost>("/posts", {
|
|
188
|
+
method: "POST",
|
|
189
|
+
body: JSON.stringify(payload),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function uploadMattermostFile(
|
|
194
|
+
client: MattermostClient,
|
|
195
|
+
params: {
|
|
196
|
+
channelId: string;
|
|
197
|
+
buffer: Buffer;
|
|
198
|
+
fileName: string;
|
|
199
|
+
contentType?: string;
|
|
200
|
+
},
|
|
201
|
+
): Promise<MattermostFileInfo> {
|
|
202
|
+
const form = new FormData();
|
|
203
|
+
const fileName = params.fileName?.trim() || "upload";
|
|
204
|
+
const bytes = Uint8Array.from(params.buffer);
|
|
205
|
+
const blob = params.contentType
|
|
206
|
+
? new Blob([bytes], { type: params.contentType })
|
|
207
|
+
: new Blob([bytes]);
|
|
208
|
+
form.append("files", blob, fileName);
|
|
209
|
+
form.append("channel_id", params.channelId);
|
|
210
|
+
|
|
211
|
+
const res = await fetch(`${client.apiBaseUrl}/files`, {
|
|
212
|
+
method: "POST",
|
|
213
|
+
headers: {
|
|
214
|
+
Authorization: `Bearer ${client.token}`,
|
|
215
|
+
},
|
|
216
|
+
body: form,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
if (!res.ok) {
|
|
220
|
+
const detail = await readMattermostError(res);
|
|
221
|
+
throw new Error(`Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const data = (await res.json()) as { file_infos?: MattermostFileInfo[] };
|
|
225
|
+
const info = data.file_infos?.[0];
|
|
226
|
+
if (!info?.id) {
|
|
227
|
+
throw new Error("Mattermost file upload failed");
|
|
228
|
+
}
|
|
229
|
+
return info;
|
|
230
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export {
|
|
2
|
+
listEnabledMattermostAccounts,
|
|
3
|
+
listMattermostAccountIds,
|
|
4
|
+
resolveDefaultMattermostAccountId,
|
|
5
|
+
resolveMattermostAccount,
|
|
6
|
+
} from "./accounts.js";
|
|
7
|
+
export { monitorMattermostProvider } from "./monitor.js";
|
|
8
|
+
export { probeMattermost } from "./probe.js";
|
|
9
|
+
export { sendMessageMattermost } from "./send.js";
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
export { createDedupeCache, rawDataToString } from "openclaw/plugin-sdk";
|
|
3
|
+
|
|
4
|
+
export type ResponsePrefixContext = {
|
|
5
|
+
model?: string;
|
|
6
|
+
modelFull?: string;
|
|
7
|
+
provider?: string;
|
|
8
|
+
thinkingLevel?: string;
|
|
9
|
+
identityName?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function extractShortModelName(fullModel: string): string {
|
|
13
|
+
const slash = fullModel.lastIndexOf("/");
|
|
14
|
+
const modelPart = slash >= 0 ? fullModel.slice(slash + 1) : fullModel;
|
|
15
|
+
return modelPart.replace(/-\d{8}$/, "").replace(/-latest$/, "");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function formatInboundFromLabel(params: {
|
|
19
|
+
isGroup: boolean;
|
|
20
|
+
groupLabel?: string;
|
|
21
|
+
groupId?: string;
|
|
22
|
+
directLabel: string;
|
|
23
|
+
directId?: string;
|
|
24
|
+
groupFallback?: string;
|
|
25
|
+
}): string {
|
|
26
|
+
if (params.isGroup) {
|
|
27
|
+
const label = params.groupLabel?.trim() || params.groupFallback || "Group";
|
|
28
|
+
const id = params.groupId?.trim();
|
|
29
|
+
return id ? `${label} id:${id}` : label;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const directLabel = params.directLabel.trim();
|
|
33
|
+
const directId = params.directId?.trim();
|
|
34
|
+
if (!directId || directId === directLabel) {
|
|
35
|
+
return directLabel;
|
|
36
|
+
}
|
|
37
|
+
return `${directLabel} id:${directId}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeAgentId(value: string | undefined | null): string {
|
|
41
|
+
const trimmed = (value ?? "").trim();
|
|
42
|
+
if (!trimmed) {
|
|
43
|
+
return "main";
|
|
44
|
+
}
|
|
45
|
+
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) {
|
|
46
|
+
return trimmed;
|
|
47
|
+
}
|
|
48
|
+
return (
|
|
49
|
+
trimmed
|
|
50
|
+
.toLowerCase()
|
|
51
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
52
|
+
.replace(/^-+/, "")
|
|
53
|
+
.replace(/-+$/, "")
|
|
54
|
+
.slice(0, 64) || "main"
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number];
|
|
59
|
+
|
|
60
|
+
function listAgents(cfg: OpenClawConfig): AgentEntry[] {
|
|
61
|
+
const list = cfg.agents?.list;
|
|
62
|
+
if (!Array.isArray(list)) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolveAgentEntry(cfg: OpenClawConfig, agentId: string): AgentEntry | undefined {
|
|
69
|
+
const id = normalizeAgentId(agentId);
|
|
70
|
+
return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function resolveIdentityName(cfg: OpenClawConfig, agentId: string): string | undefined {
|
|
74
|
+
const entry = resolveAgentEntry(cfg, agentId);
|
|
75
|
+
return entry?.identity?.name?.trim() || undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function resolveThreadSessionKeys(params: {
|
|
79
|
+
baseSessionKey: string;
|
|
80
|
+
threadId?: string | null;
|
|
81
|
+
parentSessionKey?: string;
|
|
82
|
+
useSuffix?: boolean;
|
|
83
|
+
}): { sessionKey: string; parentSessionKey?: string } {
|
|
84
|
+
const threadId = (params.threadId ?? "").trim();
|
|
85
|
+
if (!threadId) {
|
|
86
|
+
return { sessionKey: params.baseSessionKey, parentSessionKey: undefined };
|
|
87
|
+
}
|
|
88
|
+
const useSuffix = params.useSuffix ?? true;
|
|
89
|
+
const sessionKey = useSuffix
|
|
90
|
+
? `${params.baseSessionKey}:thread:${threadId}`
|
|
91
|
+
: params.baseSessionKey;
|
|
92
|
+
return { sessionKey, parentSessionKey: params.parentSessionKey };
|
|
93
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const DEFAULT_ONCHAR_PREFIXES = [">", "!"];
|
|
2
|
+
|
|
3
|
+
export function resolveOncharPrefixes(prefixes: string[] | undefined): string[] {
|
|
4
|
+
const cleaned = prefixes?.map((entry) => entry.trim()).filter(Boolean) ?? DEFAULT_ONCHAR_PREFIXES;
|
|
5
|
+
return cleaned.length > 0 ? cleaned : DEFAULT_ONCHAR_PREFIXES;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function stripOncharPrefix(
|
|
9
|
+
text: string,
|
|
10
|
+
prefixes: string[],
|
|
11
|
+
): { triggered: boolean; stripped: string } {
|
|
12
|
+
const trimmed = text.trimStart();
|
|
13
|
+
for (const prefix of prefixes) {
|
|
14
|
+
if (!prefix) {
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (trimmed.startsWith(prefix)) {
|
|
18
|
+
return {
|
|
19
|
+
triggered: true,
|
|
20
|
+
stripped: trimmed.slice(prefix.length).trimStart(),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return { triggered: false, stripped: text };
|
|
25
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
createMattermostConnectOnce,
|
|
5
|
+
type MattermostWebSocketLike,
|
|
6
|
+
WebSocketClosedBeforeOpenError,
|
|
7
|
+
} from "./monitor-websocket.js";
|
|
8
|
+
import { runWithReconnect } from "./reconnect.js";
|
|
9
|
+
|
|
10
|
+
class FakeWebSocket implements MattermostWebSocketLike {
|
|
11
|
+
public readonly sent: string[] = [];
|
|
12
|
+
public closeCalls = 0;
|
|
13
|
+
public terminateCalls = 0;
|
|
14
|
+
private openListeners: Array<() => void> = [];
|
|
15
|
+
private messageListeners: Array<(data: Buffer) => void | Promise<void>> = [];
|
|
16
|
+
private closeListeners: Array<(code: number, reason: Buffer) => void> = [];
|
|
17
|
+
private errorListeners: Array<(err: unknown) => void> = [];
|
|
18
|
+
|
|
19
|
+
on(event: "open", listener: () => void): void;
|
|
20
|
+
on(event: "message", listener: (data: Buffer) => void | Promise<void>): void;
|
|
21
|
+
on(event: "close", listener: (code: number, reason: Buffer) => void): void;
|
|
22
|
+
on(event: "error", listener: (err: unknown) => void): void;
|
|
23
|
+
on(event: "open" | "message" | "close" | "error", listener: unknown): void {
|
|
24
|
+
if (event === "open") {
|
|
25
|
+
this.openListeners.push(listener as () => void);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (event === "message") {
|
|
29
|
+
this.messageListeners.push(listener as (data: Buffer) => void | Promise<void>);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (event === "close") {
|
|
33
|
+
this.closeListeners.push(listener as (code: number, reason: Buffer) => void);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
this.errorListeners.push(listener as (err: unknown) => void);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
send(data: string): void {
|
|
40
|
+
this.sent.push(data);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
close(): void {
|
|
44
|
+
this.closeCalls++;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
terminate(): void {
|
|
48
|
+
this.terminateCalls++;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
emitOpen(): void {
|
|
52
|
+
for (const listener of this.openListeners) {
|
|
53
|
+
listener();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
emitMessage(data: Buffer): void {
|
|
58
|
+
for (const listener of this.messageListeners) {
|
|
59
|
+
void listener(data);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
emitClose(code: number, reason = ""): void {
|
|
64
|
+
const buffer = Buffer.from(reason, "utf8");
|
|
65
|
+
for (const listener of this.closeListeners) {
|
|
66
|
+
listener(code, buffer);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
emitError(err: unknown): void {
|
|
71
|
+
for (const listener of this.errorListeners) {
|
|
72
|
+
listener(err);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const testRuntime = (): RuntimeEnv =>
|
|
78
|
+
({
|
|
79
|
+
log: vi.fn(),
|
|
80
|
+
error: vi.fn(),
|
|
81
|
+
exit: ((code: number): never => {
|
|
82
|
+
throw new Error(`exit ${code}`);
|
|
83
|
+
}) as RuntimeEnv["exit"],
|
|
84
|
+
}) as RuntimeEnv;
|
|
85
|
+
|
|
86
|
+
describe("mattermost websocket monitor", () => {
|
|
87
|
+
it("rejects when websocket closes before open", async () => {
|
|
88
|
+
const socket = new FakeWebSocket();
|
|
89
|
+
const connectOnce = createMattermostConnectOnce({
|
|
90
|
+
wsUrl: "wss://example.invalid/api/v4/websocket",
|
|
91
|
+
botToken: "token",
|
|
92
|
+
runtime: testRuntime(),
|
|
93
|
+
nextSeq: () => 1,
|
|
94
|
+
onPosted: async () => {},
|
|
95
|
+
webSocketFactory: () => socket,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
queueMicrotask(() => {
|
|
99
|
+
socket.emitClose(1006, "connection refused");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const failure = connectOnce();
|
|
103
|
+
await expect(failure).rejects.toBeInstanceOf(WebSocketClosedBeforeOpenError);
|
|
104
|
+
await expect(failure).rejects.toMatchObject({
|
|
105
|
+
message: "websocket closed before open (code 1006)",
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("retries when first attempt errors before open and next attempt succeeds", async () => {
|
|
110
|
+
const abort = new AbortController();
|
|
111
|
+
const reconnectDelays: number[] = [];
|
|
112
|
+
const onError = vi.fn();
|
|
113
|
+
const patches: Array<Record<string, unknown>> = [];
|
|
114
|
+
const sockets: FakeWebSocket[] = [];
|
|
115
|
+
let disconnects = 0;
|
|
116
|
+
|
|
117
|
+
const connectOnce = createMattermostConnectOnce({
|
|
118
|
+
wsUrl: "wss://example.invalid/api/v4/websocket",
|
|
119
|
+
botToken: "token",
|
|
120
|
+
runtime: testRuntime(),
|
|
121
|
+
nextSeq: (() => {
|
|
122
|
+
let seq = 1;
|
|
123
|
+
return () => seq++;
|
|
124
|
+
})(),
|
|
125
|
+
onPosted: async () => {},
|
|
126
|
+
abortSignal: abort.signal,
|
|
127
|
+
statusSink: (patch) => {
|
|
128
|
+
patches.push(patch as Record<string, unknown>);
|
|
129
|
+
if (patch.lastDisconnect) {
|
|
130
|
+
disconnects++;
|
|
131
|
+
if (disconnects >= 2) {
|
|
132
|
+
abort.abort();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
webSocketFactory: () => {
|
|
137
|
+
const socket = new FakeWebSocket();
|
|
138
|
+
const attempt = sockets.length;
|
|
139
|
+
sockets.push(socket);
|
|
140
|
+
queueMicrotask(() => {
|
|
141
|
+
if (attempt === 0) {
|
|
142
|
+
socket.emitError(new Error("boom"));
|
|
143
|
+
socket.emitClose(1006, "connection refused");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
socket.emitOpen();
|
|
147
|
+
socket.emitClose(1000);
|
|
148
|
+
});
|
|
149
|
+
return socket;
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await runWithReconnect(connectOnce, {
|
|
154
|
+
abortSignal: abort.signal,
|
|
155
|
+
initialDelayMs: 1,
|
|
156
|
+
onError,
|
|
157
|
+
onReconnect: (delay) => reconnectDelays.push(delay),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
expect(sockets).toHaveLength(2);
|
|
161
|
+
expect(sockets[0].closeCalls).toBe(1);
|
|
162
|
+
expect(sockets[1].sent).toHaveLength(1);
|
|
163
|
+
expect(JSON.parse(sockets[1].sent[0])).toMatchObject({
|
|
164
|
+
action: "authentication_challenge",
|
|
165
|
+
data: { token: "token" },
|
|
166
|
+
seq: 1,
|
|
167
|
+
});
|
|
168
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
169
|
+
expect(reconnectDelays).toEqual([1]);
|
|
170
|
+
expect(patches.some((patch) => patch.connected === true)).toBe(true);
|
|
171
|
+
expect(patches.filter((patch) => patch.connected === false)).toHaveLength(2);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("dispatches reaction events to the reaction handler", async () => {
|
|
175
|
+
const socket = new FakeWebSocket();
|
|
176
|
+
const onPosted = vi.fn(async () => {});
|
|
177
|
+
const onReaction = vi.fn(async (payload) => payload);
|
|
178
|
+
const connectOnce = createMattermostConnectOnce({
|
|
179
|
+
wsUrl: "wss://example.invalid/api/v4/websocket",
|
|
180
|
+
botToken: "token",
|
|
181
|
+
runtime: testRuntime(),
|
|
182
|
+
nextSeq: () => 1,
|
|
183
|
+
onPosted,
|
|
184
|
+
onReaction,
|
|
185
|
+
webSocketFactory: () => socket,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const connected = connectOnce();
|
|
189
|
+
queueMicrotask(() => {
|
|
190
|
+
socket.emitOpen();
|
|
191
|
+
socket.emitMessage(
|
|
192
|
+
Buffer.from(
|
|
193
|
+
JSON.stringify({
|
|
194
|
+
event: "reaction_added",
|
|
195
|
+
data: {
|
|
196
|
+
reaction: JSON.stringify({
|
|
197
|
+
user_id: "user-1",
|
|
198
|
+
post_id: "post-1",
|
|
199
|
+
emoji_name: "thumbsup",
|
|
200
|
+
}),
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
socket.emitClose(1000);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
await connected;
|
|
209
|
+
|
|
210
|
+
expect(onReaction).toHaveBeenCalledTimes(1);
|
|
211
|
+
expect(onPosted).not.toHaveBeenCalled();
|
|
212
|
+
const payload = onReaction.mock.calls[0]?.[0];
|
|
213
|
+
expect(payload).toMatchObject({
|
|
214
|
+
event: "reaction_added",
|
|
215
|
+
data: {
|
|
216
|
+
reaction: JSON.stringify({
|
|
217
|
+
user_id: "user-1",
|
|
218
|
+
post_id: "post-1",
|
|
219
|
+
emoji_name: "thumbsup",
|
|
220
|
+
}),
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
expect(payload.data?.reaction).toBe(
|
|
224
|
+
JSON.stringify({
|
|
225
|
+
user_id: "user-1",
|
|
226
|
+
post_id: "post-1",
|
|
227
|
+
emoji_name: "thumbsup",
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
expect(payload.data?.reaction).toBeDefined();
|
|
231
|
+
});
|
|
232
|
+
});
|