@lovenyberg/ove 0.7.0 → 0.9.0

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.
@@ -1,7 +1,8 @@
1
1
  import { readFileSync, existsSync } from "node:fs";
2
2
  import { join, extname, resolve } from "node:path";
3
- import { timingSafeEqual } from "node:crypto";
4
- import type { EventAdapter, IncomingEvent, IncomingMessage, ChatAdapter, AdapterStatus } from "./types";
3
+ import { timingSafeEqual, createHmac } from "node:crypto";
4
+ import type { EventAdapter, IncomingEvent, IncomingMessage, ChatAdapter, AdapterStatus, EventSource } from "./types";
5
+ import { parseMention } from "./github";
5
6
  import type { TraceStore } from "../trace";
6
7
  import type { TaskQueue } from "../queue";
7
8
  import type { SessionStore } from "../sessions";
@@ -21,6 +22,11 @@ function safeEqual(a: string, b: string): boolean {
21
22
  return timingSafeEqual(bufA, bufB);
22
23
  }
23
24
 
25
+ function verifyGitHubSignature(secret: string, rawBody: string, signature: string): boolean {
26
+ const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
27
+ return safeEqual(expected, signature);
28
+ }
29
+
24
30
  export class HttpApiAdapter implements EventAdapter {
25
31
  private port: number;
26
32
  private apiKey: string;
@@ -34,20 +40,31 @@ export class HttpApiAdapter implements EventAdapter {
34
40
  private webUiHtml: string;
35
41
  private traceUiHtml: string;
36
42
  private statusUiHtml: string;
43
+ private metricsUiHtml: string;
37
44
  private publicDir: string;
38
45
  private chatAdapters: ChatAdapter[] = [];
39
46
  private eventAdapters: EventAdapter[] = [];
40
47
  private startedAt?: string;
48
+ private githubWebhookSecret: string;
49
+ private botName: string;
50
+ private runningProcesses: Map<string, { abort: AbortController; task: any }> | null = null;
41
51
 
42
52
  private hostname: string;
43
53
 
44
- constructor(port: number, apiKey: string, trace: TraceStore, queue?: TaskQueue, sessions?: SessionStore, hostname?: string) {
54
+ /** Register the running processes map so the cancel endpoint can abort running tasks */
55
+ setRunningProcesses(processes: Map<string, { abort: AbortController; task: any }>): void {
56
+ this.runningProcesses = processes;
57
+ }
58
+
59
+ constructor(port: number, apiKey: string, trace: TraceStore, queue?: TaskQueue, sessions?: SessionStore, hostname?: string, githubWebhookSecret?: string, botName?: string) {
45
60
  this.port = port;
46
61
  this.apiKey = apiKey;
47
62
  this.hostname = hostname || "0.0.0.0";
48
63
  this.trace = trace;
49
64
  this.queue = queue || null;
50
65
  this.sessions = sessions || null;
66
+ this.githubWebhookSecret = githubWebhookSecret || process.env.GITHUB_WEBHOOK_SECRET || "";
67
+ this.botName = botName || process.env.GITHUB_BOT_NAME || "ove";
51
68
  const publicDir = resolve(import.meta.dir, "../../public");
52
69
  this.publicDir = publicDir;
53
70
  try {
@@ -65,6 +82,11 @@ export class HttpApiAdapter implements EventAdapter {
65
82
  } catch {
66
83
  this.statusUiHtml = "<html><body><p>Status page not found. Place public/status.html in project root.</p></body></html>";
67
84
  }
85
+ try {
86
+ this.metricsUiHtml = readFileSync(join(publicDir, "metrics.html"), "utf-8");
87
+ } catch {
88
+ this.metricsUiHtml = "<html><body><p>Metrics page not found. Place public/metrics.html in project root.</p></body></html>";
89
+ }
68
90
  }
69
91
 
70
92
  /** Set the chat message handler so web UI messages go through the full chat pipeline */
@@ -119,6 +141,112 @@ export class HttpApiAdapter implements EventAdapter {
119
141
  });
120
142
  }
121
143
 
144
+ if (path === "/metrics" || path === "/metrics.html") {
145
+ return new Response(self.metricsUiHtml, {
146
+ headers: { "Content-Type": "text/html" },
147
+ });
148
+ }
149
+
150
+ // POST /api/webhooks/github — GitHub webhook with HMAC-SHA256 signature validation
151
+ if (path === "/api/webhooks/github" && req.method === "POST") {
152
+ if (!self.githubWebhookSecret) {
153
+ return Response.json({ error: "GitHub webhook secret not configured" }, { status: 500 });
154
+ }
155
+
156
+ const signature = req.headers.get("X-Hub-Signature-256");
157
+ if (!signature) {
158
+ return Response.json({ error: "Missing signature" }, { status: 401 });
159
+ }
160
+
161
+ const rawBody = await req.text();
162
+ if (rawBody.length > 1_048_576) {
163
+ return Response.json({ error: "Payload too large (max 1MB)" }, { status: 413 });
164
+ }
165
+ if (!verifyGitHubSignature(self.githubWebhookSecret, rawBody, signature)) {
166
+ return Response.json({ error: "Invalid signature" }, { status: 401 });
167
+ }
168
+
169
+ const githubEvent = req.headers.get("X-GitHub-Event");
170
+ if (githubEvent !== "issue_comment" && githubEvent !== "pull_request_review_comment") {
171
+ return Response.json({ ok: true, skipped: true, reason: `Unsupported event: ${githubEvent}` });
172
+ }
173
+
174
+ let payload: any;
175
+ try {
176
+ payload = JSON.parse(rawBody);
177
+ } catch {
178
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
179
+ }
180
+
181
+ // Only process created comments
182
+ if (payload.action !== "created") {
183
+ return Response.json({ ok: true, skipped: true, reason: `Ignored action: ${payload.action}` });
184
+ }
185
+
186
+ const comment = payload.comment;
187
+ if (!comment?.body || !comment?.user?.login) {
188
+ return Response.json({ error: "Missing comment data" }, { status: 400 });
189
+ }
190
+
191
+ // Skip bot's own comments to prevent infinite loops
192
+ if (comment.user.login === self.botName) {
193
+ return Response.json({ ok: true, skipped: true, reason: "Own comment" });
194
+ }
195
+
196
+ const repoFullName = payload.repository?.full_name;
197
+ if (!repoFullName) {
198
+ return Response.json({ error: "Missing repository data" }, { status: 400 });
199
+ }
200
+
201
+ // Parse @mention — same logic as github.ts polling adapter
202
+ const text = parseMention(comment.body, self.botName);
203
+ if (!text) {
204
+ return Response.json({ ok: true, skipped: true, reason: "No mention found" });
205
+ }
206
+
207
+ // Determine source type and issue/PR number
208
+ let sourceType: "issue" | "pr";
209
+ let number: number;
210
+
211
+ if (githubEvent === "pull_request_review_comment") {
212
+ sourceType = "pr";
213
+ number = payload.pull_request?.number;
214
+ } else {
215
+ // issue_comment can be on issues or PRs
216
+ if (payload.issue?.pull_request) {
217
+ sourceType = "pr";
218
+ } else {
219
+ sourceType = "issue";
220
+ }
221
+ number = payload.issue?.number;
222
+ }
223
+
224
+ if (!number) {
225
+ return Response.json({ error: "Could not determine issue/PR number" }, { status: 400 });
226
+ }
227
+
228
+ const source: EventSource = { type: sourceType, repo: repoFullName, number };
229
+ const eventId = `github:${repoFullName}:${sourceType}:${number}`;
230
+
231
+ const event: IncomingEvent = {
232
+ eventId,
233
+ userId: `github:${comment.user.login}`,
234
+ platform: "github",
235
+ source,
236
+ text,
237
+ };
238
+
239
+ logger.info("github webhook event received", {
240
+ repo: repoFullName,
241
+ user: comment.user.login,
242
+ event: githubEvent,
243
+ number,
244
+ });
245
+
246
+ self.onEvent?.(event);
247
+ return Response.json({ ok: true, eventId });
248
+ }
249
+
122
250
  // Auth check for API routes
123
251
  if (path.startsWith("/api/")) {
124
252
  const key = req.headers.get("X-API-Key") || url.searchParams.get("key");
@@ -127,6 +255,48 @@ export class HttpApiAdapter implements EventAdapter {
127
255
  }
128
256
  }
129
257
 
258
+ // POST /api/webhooks/generic — generic webhook with API key auth
259
+ if (path === "/api/webhooks/generic" && req.method === "POST") {
260
+ let body: { repo: string; text: string; userId?: string };
261
+ try {
262
+ const rawBody = await req.text();
263
+ if (rawBody.length > 1_048_576) {
264
+ return Response.json({ error: "Payload too large (max 1MB)" }, { status: 413 });
265
+ }
266
+ body = JSON.parse(rawBody) as { repo: string; text: string; userId?: string };
267
+ } catch {
268
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
269
+ }
270
+
271
+ if (!body || typeof body.repo !== "string" || !body.repo.trim()) {
272
+ return Response.json({ error: "Missing or invalid 'repo' field" }, { status: 400 });
273
+ }
274
+ if (typeof body.text !== "string" || !body.text.trim()) {
275
+ return Response.json({ error: "Missing or invalid 'text' field" }, { status: 400 });
276
+ }
277
+
278
+ const userId = (body.userId && typeof body.userId === "string") ? body.userId : "webhook:generic";
279
+ const eventId = `webhook:${crypto.randomUUID()}`;
280
+
281
+ const source: EventSource = { type: "http", requestId: eventId, repo: body.repo.trim() };
282
+ const event: IncomingEvent = {
283
+ eventId,
284
+ userId,
285
+ platform: "webhook",
286
+ source,
287
+ text: body.text.trim(),
288
+ };
289
+
290
+ logger.info("generic webhook event received", {
291
+ repo: body.repo,
292
+ userId,
293
+ textLength: body.text.length,
294
+ });
295
+
296
+ self.onEvent?.(event);
297
+ return Response.json({ ok: true, eventId }, { status: 202 });
298
+ }
299
+
130
300
  // GET /api/status — adapter health + queue stats
131
301
  if (path === "/api/status" && req.method === "GET") {
132
302
  const adapterStatuses: AdapterStatus[] = [];
@@ -145,6 +315,27 @@ export class HttpApiAdapter implements EventAdapter {
145
315
  });
146
316
  }
147
317
 
318
+ // GET /api/metrics — aggregated metrics
319
+ if (path === "/api/metrics" && req.method === "GET") {
320
+ if (!self.queue) {
321
+ return Response.json({ error: "Task queue not available" }, { status: 503 });
322
+ }
323
+ const adapterStatuses: AdapterStatus[] = [];
324
+ for (const a of self.chatAdapters) {
325
+ adapterStatuses.push(a.getStatus?.() ?? { name: a.constructor.name, type: "chat", status: "unknown" });
326
+ }
327
+ for (const a of self.eventAdapters) {
328
+ adapterStatuses.push(a.getStatus?.() ?? { name: a.constructor.name, type: "event", status: "unknown" });
329
+ }
330
+ const metrics = self.queue.metrics();
331
+ return Response.json({
332
+ ...metrics,
333
+ adapters: adapterStatuses,
334
+ uptime: process.uptime(),
335
+ timestamp: new Date().toISOString(),
336
+ });
337
+ }
338
+
148
339
  // GET /api/tasks — list recent tasks
149
340
  if (path === "/api/tasks" && req.method === "GET") {
150
341
  if (!self.queue) {
@@ -159,12 +350,40 @@ export class HttpApiAdapter implements EventAdapter {
159
350
  repo: t.repo,
160
351
  prompt: t.prompt,
161
352
  status: t.status,
353
+ priority: t.priority,
162
354
  result: t.result && t.result.length > 300 ? t.result.slice(0, 300) + "..." : t.result,
163
355
  createdAt: t.createdAt,
164
356
  completedAt: t.completedAt,
165
357
  })));
