@rowger_go/chatu 0.1.3
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/.github/workflows/ci.yml +30 -0
- package/.github/workflows/publish.yml +55 -0
- package/INSTALL.md +285 -0
- package/INSTALL.zh.md +285 -0
- package/LICENSE +21 -0
- package/README.md +293 -0
- package/README.zh.md +293 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1381 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +5 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +334 -0
- package/dist/index.test.js.map +1 -0
- package/dist/sdk/adapters/cache.d.ts +94 -0
- package/dist/sdk/adapters/cache.d.ts.map +1 -0
- package/dist/sdk/adapters/cache.js +158 -0
- package/dist/sdk/adapters/cache.js.map +1 -0
- package/dist/sdk/adapters/cache.test.d.ts +14 -0
- package/dist/sdk/adapters/cache.test.d.ts.map +1 -0
- package/dist/sdk/adapters/cache.test.js +178 -0
- package/dist/sdk/adapters/cache.test.js.map +1 -0
- package/dist/sdk/adapters/default.d.ts +24 -0
- package/dist/sdk/adapters/default.d.ts.map +1 -0
- package/dist/sdk/adapters/default.js +151 -0
- package/dist/sdk/adapters/default.js.map +1 -0
- package/dist/sdk/adapters/webhub.d.ts +336 -0
- package/dist/sdk/adapters/webhub.d.ts.map +1 -0
- package/dist/sdk/adapters/webhub.js +663 -0
- package/dist/sdk/adapters/webhub.js.map +1 -0
- package/dist/sdk/adapters/websocket.d.ts +133 -0
- package/dist/sdk/adapters/websocket.d.ts.map +1 -0
- package/dist/sdk/adapters/websocket.js +314 -0
- package/dist/sdk/adapters/websocket.js.map +1 -0
- package/dist/sdk/core/channel.d.ts +104 -0
- package/dist/sdk/core/channel.d.ts.map +1 -0
- package/dist/sdk/core/channel.js +158 -0
- package/dist/sdk/core/channel.js.map +1 -0
- package/dist/sdk/index.d.ts +27 -0
- package/dist/sdk/index.d.ts.map +1 -0
- package/dist/sdk/index.js +33 -0
- package/dist/sdk/index.js.map +1 -0
- package/dist/sdk/types/adapters.d.ts +128 -0
- package/dist/sdk/types/adapters.d.ts.map +1 -0
- package/dist/sdk/types/adapters.js +10 -0
- package/dist/sdk/types/adapters.js.map +1 -0
- package/dist/sdk/types/channel.d.ts +270 -0
- package/dist/sdk/types/channel.d.ts.map +1 -0
- package/dist/sdk/types/channel.js +36 -0
- package/dist/sdk/types/channel.js.map +1 -0
- package/docs/channel/01-overview.md +117 -0
- package/docs/channel/02-configuration.md +138 -0
- package/docs/channel/03-capabilities.md +86 -0
- package/docs/channel/04-api-reference.md +394 -0
- package/docs/channel/05-message-protocol.md +194 -0
- package/docs/channel/06-security.md +83 -0
- package/docs/channel/README.md +30 -0
- package/docs/sdk/README.md +13 -0
- package/docs/sdk/v2026.1.29-v2026.2.19.md +630 -0
- package/jest.config.js +19 -0
- package/openclaw.plugin.json +113 -0
- package/package.json +74 -0
- package/run-poll.mjs +209 -0
- package/scripts/reload-plugin.sh +78 -0
- package/src/index.test.ts +432 -0
- package/src/index.ts +1638 -0
- package/src/sdk/adapters/cache.test.ts +205 -0
- package/src/sdk/adapters/cache.ts +193 -0
- package/src/sdk/adapters/default.ts +196 -0
- package/src/sdk/adapters/webhub.ts +857 -0
- package/src/sdk/adapters/websocket.ts +378 -0
- package/src/sdk/core/channel.ts +230 -0
- package/src/sdk/index.ts +36 -0
- package/src/sdk/types/adapters.ts +169 -0
- package/src/sdk/types/channel.ts +346 -0
- package/tsconfig.json +31 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T037b — MessageCache unit tests
|
|
3
|
+
*
|
|
4
|
+
* constitution §IV: New modules must have a test file.
|
|
5
|
+
*
|
|
6
|
+
* Tests cover:
|
|
7
|
+
* - enqueue → FIFO order guarantee
|
|
8
|
+
* - Capacity limit: evicts oldest + logger.warn (single STRING — §VII)
|
|
9
|
+
* - flush → calls sendFn in FIFO order; marks submitted
|
|
10
|
+
* - ack → removes item, prevents re-send
|
|
11
|
+
* - File persistence: write then re-instantiate → queue restored
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import os from 'os';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import { MessageCache, CachedMessage } from '../../sdk/adapters/cache';
|
|
18
|
+
|
|
19
|
+
function makeMsg(id: string, channelId = 'ch-1'): CachedMessage {
|
|
20
|
+
return { id, channelId, content: { text: `msg-${id}` }, enqueuedAt: Date.now(), status: 'pending' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('MessageCache', () => {
|
|
24
|
+
// ── enqueue / FIFO ──────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
describe('enqueue', () => {
|
|
27
|
+
it('maintains FIFO insertion order in snapshot', () => {
|
|
28
|
+
const cache = new MessageCache();
|
|
29
|
+
cache.enqueue(makeMsg('a'));
|
|
30
|
+
cache.enqueue(makeMsg('b'));
|
|
31
|
+
cache.enqueue(makeMsg('c'));
|
|
32
|
+
|
|
33
|
+
const ids = cache.snapshot().map((m) => m.id);
|
|
34
|
+
expect(ids).toEqual(['a', 'b', 'c']);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('size reflects enqueued count', () => {
|
|
38
|
+
const cache = new MessageCache();
|
|
39
|
+
cache.enqueue(makeMsg('x'));
|
|
40
|
+
cache.enqueue(makeMsg('y'));
|
|
41
|
+
expect(cache.size).toBe(2);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// ── Capacity limit ──────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
describe('capacity enforcement', () => {
|
|
48
|
+
it('evicts the oldest item when limit is reached', () => {
|
|
49
|
+
const cache = new MessageCache({ maxCapacity: 3 });
|
|
50
|
+
cache.enqueue(makeMsg('first'));
|
|
51
|
+
cache.enqueue(makeMsg('second'));
|
|
52
|
+
cache.enqueue(makeMsg('third'));
|
|
53
|
+
cache.enqueue(makeMsg('fourth')); // should evict 'first'
|
|
54
|
+
|
|
55
|
+
const ids = cache.snapshot().map((m) => m.id);
|
|
56
|
+
expect(ids).not.toContain('first');
|
|
57
|
+
expect(ids).toContain('fourth');
|
|
58
|
+
expect(cache.size).toBe(3);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('calls logger.warn with a SINGLE STRING on eviction (§VII)', () => {
|
|
62
|
+
const warnMessages: string[] = [];
|
|
63
|
+
const logger = { warn: (msg: string) => { warnMessages.push(msg); } };
|
|
64
|
+
|
|
65
|
+
const cache = new MessageCache({ maxCapacity: 1, logger });
|
|
66
|
+
cache.enqueue(makeMsg('m1', 'ch-test'));
|
|
67
|
+
cache.enqueue(makeMsg('m2', 'ch-test')); // evicts 'm1'
|
|
68
|
+
|
|
69
|
+
expect(warnMessages.length).toBe(1);
|
|
70
|
+
// Must be a string — not an object (§VII)
|
|
71
|
+
expect(typeof warnMessages[0]).toBe('string');
|
|
72
|
+
expect(warnMessages[0]).toContain('cache_capacity_exceeded');
|
|
73
|
+
expect(warnMessages[0]).toContain('ch-test');
|
|
74
|
+
expect(warnMessages[0]).toContain('m1');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ── flush ───────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
describe('flush', () => {
|
|
81
|
+
it('calls sendFn for each pending message in FIFO order', async () => {
|
|
82
|
+
const cache = new MessageCache();
|
|
83
|
+
cache.enqueue(makeMsg('p1'));
|
|
84
|
+
cache.enqueue(makeMsg('p2'));
|
|
85
|
+
cache.enqueue(makeMsg('p3'));
|
|
86
|
+
|
|
87
|
+
const order: string[] = [];
|
|
88
|
+
const count = await cache.flush(async (msg) => {
|
|
89
|
+
order.push(msg.id);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(count).toBe(3);
|
|
93
|
+
expect(order).toEqual(['p1', 'p2', 'p3']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('marks flushed messages as submitted', async () => {
|
|
97
|
+
const cache = new MessageCache();
|
|
98
|
+
cache.enqueue(makeMsg('s1'));
|
|
99
|
+
await cache.flush(async () => { /* no-op */ });
|
|
100
|
+
const snap = cache.snapshot();
|
|
101
|
+
expect(snap[0].status).toBe('submitted');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('stops flushing on first sendFn error, leaving remaining as pending', async () => {
|
|
105
|
+
const cache = new MessageCache();
|
|
106
|
+
cache.enqueue(makeMsg('ok1'));
|
|
107
|
+
cache.enqueue(makeMsg('fail'));
|
|
108
|
+
cache.enqueue(makeMsg('ok2'));
|
|
109
|
+
|
|
110
|
+
let calls = 0;
|
|
111
|
+
await cache.flush(async (msg) => {
|
|
112
|
+
calls++;
|
|
113
|
+
if (msg.id === 'fail') throw new Error('send failed');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Stopped at 'fail' — only 'ok1' submitted
|
|
117
|
+
expect(calls).toBe(2);
|
|
118
|
+
const snaps = cache.snapshot();
|
|
119
|
+
expect(snaps.find((m) => m.id === 'ok1')?.status).toBe('submitted');
|
|
120
|
+
expect(snaps.find((m) => m.id === 'fail')?.status).toBe('pending');
|
|
121
|
+
expect(snaps.find((m) => m.id === 'ok2')?.status).toBe('pending');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('does not re-send already-submitted messages on subsequent flush', async () => {
|
|
125
|
+
const cache = new MessageCache();
|
|
126
|
+
cache.enqueue(makeMsg('d1'));
|
|
127
|
+
await cache.flush(async () => { /* no-op */ });
|
|
128
|
+
|
|
129
|
+
const sentIds: string[] = [];
|
|
130
|
+
await cache.flush(async (msg) => { sentIds.push(msg.id); });
|
|
131
|
+
expect(sentIds).not.toContain('d1');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── ack ─────────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
describe('ack', () => {
|
|
138
|
+
it('removes the message from the queue by id', () => {
|
|
139
|
+
const cache = new MessageCache();
|
|
140
|
+
cache.enqueue(makeMsg('ack-1'));
|
|
141
|
+
cache.enqueue(makeMsg('ack-2'));
|
|
142
|
+
|
|
143
|
+
cache.ack('ack-1');
|
|
144
|
+
|
|
145
|
+
const ids = cache.snapshot().map((m) => m.id);
|
|
146
|
+
expect(ids).not.toContain('ack-1');
|
|
147
|
+
expect(ids).toContain('ack-2');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('prevents the acked message from being re-sent on flush', async () => {
|
|
151
|
+
const cache = new MessageCache();
|
|
152
|
+
cache.enqueue(makeMsg('r1'));
|
|
153
|
+
cache.enqueue(makeMsg('r2'));
|
|
154
|
+
cache.ack('r1');
|
|
155
|
+
|
|
156
|
+
const sent: string[] = [];
|
|
157
|
+
await cache.flush(async (msg) => { sent.push(msg.id); });
|
|
158
|
+
expect(sent).not.toContain('r1');
|
|
159
|
+
expect(sent).toContain('r2');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('is a no-op for unknown ids', () => {
|
|
163
|
+
const cache = new MessageCache();
|
|
164
|
+
cache.enqueue(makeMsg('x'));
|
|
165
|
+
expect(() => cache.ack('nonexistent')).not.toThrow();
|
|
166
|
+
expect(cache.size).toBe(1);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ── File persistence ─────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
describe('file persistence', () => {
|
|
173
|
+
let tmpFile: string;
|
|
174
|
+
|
|
175
|
+
beforeEach(() => {
|
|
176
|
+
tmpFile = path.join(os.tmpdir(), `cache-test-${Date.now()}.json`);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
afterEach(() => {
|
|
180
|
+
try { fs.unlinkSync(tmpFile); } catch { /* ignore */ }
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('persists queue to file and restores on re-instantiation', () => {
|
|
184
|
+
const cache1 = new MessageCache({ filePath: tmpFile });
|
|
185
|
+
cache1.enqueue(makeMsg('persist-1'));
|
|
186
|
+
cache1.enqueue(makeMsg('persist-2'));
|
|
187
|
+
|
|
188
|
+
// Re-instantiate from same file path
|
|
189
|
+
const cache2 = new MessageCache({ filePath: tmpFile });
|
|
190
|
+
const ids = cache2.snapshot().map((m) => m.id);
|
|
191
|
+
expect(ids).toEqual(['persist-1', 'persist-2']);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('starts with empty queue if file does not exist', () => {
|
|
195
|
+
const cache = new MessageCache({ filePath: '/tmp/definitely-does-not-exist-xyz123.json' });
|
|
196
|
+
expect(cache.size).toBe(0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('starts with empty queue if file is corrupt JSON', () => {
|
|
200
|
+
fs.writeFileSync(tmpFile, 'not valid json');
|
|
201
|
+
const cache = new MessageCache({ filePath: tmpFile });
|
|
202
|
+
expect(cache.size).toBe(0);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/******************************************************************
|
|
2
|
+
* Plugin Local Message Cache (cache.ts)
|
|
3
|
+
*
|
|
4
|
+
* In-memory FIFO queue for outbound messages that couldn't be sent
|
|
5
|
+
* while the WebSocket was disconnected.
|
|
6
|
+
*
|
|
7
|
+
* Key behaviour (spec FR-007/US2):
|
|
8
|
+
* - Capacity limited to CHATU_CACHE_MAX (default 1000)
|
|
9
|
+
* - On overflow: oldest item is evicted; PluginLogger.warn is called
|
|
10
|
+
* with a SINGLE STRING (constitution §VII — NON-NEGOTIABLE)
|
|
11
|
+
* - Optional JSON file persistence via CHATU_CACHE_FILE env var
|
|
12
|
+
* - flush(sendFn) sends items in FIFO order; ack(id) marks delivered
|
|
13
|
+
*
|
|
14
|
+
* @see specs/001-plugin-channel-realtime/tasks.md T013
|
|
15
|
+
******************************************************************/
|
|
16
|
+
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
|
|
20
|
+
/** Minimal subset of PluginLogger used here (§VII: warn accepts single string). */
|
|
21
|
+
export interface PluginLoggerSubset {
|
|
22
|
+
warn: (msg: string) => void;
|
|
23
|
+
info?: (msg: string) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A cached outbound message entry. */
|
|
27
|
+
export interface CachedMessage {
|
|
28
|
+
id: string;
|
|
29
|
+
channelId: string;
|
|
30
|
+
content: unknown;
|
|
31
|
+
enqueuedAt: number;
|
|
32
|
+
/** 'pending' until ACKed by the server. */
|
|
33
|
+
status: 'pending' | 'submitted';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CacheOptions {
|
|
37
|
+
/** Max items before evicting the oldest. Defaults to CHATU_CACHE_MAX env or 1000. */
|
|
38
|
+
maxCapacity?: number;
|
|
39
|
+
/** Path for JSON file persistence. Defaults to CHATU_CACHE_FILE env or undefined. */
|
|
40
|
+
filePath?: string;
|
|
41
|
+
/** Logger used for capacity warnings (§VII). */
|
|
42
|
+
logger?: PluginLoggerSubset;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_CAPACITY = 1000;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* MessageCache — FIFO queue for offline-buffered outbound messages.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```typescript
|
|
52
|
+
* const cache = new MessageCache({ logger: api.logger });
|
|
53
|
+
* cache.enqueue({ id: uuid(), channelId: 'wh_ch_xxx', content: { text: 'hello' }, enqueuedAt: Date.now(), status: 'pending' });
|
|
54
|
+
* await cache.flush(async (msg) => adapter.send(msg));
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export class MessageCache {
|
|
58
|
+
private queue: CachedMessage[] = [];
|
|
59
|
+
private maxCapacity: number;
|
|
60
|
+
private filePath: string | undefined;
|
|
61
|
+
private logger: PluginLoggerSubset | undefined;
|
|
62
|
+
|
|
63
|
+
constructor(options: CacheOptions = {}) {
|
|
64
|
+
this.maxCapacity =
|
|
65
|
+
options.maxCapacity ??
|
|
66
|
+
(process.env.CHATU_CACHE_MAX ? parseInt(process.env.CHATU_CACHE_MAX, 10) : DEFAULT_CAPACITY);
|
|
67
|
+
|
|
68
|
+
this.filePath =
|
|
69
|
+
options.filePath ??
|
|
70
|
+
(process.env.CHATU_CACHE_FILE ? path.resolve(process.env.CHATU_CACHE_FILE) : undefined);
|
|
71
|
+
|
|
72
|
+
this.logger = options.logger;
|
|
73
|
+
|
|
74
|
+
// Restore from file if configured
|
|
75
|
+
if (this.filePath) {
|
|
76
|
+
this.loadFromFile();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Enqueue a message for later delivery.
|
|
84
|
+
* If at capacity, evicts the oldest pending item and logs a warning.
|
|
85
|
+
*/
|
|
86
|
+
enqueue(msg: CachedMessage): void {
|
|
87
|
+
if (this.queue.length >= this.maxCapacity) {
|
|
88
|
+
const dropped = this.queue.shift();
|
|
89
|
+
if (dropped && this.logger) {
|
|
90
|
+
// §VII: PluginLogger.warn must receive a SINGLE STRING — no object argument
|
|
91
|
+
this.logger.warn(
|
|
92
|
+
'cache_capacity_exceeded: channelId=' + msg.channelId + ' dropped=' + dropped.id,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this.queue.push(msg);
|
|
98
|
+
this.persist();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Flush all 'pending' items in FIFO order by calling sendFn for each.
|
|
103
|
+
* Items that sendFn resolves successfully are marked 'submitted'.
|
|
104
|
+
* Items where sendFn throws remain 'pending' for the next flush.
|
|
105
|
+
*
|
|
106
|
+
* Returns the number of successfully flushed items.
|
|
107
|
+
*/
|
|
108
|
+
async flush(sendFn: (msg: CachedMessage) => Promise<void>): Promise<number> {
|
|
109
|
+
let flushed = 0;
|
|
110
|
+
const pending = this.queue.filter((m) => m.status === 'pending');
|
|
111
|
+
|
|
112
|
+
for (const msg of pending) {
|
|
113
|
+
try {
|
|
114
|
+
await sendFn(msg);
|
|
115
|
+
msg.status = 'submitted';
|
|
116
|
+
flushed++;
|
|
117
|
+
} catch {
|
|
118
|
+
// Leave as pending — will retry on next flush
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.persist();
|
|
124
|
+
return flushed;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Acknowledge delivery of a message by its id.
|
|
129
|
+
* Removes it from the queue to prevent duplicate re-sends.
|
|
130
|
+
*/
|
|
131
|
+
ack(id: string): void {
|
|
132
|
+
const idx = this.queue.findIndex((m) => m.id === id);
|
|
133
|
+
if (idx !== -1) {
|
|
134
|
+
this.queue.splice(idx, 1);
|
|
135
|
+
this.persist();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Returns a snapshot of the current queue (read-only copy).
|
|
141
|
+
*/
|
|
142
|
+
snapshot(): ReadonlyArray<Readonly<CachedMessage>> {
|
|
143
|
+
return [...this.queue];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Number of items currently in the queue.
|
|
148
|
+
*/
|
|
149
|
+
get size(): number {
|
|
150
|
+
return this.queue.length;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Number of pending (not yet ACKed) items.
|
|
155
|
+
*/
|
|
156
|
+
get pendingCount(): number {
|
|
157
|
+
return this.queue.filter((m) => m.status === 'pending').length;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ─── Persistence ─────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Persist the current queue to the JSON file (if configured).
|
|
164
|
+
* Errors are silently swallowed to avoid disrupting the main flow.
|
|
165
|
+
*/
|
|
166
|
+
private persist(): void {
|
|
167
|
+
if (!this.filePath) return;
|
|
168
|
+
try {
|
|
169
|
+
const dir = path.dirname(this.filePath);
|
|
170
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
171
|
+
fs.writeFileSync(this.filePath, JSON.stringify(this.queue, null, 2), 'utf-8');
|
|
172
|
+
} catch {
|
|
173
|
+
// best-effort: silently ignore fs errors
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Load queue from the JSON file on startup (if the file exists).
|
|
179
|
+
*/
|
|
180
|
+
private loadFromFile(): void {
|
|
181
|
+
if (!this.filePath) return;
|
|
182
|
+
try {
|
|
183
|
+
if (!fs.existsSync(this.filePath)) return;
|
|
184
|
+
const raw = fs.readFileSync(this.filePath, 'utf-8');
|
|
185
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
186
|
+
if (Array.isArray(parsed)) {
|
|
187
|
+
this.queue = parsed as CachedMessage[];
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
// best-effort: ignore corrupt/missing files
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/******************************************************************
|
|
2
|
+
* Channel SDK - Default Adapter Factory
|
|
3
|
+
*
|
|
4
|
+
* OpenClaw Channel SDK 的默认适配器工厂
|
|
5
|
+
*
|
|
6
|
+
* @see https://github.com/chatu-ai/openclaw-web-hub-channel
|
|
7
|
+
******************************************************************/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
ConnectionConfig,
|
|
11
|
+
ChannelStats,
|
|
12
|
+
ConnectionStatus,
|
|
13
|
+
ChannelCapabilities,
|
|
14
|
+
MessageType,
|
|
15
|
+
TargetType,
|
|
16
|
+
} from '../types/channel';
|
|
17
|
+
import type {
|
|
18
|
+
ConnectionAdapter,
|
|
19
|
+
MessageAdapter,
|
|
20
|
+
HeartbeatAdapter,
|
|
21
|
+
AuthAdapter,
|
|
22
|
+
CapabilitiesAdapter,
|
|
23
|
+
LoggerAdapter,
|
|
24
|
+
MessageCallback,
|
|
25
|
+
StatusCallback,
|
|
26
|
+
AdapterFactory,
|
|
27
|
+
} from '../types/adapters';
|
|
28
|
+
import { WebSocketAdapter } from './websocket';
|
|
29
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 消息适配器实现 [Channel SDK 标准]
|
|
33
|
+
*/
|
|
34
|
+
class DefaultMessageAdapter implements MessageAdapter {
|
|
35
|
+
parseInbound(raw: unknown): any {
|
|
36
|
+
return raw;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
formatOutbound(message: any): any {
|
|
40
|
+
return message;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
validate(message: any): boolean {
|
|
44
|
+
return message && typeof message === 'object';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
sanitize(text: string): string {
|
|
48
|
+
return text
|
|
49
|
+
.replace(/</g, '<')
|
|
50
|
+
.replace(/>/g, '>')
|
|
51
|
+
.replace(/"/g, '"');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 心跳适配器实现 [Channel SDK 标准]
|
|
57
|
+
*/
|
|
58
|
+
class DefaultHeartbeatAdapter implements HeartbeatAdapter {
|
|
59
|
+
private lastResponse: number = Date.now();
|
|
60
|
+
private timeout: number;
|
|
61
|
+
|
|
62
|
+
constructor(timeout: number = 10000) {
|
|
63
|
+
this.timeout = timeout;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async send(): Promise<boolean> {
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
handleResponse(): void {
|
|
71
|
+
this.lastResponse = Date.now();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
isTimeout(): boolean {
|
|
75
|
+
return Date.now() - this.lastResponse > this.timeout;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getNextHeartbeatTime(): number {
|
|
79
|
+
return Date.now() + 30000;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 认证适配器实现 [Channel SDK 标准]
|
|
85
|
+
*/
|
|
86
|
+
class DefaultAuthAdapter implements AuthAdapter {
|
|
87
|
+
private token: string;
|
|
88
|
+
|
|
89
|
+
constructor(token: string) {
|
|
90
|
+
this.token = token;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
getAuthHeaders(): Record<string, string> {
|
|
94
|
+
return {
|
|
95
|
+
'Authorization': `Bearer ${this.token}`,
|
|
96
|
+
'Content-Type': 'application/json',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
validateResponse(response: unknown): boolean {
|
|
101
|
+
return response !== null && response !== undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async refreshToken(): Promise<boolean> {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 能力适配器实现 [Channel SDK 标准]
|
|
111
|
+
*/
|
|
112
|
+
class DefaultCapabilitiesAdapter implements CapabilitiesAdapter {
|
|
113
|
+
async getCapabilities(): Promise<ChannelCapabilities> {
|
|
114
|
+
return {
|
|
115
|
+
messageTypes: [
|
|
116
|
+
MessageType.TEXT,
|
|
117
|
+
MessageType.IMAGE,
|
|
118
|
+
MessageType.VIDEO,
|
|
119
|
+
MessageType.AUDIO,
|
|
120
|
+
MessageType.FILE,
|
|
121
|
+
],
|
|
122
|
+
targetTypes: [TargetType.USER, TargetType.GROUP],
|
|
123
|
+
richFormats: ['markdown'],
|
|
124
|
+
attachments: true,
|
|
125
|
+
reply: true,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
hasCapability(capability: keyof ChannelCapabilities): boolean {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 日志适配器实现 [Channel SDK 标准]
|
|
136
|
+
*/
|
|
137
|
+
class DefaultLoggerAdapter implements LoggerAdapter {
|
|
138
|
+
debug(message: string, data?: unknown): void {
|
|
139
|
+
console.debug(`[DEBUG] ${message}`, data || '');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
info(message: string, data?: unknown): void {
|
|
143
|
+
console.info(`[INFO] ${message}`, data || '');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
warn(message: string, data?: unknown): void {
|
|
147
|
+
console.warn(`[WARN] ${message}`, data || '');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
error(message: string, error?: Error): void {
|
|
151
|
+
console.error(`[ERROR] ${message}`, error || '');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 默认适配器工厂 [Channel SDK 标准]
|
|
157
|
+
*
|
|
158
|
+
* 提供所有适配器的默认实现
|
|
159
|
+
*/
|
|
160
|
+
export class DefaultAdapterFactory implements AdapterFactory {
|
|
161
|
+
createConnectionAdapter(config: ConnectionConfig): ConnectionAdapter {
|
|
162
|
+
return new WebSocketAdapter(config);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
createMessageAdapter(): MessageAdapter {
|
|
166
|
+
return new DefaultMessageAdapter();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
createHeartbeatAdapter(): HeartbeatAdapter {
|
|
170
|
+
return new DefaultHeartbeatAdapter();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
createAuthAdapter(token?: string): AuthAdapter {
|
|
174
|
+
return new DefaultAuthAdapter(token || '');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
createCapabilitiesAdapter(): CapabilitiesAdapter {
|
|
178
|
+
return new DefaultCapabilitiesAdapter();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
createLoggerAdapter(): LoggerAdapter {
|
|
182
|
+
return new DefaultLoggerAdapter();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 创建默认工厂单例 [Channel SDK 标准]
|
|
188
|
+
*/
|
|
189
|
+
let factory: DefaultAdapterFactory | null = null;
|
|
190
|
+
|
|
191
|
+
export function createDefaultFactory(): DefaultAdapterFactory {
|
|
192
|
+
if (!factory) {
|
|
193
|
+
factory = new DefaultAdapterFactory();
|
|
194
|
+
}
|
|
195
|
+
return factory;
|
|
196
|
+
}
|