@agentlip/hub 0.1.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.
- package/README.md +126 -0
- package/package.json +36 -0
- package/src/agentlipd.ts +309 -0
- package/src/apiV1.ts +1468 -0
- package/src/authMiddleware.ts +134 -0
- package/src/authToken.ts +32 -0
- package/src/bodyParser.ts +272 -0
- package/src/config.ts +273 -0
- package/src/derivedStaleness.ts +255 -0
- package/src/extractorDerived.ts +374 -0
- package/src/index.ts +878 -0
- package/src/linkifierDerived.ts +407 -0
- package/src/lock.ts +172 -0
- package/src/pluginRuntime.ts +402 -0
- package/src/pluginWorker.ts +296 -0
- package/src/rateLimiter.ts +286 -0
- package/src/serverJson.ts +138 -0
- package/src/ui.ts +843 -0
- package/src/wsEndpoint.ts +481 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket endpoint implementation for Agentlip Hub
|
|
3
|
+
*
|
|
4
|
+
* Implements the WS protocol from AGENTLIP_PLAN.md:
|
|
5
|
+
* - Hello handshake with token validation
|
|
6
|
+
* - Event replay with subscription filtering
|
|
7
|
+
* - Live event streaming with backpressure disconnect
|
|
8
|
+
* - Size validation and proper error handling
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Database } from "bun:sqlite";
|
|
12
|
+
import type { ServerWebSocket } from "bun";
|
|
13
|
+
import { requireWsToken } from "./authMiddleware";
|
|
14
|
+
import { parseWsMessage, validateWsMessageSize, SIZE_LIMITS } from "./bodyParser";
|
|
15
|
+
import {
|
|
16
|
+
getLatestEventId,
|
|
17
|
+
replayEvents,
|
|
18
|
+
getEventById,
|
|
19
|
+
type ParsedEvent,
|
|
20
|
+
} from "@agentlip/kernel";
|
|
21
|
+
import { randomBytes } from "node:crypto";
|
|
22
|
+
|
|
23
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
// Types
|
|
25
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
interface HelloMessage {
|
|
28
|
+
type: "hello";
|
|
29
|
+
after_event_id: number;
|
|
30
|
+
subscriptions?: {
|
|
31
|
+
channels?: string[];
|
|
32
|
+
topics?: string[];
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface HelloOkMessage {
|
|
37
|
+
type: "hello_ok";
|
|
38
|
+
replay_until: number;
|
|
39
|
+
instance_id: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface EventEnvelope {
|
|
43
|
+
type: "event";
|
|
44
|
+
event_id: number;
|
|
45
|
+
ts: string;
|
|
46
|
+
name: string;
|
|
47
|
+
scope: {
|
|
48
|
+
channel_id?: string | null;
|
|
49
|
+
topic_id?: string | null;
|
|
50
|
+
topic_id2?: string | null;
|
|
51
|
+
};
|
|
52
|
+
data: Record<string, unknown>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface WsConnectionData {
|
|
56
|
+
authenticated: boolean;
|
|
57
|
+
handshakeComplete: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Subscription filters:
|
|
60
|
+
* - `null` for channels/topics means "wildcard" (subscribe to all) - used when hello.subscriptions is omitted
|
|
61
|
+
* - Empty Set means "subscribe to none" - used when hello.subscriptions is provided but empty
|
|
62
|
+
* - Non-empty Set means "subscribe to specific IDs"
|
|
63
|
+
*/
|
|
64
|
+
subscriptions: {
|
|
65
|
+
channels: Set<string> | null;
|
|
66
|
+
topics: Set<string> | null;
|
|
67
|
+
};
|
|
68
|
+
replayUntil?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
// WebSocket Hub (manages connections and fanout)
|
|
73
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export interface WsHub {
|
|
76
|
+
/**
|
|
77
|
+
* Notify hub of a new event for fanout to subscribed clients.
|
|
78
|
+
* Called after event is committed to DB.
|
|
79
|
+
*/
|
|
80
|
+
publishEvent(event: ParsedEvent): void;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Notify hub of new events by ID (will fetch from DB).
|
|
84
|
+
* Alternative to publishEvent when you only have event IDs.
|
|
85
|
+
*/
|
|
86
|
+
publishEventIds(eventIds: number[]): void;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Get current connection count (for monitoring).
|
|
90
|
+
*/
|
|
91
|
+
getConnectionCount(): number;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Close all connections (for graceful shutdown).
|
|
95
|
+
*/
|
|
96
|
+
closeAll(): void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface WsHubOptions {
|
|
100
|
+
db: Database;
|
|
101
|
+
instanceId?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Create a WebSocket hub for managing connections and event fanout.
|
|
106
|
+
*
|
|
107
|
+
* @param options - Database and instance ID
|
|
108
|
+
* @returns WsHub instance
|
|
109
|
+
*/
|
|
110
|
+
export function createWsHub(options: WsHubOptions): WsHub {
|
|
111
|
+
const { db } = options;
|
|
112
|
+
const instanceId = options.instanceId ?? randomBytes(16).toString("hex");
|
|
113
|
+
const connections = new Set<ServerWebSocket<WsConnectionData>>();
|
|
114
|
+
|
|
115
|
+
function publishEvent(event: ParsedEvent): void {
|
|
116
|
+
const envelope: EventEnvelope = {
|
|
117
|
+
type: "event",
|
|
118
|
+
event_id: event.event_id,
|
|
119
|
+
ts: event.ts,
|
|
120
|
+
name: event.name,
|
|
121
|
+
scope: event.scope,
|
|
122
|
+
data: event.data,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
for (const ws of connections) {
|
|
126
|
+
if (!ws.data.handshakeComplete) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Check if event matches subscription
|
|
131
|
+
const matchesSubscription = isEventSubscribed(event, ws.data.subscriptions);
|
|
132
|
+
if (!matchesSubscription) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Only send live events (> replay_until)
|
|
137
|
+
if (ws.data.replayUntil !== undefined && event.event_id <= ws.data.replayUntil) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Send event with backpressure check
|
|
142
|
+
sendEventWithBackpressure(ws, envelope);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function publishEventIds(eventIds: number[]): void {
|
|
147
|
+
for (const eventId of eventIds) {
|
|
148
|
+
const event = getEventById(db, eventId);
|
|
149
|
+
if (event) {
|
|
150
|
+
publishEvent(event);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function getConnectionCount(): number {
|
|
156
|
+
return connections.size;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function closeAll(): void {
|
|
160
|
+
for (const ws of connections) {
|
|
161
|
+
try {
|
|
162
|
+
ws.close(1001, "Server shutting down");
|
|
163
|
+
} catch {
|
|
164
|
+
// Ignore errors during shutdown
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
connections.clear();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
publishEvent,
|
|
172
|
+
publishEventIds,
|
|
173
|
+
getConnectionCount,
|
|
174
|
+
closeAll,
|
|
175
|
+
// Internal: register/unregister connections (called by handlers)
|
|
176
|
+
_registerConnection: (ws: ServerWebSocket<WsConnectionData>) => connections.add(ws),
|
|
177
|
+
_unregisterConnection: (ws: ServerWebSocket<WsConnectionData>) => connections.delete(ws),
|
|
178
|
+
_getInstanceId: () => instanceId,
|
|
179
|
+
} as WsHub & {
|
|
180
|
+
_registerConnection: (ws: ServerWebSocket<WsConnectionData>) => void;
|
|
181
|
+
_unregisterConnection: (ws: ServerWebSocket<WsConnectionData>) => void;
|
|
182
|
+
_getInstanceId: () => string;
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
187
|
+
// WebSocket Handlers (for Bun.serve)
|
|
188
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
interface CreateWsHandlersOptions {
|
|
191
|
+
db: Database;
|
|
192
|
+
authToken: string;
|
|
193
|
+
hub: WsHub & {
|
|
194
|
+
_registerConnection: (ws: ServerWebSocket<WsConnectionData>) => void;
|
|
195
|
+
_unregisterConnection: (ws: ServerWebSocket<WsConnectionData>) => void;
|
|
196
|
+
_getInstanceId: () => string;
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface WsHandlers {
|
|
201
|
+
upgrade: (req: Request, server: unknown) => Response | undefined;
|
|
202
|
+
open: (ws: ServerWebSocket<WsConnectionData>) => void;
|
|
203
|
+
message: (ws: ServerWebSocket<WsConnectionData>, message: string | Buffer) => void;
|
|
204
|
+
close: (ws: ServerWebSocket<WsConnectionData>) => void;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Create WebSocket handlers for Bun.serve.
|
|
209
|
+
*
|
|
210
|
+
* Usage:
|
|
211
|
+
* ```ts
|
|
212
|
+
* const hub = createWsHub({ db });
|
|
213
|
+
* const handlers = createWsHandlers({ db, authToken, hub });
|
|
214
|
+
*
|
|
215
|
+
* Bun.serve({
|
|
216
|
+
* fetch(req, server) {
|
|
217
|
+
* if (url.pathname === "/ws") {
|
|
218
|
+
* return handlers.upgrade(req, server);
|
|
219
|
+
* }
|
|
220
|
+
* // ... other routes
|
|
221
|
+
* },
|
|
222
|
+
* websocket: {
|
|
223
|
+
* open: handlers.open,
|
|
224
|
+
* message: handlers.message,
|
|
225
|
+
* close: handlers.close,
|
|
226
|
+
* },
|
|
227
|
+
* });
|
|
228
|
+
* ```
|
|
229
|
+
*/
|
|
230
|
+
export function createWsHandlers(options: CreateWsHandlersOptions): WsHandlers {
|
|
231
|
+
const { db, authToken, hub } = options;
|
|
232
|
+
|
|
233
|
+
function upgrade(req: Request, server: any): Response | undefined {
|
|
234
|
+
const url = new URL(req.url);
|
|
235
|
+
|
|
236
|
+
// Validate auth token
|
|
237
|
+
const authResult = requireWsToken(url, authToken);
|
|
238
|
+
if (!authResult.ok) {
|
|
239
|
+
// Return HTTP 401 for upgrade failures (before WS handshake)
|
|
240
|
+
return new Response("Unauthorized", { status: 401 });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Upgrade to WebSocket
|
|
244
|
+
const upgraded = server.upgrade(req, {
|
|
245
|
+
data: {
|
|
246
|
+
authenticated: true,
|
|
247
|
+
handshakeComplete: false,
|
|
248
|
+
subscriptions: {
|
|
249
|
+
channels: null, // Will be set in handleHello
|
|
250
|
+
topics: null,
|
|
251
|
+
},
|
|
252
|
+
} as WsConnectionData,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
if (!upgraded) {
|
|
256
|
+
return new Response("WebSocket upgrade failed", { status: 500 });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return undefined; // Upgrade successful
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function open(ws: ServerWebSocket<WsConnectionData>): void {
|
|
263
|
+
hub._registerConnection(ws);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function message(ws: ServerWebSocket<WsConnectionData>, message: string | Buffer): void {
|
|
267
|
+
// Validate message size
|
|
268
|
+
if (!validateWsMessageSize(message, SIZE_LIMITS.WS_MESSAGE)) {
|
|
269
|
+
ws.close(1009, "Message too large");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Parse JSON
|
|
274
|
+
const parsed = parseWsMessage<HelloMessage>(message, SIZE_LIMITS.WS_MESSAGE);
|
|
275
|
+
if (!parsed) {
|
|
276
|
+
ws.close(1003, "Invalid JSON");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Only accept "hello" message before handshake
|
|
281
|
+
if (!ws.data.handshakeComplete) {
|
|
282
|
+
if (parsed.type !== "hello") {
|
|
283
|
+
ws.close(1003, "Expected hello message");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
handleHello(ws, parsed, db, hub._getInstanceId());
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// After handshake, we don't expect client messages in v1
|
|
292
|
+
// (Future: could support ping/pong, subscription updates, etc.)
|
|
293
|
+
ws.close(1003, "Unexpected message after handshake");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function close(ws: ServerWebSocket<WsConnectionData>): void {
|
|
297
|
+
hub._unregisterConnection(ws);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { upgrade, open, message, close };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
304
|
+
// Protocol Handlers
|
|
305
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
function handleHello(
|
|
308
|
+
ws: ServerWebSocket<WsConnectionData>,
|
|
309
|
+
hello: HelloMessage,
|
|
310
|
+
db: Database,
|
|
311
|
+
instanceId: string
|
|
312
|
+
): void {
|
|
313
|
+
// Validate hello message
|
|
314
|
+
if (typeof hello.after_event_id !== "number" || hello.after_event_id < 0) {
|
|
315
|
+
ws.close(1003, "Invalid after_event_id");
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Determine subscription mode:
|
|
320
|
+
// - omitted subscriptions = wildcard (null) = subscribe to ALL
|
|
321
|
+
// - provided but empty = subscribe to NONE
|
|
322
|
+
// - provided with values = filter to those values
|
|
323
|
+
const subscriptionsOmitted = hello.subscriptions === undefined;
|
|
324
|
+
|
|
325
|
+
// Extract channel/topic arrays if subscriptions was provided
|
|
326
|
+
const channelIds = hello.subscriptions?.channels ?? [];
|
|
327
|
+
const topicIds = hello.subscriptions?.topics ?? [];
|
|
328
|
+
|
|
329
|
+
// Validate subscriptions are arrays of strings (only if provided)
|
|
330
|
+
if (!subscriptionsOmitted) {
|
|
331
|
+
if (!Array.isArray(channelIds) || !channelIds.every(id => typeof id === "string")) {
|
|
332
|
+
ws.close(1003, "Invalid channel subscriptions");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (!Array.isArray(topicIds) || !topicIds.every(id => typeof id === "string")) {
|
|
336
|
+
ws.close(1003, "Invalid topic subscriptions");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Store subscriptions:
|
|
342
|
+
// - null means wildcard (subscribe to all) - when subscriptions omitted
|
|
343
|
+
// - Set means filter to those IDs (empty Set = subscribe to none)
|
|
344
|
+
if (subscriptionsOmitted) {
|
|
345
|
+
ws.data.subscriptions.channels = null; // wildcard
|
|
346
|
+
ws.data.subscriptions.topics = null; // wildcard
|
|
347
|
+
} else {
|
|
348
|
+
ws.data.subscriptions.channels = new Set(channelIds);
|
|
349
|
+
ws.data.subscriptions.topics = new Set(topicIds);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Compute replay boundary (snapshot of latest event_id)
|
|
353
|
+
const replayUntil = getLatestEventId(db);
|
|
354
|
+
ws.data.replayUntil = replayUntil;
|
|
355
|
+
|
|
356
|
+
// Send hello_ok
|
|
357
|
+
const helloOk: HelloOkMessage = {
|
|
358
|
+
type: "hello_ok",
|
|
359
|
+
replay_until: replayUntil,
|
|
360
|
+
instance_id: instanceId,
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
const sendStatus = ws.send(JSON.stringify(helloOk));
|
|
364
|
+
if (sendStatus === -1 || sendStatus === 0) {
|
|
365
|
+
ws.close(1008, "backpressure");
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Mark handshake complete
|
|
370
|
+
ws.data.handshakeComplete = true;
|
|
371
|
+
|
|
372
|
+
// Determine if we should do replay:
|
|
373
|
+
// - If subscriptions was explicitly provided but both channels and topics are empty,
|
|
374
|
+
// that means "subscribe to none" - skip replay entirely
|
|
375
|
+
// - Otherwise, replay with appropriate filters
|
|
376
|
+
const subscribeToNone = !subscriptionsOmitted &&
|
|
377
|
+
channelIds.length === 0 && topicIds.length === 0;
|
|
378
|
+
|
|
379
|
+
if (subscribeToNone) {
|
|
380
|
+
// No replay when explicitly subscribing to nothing
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Send replay events if any
|
|
385
|
+
if (replayUntil > hello.after_event_id) {
|
|
386
|
+
try {
|
|
387
|
+
// For wildcard (omitted subscriptions), pass undefined to get all events
|
|
388
|
+
// For filtered, pass the arrays (or undefined if that filter is empty but other isn't)
|
|
389
|
+
const events = replayEvents({
|
|
390
|
+
db,
|
|
391
|
+
afterEventId: hello.after_event_id,
|
|
392
|
+
replayUntil,
|
|
393
|
+
channelIds: subscriptionsOmitted ? undefined : (channelIds.length > 0 ? channelIds : undefined),
|
|
394
|
+
topicIds: subscriptionsOmitted ? undefined : (topicIds.length > 0 ? topicIds : undefined),
|
|
395
|
+
limit: 1000, // Plan default
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
for (const event of events) {
|
|
399
|
+
const envelope: EventEnvelope = {
|
|
400
|
+
type: "event",
|
|
401
|
+
event_id: event.event_id,
|
|
402
|
+
ts: event.ts,
|
|
403
|
+
name: event.name,
|
|
404
|
+
scope: event.scope,
|
|
405
|
+
data: event.data,
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
sendEventWithBackpressure(ws, envelope);
|
|
409
|
+
|
|
410
|
+
// If connection was closed due to backpressure, stop
|
|
411
|
+
if (ws.readyState !== 1) { // 1 = OPEN
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
} catch (error) {
|
|
416
|
+
console.error("Replay error:", error);
|
|
417
|
+
ws.close(1011, "Internal error during replay");
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function sendEventWithBackpressure(
|
|
424
|
+
ws: ServerWebSocket<WsConnectionData>,
|
|
425
|
+
envelope: EventEnvelope
|
|
426
|
+
): void {
|
|
427
|
+
try {
|
|
428
|
+
const serialized = JSON.stringify(envelope);
|
|
429
|
+
const sendStatus = ws.send(serialized);
|
|
430
|
+
|
|
431
|
+
// Backpressure detection (plan spec: close on -1 or 0)
|
|
432
|
+
if (sendStatus === -1 || sendStatus === 0) {
|
|
433
|
+
ws.close(1008, "backpressure");
|
|
434
|
+
}
|
|
435
|
+
} catch (error) {
|
|
436
|
+
// Send error (connection may be closed)
|
|
437
|
+
try {
|
|
438
|
+
ws.close(1011, "Send error");
|
|
439
|
+
} catch {
|
|
440
|
+
// Ignore double-close errors
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function isEventSubscribed(
|
|
446
|
+
event: ParsedEvent,
|
|
447
|
+
subscriptions: { channels: Set<string> | null; topics: Set<string> | null }
|
|
448
|
+
): boolean {
|
|
449
|
+
// Wildcard mode: if both channels and topics are null, match ALL events
|
|
450
|
+
// This happens when hello.subscriptions is omitted entirely
|
|
451
|
+
if (subscriptions.channels === null && subscriptions.topics === null) {
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// If both are non-null Sets and both are empty, match NONE
|
|
456
|
+
// This happens when hello.subscriptions was provided but empty: { channels: [], topics: [] }
|
|
457
|
+
if (subscriptions.channels !== null && subscriptions.topics !== null &&
|
|
458
|
+
subscriptions.channels.size === 0 && subscriptions.topics.size === 0) {
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Filter mode: check if event matches any subscribed channel or topic
|
|
463
|
+
// Match by channel
|
|
464
|
+
if (subscriptions.channels !== null && subscriptions.channels.size > 0) {
|
|
465
|
+
if (event.scope.channel_id && subscriptions.channels.has(event.scope.channel_id)) {
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Match by topic (scope_topic_id or scope_topic_id2)
|
|
471
|
+
if (subscriptions.topics !== null && subscriptions.topics.size > 0) {
|
|
472
|
+
if (event.scope.topic_id && subscriptions.topics.has(event.scope.topic_id)) {
|
|
473
|
+
return true;
|
|
474
|
+
}
|
|
475
|
+
if (event.scope.topic_id2 && subscriptions.topics.has(event.scope.topic_id2)) {
|
|
476
|
+
return true;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return false;
|
|
481
|
+
}
|