openclaw-opincer 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 +128 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/src/channel.d.ts +61 -0
- package/dist/src/channel.d.ts.map +1 -0
- package/dist/src/channel.js +767 -0
- package/dist/src/channel.js.map +1 -0
- package/index.ts +12 -0
- package/openclaw.plugin.json +17 -0
- package/package.json +43 -0
- package/src/channel.ts +868 -0
package/src/channel.ts
ADDED
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channel.ts — Opincer channel plugin (WebSocket inbound, HTTP outbound)
|
|
3
|
+
* Adapted for Opincer protocol: REGISTER → AUTH → receive envelopes
|
|
4
|
+
*
|
|
5
|
+
* Key differences from openclaw-pincer:
|
|
6
|
+
* - WS URL: /api/v1/agents/{agent_id}/ws (not /ws?agent_id=)
|
|
7
|
+
* - Groups API: /api/v1/agents/{id}/groups (not /api/v1/projects)
|
|
8
|
+
* - Room WS: /api/v1/rooms/{room_id}/ws?api_key= (same)
|
|
9
|
+
*
|
|
10
|
+
* Context compression:
|
|
11
|
+
* - Fetches recent room messages and attaches them as context
|
|
12
|
+
* - When unsummarized message count >= SUMMARY_THRESHOLD, triggers AI summarization
|
|
13
|
+
* - Summaries stored in ~/.openclaw/opincer-summaries/{room_id}.json
|
|
14
|
+
* - Each dispatch: inject summary (if any) + last CONTEXT_MSG_COUNT raw messages
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import WebSocket from "ws";
|
|
18
|
+
import { randomUUID } from "crypto";
|
|
19
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
20
|
+
import { homedir } from "os";
|
|
21
|
+
import { join } from "path";
|
|
22
|
+
|
|
23
|
+
// ── Context compression settings ────────────────────────────────────────────
|
|
24
|
+
/** Number of recent raw messages to attach as context per dispatch */
|
|
25
|
+
const CONTEXT_MSG_COUNT = 10;
|
|
26
|
+
/** Trigger AI summarization when unsummarized messages reach this count */
|
|
27
|
+
const SUMMARY_THRESHOLD = 30;
|
|
28
|
+
/** Directory where per-room summary state is persisted */
|
|
29
|
+
const SUMMARY_DIR = join(homedir(), ".openclaw", "opincer-summaries");
|
|
30
|
+
|
|
31
|
+
interface RoomSummaryState {
|
|
32
|
+
roomId: string;
|
|
33
|
+
summary: string; // latest summary text
|
|
34
|
+
summarizedUpTo: string; // message ID (or ISO timestamp) up to which we've summarized
|
|
35
|
+
summarizedCount: number; // how many messages are covered by the summary
|
|
36
|
+
updatedAt: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getSummaryPath(roomId: string): string {
|
|
40
|
+
return join(SUMMARY_DIR, `${roomId}.json`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function loadSummary(roomId: string): RoomSummaryState | null {
|
|
44
|
+
try {
|
|
45
|
+
const raw = readFileSync(getSummaryPath(roomId), "utf-8");
|
|
46
|
+
return JSON.parse(raw) as RoomSummaryState;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function saveSummary(state: RoomSummaryState): void {
|
|
53
|
+
try {
|
|
54
|
+
if (!existsSync(SUMMARY_DIR)) mkdirSync(SUMMARY_DIR, { recursive: true });
|
|
55
|
+
writeFileSync(getSummaryPath(state.roomId), JSON.stringify(state, null, 2), "utf-8");
|
|
56
|
+
} catch (e) {
|
|
57
|
+
console.error("[openclaw-opincer] Failed to save summary:", e);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Fetch recent messages from a room via REST API */
|
|
62
|
+
async function fetchRecentMessages(
|
|
63
|
+
config: OpincerConfig,
|
|
64
|
+
roomId: string,
|
|
65
|
+
limit = 50,
|
|
66
|
+
): Promise<Array<{ id: string; sender_name?: string; sender_agent_id?: string; content: string; created_at: string }>> {
|
|
67
|
+
try {
|
|
68
|
+
const url = `${config.baseUrl.replace(/\/$/, "")}/api/v1/rooms/${roomId}/messages?limit=${limit}`;
|
|
69
|
+
const res = await fetch(url, {
|
|
70
|
+
headers: { "X-API-Key": config.token },
|
|
71
|
+
});
|
|
72
|
+
if (!res.ok) return [];
|
|
73
|
+
const data = await res.json();
|
|
74
|
+
const msgs = Array.isArray(data) ? data : (data.messages ?? data.data ?? []);
|
|
75
|
+
return msgs;
|
|
76
|
+
} catch {
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Format messages into a readable transcript for summarization */
|
|
82
|
+
function formatTranscript(
|
|
83
|
+
messages: Array<{ sender_name?: string; sender_agent_id?: string; content: string; created_at: string }>,
|
|
84
|
+
): string {
|
|
85
|
+
return messages.map(m => {
|
|
86
|
+
const who = m.sender_name ?? m.sender_agent_id?.slice(0, 8) ?? "?";
|
|
87
|
+
const ts = m.created_at ? new Date(m.created_at).toISOString().slice(11, 16) : "";
|
|
88
|
+
return `[${ts}] ${who}: ${m.content}`;
|
|
89
|
+
}).join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Generate an AI summary via:
|
|
94
|
+
* 1. Opincer backend POST /rooms/{id}/summary (if available)
|
|
95
|
+
* 2. OpenAI-compatible LLM API (config.summaryApiBase / summaryApiKey)
|
|
96
|
+
* 3. Local structured compression fallback
|
|
97
|
+
*/
|
|
98
|
+
async function generateSummary(
|
|
99
|
+
config: OpincerConfig,
|
|
100
|
+
roomId: string,
|
|
101
|
+
messages: Array<{ sender_name?: string; sender_agent_id?: string; content: string; created_at: string }>,
|
|
102
|
+
): Promise<string> {
|
|
103
|
+
// 1. Try Opincer backend summary API
|
|
104
|
+
try {
|
|
105
|
+
const url = `${config.baseUrl.replace(/\/$/, "")}/api/v1/rooms/${roomId}/summary`;
|
|
106
|
+
const res = await fetch(url, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { "X-API-Key": config.token, "Content-Type": "application/json" },
|
|
109
|
+
body: JSON.stringify({ messages }),
|
|
110
|
+
});
|
|
111
|
+
if (res.ok) {
|
|
112
|
+
const data = await res.json();
|
|
113
|
+
const s = data.summary ?? data.content ?? "";
|
|
114
|
+
if (s) return s;
|
|
115
|
+
}
|
|
116
|
+
} catch { /* fall through */ }
|
|
117
|
+
|
|
118
|
+
// 2. Try OpenAI-compatible LLM (LiteLLM proxy or any compatible endpoint)
|
|
119
|
+
const apiBase = config.summaryApiBase;
|
|
120
|
+
const apiKey = config.summaryApiKey;
|
|
121
|
+
if (apiBase && apiKey) {
|
|
122
|
+
try {
|
|
123
|
+
const transcript = formatTranscript(messages);
|
|
124
|
+
const prompt = [
|
|
125
|
+
"以下是一个群聊的历史消息记录。请用简洁的中文总结关键信息:",
|
|
126
|
+
"- 讨论了哪些主题/任务",
|
|
127
|
+
"- 做了哪些决定",
|
|
128
|
+
"- 有哪些待办项",
|
|
129
|
+
"- 重要的人名/任务名/版本号",
|
|
130
|
+
"保持简洁(200字以内),不要逐条复述每条消息。",
|
|
131
|
+
"",
|
|
132
|
+
"消息记录:",
|
|
133
|
+
transcript,
|
|
134
|
+
].join("\n");
|
|
135
|
+
|
|
136
|
+
const model = config.summaryModel ?? "gpt-4o-mini";
|
|
137
|
+
const res = await fetch(`${apiBase.replace(/\/$/, "")}/chat/completions`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: {
|
|
140
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
141
|
+
"Content-Type": "application/json",
|
|
142
|
+
},
|
|
143
|
+
body: JSON.stringify({
|
|
144
|
+
model,
|
|
145
|
+
messages: [{ role: "user", content: prompt }],
|
|
146
|
+
max_tokens: 400,
|
|
147
|
+
temperature: 0.3,
|
|
148
|
+
}),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
if (res.ok) {
|
|
152
|
+
const data = await res.json();
|
|
153
|
+
const text: string = data.choices?.[0]?.message?.content ?? "";
|
|
154
|
+
if (text) {
|
|
155
|
+
console.log(`[openclaw-opincer] ✨ AI summary generated (${text.length} chars)`);
|
|
156
|
+
return `[AI摘要 — 涵盖 ${messages.length} 条消息]\n${text}`;
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
const errText = await res.text().catch(() => "");
|
|
160
|
+
console.warn(`[openclaw-opincer] LLM summary API error ${res.status}: ${errText.slice(0, 100)}`);
|
|
161
|
+
}
|
|
162
|
+
} catch (e) {
|
|
163
|
+
console.warn("[openclaw-opincer] LLM summary failed:", e);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 3. Local structured compression fallback
|
|
168
|
+
const lines = messages.map(m => {
|
|
169
|
+
const who = m.sender_name ?? m.sender_agent_id?.slice(0, 8) ?? "?";
|
|
170
|
+
const ts = m.created_at ? new Date(m.created_at).toISOString().slice(11, 16) : "";
|
|
171
|
+
const text = m.content.length > 120 ? m.content.slice(0, 120) + "…" : m.content;
|
|
172
|
+
return `[${ts}] ${who}: ${text}`;
|
|
173
|
+
});
|
|
174
|
+
return `[历史消息摘要 — ${messages.length} 条,未配置 AI]\n` + lines.join("\n");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface OpincerConfig {
|
|
178
|
+
baseUrl: string;
|
|
179
|
+
token: string; // api_key
|
|
180
|
+
agentId: string; // registered agent UUID on Opincer
|
|
181
|
+
agentName: string; // display name
|
|
182
|
+
/** OpenAI-compatible API base URL for AI summarization (e.g. LiteLLM proxy) */
|
|
183
|
+
summaryApiBase?: string;
|
|
184
|
+
/** API key for the summary LLM endpoint */
|
|
185
|
+
summaryApiKey?: string;
|
|
186
|
+
/** Model to use for summarization (default: gpt-4o-mini or any available) */
|
|
187
|
+
summaryModel?: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function resolveConfig(cfg: any): OpincerConfig {
|
|
191
|
+
return (cfg?.channels?.["openclaw-opincer"] ?? {}) as OpincerConfig;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function wsUrl(config: OpincerConfig): string {
|
|
195
|
+
const base = config.baseUrl.replace(/\/$/, "").replace(/^http/, "ws");
|
|
196
|
+
return `${base}/api/v1/agents/${config.agentId}/ws`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function makeEnvelope(type: string, from: string, to: string, payload: any): string {
|
|
200
|
+
return JSON.stringify({
|
|
201
|
+
id: randomUUID(),
|
|
202
|
+
type,
|
|
203
|
+
from,
|
|
204
|
+
to,
|
|
205
|
+
ts: new Date().toISOString(),
|
|
206
|
+
payload,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function httpPost(config: OpincerConfig, path: string, body: any): Promise<void> {
|
|
211
|
+
const url = `${config.baseUrl.replace(/\/$/, "")}/api/v1${path}`;
|
|
212
|
+
const res = await fetch(url, {
|
|
213
|
+
method: "POST",
|
|
214
|
+
headers: { "X-API-Key": config.token, "Content-Type": "application/json" },
|
|
215
|
+
body: JSON.stringify(body),
|
|
216
|
+
});
|
|
217
|
+
if (!res.ok) throw new Error(`Opincer POST ${path} ${res.status}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Fetch groups that this agent belongs to (via /api/v1/agents/{id}/groups)
|
|
221
|
+
async function fetchAgentGroups(config: OpincerConfig): Promise<string[]> {
|
|
222
|
+
const url = `${config.baseUrl.replace(/\/$/, "")}/api/v1/agents/${config.agentId}/groups`;
|
|
223
|
+
try {
|
|
224
|
+
const res = await fetch(url, {
|
|
225
|
+
headers: { "X-API-Key": config.token, "User-Agent": "openclaw-opincer/0.1" },
|
|
226
|
+
});
|
|
227
|
+
if (!res.ok) return [];
|
|
228
|
+
const data = await res.json();
|
|
229
|
+
const groups = Array.isArray(data) ? data : [];
|
|
230
|
+
// Each group has a room_id or id field
|
|
231
|
+
return groups.map((g: any) => g.room_id ?? g.id).filter(Boolean);
|
|
232
|
+
} catch {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Subscribe to a single room WebSocket and dispatch messages
|
|
238
|
+
function connectRoomWs(params: {
|
|
239
|
+
config: OpincerConfig;
|
|
240
|
+
ctx: any;
|
|
241
|
+
roomId: string;
|
|
242
|
+
signal: AbortSignal;
|
|
243
|
+
}): void {
|
|
244
|
+
const { config, ctx, roomId, signal } = params;
|
|
245
|
+
let retryMs = 2000;
|
|
246
|
+
|
|
247
|
+
function connect() {
|
|
248
|
+
if (signal.aborted) return;
|
|
249
|
+
const wsBase = config.baseUrl.replace(/\/$/, "").replace(/^http/, "ws");
|
|
250
|
+
const ws = new WebSocket(`${wsBase}/api/v1/rooms/${roomId}/ws?api_key=${config.token}`);
|
|
251
|
+
|
|
252
|
+
ws.on("open", () => {
|
|
253
|
+
retryMs = 2000;
|
|
254
|
+
console.log(`[openclaw-opincer] Room WS connected: ${roomId}`);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
ws.on("message", (data: WebSocket.Data) => {
|
|
258
|
+
try {
|
|
259
|
+
const msg = JSON.parse(data.toString());
|
|
260
|
+
if (msg.type !== "room.message") return;
|
|
261
|
+
const payload = msg.data ?? msg.payload ?? {};
|
|
262
|
+
const sender = payload.sender_agent_id ?? "unknown";
|
|
263
|
+
const content = payload.content ?? "";
|
|
264
|
+
if (sender === config.agentId || !content) return;
|
|
265
|
+
// Mention-only: respond only when @agentName or @all
|
|
266
|
+
const agentName = config.agentName ?? "";
|
|
267
|
+
const isMentioned = agentName && content.includes(`@${agentName}`);
|
|
268
|
+
const isBroadcast = content.includes("@all") || content.includes("@所有人");
|
|
269
|
+
if (!isMentioned && !isBroadcast) return;
|
|
270
|
+
const runtime = ctx.channelRuntime;
|
|
271
|
+
if (!runtime) return;
|
|
272
|
+
dispatchToAgent(config, ctx, runtime, sender, content, roomId);
|
|
273
|
+
} catch { /* ignore */ }
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
ws.on("close", () => {
|
|
277
|
+
if (signal.aborted) return;
|
|
278
|
+
setTimeout(connect, retryMs);
|
|
279
|
+
retryMs = Math.min(retryMs * 1.5, 30000);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
ws.on("error", () => ws.close());
|
|
283
|
+
signal.addEventListener("abort", () => ws.close(), { once: true });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
connect();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Subscribe to all agent groups, refresh every GROUP_REFRESH_INTERVAL ms
|
|
290
|
+
function startGroupSubscriptions(params: {
|
|
291
|
+
config: OpincerConfig;
|
|
292
|
+
ctx: any;
|
|
293
|
+
signal: AbortSignal;
|
|
294
|
+
}): void {
|
|
295
|
+
const { config, ctx, signal } = params;
|
|
296
|
+
const GROUP_REFRESH_MS = 60_000;
|
|
297
|
+
const subscribedRooms = new Set<string>();
|
|
298
|
+
|
|
299
|
+
async function refresh() {
|
|
300
|
+
if (signal.aborted) return;
|
|
301
|
+
const rooms = await fetchAgentGroups(config);
|
|
302
|
+
for (const roomId of rooms) {
|
|
303
|
+
if (!subscribedRooms.has(roomId)) {
|
|
304
|
+
subscribedRooms.add(roomId);
|
|
305
|
+
connectRoomWs({ config, ctx, roomId, signal });
|
|
306
|
+
console.log(`[openclaw-opincer] Subscribed to group room: ${roomId}`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
refresh();
|
|
312
|
+
const timer = setInterval(refresh, GROUP_REFRESH_MS);
|
|
313
|
+
signal.addEventListener("abort", () => clearInterval(timer), { once: true });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function connectWs(params: {
|
|
317
|
+
config: OpincerConfig;
|
|
318
|
+
ctx: any;
|
|
319
|
+
signal: AbortSignal;
|
|
320
|
+
}) {
|
|
321
|
+
const { config, ctx, signal } = params;
|
|
322
|
+
let retryMs = 1000;
|
|
323
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
324
|
+
|
|
325
|
+
function connect() {
|
|
326
|
+
if (signal.aborted) return;
|
|
327
|
+
|
|
328
|
+
const ws = new WebSocket(wsUrl(config));
|
|
329
|
+
|
|
330
|
+
ws.on("open", () => {
|
|
331
|
+
retryMs = 1000;
|
|
332
|
+
console.log("[openclaw-opincer] WebSocket connected, sending REGISTER + AUTH");
|
|
333
|
+
|
|
334
|
+
// Opincer handshake: REGISTER then AUTH
|
|
335
|
+
ws.send(makeEnvelope("REGISTER", config.agentId, "hub", {
|
|
336
|
+
name: config.agentName,
|
|
337
|
+
capabilities: [],
|
|
338
|
+
runtime_version: "openclaw/openclaw-opincer/0.1",
|
|
339
|
+
messaging_mode: "ws",
|
|
340
|
+
}));
|
|
341
|
+
ws.send(makeEnvelope("AUTH", config.agentId, "hub", {
|
|
342
|
+
api_key: config.token,
|
|
343
|
+
}));
|
|
344
|
+
|
|
345
|
+
// Heartbeat every 30s
|
|
346
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
347
|
+
heartbeatTimer = setInterval(() => {
|
|
348
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
349
|
+
ws.send(makeEnvelope("HEARTBEAT", config.agentId, "hub", {
|
|
350
|
+
agent_id: config.agentId,
|
|
351
|
+
}));
|
|
352
|
+
}
|
|
353
|
+
}, 30000);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
ws.on("message", (data: WebSocket.Data) => {
|
|
357
|
+
try {
|
|
358
|
+
const msg = JSON.parse(data.toString());
|
|
359
|
+
handleMessage(config, ctx, msg);
|
|
360
|
+
} catch {}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
ws.on("close", () => {
|
|
364
|
+
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
365
|
+
if (signal.aborted) return;
|
|
366
|
+
console.log(`[openclaw-opincer] WS closed, reconnecting in ${retryMs}ms`);
|
|
367
|
+
setTimeout(connect, retryMs);
|
|
368
|
+
retryMs = Math.min(retryMs * 2, 30000);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
ws.on("error", (err: Error) => {
|
|
372
|
+
if (!signal.aborted) console.error("[openclaw-opincer] WS error:", err.message);
|
|
373
|
+
ws.close();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
signal.addEventListener("abort", () => {
|
|
377
|
+
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
378
|
+
ws.close();
|
|
379
|
+
}, { once: true });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
connect();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function handleMessage(config: OpincerConfig, ctx: any, msg: any) {
|
|
386
|
+
const runtime = ctx.channelRuntime;
|
|
387
|
+
if (!runtime) return;
|
|
388
|
+
|
|
389
|
+
const msgType = msg.type ?? "";
|
|
390
|
+
const payload = msg.payload ?? {};
|
|
391
|
+
const fromId = msg.from ?? "unknown";
|
|
392
|
+
|
|
393
|
+
// ACK
|
|
394
|
+
if (msgType === "ACK") {
|
|
395
|
+
if (payload.status === "ok") {
|
|
396
|
+
console.log("[openclaw-opincer] ✓ Authenticated with Opincer hub");
|
|
397
|
+
} else {
|
|
398
|
+
console.error("[openclaw-opincer] AUTH failed:", payload.error);
|
|
399
|
+
}
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// HEARTBEAT_ACK — check for inbox
|
|
404
|
+
if (msgType === "HEARTBEAT_ACK" || msgType === "heartbeat.ack") {
|
|
405
|
+
const inbox = payload.inbox ?? [];
|
|
406
|
+
for (const item of inbox) {
|
|
407
|
+
const inner = item.payload ?? {};
|
|
408
|
+
const text = inner.text ?? JSON.stringify(inner);
|
|
409
|
+
const sender = item.from ?? "unknown";
|
|
410
|
+
dispatchToAgent(config, ctx, runtime, sender, text, undefined);
|
|
411
|
+
}
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Ignore messages from self
|
|
416
|
+
if (fromId === config.agentId) return;
|
|
417
|
+
|
|
418
|
+
// PING → we just keep WS alive via heartbeat
|
|
419
|
+
if (msgType === "PING") return;
|
|
420
|
+
|
|
421
|
+
// DM message
|
|
422
|
+
if (msgType === "MESSAGE" || msgType === "agent.message") {
|
|
423
|
+
const text = payload.text ?? "";
|
|
424
|
+
if (!text) return;
|
|
425
|
+
console.log(`[openclaw-opincer] 💬 DM from ${fromId.slice(0, 8)}: ${text.slice(0, 60)}`);
|
|
426
|
+
dispatchToAgent(config, ctx, runtime, fromId, text, undefined);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Inbox delivery (reconnect catch-up)
|
|
431
|
+
if (msgType === "inbox.delivery") {
|
|
432
|
+
const items = Array.isArray(payload) ? payload : [payload];
|
|
433
|
+
for (const item of items) {
|
|
434
|
+
const inner = item.payload ?? {};
|
|
435
|
+
const text = inner.text ?? JSON.stringify(inner);
|
|
436
|
+
const sender = item.from ?? "unknown";
|
|
437
|
+
console.log(`[openclaw-opincer] 📬 Inbox from ${sender.slice(0, 8)}`);
|
|
438
|
+
dispatchToAgent(config, ctx, runtime, sender, text, undefined);
|
|
439
|
+
}
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Room message
|
|
444
|
+
if (msgType === "room.message") {
|
|
445
|
+
const data = msg.data ?? msg.payload ?? {};
|
|
446
|
+
const sender = data.sender_agent_id ?? "unknown";
|
|
447
|
+
const content = data.content ?? "";
|
|
448
|
+
const roomId = data.room_id ?? msg.room_id ?? "";
|
|
449
|
+
if (sender === config.agentId || !content) return;
|
|
450
|
+
const agentName = config.agentName ?? "";
|
|
451
|
+
const isMentioned = agentName && content.includes(`@${agentName}`);
|
|
452
|
+
const isBroadcast = content.includes("@all") || content.includes("@所有人");
|
|
453
|
+
if (!isMentioned && !isBroadcast) return;
|
|
454
|
+
console.log(`[openclaw-opincer] 💬 Room msg from ${sender.slice(0, 8)}: ${content.slice(0, 60)}`);
|
|
455
|
+
dispatchToAgent(config, ctx, runtime, sender, content, roomId);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// TASK_ASSIGN — notify project room + dispatch to agent
|
|
460
|
+
if (msgType === "TASK_ASSIGN" || msgType === "task.assigned") {
|
|
461
|
+
const taskId = payload.task_id ?? "?";
|
|
462
|
+
const title = payload.title ?? "";
|
|
463
|
+
const description = payload.description ?? "";
|
|
464
|
+
const assignedTo = payload.assigned_to ?? payload.assigned_agent_name ?? config.agentName;
|
|
465
|
+
const projectId = payload.project_id ?? "";
|
|
466
|
+
|
|
467
|
+
// Notify project room if we know the project
|
|
468
|
+
if (projectId) {
|
|
469
|
+
notifyProjectRoom(config, projectId, taskId, title, assignedTo).catch(() => {});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const text = `[Opincer Task]\ntask_id: ${taskId}\ntitle: ${title}\ndescription:\n${description}`;
|
|
473
|
+
console.log(`[openclaw-opincer] 📋 Task assigned: ${taskId.slice(0, 8)} ${title}`);
|
|
474
|
+
dispatchToAgent(config, ctx, runtime, fromId, text, undefined);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// BROADCAST
|
|
479
|
+
if (msgType === "BROADCAST" || msgType === "broadcast") {
|
|
480
|
+
console.log(`[openclaw-opincer] 📢 Broadcast received`);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ── Task assignment room notification ────────────────────────────────────────
|
|
486
|
+
|
|
487
|
+
/** Post a notification to the project room when a task is assigned */
|
|
488
|
+
async function notifyProjectRoom(
|
|
489
|
+
config: OpincerConfig,
|
|
490
|
+
projectId: string,
|
|
491
|
+
taskId: string,
|
|
492
|
+
taskTitle: string,
|
|
493
|
+
assigneeName: string,
|
|
494
|
+
): Promise<void> {
|
|
495
|
+
try {
|
|
496
|
+
// Fetch project to get room_id
|
|
497
|
+
const url = `${config.baseUrl.replace(/\/$/, "")}/api/v1/projects/${projectId}`;
|
|
498
|
+
const res = await fetch(url, { headers: { "X-API-Key": config.token } });
|
|
499
|
+
if (!res.ok) return;
|
|
500
|
+
const project: { room_id?: string; name?: string } = await res.json();
|
|
501
|
+
const roomId = project.room_id;
|
|
502
|
+
if (!roomId) return;
|
|
503
|
+
|
|
504
|
+
const msg = `📋 任务已分配\n任务:${taskTitle}\n分配给:${assigneeName}\ntask_id: ${taskId}`;
|
|
505
|
+
await httpPost(config, `/rooms/${roomId}/messages`, {
|
|
506
|
+
sender_agent_id: config.agentId,
|
|
507
|
+
content: msg,
|
|
508
|
+
});
|
|
509
|
+
console.log(`[openclaw-opincer] 🔔 Task assignment notified in room ${roomId.slice(0, 8)}`);
|
|
510
|
+
} catch (e) {
|
|
511
|
+
console.warn("[openclaw-opincer] Failed to notify project room:", e);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ── Project context helpers ───────────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
interface ProjectInfo {
|
|
518
|
+
id: string;
|
|
519
|
+
name: string;
|
|
520
|
+
room_id?: string;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
interface AgentInfo {
|
|
524
|
+
id: string;
|
|
525
|
+
name: string;
|
|
526
|
+
status?: string; // online / offline / busy
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
interface TaskSummary {
|
|
530
|
+
pending: number;
|
|
531
|
+
assigned: number;
|
|
532
|
+
running: number;
|
|
533
|
+
review: number;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Cache room_id → ProjectInfo to avoid fetching full project list on every message
|
|
537
|
+
const roomProjectCache = new Map<string, { project: ProjectInfo | null; expiresAt: number }>();
|
|
538
|
+
const ROOM_PROJECT_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
539
|
+
|
|
540
|
+
/** Find the project that owns this room (if any), with caching */
|
|
541
|
+
async function fetchRoomProject(config: OpincerConfig, roomId: string): Promise<ProjectInfo | null> {
|
|
542
|
+
const cached = roomProjectCache.get(roomId);
|
|
543
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
544
|
+
return cached.project;
|
|
545
|
+
}
|
|
546
|
+
try {
|
|
547
|
+
const url = `${config.baseUrl.replace(/\/$/, "")}/api/v1/projects`;
|
|
548
|
+
const res = await fetch(url, { headers: { "X-API-Key": config.token } });
|
|
549
|
+
if (!res.ok) return null;
|
|
550
|
+
const data = await res.json();
|
|
551
|
+
const projects: ProjectInfo[] = Array.isArray(data) ? data : (data.projects ?? data.data ?? []);
|
|
552
|
+
const project = projects.find(p => p.room_id === roomId) ?? null;
|
|
553
|
+
// Cache result (including null = "no project for this room")
|
|
554
|
+
roomProjectCache.set(roomId, { project, expiresAt: Date.now() + ROOM_PROJECT_CACHE_TTL_MS });
|
|
555
|
+
return project;
|
|
556
|
+
} catch {
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/** Get task count summary for a project */
|
|
562
|
+
async function fetchProjectTaskSummary(config: OpincerConfig, projectId: string): Promise<TaskSummary | null> {
|
|
563
|
+
try {
|
|
564
|
+
const url = `${config.baseUrl.replace(/\/$/, "")}/api/v1/projects/${projectId}/tasks`;
|
|
565
|
+
const res = await fetch(url, { headers: { "X-API-Key": config.token } });
|
|
566
|
+
if (!res.ok) return null;
|
|
567
|
+
const data = await res.json();
|
|
568
|
+
const tasks: Array<{ status: string }> = Array.isArray(data) ? data : (data.tasks ?? data.data ?? []);
|
|
569
|
+
const summary: TaskSummary = { pending: 0, assigned: 0, running: 0, review: 0 };
|
|
570
|
+
for (const t of tasks) {
|
|
571
|
+
if (t.status === "pending") summary.pending++;
|
|
572
|
+
else if (t.status === "assigned") summary.assigned++;
|
|
573
|
+
else if (t.status === "running") summary.running++;
|
|
574
|
+
else if (t.status === "review") summary.review++;
|
|
575
|
+
}
|
|
576
|
+
return summary;
|
|
577
|
+
} catch {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Get room members with their online status */
|
|
583
|
+
async function fetchRoomMembersWithStatus(config: OpincerConfig, roomId: string): Promise<AgentInfo[]> {
|
|
584
|
+
try {
|
|
585
|
+
const url = `${config.baseUrl.replace(/\/$/, "")}/api/v1/rooms/${roomId}/members`;
|
|
586
|
+
const res = await fetch(url, { headers: { "X-API-Key": config.token } });
|
|
587
|
+
if (!res.ok) return [];
|
|
588
|
+
const data = await res.json();
|
|
589
|
+
const members: any[] = Array.isArray(data) ? data : (data.members ?? data.data ?? []);
|
|
590
|
+
return members.map((m: any) => ({
|
|
591
|
+
id: m.agent_id ?? m.id ?? "",
|
|
592
|
+
name: m.name ?? m.agent_name ?? m.display_name ?? m.id?.slice(0, 8) ?? "?",
|
|
593
|
+
status: m.status ?? m.online_status ?? "offline",
|
|
594
|
+
})).filter(m => m.id && m.id !== config.agentId);
|
|
595
|
+
} catch {
|
|
596
|
+
return [];
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Build dynamic project context header */
|
|
601
|
+
async function buildProjectHeader(config: OpincerConfig, roomId: string): Promise<string> {
|
|
602
|
+
const [project, members] = await Promise.all([
|
|
603
|
+
fetchRoomProject(config, roomId),
|
|
604
|
+
fetchRoomMembersWithStatus(config, roomId),
|
|
605
|
+
]);
|
|
606
|
+
|
|
607
|
+
const lines: string[] = [];
|
|
608
|
+
|
|
609
|
+
if (project) {
|
|
610
|
+
lines.push(`project_id: ${project.id}`);
|
|
611
|
+
lines.push(`project_name: ${project.name}`);
|
|
612
|
+
|
|
613
|
+
// Fetch task summary for this project
|
|
614
|
+
const taskSummary = await fetchProjectTaskSummary(config, project.id);
|
|
615
|
+
if (taskSummary) {
|
|
616
|
+
const taskParts: string[] = [];
|
|
617
|
+
if (taskSummary.pending > 0) taskParts.push(`${taskSummary.pending} pending`);
|
|
618
|
+
if (taskSummary.assigned > 0) taskParts.push(`${taskSummary.assigned} assigned`);
|
|
619
|
+
if (taskSummary.running > 0) taskParts.push(`${taskSummary.running} running`);
|
|
620
|
+
if (taskSummary.review > 0) taskParts.push(`${taskSummary.review} in review`);
|
|
621
|
+
if (taskParts.length > 0) {
|
|
622
|
+
lines.push(`tasks: ${taskParts.join(", ")}`);
|
|
623
|
+
} else {
|
|
624
|
+
lines.push(`tasks: none active`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (members.length > 0) {
|
|
630
|
+
const onlineMembers = members.filter(m => m.status === "online" || m.status === "busy");
|
|
631
|
+
const offlineMembers = members.filter(m => m.status !== "online" && m.status !== "busy");
|
|
632
|
+
if (onlineMembers.length > 0) {
|
|
633
|
+
lines.push(`online members: ${onlineMembers.map(m => `${m.name}(${m.id.slice(0, 8)})`).join(", ")}`);
|
|
634
|
+
}
|
|
635
|
+
if (offlineMembers.length > 0) {
|
|
636
|
+
lines.push(`offline members: ${offlineMembers.map(m => m.name).join(", ")}`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (lines.length === 0) return "";
|
|
641
|
+
return `[Opincer Room info]\nroom_id: ${roomId}\n${lines.join("\n")}`;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
645
|
+
|
|
646
|
+
async function buildRoomContext(
|
|
647
|
+
config: OpincerConfig,
|
|
648
|
+
roomId: string,
|
|
649
|
+
): Promise<string> {
|
|
650
|
+
// Run message fetch and project header in parallel
|
|
651
|
+
const [allMsgsNewestFirst, projectHeader] = await Promise.all([
|
|
652
|
+
fetchRecentMessages(config, roomId, Math.max(SUMMARY_THRESHOLD + CONTEXT_MSG_COUNT, 60)),
|
|
653
|
+
buildProjectHeader(config, roomId),
|
|
654
|
+
]);
|
|
655
|
+
|
|
656
|
+
const parts: string[] = [];
|
|
657
|
+
|
|
658
|
+
// Inject project/member context header
|
|
659
|
+
if (projectHeader) {
|
|
660
|
+
parts.push(projectHeader);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (allMsgsNewestFirst.length === 0) return parts.join("\n\n");
|
|
664
|
+
|
|
665
|
+
// API returns newest-first; reverse for chronological order
|
|
666
|
+
const allMsgs = [...allMsgsNewestFirst].reverse();
|
|
667
|
+
|
|
668
|
+
const state = loadSummary(roomId);
|
|
669
|
+
const summarizedUpToId = state?.summarizedUpTo ?? "";
|
|
670
|
+
|
|
671
|
+
// Find index of last summarized message
|
|
672
|
+
let summarizedIdx = -1;
|
|
673
|
+
if (summarizedUpToId) {
|
|
674
|
+
summarizedIdx = allMsgs.findIndex(m => m.id === summarizedUpToId);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Messages after the summarized boundary
|
|
678
|
+
const unsummarizedMsgs = summarizedIdx >= 0
|
|
679
|
+
? allMsgs.slice(summarizedIdx + 1)
|
|
680
|
+
: allMsgs;
|
|
681
|
+
|
|
682
|
+
// Trigger summarization if unsummarized count hits threshold
|
|
683
|
+
if (unsummarizedMsgs.length >= SUMMARY_THRESHOLD) {
|
|
684
|
+
// Summarize everything except the most recent CONTEXT_MSG_COUNT messages
|
|
685
|
+
const toSummarize = unsummarizedMsgs.slice(0, unsummarizedMsgs.length - CONTEXT_MSG_COUNT);
|
|
686
|
+
if (toSummarize.length > 0) {
|
|
687
|
+
console.log(`[openclaw-opincer] 📝 Generating summary for room ${roomId.slice(0, 8)} (${toSummarize.length} msgs)`);
|
|
688
|
+
try {
|
|
689
|
+
// For AI summary, present in chronological order
|
|
690
|
+
const existingSummary = state?.summary ?? "";
|
|
691
|
+
const combinedForSummary = existingSummary
|
|
692
|
+
? [{ id: "", sender_name: "摘要", sender_agent_id: undefined, content: existingSummary, created_at: "" }, ...toSummarize]
|
|
693
|
+
: toSummarize;
|
|
694
|
+
const newSummaryText = await generateSummary(config, roomId, combinedForSummary);
|
|
695
|
+
const newState: RoomSummaryState = {
|
|
696
|
+
roomId,
|
|
697
|
+
summary: newSummaryText,
|
|
698
|
+
summarizedUpTo: toSummarize[toSummarize.length - 1]?.id ?? "",
|
|
699
|
+
summarizedCount: (state?.summarizedCount ?? 0) + toSummarize.length,
|
|
700
|
+
updatedAt: new Date().toISOString(),
|
|
701
|
+
};
|
|
702
|
+
saveSummary(newState);
|
|
703
|
+
console.log(`[openclaw-opincer] ✅ Summary saved for room ${roomId.slice(0, 8)}`);
|
|
704
|
+
} catch (e) {
|
|
705
|
+
console.error("[openclaw-opincer] Summary generation failed:", e);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Build context block to inject
|
|
711
|
+
const freshState = loadSummary(roomId);
|
|
712
|
+
// Show most recent CONTEXT_MSG_COUNT messages in chronological order
|
|
713
|
+
const recentMsgs = allMsgs.slice(-CONTEXT_MSG_COUNT);
|
|
714
|
+
|
|
715
|
+
if (freshState?.summary) {
|
|
716
|
+
parts.push(`[Opincer Room context (summary of earlier messages)]\n${freshState.summary}`);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
if (recentMsgs.length > 0) {
|
|
720
|
+
const recent = recentMsgs.map(m => {
|
|
721
|
+
const who = m.sender_name ?? m.sender_agent_id?.slice(0, 8) ?? "?";
|
|
722
|
+
return `[${who}]: ${m.content}`;
|
|
723
|
+
}).join("\n");
|
|
724
|
+
parts.push(`[Pincer Room context (last ${recentMsgs.length} msgs)]\n${recent}`);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
return parts.join("\n\n");
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function dispatchToAgent(
|
|
731
|
+
config: OpincerConfig, ctx: any, runtime: any,
|
|
732
|
+
senderId: string, text: string, roomId: string | undefined,
|
|
733
|
+
) {
|
|
734
|
+
const peer = roomId
|
|
735
|
+
? { kind: "group" as const, id: roomId }
|
|
736
|
+
: { kind: "direct" as const, id: senderId };
|
|
737
|
+
|
|
738
|
+
const route = runtime.routing.resolveAgentRoute({
|
|
739
|
+
cfg: ctx.cfg,
|
|
740
|
+
channel: "openclaw-opincer",
|
|
741
|
+
accountId: ctx.accountId,
|
|
742
|
+
peer,
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
// Build enriched body: inject room context for group messages
|
|
746
|
+
const buildAndDispatch = async () => {
|
|
747
|
+
let bodyForAgent = text;
|
|
748
|
+
|
|
749
|
+
if (roomId) {
|
|
750
|
+
try {
|
|
751
|
+
const roomCtx = await buildRoomContext(config, roomId);
|
|
752
|
+
if (roomCtx) {
|
|
753
|
+
bodyForAgent = `${roomCtx}\n\n[Pincer Room msg from ${senderId}]\n${text}`;
|
|
754
|
+
}
|
|
755
|
+
} catch (e) {
|
|
756
|
+
console.warn("[openclaw-opincer] Context build failed:", e);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
runtime.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
761
|
+
ctx: {
|
|
762
|
+
Body: text,
|
|
763
|
+
BodyForAgent: bodyForAgent,
|
|
764
|
+
From: senderId,
|
|
765
|
+
SessionKey: route.sessionKey,
|
|
766
|
+
Channel: "openclaw-opincer",
|
|
767
|
+
AccountId: ctx.accountId,
|
|
768
|
+
},
|
|
769
|
+
cfg: ctx.cfg,
|
|
770
|
+
dispatcherOptions: {
|
|
771
|
+
deliver: async (payload: any) => {
|
|
772
|
+
try {
|
|
773
|
+
const replyText = payload.text ?? payload.content ?? JSON.stringify(payload);
|
|
774
|
+
console.log(`[openclaw-opincer] 📤 Delivering reply: ${replyText.slice(0, 60)}`);
|
|
775
|
+
if (roomId) {
|
|
776
|
+
await httpPost(config, `/rooms/${roomId}/messages`, {
|
|
777
|
+
sender_agent_id: config.agentId,
|
|
778
|
+
content: replyText,
|
|
779
|
+
});
|
|
780
|
+
} else {
|
|
781
|
+
await httpPost(config, "/messages/send", {
|
|
782
|
+
from_agent_id: config.agentId,
|
|
783
|
+
to_agent_id: senderId,
|
|
784
|
+
payload: { text: replyText },
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
console.log(`[openclaw-opincer] ✅ Reply sent`);
|
|
788
|
+
} catch (err: any) {
|
|
789
|
+
console.error(`[openclaw-opincer] ❌ Reply failed:`, err.message);
|
|
790
|
+
}
|
|
791
|
+
},
|
|
792
|
+
},
|
|
793
|
+
});
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
buildAndDispatch().catch(e => console.error("[openclaw-opincer] dispatchToAgent error:", e));
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
export const opincerChannel = {
|
|
800
|
+
id: "openclaw-opincer",
|
|
801
|
+
meta: {
|
|
802
|
+
id: "openclaw-opincer",
|
|
803
|
+
label: "Opincer",
|
|
804
|
+
selectionLabel: "Opincer (agent hub)",
|
|
805
|
+
docsPath: "/channels/opincer",
|
|
806
|
+
docsLabel: "opincer",
|
|
807
|
+
blurb: "Opincer agent hub — WebSocket connection.",
|
|
808
|
+
order: 81,
|
|
809
|
+
},
|
|
810
|
+
capabilities: {
|
|
811
|
+
chatTypes: ["direct", "group"],
|
|
812
|
+
media: false,
|
|
813
|
+
reactions: false,
|
|
814
|
+
threads: false,
|
|
815
|
+
},
|
|
816
|
+
config: {
|
|
817
|
+
listAccountIds: (cfg: any) => {
|
|
818
|
+
const c = resolveConfig(cfg);
|
|
819
|
+
return c.baseUrl && c.token && c.agentId ? ["default"] : [];
|
|
820
|
+
},
|
|
821
|
+
resolveAccount: (_cfg: any, accountId: string) => ({ accountId }),
|
|
822
|
+
},
|
|
823
|
+
gateway: {
|
|
824
|
+
startAccount: async (ctx: any) => {
|
|
825
|
+
const config = resolveConfig(ctx.cfg);
|
|
826
|
+
if (!config.baseUrl || !config.token || !config.agentId) {
|
|
827
|
+
console.warn("[openclaw-opincer] Missing baseUrl, token, or agentId. Channel not started.");
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (!config.agentName) config.agentName = "openclaw-agent";
|
|
831
|
+
|
|
832
|
+
const signal: AbortSignal = ctx.abortSignal;
|
|
833
|
+
|
|
834
|
+
// Connect to agent DM hub (WebSocket)
|
|
835
|
+
connectWs({ config, ctx, signal });
|
|
836
|
+
|
|
837
|
+
// Subscribe to all agent groups (and refresh every 60s for new groups)
|
|
838
|
+
startGroupSubscriptions({ config, ctx, signal });
|
|
839
|
+
|
|
840
|
+
console.log("[openclaw-opincer] Started, connecting via WebSocket");
|
|
841
|
+
|
|
842
|
+
await new Promise<void>((resolve) => {
|
|
843
|
+
if (signal.aborted) return resolve();
|
|
844
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
845
|
+
});
|
|
846
|
+
},
|
|
847
|
+
},
|
|
848
|
+
outbound: {
|
|
849
|
+
deliveryMode: "direct",
|
|
850
|
+
sendText: async (ctx: any) => {
|
|
851
|
+
const config = resolveConfig(ctx.cfg);
|
|
852
|
+
const to: string = ctx.to ?? "";
|
|
853
|
+
if (to.startsWith("room:")) {
|
|
854
|
+
await httpPost(config, `/rooms/${to.slice(5)}/messages`, {
|
|
855
|
+
sender_agent_id: config.agentId,
|
|
856
|
+
content: ctx.text,
|
|
857
|
+
});
|
|
858
|
+
} else {
|
|
859
|
+
await httpPost(config, "/messages/send", {
|
|
860
|
+
from_agent_id: config.agentId,
|
|
861
|
+
to_agent_id: to,
|
|
862
|
+
payload: { text: ctx.text },
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
return { ok: true };
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
};
|