166
358
  }
167
359
 
360
+ // POST /api/tasks/:id/cancel — cancel a running or pending task
361
+ const cancelMatch = path.match(/^\/api\/tasks\/([^/]+)\/cancel$/);
362
+ if (cancelMatch && req.method === "POST") {
363
+ if (!self.queue) {
364
+ return Response.json({ error: "Task queue not available" }, { status: 503 });
365
+ }
366
+ const taskId = cancelMatch[1];
367
+ const task = self.queue.get(taskId);
368
+ if (!task) {
369
+ return Response.json({ error: "Task not found" }, { status: 404 });
370
+ }
371
+ if (task.status !== "running" && task.status !== "pending") {
372
+ return Response.json({ error: "Task is not cancellable", status: task.status }, { status: 409 });
373
+ }
374
+
375
+ // If it's running, abort the process
376
+ if (task.status === "running" && self.runningProcesses) {
377
+ const entry = self.runningProcesses.get(taskId);
378
+ if (entry) {
379
+ entry.abort.abort();
380
+ }
381
+ }
382
+
383
+ self.queue.cancel(taskId);
384
+ return Response.json({ ok: true, taskId: task.id, cancelled: true });
385
+ }
386
+
168
387
  // POST /api/message — submit a chat message (full chat pipeline)
