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/README.md +156 -0
- package/dist/bridge.d.ts +90 -0
- package/dist/bridge.d.ts.map +1 -0
- package/dist/bridge.js +232 -0
- package/dist/bridge.js.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +240 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +150 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/openclaw.plugin.json +58 -0
- package/package.json +64 -0
- package/src/bridge.ts +284 -0
- package/src/index.ts +288 -0
- package/src/types.ts +155 -0
- package/tsconfig.json +26 -0
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 };
|