openclaw-bitchat 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/src/bridge.ts ADDED
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Bitchat Bridge
3
+ *
4
+ * Handles communication with the bitchat-node HTTP bridge.
5
+ * Supports both polling and WebSocket connections for real-time messages.
6
+ */
7
+
8
+ import type { BitchatInboundMessage } from './index.js';
9
+ import type { Logger } from './types.js';
10
+
11
+ export interface BridgeOptions {
12
+ bridgeUrl: string;
13
+ nickname: string;
14
+ onMessage: (msg: BitchatInboundMessage) => Promise<void>;
15
+ logger: Logger;
16
+ pollIntervalMs?: number;
17
+ }
18
+
19
+ interface BridgeStatus {
20
+ connected: boolean;
21
+ peerID?: string;
22
+ nickname?: string;
23
+ peersCount?: number;
24
+ }
25
+
26
+ interface PendingMessage {
27
+ id: string;
28
+ type: 'public' | 'direct';
29
+ senderPeerID: string;
30
+ senderNickname: string;
31
+ text: string;
32
+ timestamp: number;
33
+ }
34
+
35
+ export class BitchatBridge {
36
+ private readonly bridgeUrl: string;
37
+ private readonly onMessage: (msg: BitchatInboundMessage) => Promise<void>;
38
+ private readonly logger: Logger;
39
+ private readonly pollIntervalMs: number;
40
+
41
+ private connected = false;
42
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
43
+ private lastMessageTimestamp = 0;
44
+ private ws: WebSocket | null = null;
45
+
46
+ constructor(options: BridgeOptions) {
47
+ this.bridgeUrl = options.bridgeUrl.replace(/\/$/, ''); // Remove trailing slash
48
+ this.onMessage = options.onMessage;
49
+ this.logger = options.logger;
50
+ this.pollIntervalMs = options.pollIntervalMs ?? 2000;
51
+ }
52
+
53
+ /**
54
+ * Connect to the bitchat-node bridge
55
+ */
56
+ async connect(): Promise<void> {
57
+ // First, verify the bridge is reachable
58
+ try {
59
+ const status = await this.getStatus();
60
+ this.logger.info(`[bitchat-bridge] Connected to bridge. PeerID: ${status.peerID}`);
61
+ this.connected = true;
62
+ } catch (err) {
63
+ this.logger.error('[bitchat-bridge] Failed to connect to bridge:', err);
64
+ throw err;
65
+ }
66
+
67
+ // Try WebSocket first, fall back to polling
68
+ const wsUrl = this.bridgeUrl.replace(/^http/, 'ws') + '/ws';
69
+ try {
70
+ await this.connectWebSocket(wsUrl);
71
+ this.logger.info('[bitchat-bridge] WebSocket connected');
72
+ } catch {
73
+ this.logger.info('[bitchat-bridge] WebSocket unavailable, using polling');
74
+ this.startPolling();
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Disconnect from the bridge
80
+ */
81
+ async disconnect(): Promise<void> {
82
+ this.connected = false;
83
+
84
+ if (this.pollTimer) {
85
+ clearInterval(this.pollTimer);
86
+ this.pollTimer = null;
87
+ }
88
+
89
+ if (this.ws) {
90
+ this.ws.close();
91
+ this.ws = null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Check if connected
97
+ */
98
+ isConnected(): boolean {
99
+ return this.connected;
100
+ }
101
+
102
+ /**
103
+ * Get bridge status
104
+ */
105
+ async getStatus(): Promise<BridgeStatus> {
106
+ const response = await fetch(`${this.bridgeUrl}/api/status`);
107
+ if (!response.ok) {
108
+ throw new Error(`Bridge status failed: ${response.status}`);
109
+ }
110
+ return response.json() as Promise<BridgeStatus>;
111
+ }
112
+
113
+ /**
114
+ * Send a public message to all peers
115
+ */
116
+ async sendPublicMessage(text: string): Promise<void> {
117
+ const response = await fetch(`${this.bridgeUrl}/api/send`, {
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'application/json' },
120
+ body: JSON.stringify({ type: 'public', text }),
121
+ });
122
+
123
+ if (!response.ok) {
124
+ const error = await response.text();
125
+ throw new Error(`Failed to send public message: ${error}`);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Send a direct message to a specific peer
131
+ */
132
+ async sendDirectMessage(recipientPeerID: string, text: string): Promise<void> {
133
+ const response = await fetch(`${this.bridgeUrl}/api/send`, {
134
+ method: 'POST',
135
+ headers: { 'Content-Type': 'application/json' },
136
+ body: JSON.stringify({ type: 'direct', recipientPeerID, text }),
137
+ });
138
+
139
+ if (!response.ok) {
140
+ const error = await response.text();
141
+ throw new Error(`Failed to send direct message: ${error}`);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Get list of known peers
147
+ */
148
+ async getPeers(): Promise<{ peerID: string; nickname: string; lastSeen: number }[]> {
149
+ const response = await fetch(`${this.bridgeUrl}/api/peers`);
150
+ if (!response.ok) {
151
+ throw new Error(`Failed to get peers: ${response.status}`);
152
+ }
153
+ return response.json() as Promise<{ peerID: string; nickname: string; lastSeen: number }[]>;
154
+ }
155
+
156
+ /**
157
+ * Get recent messages (for polling)
158
+ */
159
+ async getMessages(since?: number): Promise<PendingMessage[]> {
160
+ const url = since
161
+ ? `${this.bridgeUrl}/api/messages?since=${since}`
162
+ : `${this.bridgeUrl}/api/messages`;
163
+
164
+ const response = await fetch(url);
165
+ if (!response.ok) {
166
+ throw new Error(`Failed to get messages: ${response.status}`);
167
+ }
168
+ return response.json() as Promise<PendingMessage[]>;
169
+ }
170
+
171
+ /**
172
+ * Register webhook for incoming messages
173
+ */
174
+ async registerWebhook(webhookUrl: string): Promise<void> {
175
+ const response = await fetch(`${this.bridgeUrl}/api/webhook`, {
176
+ method: 'POST',
177
+ headers: { 'Content-Type': 'application/json' },
178
+ body: JSON.stringify({ url: webhookUrl }),
179
+ });
180
+
181
+ if (!response.ok) {
182
+ const error = await response.text();
183
+ throw new Error(`Failed to register webhook: ${error}`);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Connect via WebSocket for real-time messages
189
+ */
190
+ private async connectWebSocket(wsUrl: string): Promise<void> {
191
+ return new Promise((resolve, reject) => {
192
+ try {
193
+ // Note: In Node.js, we'd use the 'ws' package
194
+ // This is a simplified version that may need adjustment
195
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
196
+ const WebSocketImpl = typeof WebSocket !== 'undefined' ? WebSocket : require('ws');
197
+ const ws = new WebSocketImpl(wsUrl) as WebSocket;
198
+ this.ws = ws;
199
+
200
+ const timeout = setTimeout(() => {
201
+ reject(new Error('WebSocket connection timeout'));
202
+ }, 5000);
203
+
204
+ ws.onopen = () => {
205
+ clearTimeout(timeout);
206
+ resolve();
207
+ };
208
+
209
+ ws.onerror = (err: Event) => {
210
+ clearTimeout(timeout);
211
+ reject(err);
212
+ };
213
+
214
+ ws.onmessage = async (event: MessageEvent) => {
215
+ try {
216
+ const data = JSON.parse(String(event.data));
217
+ if (data.type === 'message') {
218
+ await this.onMessage({
219
+ type: data.isDirect ? 'direct' : 'public',
220
+ senderPeerID: data.senderPeerID,
221
+ senderNickname: data.senderNickname,
222
+ text: data.text,
223
+ timestamp: data.timestamp,
224
+ messageId: data.id ?? `ws-${Date.now()}`,
225
+ });
226
+ }
227
+ } catch (err) {
228
+ this.logger.error('[bitchat-bridge] WebSocket message parse error:', err);
229
+ }
230
+ };
231
+
232
+ ws.onclose = () => {
233
+ this.logger.info('[bitchat-bridge] WebSocket closed');
234
+ this.ws = null;
235
+ // Reconnect or fall back to polling
236
+ if (this.connected) {
237
+ this.startPolling();
238
+ }
239
+ };
240
+ } catch (err) {
241
+ reject(err);
242
+ }
243
+ });
244
+ }
245
+
246
+ /**
247
+ * Start polling for messages
248
+ */
249
+ private startPolling(): void {
250
+ if (this.pollTimer) {
251
+ return; // Already polling
252
+ }
253
+
254
+ this.logger.info(`[bitchat-bridge] Starting message polling (${this.pollIntervalMs}ms)`);
255
+
256
+ this.pollTimer = setInterval(async () => {
257
+ if (!this.connected) {
258
+ return;
259
+ }
260
+
261
+ try {
262
+ const messages = await this.getMessages(this.lastMessageTimestamp);
263
+
264
+ for (const msg of messages) {
265
+ // Update timestamp to avoid duplicates
266
+ if (msg.timestamp > this.lastMessageTimestamp) {
267
+ this.lastMessageTimestamp = msg.timestamp;
268
+ }
269
+
270
+ await this.onMessage({
271
+ type: msg.type,
272
+ senderPeerID: msg.senderPeerID,
273
+ senderNickname: msg.senderNickname,
274
+ text: msg.text,
275
+ timestamp: msg.timestamp,
276
+ messageId: msg.id,
277
+ });
278
+ }
279
+ } catch (err) {
280
+ this.logger.error('[bitchat-bridge] Poll error:', err);
281
+ }
282
+ }, this.pollIntervalMs);
283
+ }
284
+ }
package/src/index.ts ADDED
@@ -0,0 +1,288 @@
1
+ /**
2
+ * @openclaw/bitchat - Bitchat BLE Mesh Channel Plugin
3
+ *
4
+ * This plugin enables OpenClaw to communicate via the Bitchat
5
+ * peer-to-peer BLE mesh network.
6
+ *
7
+ * Architecture:
8
+ * - bitchat-node runs as a background service (separate process)
9
+ * - This plugin communicates with it via HTTP bridge
10
+ * - Inbound: webhook from bitchat-node → OpenClaw session
11
+ * - Outbound: OpenClaw → HTTP API → bitchat-node → BLE mesh
12
+ */
13
+
14
+ import type { PluginApi, ChannelPlugin } from './types.js';
15
+ import { BitchatBridge } from './bridge.js';
16
+
17
+ // Plugin configuration interface
18
+ export interface BitchatConfig {
19
+ enabled?: boolean;
20
+ nickname?: string;
21
+ bridgeUrl?: string;
22
+ webhookPath?: string;
23
+ autoStart?: boolean;
24
+ dmPolicy?: 'open' | 'allowlist' | 'disabled';
25
+ allowFrom?: string[];
26
+ }
27
+
28
+ // Message from bitchat-node
29
+ export interface BitchatInboundMessage {
30
+ type: 'public' | 'direct';
31
+ senderPeerID: string;
32
+ senderNickname: string;
33
+ text: string;
34
+ timestamp: number;
35
+ messageId: string;
36
+ }
37
+
38
+ // Default config values
39
+ const DEFAULT_CONFIG: Required<BitchatConfig> = {
40
+ enabled: true,
41
+ nickname: 'openclaw',
42
+ bridgeUrl: 'http://localhost:3939',
43
+ webhookPath: '/bitchat-webhook',
44
+ autoStart: false,
45
+ dmPolicy: 'open',
46
+ allowFrom: [],
47
+ };
48
+
49
+ // Bridge instance (singleton for the gateway)
50
+ let bridge: BitchatBridge | null = null;
51
+
52
+ /**
53
+ * Resolve channel config from OpenClaw config
54
+ */
55
+ function resolveConfig(cfg: Record<string, unknown>): Required<BitchatConfig> {
56
+ const channelConfig = (cfg.channels as Record<string, unknown>)?.bitchat as BitchatConfig | undefined;
57
+ return {
58
+ ...DEFAULT_CONFIG,
59
+ ...channelConfig,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Check if a sender is allowed based on policy
65
+ */
66
+ function isSenderAllowed(config: Required<BitchatConfig>, senderPeerID: string): boolean {
67
+ switch (config.dmPolicy) {
68
+ case 'disabled':
69
+ return false;
70
+ case 'allowlist':
71
+ return config.allowFrom.includes(senderPeerID) || config.allowFrom.includes('*');
72
+ case 'open':
73
+ default:
74
+ return true;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Create the Bitchat channel plugin
80
+ */
81
+ function createBitchatPlugin(): ChannelPlugin {
82
+ return {
83
+ id: 'bitchat',
84
+ meta: {
85
+ id: 'bitchat',
86
+ label: 'Bitchat',
87
+ selectionLabel: 'Bitchat (BLE Mesh)',
88
+ docsPath: '/channels/bitchat',
89
+ docsLabel: 'bitchat',
90
+ blurb: 'Peer-to-peer BLE mesh network messaging.',
91
+ aliases: ['ble', 'mesh'],
92
+ },
93
+ capabilities: {
94
+ chatTypes: ['direct', 'group'] as const,
95
+ media: false, // BLE bandwidth is limited
96
+ },
97
+ config: {
98
+ listAccountIds: () => ['default'],
99
+ resolveAccount: (cfg: Record<string, unknown>, accountId?: string) => {
100
+ const config = resolveConfig(cfg);
101
+ return {
102
+ accountId: accountId ?? 'default',
103
+ enabled: config.enabled,
104
+ configured: Boolean(config.bridgeUrl),
105
+ config,
106
+ };
107
+ },
108
+ defaultAccountId: () => 'default',
109
+ isConfigured: (account: { configured?: boolean }) => account.configured ?? false,
110
+ describeAccount: (account: { accountId: string; enabled?: boolean; configured?: boolean }) => ({
111
+ accountId: account.accountId,
112
+ enabled: account.enabled ?? true,
113
+ configured: account.configured ?? false,
114
+ }),
115
+ },
116
+ security: {
117
+ resolveDmPolicy: ({ account }) => ({
118
+ policy: String(account.config?.dmPolicy ?? 'open'),
119
+ allowFrom: (account.config?.allowFrom as string[]) ?? [],
120
+ policyPath: 'channels.bitchat.dmPolicy',
121
+ allowFromPath: 'channels.bitchat.allowFrom',
122
+ approveHint: 'Add peer ID to channels.bitchat.allowFrom',
123
+ normalizeEntry: (raw: string) => raw.trim().toLowerCase(),
124
+ }),
125
+ },
126
+ messaging: {
127
+ normalizeTarget: ({ target }) => {
128
+ // Accept peer ID or nickname
129
+ const cleaned = target.replace(/^bitchat:/i, '').trim();
130
+ return { target: cleaned, normalized: cleaned };
131
+ },
132
+ targetResolver: {
133
+ looksLikeId: (target: string) => /^[a-f0-9]{16}$/i.test(target),
134
+ hint: '<peerID|nickname>',
135
+ },
136
+ },
137
+ outbound: {
138
+ deliveryMode: 'direct',
139
+ sendText: async ({ text, target }) => {
140
+
141
+ if (!bridge) {
142
+ return { ok: false, error: 'Bitchat bridge not initialized' };
143
+ }
144
+
145
+ try {
146
+ // Determine if this is a DM or public message
147
+ const isDirect = target && target !== 'public' && target !== '*';
148
+
149
+ if (isDirect) {
150
+ await bridge.sendDirectMessage(target, text);
151
+ } else {
152
+ await bridge.sendPublicMessage(text);
153
+ }
154
+
155
+ return { ok: true };
156
+ } catch (err) {
157
+ const error = err instanceof Error ? err.message : String(err);
158
+ return { ok: false, error };
159
+ }
160
+ },
161
+ },
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Main plugin registration
167
+ */
168
+ export default function register(api: PluginApi): void {
169
+ const logger = api.logger;
170
+
171
+ // Register the channel
172
+ const plugin = createBitchatPlugin();
173
+ api.registerChannel({ plugin });
174
+
175
+ // Register background service to manage bridge connection
176
+ api.registerService({
177
+ id: 'bitchat-bridge',
178
+ start: async () => {
179
+ const cfg = api.config as Record<string, unknown>;
180
+ const config = resolveConfig(cfg);
181
+
182
+ if (!config.enabled) {
183
+ logger.info('[bitchat] Channel disabled');
184
+ return;
185
+ }
186
+
187
+ logger.info(`[bitchat] Starting bridge connection to ${config.bridgeUrl}`);
188
+
189
+ bridge = new BitchatBridge({
190
+ bridgeUrl: config.bridgeUrl,
191
+ nickname: config.nickname,
192
+ onMessage: async (msg: BitchatInboundMessage) => {
193
+ // Check if sender is allowed
194
+ if (!isSenderAllowed(config, msg.senderPeerID)) {
195
+ logger.debug(`[bitchat] Ignoring message from unauthorized peer: ${msg.senderPeerID}`);
196
+ return;
197
+ }
198
+
199
+ // Route message to OpenClaw session
200
+ try {
201
+ await api.injectMessage({
202
+ channel: 'bitchat',
203
+ senderId: msg.senderPeerID,
204
+ senderName: msg.senderNickname,
205
+ text: msg.text,
206
+ messageId: msg.messageId,
207
+ chatType: msg.type === 'direct' ? 'direct' : 'group',
208
+ timestamp: msg.timestamp,
209
+ });
210
+ } catch (err) {
211
+ logger.error('[bitchat] Failed to inject message:', err);
212
+ }
213
+ },
214
+ logger,
215
+ });
216
+
217
+ await bridge.connect();
218
+ logger.info('[bitchat] Bridge connected');
219
+ },
220
+ stop: async () => {
221
+ if (bridge) {
222
+ await bridge.disconnect();
223
+ bridge = null;
224
+ logger.info('[bitchat] Bridge disconnected');
225
+ }
226
+ },
227
+ });
228
+
229
+ // Register webhook handler for inbound messages
230
+ api.registerHttpHandler({
231
+ method: 'POST',
232
+ path: '/bitchat-webhook',
233
+ handler: async (req, res) => {
234
+ try {
235
+ const body = req.body as BitchatInboundMessage;
236
+
237
+ if (!body || !body.senderPeerID || !body.text) {
238
+ res.status(400).json({ error: 'Invalid message format' });
239
+ return;
240
+ }
241
+
242
+ const cfg = api.config as Record<string, unknown>;
243
+ const config = resolveConfig(cfg);
244
+
245
+ if (!isSenderAllowed(config, body.senderPeerID)) {
246
+ res.status(403).json({ error: 'Sender not allowed' });
247
+ return;
248
+ }
249
+
250
+ await api.injectMessage({
251
+ channel: 'bitchat',
252
+ senderId: body.senderPeerID,
253
+ senderName: body.senderNickname,
254
+ text: body.text,
255
+ messageId: body.messageId,
256
+ chatType: body.type === 'direct' ? 'direct' : 'group',
257
+ timestamp: body.timestamp,
258
+ });
259
+
260
+ res.json({ ok: true });
261
+ } catch (err) {
262
+ logger.error('[bitchat] Webhook error:', err);
263
+ res.status(500).json({ error: 'Internal error' });
264
+ }
265
+ },
266
+ });
267
+
268
+ // Register RPC methods for status
269
+ api.registerGatewayMethod('bitchat.status', async ({ respond }) => {
270
+ const cfg = api.config as Record<string, unknown>;
271
+ const config = resolveConfig(cfg);
272
+
273
+ const status = {
274
+ enabled: config.enabled,
275
+ bridgeUrl: config.bridgeUrl,
276
+ connected: bridge?.isConnected() ?? false,
277
+ nickname: config.nickname,
278
+ dmPolicy: config.dmPolicy,
279
+ };
280
+
281
+ respond(true, status);
282
+ });
283
+
284
+ logger.info('[bitchat] Plugin registered');
285
+ }
286
+
287
+ // Export types
288
+ export type { PluginApi, ChannelPlugin };