169
388
  if (path === "/api/message" && req.method === "POST") {
170
389
  let body: { text: string };
@@ -0,0 +1,233 @@
1
+ import { describe, test, expect, mock, beforeEach } from "bun:test";
2
+ import type { IncomingMessage } from "./types";
3
+
4
+ // Capture registered handlers so we can invoke them in tests
5
+ let messageHandler: Function;
6
+ let appMentionHandler: Function;
7
+ let mockStart: ReturnType<typeof mock>;
8
+ let mockStop: ReturnType<typeof mock>;
9
+ let mockConversationsOpen: ReturnType<typeof mock>;
10
+ let mockChatPostMessage: ReturnType<typeof mock>;
11
+ let mockChatUpdate: ReturnType<typeof mock>;
12
+
13
+ // Mock @slack/bolt before importing the adapter
14
+ mock.module("@slack/bolt", () => ({
15
+ App: class FakeApp {
16
+ client = {
17
+ conversations: { open: (...args: any[]) => mockConversationsOpen(...args) },
18
+ chat: {
19
+ postMessage: (...args: any[]) => mockChatPostMessage(...args),
20
+ update: (...args: any[]) => mockChatUpdate(...args),
21
+ },
22
+ };
23
+ message(handler: Function) {
24
+ messageHandler = handler;
25
+ }
26
+ event(name: string, handler: Function) {
27
+ if (name === "app_mention") appMentionHandler = handler;
28
+ }
29
+ start() {
30
+ mockStart();
31
+ return Promise.resolve();
32
+ }
33
+ stop() {
34
+ mockStop();
35
+ return Promise.resolve();
36
+ }
37
+ },
38
+ }));
39
+
40
+ // Import after mocking
41
+ const { SlackAdapter } = await import("./slack");
42
+
43
+ describe("SlackAdapter", () => {
44
+ beforeEach(() => {
45
+ mockStart = mock(() => {});
46
+ mockStop = mock(() => {});
47
+ mockConversationsOpen = mock(() =>
48
+ Promise.resolve({ channel: { id: "C_DM_CHANNEL" } })
49
+ );
50
+ mockChatPostMessage = mock(() => Promise.resolve({ ok: true }));
51
+ mockChatUpdate = mock(() => Promise.resolve({ ok: true }));
52
+ });
53
+
54
+ test("module exports SlackAdapter class", async () => {
55
+ const mod = await import("./slack");
56
+ expect(mod.SlackAdapter).toBeDefined();
57
+ });
58
+
59
+ test("constructor creates instance without throwing", () => {
60
+ expect(() => new SlackAdapter()).not.toThrow();
61
+ });
62
+
63
+ test("getStatus() returns disconnected before start", () => {
64
+ const adapter = new SlackAdapter();
65
+ const status = adapter.getStatus();
66
+ expect(status.name).toBe("slack");
67
+ expect(status.type).toBe("chat");
68
+ expect(status.status).toBe("disconnected");
69
+ expect(status.startedAt).toBeUndefined();
70
+ });
71
+
72
+ test("getStatus() returns connected after start", async () => {
73
+ const adapter = new SlackAdapter();
74
+ await adapter.start(() => {});
75
+ const status = adapter.getStatus();
76
+ expect(status.status).toBe("connected");
77
+ expect(status.startedAt).toBeDefined();
78
+ });
79
+
80
+ test("start() registers handlers and calls app.start", async () => {
81
+ const adapter = new SlackAdapter();
82
+ await adapter.start(() => {});
83
+ expect(mockStart).toHaveBeenCalledTimes(1);
84
+ expect(messageHandler).toBeDefined();
85
+ expect(appMentionHandler).toBeDefined();
86
+ });
87
+
88
+ test("message handler ignores messages with subtype", async () => {
89
+ const received: IncomingMessage[] = [];
90
+ const adapter = new SlackAdapter();
91
+ await adapter.start((msg) => received.push(msg));
92
+
93
+ await messageHandler({
94
+ message: { subtype: "bot_message", text: "hello", user: "U123", channel: "C1" },
95
+ say: mock(() => Promise.resolve()),
96
+ });
97
+
98
+ expect(received).toHaveLength(0);
99
+ });
100
+
101
+ test("message handler ignores messages without text", async () => {
102
+ const received: IncomingMessage[] = [];
103
+ const adapter = new SlackAdapter();
104
+ await adapter.start((msg) => received.push(msg));
105
+
106
+ await messageHandler({
107
+ message: { user: "U123", channel: "C1" },
108
+ say: mock(() => Promise.resolve()),
109
+ });
110
+
111
+ expect(received).toHaveLength(0);
112
+ });
113
+
114
+ test("message handler ignores messages without user", async () => {
115
+ const received: IncomingMessage[] = [];
116
+ const adapter = new SlackAdapter();
117
+ await adapter.start((msg) => received.push(msg));
118
+
119
+ await messageHandler({
120
+ message: { text: "hello", channel: "C1" },
121
+ say: mock(() => Promise.resolve()),
122
+ });
123
+
124
+ expect(received).toHaveLength(0);
125
+ });
126
+
127
+ test("message handler delivers valid DM with slack: prefix on userId", async () => {
128
+ const received: IncomingMessage[] = [];
129
+ const adapter = new SlackAdapter();
130
+ await adapter.start((msg) => received.push(msg));
131
+
132
+ await messageHandler({
133
+ message: { text: "fix the bug", user: "U42", channel: "C1" },
134
+ say: mock(() => Promise.resolve()),
135
+ });
136
+
137
+ expect(received).toHaveLength(1);
138
+ expect(received[0].userId).toBe("slack:U42");
139
+ expect(received[0].platform).toBe("slack");
140
+ expect(received[0].text).toBe("fix the bug");
141
+ });
142
+
143
+ test("app_mention handler strips <@MENTION> tags from text", async () => {
144
+ const received: IncomingMessage[] = [];
145
+ const adapter = new SlackAdapter();
146
+ await adapter.start((msg) => received.push(msg));
147
+
148
+ await appMentionHandler({
149
+ event: { text: "<@U00BOT> deploy to prod", user: "U99", channel: "C2" },
150
+ say: mock(() => Promise.resolve()),
151
+ });
152
+
153
+ expect(received).toHaveLength(1);
154
+ expect(received[0].text).toBe("deploy to prod");
155
+ expect(received[0].userId).toBe("slack:U99");
156
+ });
157
+
158
+ test("app_mention strips multiple <@MENTION> tags", async () => {
159
+ const received: IncomingMessage[] = [];
160
+ const adapter = new SlackAdapter();
161
+ await adapter.start((msg) => received.push(msg));
162
+
163
+ await appMentionHandler({
164
+ event: { text: "<@U00BOT> hey <@U00OTHER> help", user: "U99", channel: "C2" },
165
+ say: mock(() => Promise.resolve()),
166
+ });
167
+
168
+ expect(received).toHaveLength(1);
169
+ expect(received[0].text).toBe("hey help");
170
+ });
171
+
172
+ test("sendToUser strips slack: prefix and opens conversation", async () => {
173
+ const adapter = new SlackAdapter();
174
+ await adapter.start(() => {});
175
+ await adapter.sendToUser("slack:U42", "task done");
176
+
177
+ expect(mockConversationsOpen).toHaveBeenCalledWith({ users: "U42" });
178
+ expect(mockChatPostMessage).toHaveBeenCalledWith({
179
+ channel: "C_DM_CHANNEL",
180
+ text: "task done",
181
+ });
182
+ });
183
+
184
+ test("reply callback calls say", async () => {
185
+ const received: IncomingMessage[] = [];
186
+ const saySpy = mock(() => Promise.resolve());
187
+ const adapter = new SlackAdapter();
188
+ await adapter.start((msg) => received.push(msg));
189
+
190
+ await messageHandler({
191
+ message: { text: "hello", user: "U1", channel: "C1" },
192
+ say: saySpy,
193
+ });
194
+
195
+ expect(received).toHaveLength(1);
196
+ await received[0].reply("response text");
197
+ expect(saySpy).toHaveBeenCalledWith("response text");
198
+ });
199
+
200
+ test("updateStatus debounces calls", async () => {
201
+ const received: IncomingMessage[] = [];
202
+ const saySpy = mock(() => Promise.resolve({ ts: "1234.5678" }));
203
+ const adapter = new SlackAdapter();
204
+ await adapter.start((msg) => received.push(msg));
205
+
206
+ await messageHandler({
207
+ message: { text: "work", user: "U1", channel: "C1" },
208
+ say: saySpy,
209
+ });
210
+
211
+ expect(received).toHaveLength(1);
212
+
213
+ // Fire multiple rapid status updates - debounce should collapse them
214
+ received[0].updateStatus("step 1");
215
+ received[0].updateStatus("step 2");
216
+ received[0].updateStatus("step 3");
217
+
218
+ // say should not have been called yet (debounce delay is 3000ms)
219
+ // Only the initial message handler call to say happened (0 times for status)
220
+ // The debounce timer hasn't fired yet
221
+ expect(saySpy).toHaveBeenCalledTimes(0);
222
+ });
223
+
224
+ test("stop() marks adapter as disconnected", async () => {
225
+ const adapter = new SlackAdapter();
226
+ await adapter.start(() => {});
227
+ expect(adapter.getStatus().status).toBe("connected");
228
+
229
+ await adapter.stop();
230
+ expect(adapter.getStatus().status).toBe("disconnected");
231
+ expect(mockStop).toHaveBeenCalledTimes(1);
232
+ });
233
+ });
@@ -25,7 +25,7 @@ export interface ChatAdapter {
25
25
  export type EventSource =
26
26
  | { type: "issue"; repo: string; number: number }
27
27
  | { type: "pr"; repo: string; number: number }
28
- | { type: "http"; requestId: string };
28
+ | { type: "http"; requestId: string; repo?: string };
29
29
 
30
30
  export interface IncomingEvent {
31
31
  eventId: string;
@@ -0,0 +1,102 @@
1
+ import { describe, test, expect, mock } from "bun:test";
2
+
3
+ // Mock baileys before importing the adapter
4
+ mock.module("baileys", () => ({
5
+ default: () => ({
6
+ ev: {
7
+ on: () => {},
8
+ },
9
+ end: () => {},
10
+ sendMessage: mock(() => Promise.resolve({ key: { id: "mock-id" } })),
11
+ }),
12
+ useMultiFileAuthState: () =>
13
+ Promise.resolve({ state: {}, saveCreds: () => {} }),
14
+ DisconnectReason: { loggedOut: 401 },
15
+ }));
16
+
17
+ import { WhatsAppAdapter } from "./whatsapp";
18
+
19
+ describe("WhatsAppAdapter", () => {
20
+ test("module exports WhatsAppAdapter class", async () => {
21
+ const mod = await import("./whatsapp");
22
+ expect(mod.WhatsAppAdapter).toBeDefined();
23
+ });
24
+
25
+ test("constructor defaults authDir to ./auth/whatsapp", () => {
26
+ const adapter = new WhatsAppAdapter();
27
+ // Access private field via getStatus details or by casting
28
+ const status = adapter.getStatus();
29
+ // authDir is private, but we can verify the object was constructed
30
+ expect(adapter).toBeInstanceOf(WhatsAppAdapter);
31
+ });
32
+
33
+ test("constructor defaults allowedChats to empty Set", () => {
34
+ const adapter = new WhatsAppAdapter();
35
+ // allowedChats is private; we verify construction succeeds with defaults
36
+ expect(adapter).toBeInstanceOf(WhatsAppAdapter);
37
+ });
38
+
39
+ test("constructor accepts custom options", () => {
40
+ const adapter = new WhatsAppAdapter({
41
+ authDir: "/custom/auth",
42
+ phoneNumber: "+1234567890",
43
+ allowedChats: ["chat1", "chat2"],
44
+ });
45
+ expect(adapter).toBeInstanceOf(WhatsAppAdapter);
46
+ });
47
+
48
+ test("getStatus() returns name and type", () => {
49
+ const adapter = new WhatsAppAdapter();
50
+ const status = adapter.getStatus();
51
+ expect(status.name).toBe("whatsapp");
52
+ expect(status.type).toBe("chat");
53
+ });
54
+
55
+ test("getStatus() returns 'unknown' before start (initial connecting state)", () => {
56
+ const adapter = new WhatsAppAdapter();
57
+ const status = adapter.getStatus();
58
+ // Initial connectionState is "connecting" with reconnectAttempt=0 => "unknown"
59
+ expect(status.status).toBe("unknown");
60
+ });
61
+
62
+ test("getStatus() includes details with reconnectAttempt and pairingCode", () => {
63
+ const adapter = new WhatsAppAdapter();
64
+ const status = adapter.getStatus();
65
+ expect(status.details).toBeDefined();
66
+ expect(status.details).toHaveProperty("reconnectAttempt", 0);
67
+ expect(status.details).toHaveProperty("pairingCode", undefined);
68
+ });
69
+
70
+ test("getStatus() error is undefined initially", () => {
71
+ const adapter = new WhatsAppAdapter();
72
+ const status = adapter.getStatus();
73
+ expect(status.error).toBeUndefined();
74
+ });
75
+
76
+ test("sendToUser() formats JID from userId", async () => {
77
+ const adapter = new WhatsAppAdapter();
78
+
79
+ // Start the adapter so sock is initialized
80
+ await adapter.start(() => {});
81
+
82
+ // sendToUser should format "whatsapp:1234" -> "1234@s.whatsapp.net"
83
+ // Since sock is mocked, this won't throw
84
+ await adapter.sendToUser("whatsapp:1234", "hello");
85
+
86
+ // Access the mock to verify the JID format
87
+ const sock = (adapter as any).sock;
88
+ expect(sock.sendMessage).toHaveBeenCalledWith("1234@s.whatsapp.net", {
89
+ text: "hello",
90
+ });
91
+ });
92
+
93
+ test("stop() nullifies the socket", async () => {
94
+ const adapter = new WhatsAppAdapter();
95
+
96
+ await adapter.start(() => {});
97
+ expect((adapter as any).sock).not.toBeNull();
98
+
99
+ await adapter.stop();
100
+ expect((adapter as any).sock).toBeNull();
101
+ });
102
+ });
@@ -7,10 +7,12 @@ describe("adapter wiring", () => {
7
7
  const discord = await import("./discord");
8
8
  const http = await import("./http");
9
9
  const github = await import("./github");
10
+ const slack = await import("./slack");
10
11
 
11
12
  expect(telegram.TelegramAdapter).toBeDefined();
12
13
  expect(discord.DiscordAdapter).toBeDefined();
13
14
  expect(http.HttpApiAdapter).toBeDefined();
14
15
  expect(github.GitHubAdapter).toBeDefined();
16
+ expect(slack.SlackAdapter).toBeDefined();
15
17
  });
16
18
  });