@vdoninja/ninja-p2p 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 +138 -0
- package/dashboard.html +602 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/message-bus.d.ts +79 -0
- package/dist/message-bus.js +234 -0
- package/dist/peer-registry.d.ts +69 -0
- package/dist/peer-registry.js +200 -0
- package/dist/protocol.d.ts +57 -0
- package/dist/protocol.js +67 -0
- package/dist/vdo-bridge.d.ts +66 -0
- package/dist/vdo-bridge.js +318 -0
- package/package.json +78 -0
- package/skills/ninja-p2p/SKILL.md +74 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message Bus
|
|
3
|
+
*
|
|
4
|
+
* Pub/sub messaging layer with:
|
|
5
|
+
* - Topic-based subscriptions
|
|
6
|
+
* - Ring buffer message history
|
|
7
|
+
* - Offline queue for disconnected peers
|
|
8
|
+
* - Keyword triggers for async bot wakeup
|
|
9
|
+
*
|
|
10
|
+
* Zero dependencies on stevesbot internals.
|
|
11
|
+
*/
|
|
12
|
+
import { EventEmitter } from "node:events";
|
|
13
|
+
import type { PeerRegistry } from "./peer-registry.js";
|
|
14
|
+
import { type MessageEnvelope, type MessageType, type PeerIdentity } from "./protocol.js";
|
|
15
|
+
export type KeywordTrigger = {
|
|
16
|
+
pattern: RegExp;
|
|
17
|
+
handler: (msg: MessageEnvelope) => void;
|
|
18
|
+
};
|
|
19
|
+
export type SendDataFn = (data: object, target?: unknown) => void;
|
|
20
|
+
export type MessageBusOptions = {
|
|
21
|
+
/** Max messages kept in history ring buffer. Default: 200. */
|
|
22
|
+
historySize?: number;
|
|
23
|
+
/** Max messages queued per offline peer. Default: 50. */
|
|
24
|
+
offlineQueueSize?: number;
|
|
25
|
+
};
|
|
26
|
+
export declare class MessageBus extends EventEmitter {
|
|
27
|
+
private readonly identity;
|
|
28
|
+
private readonly peers;
|
|
29
|
+
private sendDataFn;
|
|
30
|
+
/** Our own topic subscriptions. */
|
|
31
|
+
private readonly subscriptions;
|
|
32
|
+
/** Ring buffer of recent messages. */
|
|
33
|
+
private readonly history;
|
|
34
|
+
private readonly historySize;
|
|
35
|
+
/** Per-peer offline message queues. Map<streamId, MessageEnvelope[]>. */
|
|
36
|
+
private readonly offlineQueues;
|
|
37
|
+
private readonly offlineQueueSize;
|
|
38
|
+
/** Registered keyword triggers. */
|
|
39
|
+
private readonly triggers;
|
|
40
|
+
constructor(identity: PeerIdentity, peers: PeerRegistry, options?: MessageBusOptions);
|
|
41
|
+
/** Set the low-level send function (provided by VDOBridge after SDK connects). */
|
|
42
|
+
setSendDataFn(fn: SendDataFn): void;
|
|
43
|
+
subscribe(topic: string): void;
|
|
44
|
+
unsubscribe(topic: string): void;
|
|
45
|
+
isSubscribed(topic: string): boolean;
|
|
46
|
+
getSubscriptions(): string[];
|
|
47
|
+
/** Broadcast a message to all connected peers. */
|
|
48
|
+
broadcast(type: MessageType, payload: unknown, topic?: string): MessageEnvelope;
|
|
49
|
+
/** Send a message to a specific peer (by streamId). */
|
|
50
|
+
send(targetStreamId: string, type: MessageType, payload: unknown): MessageEnvelope;
|
|
51
|
+
/** Publish a message to a topic (only peers subscribed to that topic receive it).
|
|
52
|
+
* Since we can't know remote subscriptions at this layer, we broadcast and let
|
|
53
|
+
* receivers filter. The envelope carries the topic for filtering. */
|
|
54
|
+
publish(topic: string, type: MessageType, payload: unknown): MessageEnvelope;
|
|
55
|
+
/** Called by VDOBridge when a message arrives. Routes and emits events. */
|
|
56
|
+
handleIncoming(envelope: MessageEnvelope): void;
|
|
57
|
+
/** Get the most recent N messages from history. */
|
|
58
|
+
getHistory(count?: number): MessageEnvelope[];
|
|
59
|
+
/** Get messages from/to a specific peer. */
|
|
60
|
+
getHistoryForPeer(streamId: string, count?: number): MessageEnvelope[];
|
|
61
|
+
/** Get message count. */
|
|
62
|
+
get historyCount(): number;
|
|
63
|
+
/** Flush queued messages to a peer that just reconnected. */
|
|
64
|
+
flushOfflineQueue(streamId: string): MessageEnvelope[];
|
|
65
|
+
/** Get the number of queued messages for a peer. */
|
|
66
|
+
getOfflineQueueSize(streamId: string): number;
|
|
67
|
+
/** Get all peers that have queued messages. */
|
|
68
|
+
getOfflineQueuePeers(): string[];
|
|
69
|
+
/** Register a keyword trigger. Returns an ID for removal. */
|
|
70
|
+
onKeyword(pattern: string | RegExp, handler: (msg: MessageEnvelope) => void): number;
|
|
71
|
+
/** Remove a trigger by index. */
|
|
72
|
+
removeTrigger(index: number): void;
|
|
73
|
+
private rawSend;
|
|
74
|
+
private addToHistory;
|
|
75
|
+
private enqueueOffline;
|
|
76
|
+
private checkTriggers;
|
|
77
|
+
/** Clear all state. */
|
|
78
|
+
clear(): void;
|
|
79
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message Bus
|
|
3
|
+
*
|
|
4
|
+
* Pub/sub messaging layer with:
|
|
5
|
+
* - Topic-based subscriptions
|
|
6
|
+
* - Ring buffer message history
|
|
7
|
+
* - Offline queue for disconnected peers
|
|
8
|
+
* - Keyword triggers for async bot wakeup
|
|
9
|
+
*
|
|
10
|
+
* Zero dependencies on stevesbot internals.
|
|
11
|
+
*/
|
|
12
|
+
import { EventEmitter } from "node:events";
|
|
13
|
+
import { createEnvelope, envelopeToWire, } from "./protocol.js";
|
|
14
|
+
// ── Class ────────────────────────────────────────────────────────────────────
|
|
15
|
+
export class MessageBus extends EventEmitter {
|
|
16
|
+
identity;
|
|
17
|
+
peers;
|
|
18
|
+
sendDataFn = null;
|
|
19
|
+
/** Our own topic subscriptions. */
|
|
20
|
+
subscriptions = new Set();
|
|
21
|
+
/** Ring buffer of recent messages. */
|
|
22
|
+
history = [];
|
|
23
|
+
historySize;
|
|
24
|
+
/** Per-peer offline message queues. Map<streamId, MessageEnvelope[]>. */
|
|
25
|
+
offlineQueues = new Map();
|
|
26
|
+
offlineQueueSize;
|
|
27
|
+
/** Registered keyword triggers. */
|
|
28
|
+
triggers = [];
|
|
29
|
+
constructor(identity, peers, options) {
|
|
30
|
+
super();
|
|
31
|
+
this.identity = identity;
|
|
32
|
+
this.peers = peers;
|
|
33
|
+
this.historySize = options?.historySize ?? 200;
|
|
34
|
+
this.offlineQueueSize = options?.offlineQueueSize ?? 50;
|
|
35
|
+
}
|
|
36
|
+
/** Set the low-level send function (provided by VDOBridge after SDK connects). */
|
|
37
|
+
setSendDataFn(fn) {
|
|
38
|
+
this.sendDataFn = fn;
|
|
39
|
+
}
|
|
40
|
+
// ── Subscriptions ────────────────────────────────────────────────────────
|
|
41
|
+
subscribe(topic) {
|
|
42
|
+
this.subscriptions.add(topic);
|
|
43
|
+
}
|
|
44
|
+
unsubscribe(topic) {
|
|
45
|
+
this.subscriptions.delete(topic);
|
|
46
|
+
}
|
|
47
|
+
isSubscribed(topic) {
|
|
48
|
+
return this.subscriptions.has(topic);
|
|
49
|
+
}
|
|
50
|
+
getSubscriptions() {
|
|
51
|
+
return [...this.subscriptions];
|
|
52
|
+
}
|
|
53
|
+
// ── Sending ──────────────────────────────────────────────────────────────
|
|
54
|
+
/** Broadcast a message to all connected peers. */
|
|
55
|
+
broadcast(type, payload, topic) {
|
|
56
|
+
const envelope = createEnvelope(this.identity, type, payload, { topic: topic ?? null });
|
|
57
|
+
this.addToHistory(envelope);
|
|
58
|
+
this.rawSend(envelope, null);
|
|
59
|
+
return envelope;
|
|
60
|
+
}
|
|
61
|
+
/** Send a message to a specific peer (by streamId). */
|
|
62
|
+
send(targetStreamId, type, payload) {
|
|
63
|
+
const envelope = createEnvelope(this.identity, type, payload, { to: targetStreamId });
|
|
64
|
+
this.addToHistory(envelope);
|
|
65
|
+
if (this.peers.isConnected(targetStreamId)) {
|
|
66
|
+
this.rawSend(envelope, targetStreamId);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
this.enqueueOffline(targetStreamId, envelope);
|
|
70
|
+
}
|
|
71
|
+
return envelope;
|
|
72
|
+
}
|
|
73
|
+
/** Publish a message to a topic (only peers subscribed to that topic receive it).
|
|
74
|
+
* Since we can't know remote subscriptions at this layer, we broadcast and let
|
|
75
|
+
* receivers filter. The envelope carries the topic for filtering. */
|
|
76
|
+
publish(topic, type, payload) {
|
|
77
|
+
const envelope = createEnvelope(this.identity, type, payload, { topic });
|
|
78
|
+
this.addToHistory(envelope);
|
|
79
|
+
this.rawSend(envelope, null);
|
|
80
|
+
return envelope;
|
|
81
|
+
}
|
|
82
|
+
// ── Receiving ────────────────────────────────────────────────────────────
|
|
83
|
+
/** Called by VDOBridge when a message arrives. Routes and emits events. */
|
|
84
|
+
handleIncoming(envelope) {
|
|
85
|
+
// Skip messages from ourselves
|
|
86
|
+
if (envelope.from.streamId === this.identity.streamId &&
|
|
87
|
+
envelope.from.instanceId === this.identity.instanceId) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
// Topic filtering: if a topic is set and we're not subscribed, skip
|
|
91
|
+
// (except announce/ping/pong/history which are always delivered)
|
|
92
|
+
const alwaysDeliver = new Set(["announce", "skill_update", "ping", "pong", "history_replay", "history_request"]);
|
|
93
|
+
if (envelope.topic && !alwaysDeliver.has(envelope.type) && !this.subscriptions.has(envelope.topic)) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
// Targeted message: only process if addressed to us (or broadcast)
|
|
97
|
+
if (envelope.to && envelope.to !== this.identity.streamId) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// Update peer last-seen
|
|
101
|
+
this.peers.touch(envelope.from.streamId);
|
|
102
|
+
// Store in history
|
|
103
|
+
this.addToHistory(envelope);
|
|
104
|
+
// Fire keyword triggers for chat messages
|
|
105
|
+
if (envelope.type === "chat") {
|
|
106
|
+
this.checkTriggers(envelope);
|
|
107
|
+
}
|
|
108
|
+
// Emit typed event
|
|
109
|
+
this.emit("message", envelope);
|
|
110
|
+
this.emit(`message:${envelope.type}`, envelope);
|
|
111
|
+
if (envelope.topic) {
|
|
112
|
+
this.emit(`topic:${envelope.topic}`, envelope);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// ── History ──────────────────────────────────────────────────────────────
|
|
116
|
+
/** Get the most recent N messages from history. */
|
|
117
|
+
getHistory(count) {
|
|
118
|
+
const n = count ?? this.historySize;
|
|
119
|
+
return this.history.slice(-n);
|
|
120
|
+
}
|
|
121
|
+
/** Get messages from/to a specific peer. */
|
|
122
|
+
getHistoryForPeer(streamId, count) {
|
|
123
|
+
const n = count ?? 50;
|
|
124
|
+
return this.history
|
|
125
|
+
.filter((m) => m.from.streamId === streamId || m.to === streamId)
|
|
126
|
+
.slice(-n);
|
|
127
|
+
}
|
|
128
|
+
/** Get message count. */
|
|
129
|
+
get historyCount() {
|
|
130
|
+
return this.history.length;
|
|
131
|
+
}
|
|
132
|
+
// ── Offline Queue ────────────────────────────────────────────────────────
|
|
133
|
+
/** Flush queued messages to a peer that just reconnected. */
|
|
134
|
+
flushOfflineQueue(streamId) {
|
|
135
|
+
const queue = this.offlineQueues.get(streamId);
|
|
136
|
+
if (!queue || queue.length === 0)
|
|
137
|
+
return [];
|
|
138
|
+
this.offlineQueues.delete(streamId);
|
|
139
|
+
// Send each queued message wrapped as history_replay
|
|
140
|
+
for (const msg of queue) {
|
|
141
|
+
const replay = createEnvelope(this.identity, "history_replay", msg, { to: streamId });
|
|
142
|
+
this.rawSend(replay, streamId);
|
|
143
|
+
}
|
|
144
|
+
return queue;
|
|
145
|
+
}
|
|
146
|
+
/** Get the number of queued messages for a peer. */
|
|
147
|
+
getOfflineQueueSize(streamId) {
|
|
148
|
+
return this.offlineQueues.get(streamId)?.length ?? 0;
|
|
149
|
+
}
|
|
150
|
+
/** Get all peers that have queued messages. */
|
|
151
|
+
getOfflineQueuePeers() {
|
|
152
|
+
return [...this.offlineQueues.keys()].filter((k) => (this.offlineQueues.get(k)?.length ?? 0) > 0);
|
|
153
|
+
}
|
|
154
|
+
// ── Keyword Triggers ─────────────────────────────────────────────────────
|
|
155
|
+
/** Register a keyword trigger. Returns an ID for removal. */
|
|
156
|
+
onKeyword(pattern, handler) {
|
|
157
|
+
const re = typeof pattern === "string" ? new RegExp(pattern, "i") : pattern;
|
|
158
|
+
this.triggers.push({ pattern: re, handler });
|
|
159
|
+
return this.triggers.length - 1;
|
|
160
|
+
}
|
|
161
|
+
/** Remove a trigger by index. */
|
|
162
|
+
removeTrigger(index) {
|
|
163
|
+
if (index >= 0 && index < this.triggers.length) {
|
|
164
|
+
this.triggers.splice(index, 1);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// ── Internals ────────────────────────────────────────────────────────────
|
|
168
|
+
rawSend(envelope, target) {
|
|
169
|
+
if (!this.sendDataFn)
|
|
170
|
+
return;
|
|
171
|
+
const wire = envelopeToWire(envelope);
|
|
172
|
+
if (target) {
|
|
173
|
+
// Prefer uuid targeting once we know it; streamID targeting can be ambiguous
|
|
174
|
+
// before announce/rekey completes on some SDK connection paths.
|
|
175
|
+
const peer = this.peers.getPeer(target);
|
|
176
|
+
if (peer?.uuid) {
|
|
177
|
+
this.sendDataFn(wire, { uuid: peer.uuid });
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
this.sendDataFn(wire, { streamID: target });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
// Broadcast to all
|
|
185
|
+
this.sendDataFn(wire);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
addToHistory(envelope) {
|
|
189
|
+
// Don't store pings/pongs in history
|
|
190
|
+
if (envelope.type === "ping" || envelope.type === "pong")
|
|
191
|
+
return;
|
|
192
|
+
this.history.push(envelope);
|
|
193
|
+
while (this.history.length > this.historySize) {
|
|
194
|
+
this.history.shift();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
enqueueOffline(streamId, envelope) {
|
|
198
|
+
let queue = this.offlineQueues.get(streamId);
|
|
199
|
+
if (!queue) {
|
|
200
|
+
queue = [];
|
|
201
|
+
this.offlineQueues.set(streamId, queue);
|
|
202
|
+
}
|
|
203
|
+
queue.push(envelope);
|
|
204
|
+
while (queue.length > this.offlineQueueSize) {
|
|
205
|
+
queue.shift();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
checkTriggers(envelope) {
|
|
209
|
+
const text = typeof envelope.payload === "string"
|
|
210
|
+
? envelope.payload
|
|
211
|
+
: typeof envelope.payload === "object" && envelope.payload !== null
|
|
212
|
+
? envelope.payload.text ?? envelope.payload.message ?? ""
|
|
213
|
+
: "";
|
|
214
|
+
if (!text)
|
|
215
|
+
return;
|
|
216
|
+
for (const trigger of this.triggers) {
|
|
217
|
+
if (trigger.pattern.test(text)) {
|
|
218
|
+
try {
|
|
219
|
+
trigger.handler(envelope);
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
this.emit("error", err);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/** Clear all state. */
|
|
228
|
+
clear() {
|
|
229
|
+
this.history.length = 0;
|
|
230
|
+
this.offlineQueues.clear();
|
|
231
|
+
this.triggers.length = 0;
|
|
232
|
+
this.subscriptions.clear();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peer Registry
|
|
3
|
+
*
|
|
4
|
+
* Tracks all known peers in the VDO.Ninja room: their identity, skills,
|
|
5
|
+
* status, and connection state. Emits events on changes so other layers
|
|
6
|
+
* (MessageBus, dashboard) can react.
|
|
7
|
+
*
|
|
8
|
+
* Zero dependencies on stevesbot internals.
|
|
9
|
+
*/
|
|
10
|
+
import { EventEmitter } from "node:events";
|
|
11
|
+
import type { AnnouncePayload, PeerIdentity, SkillUpdatePayload } from "./protocol.js";
|
|
12
|
+
export type PeerRecord = {
|
|
13
|
+
streamId: string;
|
|
14
|
+
uuid: string;
|
|
15
|
+
identity: PeerIdentity | null;
|
|
16
|
+
skills: string[];
|
|
17
|
+
status: string;
|
|
18
|
+
statusDetail: string;
|
|
19
|
+
topics: string[];
|
|
20
|
+
version: string;
|
|
21
|
+
connectedAt: number;
|
|
22
|
+
lastSeenAt: number;
|
|
23
|
+
connected: boolean;
|
|
24
|
+
};
|
|
25
|
+
export interface PeerRegistryEvents {
|
|
26
|
+
"peer:join": (peer: PeerRecord) => void;
|
|
27
|
+
"peer:leave": (peer: PeerRecord) => void;
|
|
28
|
+
"peer:update": (peer: PeerRecord, field: string) => void;
|
|
29
|
+
}
|
|
30
|
+
export declare class PeerRegistry extends EventEmitter {
|
|
31
|
+
/** streamId -> PeerRecord */
|
|
32
|
+
private readonly peers;
|
|
33
|
+
/** uuid -> streamId (reverse lookup, filled after announce) */
|
|
34
|
+
private readonly uuidToStream;
|
|
35
|
+
/** Add or re-activate a peer when a WebRTC connection opens. */
|
|
36
|
+
addPeer(streamId: string, uuid: string): PeerRecord;
|
|
37
|
+
/** Mark a peer as disconnected but keep its record (for offline queueing). */
|
|
38
|
+
markDisconnected(identifier: string): PeerRecord | undefined;
|
|
39
|
+
/** Fully remove a peer record. */
|
|
40
|
+
removePeer(identifier: string): boolean;
|
|
41
|
+
/** Rename a temporary uuid-keyed peer record once the real streamId is known. */
|
|
42
|
+
rekeyPeer(identifier: string, streamId: string): PeerRecord | undefined;
|
|
43
|
+
/** Update identity and skills from an announce message. */
|
|
44
|
+
updateFromAnnounce(identifier: string, identity: PeerIdentity, announce: AnnouncePayload): PeerRecord | undefined;
|
|
45
|
+
/** Update skills/status from a skill_update message. */
|
|
46
|
+
updateFromSkillUpdate(identifier: string, update: SkillUpdatePayload): PeerRecord | undefined;
|
|
47
|
+
/** Record a heartbeat (ping/pong/any message). */
|
|
48
|
+
touch(identifier: string): void;
|
|
49
|
+
/** Get a peer by streamId or uuid. */
|
|
50
|
+
getPeer(identifier: string): PeerRecord | undefined;
|
|
51
|
+
/** Resolve a streamId or uuid to a PeerRecord. */
|
|
52
|
+
private resolve;
|
|
53
|
+
/** Check if a peer is connected. */
|
|
54
|
+
isConnected(identifier: string): boolean;
|
|
55
|
+
/** Get all currently connected peers. */
|
|
56
|
+
getConnectedPeers(): PeerRecord[];
|
|
57
|
+
/** Get all known peers (including disconnected). */
|
|
58
|
+
getAllPeers(): PeerRecord[];
|
|
59
|
+
/** Get the number of connected peers. */
|
|
60
|
+
get connectedCount(): number;
|
|
61
|
+
/** Get streamId from a uuid. */
|
|
62
|
+
streamIdForUuid(uuid: string): string | undefined;
|
|
63
|
+
/** Prune peers that haven't been seen for longer than staleness threshold. */
|
|
64
|
+
pruneStale(maxAgeMs: number): PeerRecord[];
|
|
65
|
+
/** Clear all records. */
|
|
66
|
+
clear(): void;
|
|
67
|
+
/** Snapshot for serialization (dashboard, status tools). */
|
|
68
|
+
toJSON(): object[];
|
|
69
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peer Registry
|
|
3
|
+
*
|
|
4
|
+
* Tracks all known peers in the VDO.Ninja room: their identity, skills,
|
|
5
|
+
* status, and connection state. Emits events on changes so other layers
|
|
6
|
+
* (MessageBus, dashboard) can react.
|
|
7
|
+
*
|
|
8
|
+
* Zero dependencies on stevesbot internals.
|
|
9
|
+
*/
|
|
10
|
+
import { EventEmitter } from "node:events";
|
|
11
|
+
// ── Class ────────────────────────────────────────────────────────────────────
|
|
12
|
+
export class PeerRegistry extends EventEmitter {
|
|
13
|
+
/** streamId -> PeerRecord */
|
|
14
|
+
peers = new Map();
|
|
15
|
+
/** uuid -> streamId (reverse lookup, filled after announce) */
|
|
16
|
+
uuidToStream = new Map();
|
|
17
|
+
/** Add or re-activate a peer when a WebRTC connection opens. */
|
|
18
|
+
addPeer(streamId, uuid) {
|
|
19
|
+
const existing = this.peers.get(streamId);
|
|
20
|
+
if (existing) {
|
|
21
|
+
existing.uuid = uuid;
|
|
22
|
+
existing.connected = true;
|
|
23
|
+
existing.connectedAt = Date.now();
|
|
24
|
+
existing.lastSeenAt = Date.now();
|
|
25
|
+
this.uuidToStream.set(uuid, streamId);
|
|
26
|
+
this.emit("peer:join", existing);
|
|
27
|
+
return existing;
|
|
28
|
+
}
|
|
29
|
+
const existingByUuid = this.resolve(uuid);
|
|
30
|
+
if (existingByUuid) {
|
|
31
|
+
existingByUuid.connected = true;
|
|
32
|
+
existingByUuid.connectedAt = Date.now();
|
|
33
|
+
existingByUuid.lastSeenAt = Date.now();
|
|
34
|
+
this.uuidToStream.set(uuid, existingByUuid.streamId);
|
|
35
|
+
this.emit("peer:join", existingByUuid);
|
|
36
|
+
return existingByUuid;
|
|
37
|
+
}
|
|
38
|
+
const peer = {
|
|
39
|
+
streamId,
|
|
40
|
+
uuid,
|
|
41
|
+
identity: null,
|
|
42
|
+
skills: [],
|
|
43
|
+
status: "online",
|
|
44
|
+
statusDetail: "",
|
|
45
|
+
topics: [],
|
|
46
|
+
version: "",
|
|
47
|
+
connectedAt: Date.now(),
|
|
48
|
+
lastSeenAt: Date.now(),
|
|
49
|
+
connected: true,
|
|
50
|
+
};
|
|
51
|
+
this.peers.set(streamId, peer);
|
|
52
|
+
this.uuidToStream.set(uuid, streamId);
|
|
53
|
+
this.emit("peer:join", peer);
|
|
54
|
+
return peer;
|
|
55
|
+
}
|
|
56
|
+
/** Mark a peer as disconnected but keep its record (for offline queueing). */
|
|
57
|
+
markDisconnected(identifier) {
|
|
58
|
+
const peer = this.resolve(identifier);
|
|
59
|
+
if (!peer)
|
|
60
|
+
return undefined;
|
|
61
|
+
peer.connected = false;
|
|
62
|
+
peer.lastSeenAt = Date.now();
|
|
63
|
+
this.emit("peer:leave", peer);
|
|
64
|
+
return peer;
|
|
65
|
+
}
|
|
66
|
+
/** Fully remove a peer record. */
|
|
67
|
+
removePeer(identifier) {
|
|
68
|
+
const peer = this.resolve(identifier);
|
|
69
|
+
if (!peer)
|
|
70
|
+
return false;
|
|
71
|
+
this.peers.delete(peer.streamId);
|
|
72
|
+
this.uuidToStream.delete(peer.uuid);
|
|
73
|
+
this.emit("peer:leave", peer);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
/** Rename a temporary uuid-keyed peer record once the real streamId is known. */
|
|
77
|
+
rekeyPeer(identifier, streamId) {
|
|
78
|
+
const peer = this.resolve(identifier);
|
|
79
|
+
if (!peer)
|
|
80
|
+
return undefined;
|
|
81
|
+
if (peer.streamId === streamId)
|
|
82
|
+
return peer;
|
|
83
|
+
const existing = this.peers.get(streamId);
|
|
84
|
+
if (existing && existing !== peer) {
|
|
85
|
+
this.peers.delete(peer.streamId);
|
|
86
|
+
this.uuidToStream.set(peer.uuid, existing.streamId);
|
|
87
|
+
return existing;
|
|
88
|
+
}
|
|
89
|
+
this.peers.delete(peer.streamId);
|
|
90
|
+
peer.streamId = streamId;
|
|
91
|
+
this.peers.set(streamId, peer);
|
|
92
|
+
this.uuidToStream.set(peer.uuid, streamId);
|
|
93
|
+
this.emit("peer:update", peer, "rekey");
|
|
94
|
+
return peer;
|
|
95
|
+
}
|
|
96
|
+
/** Update identity and skills from an announce message. */
|
|
97
|
+
updateFromAnnounce(identifier, identity, announce) {
|
|
98
|
+
const peer = this.resolve(identifier);
|
|
99
|
+
if (!peer)
|
|
100
|
+
return undefined;
|
|
101
|
+
peer.identity = identity;
|
|
102
|
+
peer.skills = announce.skills ?? [];
|
|
103
|
+
peer.status = announce.status ?? "online";
|
|
104
|
+
peer.statusDetail = announce.statusDetail ?? "";
|
|
105
|
+
peer.topics = announce.topics ?? [];
|
|
106
|
+
peer.version = announce.version ?? "";
|
|
107
|
+
peer.lastSeenAt = Date.now();
|
|
108
|
+
this.emit("peer:update", peer, "announce");
|
|
109
|
+
return peer;
|
|
110
|
+
}
|
|
111
|
+
/** Update skills/status from a skill_update message. */
|
|
112
|
+
updateFromSkillUpdate(identifier, update) {
|
|
113
|
+
const peer = this.resolve(identifier);
|
|
114
|
+
if (!peer)
|
|
115
|
+
return undefined;
|
|
116
|
+
peer.skills = update.skills ?? peer.skills;
|
|
117
|
+
peer.status = update.status ?? peer.status;
|
|
118
|
+
peer.statusDetail = update.statusDetail ?? peer.statusDetail;
|
|
119
|
+
peer.lastSeenAt = Date.now();
|
|
120
|
+
this.emit("peer:update", peer, "skill_update");
|
|
121
|
+
return peer;
|
|
122
|
+
}
|
|
123
|
+
/** Record a heartbeat (ping/pong/any message). */
|
|
124
|
+
touch(identifier) {
|
|
125
|
+
const peer = this.resolve(identifier);
|
|
126
|
+
if (peer)
|
|
127
|
+
peer.lastSeenAt = Date.now();
|
|
128
|
+
}
|
|
129
|
+
/** Get a peer by streamId or uuid. */
|
|
130
|
+
getPeer(identifier) {
|
|
131
|
+
return this.resolve(identifier);
|
|
132
|
+
}
|
|
133
|
+
/** Resolve a streamId or uuid to a PeerRecord. */
|
|
134
|
+
resolve(identifier) {
|
|
135
|
+
const direct = this.peers.get(identifier);
|
|
136
|
+
if (direct)
|
|
137
|
+
return direct;
|
|
138
|
+
const streamId = this.uuidToStream.get(identifier);
|
|
139
|
+
if (streamId)
|
|
140
|
+
return this.peers.get(streamId);
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
/** Check if a peer is connected. */
|
|
144
|
+
isConnected(identifier) {
|
|
145
|
+
return this.resolve(identifier)?.connected ?? false;
|
|
146
|
+
}
|
|
147
|
+
/** Get all currently connected peers. */
|
|
148
|
+
getConnectedPeers() {
|
|
149
|
+
return [...this.peers.values()].filter((p) => p.connected);
|
|
150
|
+
}
|
|
151
|
+
/** Get all known peers (including disconnected). */
|
|
152
|
+
getAllPeers() {
|
|
153
|
+
return [...this.peers.values()];
|
|
154
|
+
}
|
|
155
|
+
/** Get the number of connected peers. */
|
|
156
|
+
get connectedCount() {
|
|
157
|
+
let count = 0;
|
|
158
|
+
for (const p of this.peers.values()) {
|
|
159
|
+
if (p.connected)
|
|
160
|
+
count++;
|
|
161
|
+
}
|
|
162
|
+
return count;
|
|
163
|
+
}
|
|
164
|
+
/** Get streamId from a uuid. */
|
|
165
|
+
streamIdForUuid(uuid) {
|
|
166
|
+
return this.uuidToStream.get(uuid);
|
|
167
|
+
}
|
|
168
|
+
/** Prune peers that haven't been seen for longer than staleness threshold. */
|
|
169
|
+
pruneStale(maxAgeMs) {
|
|
170
|
+
const now = Date.now();
|
|
171
|
+
const pruned = [];
|
|
172
|
+
for (const peer of this.peers.values()) {
|
|
173
|
+
if (!peer.connected && now - peer.lastSeenAt > maxAgeMs) {
|
|
174
|
+
this.peers.delete(peer.streamId);
|
|
175
|
+
this.uuidToStream.delete(peer.uuid);
|
|
176
|
+
pruned.push(peer);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return pruned;
|
|
180
|
+
}
|
|
181
|
+
/** Clear all records. */
|
|
182
|
+
clear() {
|
|
183
|
+
this.peers.clear();
|
|
184
|
+
this.uuidToStream.clear();
|
|
185
|
+
}
|
|
186
|
+
/** Snapshot for serialization (dashboard, status tools). */
|
|
187
|
+
toJSON() {
|
|
188
|
+
return [...this.peers.values()].map((p) => ({
|
|
189
|
+
streamId: p.streamId,
|
|
190
|
+
name: p.identity?.name ?? p.streamId,
|
|
191
|
+
role: p.identity?.role ?? "unknown",
|
|
192
|
+
skills: p.skills,
|
|
193
|
+
status: p.status,
|
|
194
|
+
statusDetail: p.statusDetail,
|
|
195
|
+
connected: p.connected,
|
|
196
|
+
connectedAt: p.connectedAt,
|
|
197
|
+
lastSeenAt: p.lastSeenAt,
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P2P Message Protocol
|
|
3
|
+
*
|
|
4
|
+
* Defines the wire format for all messages exchanged over VDO.Ninja
|
|
5
|
+
* WebRTC data channels. This module has zero dependencies on stevesbot
|
|
6
|
+
* internals — it can be extracted and reused by any bot.
|
|
7
|
+
*/
|
|
8
|
+
export type MessageType = "chat" | "announce" | "skill_update" | "command" | "command_response" | "event" | "ping" | "pong" | "ack" | "history_replay" | "history_request";
|
|
9
|
+
export type PeerIdentity = {
|
|
10
|
+
streamId: string;
|
|
11
|
+
role: string;
|
|
12
|
+
name: string;
|
|
13
|
+
instanceId: string;
|
|
14
|
+
};
|
|
15
|
+
export type MessageEnvelope = {
|
|
16
|
+
v: 1;
|
|
17
|
+
id: string;
|
|
18
|
+
type: MessageType;
|
|
19
|
+
from: PeerIdentity;
|
|
20
|
+
to: string | null;
|
|
21
|
+
topic: string | null;
|
|
22
|
+
ts: number;
|
|
23
|
+
payload: unknown;
|
|
24
|
+
};
|
|
25
|
+
export type AnnouncePayload = {
|
|
26
|
+
skills: string[];
|
|
27
|
+
status: string;
|
|
28
|
+
statusDetail?: string;
|
|
29
|
+
version: string;
|
|
30
|
+
topics: string[];
|
|
31
|
+
};
|
|
32
|
+
export type SkillUpdatePayload = {
|
|
33
|
+
skills: string[];
|
|
34
|
+
status: string;
|
|
35
|
+
statusDetail?: string;
|
|
36
|
+
};
|
|
37
|
+
/** Generate a short unique message ID. */
|
|
38
|
+
export declare function createMessageId(): string;
|
|
39
|
+
/** Generate a unique instance ID (per-process, distinguishes restarts). */
|
|
40
|
+
export declare function createInstanceId(): string;
|
|
41
|
+
/** Generate a cryptographically random room name. */
|
|
42
|
+
export declare function generateRoomName(): string;
|
|
43
|
+
/** Create a full message envelope. */
|
|
44
|
+
export declare function createEnvelope(from: PeerIdentity, type: MessageType, payload: unknown, options?: {
|
|
45
|
+
to?: string | null;
|
|
46
|
+
topic?: string | null;
|
|
47
|
+
}): MessageEnvelope;
|
|
48
|
+
/** Validate that a received object looks like a MessageEnvelope. */
|
|
49
|
+
export declare function isValidEnvelope(data: unknown): data is MessageEnvelope;
|
|
50
|
+
/** Parse raw data received from a data channel into an envelope. Returns null on failure. */
|
|
51
|
+
export declare function parseEnvelope(raw: unknown): MessageEnvelope | null;
|
|
52
|
+
/**
|
|
53
|
+
* Wrap a payload into a sendable JSON object.
|
|
54
|
+
* The VDO.Ninja SDK sends objects via sendData — this ensures our envelope
|
|
55
|
+
* is the top-level object sent over the wire.
|
|
56
|
+
*/
|
|
57
|
+
export declare function envelopeToWire(envelope: MessageEnvelope): object;
|