@openclaw-channel/socket-chat 1.0.5 → 1.0.7
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 +9 -0
- package/channel-api.ts +3 -0
- package/index.ts +11 -12
- package/package.json +1 -1
- package/runtime-api.ts +3 -0
- package/src/__sdk-stub__.ts +26 -8
- package/src/channel.ts +391 -336
- package/src/inbound.test.ts +405 -583
- package/src/inbound.ts +175 -407
- package/src/mqtt-client.ts +66 -50
- package/src/outbound.test.ts +36 -26
- package/src/outbound.ts +37 -75
- package/src/runtime-api.ts +28 -0
- package/src/runtime.ts +8 -12
- package/tsconfig.json +1 -1
- package/vitest.config.ts +13 -7
package/src/mqtt-client.ts
CHANGED
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
import type { MqttClient, IPublishPacket } from "mqtt";
|
|
2
2
|
import mqtt from "mqtt";
|
|
3
|
-
import type { ChannelGatewayContext } from "openclaw/plugin-sdk";
|
|
4
3
|
import { fetchMqttConfigCached, invalidateMqttConfigCache } from "./api.js";
|
|
5
|
-
import {
|
|
6
|
-
import type { ResolvedSocketChatAccount } from "./config.js";
|
|
7
|
-
import type {
|
|
8
|
-
|
|
4
|
+
import { handleSocketChatInbound } from "./inbound.js";
|
|
5
|
+
import type { CoreConfig, ResolvedSocketChatAccount } from "./config.js";
|
|
6
|
+
import type {
|
|
7
|
+
SocketChatInboundMessage,
|
|
8
|
+
SocketChatMqttConfig,
|
|
9
|
+
SocketChatOutboundPayload,
|
|
10
|
+
SocketChatStatusPatch,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
import { parseSocketChatTarget, sendSocketChatMessage } from "./outbound.js";
|
|
9
13
|
|
|
10
14
|
const MAX_RECONNECT_ATTEMPTS_DEFAULT = 10;
|
|
11
15
|
const RECONNECT_BASE_DELAY_MS_DEFAULT = 2000;
|
|
12
16
|
const RECONNECT_MAX_DELAY_MS = 60_000;
|
|
13
17
|
|
|
14
|
-
type LogSink =
|
|
18
|
+
type LogSink = {
|
|
19
|
+
info: (m: string) => void;
|
|
20
|
+
warn: (m: string) => void;
|
|
21
|
+
error: (m: string) => void;
|
|
22
|
+
debug?: (m: string) => void;
|
|
23
|
+
};
|
|
15
24
|
|
|
16
25
|
// ---------------------------------------------------------------------------
|
|
17
|
-
//
|
|
26
|
+
// Active connection registry (used by outbound to find the current MQTT client)
|
|
18
27
|
// ---------------------------------------------------------------------------
|
|
19
28
|
const activeClients = new Map<string, MqttClient>();
|
|
20
29
|
const activeMqttConfigs = new Map<string, SocketChatMqttConfig>();
|
|
@@ -43,7 +52,7 @@ export function clearActiveMqttSession(accountId: string): void {
|
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
// ---------------------------------------------------------------------------
|
|
46
|
-
//
|
|
55
|
+
// Helpers
|
|
47
56
|
// ---------------------------------------------------------------------------
|
|
48
57
|
|
|
49
58
|
function buildMqttUrl(mqttConfig: SocketChatMqttConfig): string {
|
|
@@ -77,9 +86,10 @@ function parseInboundMessage(raw: Buffer | string): SocketChatInboundMessage | n
|
|
|
77
86
|
messageId: m.messageId,
|
|
78
87
|
type: typeof m.type === "string" ? m.type : undefined,
|
|
79
88
|
url: typeof m.url === "string" ? m.url : undefined,
|
|
80
|
-
mediaInfo:
|
|
81
|
-
|
|
82
|
-
|
|
89
|
+
mediaInfo:
|
|
90
|
+
m.mediaInfo && typeof m.mediaInfo === "object" && !Array.isArray(m.mediaInfo)
|
|
91
|
+
? (m.mediaInfo as Record<string, unknown>)
|
|
92
|
+
: undefined,
|
|
83
93
|
};
|
|
84
94
|
} catch {
|
|
85
95
|
return null;
|
|
@@ -94,47 +104,48 @@ function backoffDelay(attempt: number, baseMs: number): number {
|
|
|
94
104
|
function waitMs(ms: number, signal: AbortSignal): Promise<void> {
|
|
95
105
|
return new Promise((resolve) => {
|
|
96
106
|
const timer = setTimeout(resolve, ms);
|
|
97
|
-
signal.addEventListener(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
107
|
+
signal.addEventListener(
|
|
108
|
+
"abort",
|
|
109
|
+
() => {
|
|
110
|
+
clearTimeout(timer);
|
|
111
|
+
resolve();
|
|
112
|
+
},
|
|
113
|
+
{ once: true },
|
|
114
|
+
);
|
|
101
115
|
});
|
|
102
116
|
}
|
|
103
117
|
|
|
104
118
|
// ---------------------------------------------------------------------------
|
|
105
|
-
//
|
|
119
|
+
// Core monitor loop
|
|
106
120
|
// ---------------------------------------------------------------------------
|
|
107
121
|
|
|
108
122
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
123
|
+
* Start the MQTT monitoring loop with:
|
|
124
|
+
* - Remote MQTT config fetch (with TTL cache)
|
|
125
|
+
* - Auto-reconnect with exponential backoff
|
|
126
|
+
* - Active client registry for outbound sends
|
|
127
|
+
* - AbortSignal-based graceful shutdown
|
|
114
128
|
*/
|
|
115
129
|
export async function monitorSocketChatProviderWithRegistry(params: {
|
|
116
130
|
account: ResolvedSocketChatAccount;
|
|
117
131
|
accountId: string;
|
|
118
|
-
|
|
132
|
+
config: CoreConfig;
|
|
133
|
+
abortSignal: AbortSignal;
|
|
119
134
|
log: LogSink;
|
|
135
|
+
statusSink: (patch: SocketChatStatusPatch) => void;
|
|
120
136
|
}): Promise<void> {
|
|
121
|
-
const { account, accountId,
|
|
122
|
-
const { abortSignal } = ctx;
|
|
137
|
+
const { account, accountId, config, abortSignal, log, statusSink } = params;
|
|
123
138
|
const maxReconnects = account.config.maxReconnectAttempts ?? MAX_RECONNECT_ATTEMPTS_DEFAULT;
|
|
124
139
|
const reconnectBaseMs = account.config.reconnectBaseDelayMs ?? RECONNECT_BASE_DELAY_MS_DEFAULT;
|
|
125
140
|
const mqttConfigTtlMs = (account.config.mqttConfigTtlSec ?? 300) * 1000;
|
|
126
141
|
|
|
127
142
|
let reconnectAttempts = 0;
|
|
128
143
|
|
|
129
|
-
|
|
130
|
-
ctx.setStatus({ accountId, ...patch } as Parameters<typeof ctx.setStatus>[0]);
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
setStatus({ running: true, lastStartAt: Date.now() });
|
|
144
|
+
statusSink({ running: true, lastStartAt: Date.now() });
|
|
134
145
|
|
|
135
146
|
try {
|
|
136
147
|
while (!abortSignal.aborted) {
|
|
137
|
-
// 1.
|
|
148
|
+
// 1. Fetch MQTT config
|
|
138
149
|
let mqttConfig: SocketChatMqttConfig;
|
|
139
150
|
try {
|
|
140
151
|
mqttConfig = await fetchMqttConfigCached({
|
|
@@ -147,18 +158,18 @@ export async function monitorSocketChatProviderWithRegistry(params: {
|
|
|
147
158
|
} catch (err) {
|
|
148
159
|
const message = err instanceof Error ? err.message : String(err);
|
|
149
160
|
log.error(`[${accountId}] failed to fetch MQTT config: ${message}`);
|
|
150
|
-
|
|
161
|
+
statusSink({ lastError: message });
|
|
151
162
|
reconnectAttempts++;
|
|
152
163
|
if (reconnectAttempts > maxReconnects) {
|
|
153
164
|
log.error(`[${accountId}] max reconnect attempts (${maxReconnects}) reached`);
|
|
154
165
|
break;
|
|
155
166
|
}
|
|
156
|
-
|
|
167
|
+
statusSink({ reconnectAttempts });
|
|
157
168
|
await waitMs(backoffDelay(reconnectAttempts, reconnectBaseMs), abortSignal);
|
|
158
169
|
continue;
|
|
159
170
|
}
|
|
160
171
|
|
|
161
|
-
// 2.
|
|
172
|
+
// 2. Establish MQTT connection
|
|
162
173
|
const mqttUrl = buildMqttUrl(mqttConfig);
|
|
163
174
|
log.info(`[${accountId}] connecting to ${mqttUrl} (clientId=${mqttConfig.clientId})`);
|
|
164
175
|
|
|
@@ -167,14 +178,14 @@ export async function monitorSocketChatProviderWithRegistry(params: {
|
|
|
167
178
|
username: mqttConfig.username,
|
|
168
179
|
password: mqttConfig.password,
|
|
169
180
|
clean: true,
|
|
170
|
-
reconnectPeriod: 0,
|
|
181
|
+
reconnectPeriod: 0, // disable mqtt.js auto-reconnect; managed by outer loop
|
|
171
182
|
connectTimeout: 15_000,
|
|
172
183
|
keepalive: 60,
|
|
173
184
|
});
|
|
174
185
|
|
|
175
186
|
setActiveMqttClient(accountId, client);
|
|
176
187
|
|
|
177
|
-
//
|
|
188
|
+
// Wait until connection closes (normally or on error)
|
|
178
189
|
await new Promise<void>((resolve) => {
|
|
179
190
|
const onAbort = (): void => {
|
|
180
191
|
log.info(`[${accountId}] abort signal, disconnecting`);
|
|
@@ -185,7 +196,7 @@ export async function monitorSocketChatProviderWithRegistry(params: {
|
|
|
185
196
|
|
|
186
197
|
client.on("connect", () => {
|
|
187
198
|
reconnectAttempts = 0;
|
|
188
|
-
|
|
199
|
+
statusSink({
|
|
189
200
|
connected: true,
|
|
190
201
|
reconnectAttempts: 0,
|
|
191
202
|
lastConnectedAt: Date.now(),
|
|
@@ -205,74 +216,79 @@ export async function monitorSocketChatProviderWithRegistry(params: {
|
|
|
205
216
|
|
|
206
217
|
client.on("message", (topic: string, rawPayload: Buffer, _packet: IPublishPacket) => {
|
|
207
218
|
if (topic !== mqttConfig.reciveTopic) return;
|
|
208
|
-
|
|
219
|
+
statusSink({ lastEventAt: Date.now(), lastInboundAt: Date.now() });
|
|
209
220
|
|
|
210
221
|
const msg = parseInboundMessage(rawPayload);
|
|
211
222
|
if (!msg) {
|
|
212
223
|
log.warn(`[${accountId}] unparseable message on ${topic}`);
|
|
213
224
|
return;
|
|
214
225
|
}
|
|
215
|
-
//
|
|
226
|
+
// Skip the bot's own echo messages
|
|
216
227
|
if (msg.robotId === mqttConfig.robotId && msg.senderId === mqttConfig.robotId) {
|
|
217
228
|
return;
|
|
218
229
|
}
|
|
219
230
|
|
|
220
231
|
log.info(
|
|
221
232
|
`[${accountId}] inbound msg ${msg.messageId} from ${msg.senderId}` +
|
|
222
|
-
|
|
233
|
+
(msg.isGroup ? ` in group ${msg.groupId}` : ""),
|
|
223
234
|
);
|
|
224
235
|
|
|
225
|
-
void
|
|
236
|
+
void handleSocketChatInbound({
|
|
226
237
|
msg,
|
|
227
238
|
accountId,
|
|
228
|
-
|
|
239
|
+
config,
|
|
229
240
|
log,
|
|
241
|
+
statusSink,
|
|
230
242
|
sendReply: async (to: string, text: string) => {
|
|
231
|
-
const
|
|
243
|
+
const base = parseSocketChatTarget(to);
|
|
244
|
+
const payload: SocketChatOutboundPayload = {
|
|
245
|
+
...base,
|
|
246
|
+
messages: [{ type: 1, content: text }],
|
|
247
|
+
};
|
|
232
248
|
await sendSocketChatMessage({ mqttClient: client, mqttConfig, payload });
|
|
233
249
|
},
|
|
234
250
|
}).catch((e: unknown) => {
|
|
235
251
|
log.error(
|
|
236
|
-
`[${accountId}]
|
|
252
|
+
`[${accountId}] handleSocketChatInbound error: ${e instanceof Error ? e.message : String(e)}`,
|
|
237
253
|
);
|
|
238
254
|
});
|
|
239
255
|
});
|
|
240
256
|
|
|
241
257
|
client.on("error", (err: Error) => {
|
|
242
258
|
log.warn(`[${accountId}] MQTT error: ${err.message}`);
|
|
243
|
-
|
|
259
|
+
statusSink({ lastError: err.message });
|
|
244
260
|
});
|
|
245
261
|
|
|
246
262
|
client.on("close", () => {
|
|
247
|
-
|
|
263
|
+
statusSink({ connected: false, lastDisconnect: new Date().toISOString() });
|
|
248
264
|
abortSignal.removeEventListener("abort", onAbort);
|
|
249
265
|
resolve();
|
|
250
266
|
});
|
|
251
267
|
});
|
|
252
268
|
|
|
253
|
-
//
|
|
269
|
+
// Remove from registry
|
|
254
270
|
activeClients.delete(accountId);
|
|
255
271
|
|
|
256
272
|
if (abortSignal.aborted) break;
|
|
257
273
|
|
|
258
|
-
// 3.
|
|
274
|
+
// 3. Reconnect with backoff
|
|
259
275
|
reconnectAttempts++;
|
|
260
276
|
if (reconnectAttempts > maxReconnects) {
|
|
261
277
|
log.error(`[${accountId}] max reconnect attempts (${maxReconnects}) reached, giving up`);
|
|
262
278
|
break;
|
|
263
279
|
}
|
|
264
|
-
//
|
|
280
|
+
// Invalidate config cache so next reconnect re-fetches (token may have expired)
|
|
265
281
|
invalidateMqttConfigCache({ apiBaseUrl: account.apiBaseUrl!, apiKey: account.apiKey! });
|
|
266
282
|
const delay = backoffDelay(reconnectAttempts, reconnectBaseMs);
|
|
267
283
|
log.info(
|
|
268
284
|
`[${accountId}] reconnect ${reconnectAttempts}/${maxReconnects} in ${Math.round(delay)}ms`,
|
|
269
285
|
);
|
|
270
|
-
|
|
286
|
+
statusSink({ reconnectAttempts });
|
|
271
287
|
await waitMs(delay, abortSignal);
|
|
272
288
|
}
|
|
273
289
|
} finally {
|
|
274
290
|
clearActiveMqttSession(accountId);
|
|
275
|
-
|
|
291
|
+
statusSink({ running: false, connected: false, lastStopAt: Date.now() });
|
|
276
292
|
log.info(`[${accountId}] monitor stopped`);
|
|
277
293
|
}
|
|
278
294
|
}
|
package/src/outbound.test.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
buildSocketChatMediaPayload,
|
|
4
|
+
buildSocketChatTextPayload,
|
|
5
5
|
looksLikeSocketChatTargetId,
|
|
6
6
|
normalizeSocketChatTarget,
|
|
7
7
|
parseSocketChatTarget,
|
|
8
8
|
} from "./outbound.js";
|
|
9
9
|
|
|
10
|
+
// Mock mqtt-client to prevent deep transitive imports from pulling in the full openclaw runtime
|
|
11
|
+
vi.mock("./mqtt-client.js", () => ({
|
|
12
|
+
getActiveMqttClient: vi.fn(() => null),
|
|
13
|
+
getActiveMqttConfig: vi.fn(() => null),
|
|
14
|
+
monitorSocketChatProviderWithRegistry: vi.fn(),
|
|
15
|
+
getActiveMqttSession: vi.fn(() => null),
|
|
16
|
+
clearActiveMqttSession: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
10
19
|
// ---------------------------------------------------------------------------
|
|
11
20
|
// parseSocketChatTarget
|
|
12
21
|
// ---------------------------------------------------------------------------
|
|
@@ -18,20 +27,20 @@ describe("parseSocketChatTarget", () => {
|
|
|
18
27
|
});
|
|
19
28
|
|
|
20
29
|
it("parses a group target without mentions", () => {
|
|
21
|
-
const result = parseSocketChatTarget("group:
|
|
22
|
-
expect(result).toEqual({ isGroup: true, groupId: "
|
|
30
|
+
const result = parseSocketChatTarget("group:17581395450@chatroom");
|
|
31
|
+
expect(result).toEqual({ isGroup: true, groupId: "17581395450@chatroom" });
|
|
23
32
|
});
|
|
24
33
|
|
|
25
34
|
it("parses a group target with single mention", () => {
|
|
26
|
-
const result = parseSocketChatTarget("group:
|
|
27
|
-
expect(result).toEqual({ isGroup: true, groupId: "
|
|
35
|
+
const result = parseSocketChatTarget("group:17581395450@chatroom|wxid_a");
|
|
36
|
+
expect(result).toEqual({ isGroup: true, groupId: "17581395450@chatroom", mentionIds: ["wxid_a"] });
|
|
28
37
|
});
|
|
29
38
|
|
|
30
39
|
it("parses a group target with multiple mentions", () => {
|
|
31
|
-
const result = parseSocketChatTarget("group:
|
|
40
|
+
const result = parseSocketChatTarget("group:17581395450@chatroom|wxid_a,wxid_b,wxid_c");
|
|
32
41
|
expect(result).toEqual({
|
|
33
42
|
isGroup: true,
|
|
34
|
-
groupId: "
|
|
43
|
+
groupId: "17581395450@chatroom",
|
|
35
44
|
mentionIds: ["wxid_a", "wxid_b", "wxid_c"],
|
|
36
45
|
});
|
|
37
46
|
});
|
|
@@ -42,7 +51,7 @@ describe("parseSocketChatTarget", () => {
|
|
|
42
51
|
});
|
|
43
52
|
|
|
44
53
|
it("filters empty mention ids", () => {
|
|
45
|
-
const result = parseSocketChatTarget("group:
|
|
54
|
+
const result = parseSocketChatTarget("group:17581395450@chatroom|wxid_a,,wxid_b");
|
|
46
55
|
expect(result.isGroup).toBe(true);
|
|
47
56
|
if (result.isGroup) {
|
|
48
57
|
expect(result.mentionIds).toEqual(["wxid_a", "wxid_b"]);
|
|
@@ -57,7 +66,8 @@ describe("parseSocketChatTarget", () => {
|
|
|
57
66
|
describe("normalizeSocketChatTarget", () => {
|
|
58
67
|
it("returns the target unchanged for native IDs", () => {
|
|
59
68
|
expect(normalizeSocketChatTarget("wxid_abc")).toBe("wxid_abc");
|
|
60
|
-
expect(normalizeSocketChatTarget("group:
|
|
69
|
+
expect(normalizeSocketChatTarget("group:17581395450@chatroom")).toBe("group:17581395450@chatroom");
|
|
70
|
+
expect(normalizeSocketChatTarget("group:17581395450@chatroom|wxid_a")).toBe("group:17581395450@chatroom|wxid_a");
|
|
61
71
|
});
|
|
62
72
|
|
|
63
73
|
it("strips socket-chat: prefix (case-insensitive)", () => {
|
|
@@ -103,12 +113,12 @@ describe("looksLikeSocketChatTargetId", () => {
|
|
|
103
113
|
});
|
|
104
114
|
|
|
105
115
|
// ---------------------------------------------------------------------------
|
|
106
|
-
//
|
|
116
|
+
// buildSocketChatTextPayload
|
|
107
117
|
// ---------------------------------------------------------------------------
|
|
108
118
|
|
|
109
|
-
describe("
|
|
119
|
+
describe("buildSocketChatTextPayload", () => {
|
|
110
120
|
it("builds a DM text payload", () => {
|
|
111
|
-
const payload =
|
|
121
|
+
const payload = buildSocketChatTextPayload("wxid_abc", "hello");
|
|
112
122
|
expect(payload.isGroup).toBe(false);
|
|
113
123
|
expect(payload.contactId).toBe("wxid_abc");
|
|
114
124
|
expect(payload.messages).toEqual([{ type: 1, content: "hello" }]);
|
|
@@ -116,35 +126,35 @@ describe("buildTextPayload", () => {
|
|
|
116
126
|
});
|
|
117
127
|
|
|
118
128
|
it("builds a group text payload", () => {
|
|
119
|
-
const payload =
|
|
129
|
+
const payload = buildSocketChatTextPayload("group:17581395450@chatroom", "hi group");
|
|
120
130
|
expect(payload.isGroup).toBe(true);
|
|
121
|
-
expect(payload.groupId).toBe("
|
|
131
|
+
expect(payload.groupId).toBe("17581395450@chatroom");
|
|
122
132
|
expect(payload.messages).toEqual([{ type: 1, content: "hi group" }]);
|
|
123
133
|
});
|
|
124
134
|
|
|
125
135
|
it("extracts mentions from group target string", () => {
|
|
126
|
-
const payload =
|
|
136
|
+
const payload = buildSocketChatTextPayload("group:17581395450@chatroom|wxid_a,wxid_b", "hi");
|
|
127
137
|
expect(payload.mentionIds).toEqual(["wxid_a", "wxid_b"]);
|
|
128
138
|
});
|
|
129
139
|
|
|
130
140
|
it("allows explicit override of mentionIds", () => {
|
|
131
|
-
const payload =
|
|
141
|
+
const payload = buildSocketChatTextPayload("group:17581395450@chatroom", "hi", { mentionIds: ["wxid_override"] });
|
|
132
142
|
expect(payload.mentionIds).toEqual(["wxid_override"]);
|
|
133
143
|
});
|
|
134
144
|
|
|
135
145
|
it("sets mentionIds to undefined when empty", () => {
|
|
136
|
-
const payload =
|
|
146
|
+
const payload = buildSocketChatTextPayload("group:17581395450@chatroom", "hi", { mentionIds: [] });
|
|
137
147
|
expect(payload.mentionIds).toBeUndefined();
|
|
138
148
|
});
|
|
139
149
|
});
|
|
140
150
|
|
|
141
151
|
// ---------------------------------------------------------------------------
|
|
142
|
-
//
|
|
152
|
+
// buildSocketChatMediaPayload
|
|
143
153
|
// ---------------------------------------------------------------------------
|
|
144
154
|
|
|
145
|
-
describe("
|
|
155
|
+
describe("buildSocketChatMediaPayload", () => {
|
|
146
156
|
it("builds a media-only payload without caption", () => {
|
|
147
|
-
const payload =
|
|
157
|
+
const payload = buildSocketChatMediaPayload("wxid_abc", "https://img.example.com/photo.jpg");
|
|
148
158
|
expect(payload.isGroup).toBe(false);
|
|
149
159
|
expect(payload.messages).toEqual([
|
|
150
160
|
{ type: 2, url: "https://img.example.com/photo.jpg" },
|
|
@@ -152,7 +162,7 @@ describe("buildMediaPayload", () => {
|
|
|
152
162
|
});
|
|
153
163
|
|
|
154
164
|
it("includes caption text before image when provided", () => {
|
|
155
|
-
const payload =
|
|
165
|
+
const payload = buildSocketChatMediaPayload("wxid_abc", "https://img.example.com/photo.jpg", "Look at this");
|
|
156
166
|
expect(payload.messages).toEqual([
|
|
157
167
|
{ type: 1, content: "Look at this" },
|
|
158
168
|
{ type: 2, url: "https://img.example.com/photo.jpg" },
|
|
@@ -160,15 +170,15 @@ describe("buildMediaPayload", () => {
|
|
|
160
170
|
});
|
|
161
171
|
|
|
162
172
|
it("skips empty caption", () => {
|
|
163
|
-
const payload =
|
|
173
|
+
const payload = buildSocketChatMediaPayload("wxid_abc", "https://img.example.com/photo.jpg", " ");
|
|
164
174
|
expect(payload.messages).toEqual([
|
|
165
175
|
{ type: 2, url: "https://img.example.com/photo.jpg" },
|
|
166
176
|
]);
|
|
167
177
|
});
|
|
168
178
|
|
|
169
179
|
it("builds group media payload", () => {
|
|
170
|
-
const payload =
|
|
180
|
+
const payload = buildSocketChatMediaPayload("group:17581395450@chatroom", "https://img.example.com/photo.jpg");
|
|
171
181
|
expect(payload.isGroup).toBe(true);
|
|
172
|
-
expect(payload.groupId).toBe("
|
|
182
|
+
expect(payload.groupId).toBe("17581395450@chatroom");
|
|
173
183
|
});
|
|
174
184
|
});
|
package/src/outbound.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { MqttClient } from "mqtt";
|
|
2
|
-
import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
|
|
3
2
|
import { DEFAULT_ACCOUNT_ID } from "./config.js";
|
|
4
3
|
import { getActiveMqttClient, getActiveMqttConfig } from "./mqtt-client.js";
|
|
5
4
|
import type { SocketChatMqttConfig, SocketChatOutboundPayload } from "./types.js";
|
|
@@ -9,19 +8,19 @@ import type { SocketChatMqttConfig, SocketChatOutboundPayload } from "./types.js
|
|
|
9
8
|
*
|
|
10
9
|
* 格式约定:
|
|
11
10
|
* - 私聊:contactId,例如 "wxid_abc123"
|
|
12
|
-
* - 群聊:以 "group:" 前缀,例如 "group:
|
|
13
|
-
* - 群聊带
|
|
11
|
+
* - 群聊:以 "group:" 前缀,例如 "group:17581395450@chatroom"
|
|
12
|
+
* - 群聊带 mention:用 "|" 分隔 groupId 与 mentionIds,例如 "group:17581395450@chatroom|wxid_a,wxid_b"
|
|
14
13
|
*/
|
|
15
14
|
export function parseSocketChatTarget(to: string): Omit<SocketChatOutboundPayload, "messages"> {
|
|
16
15
|
const trimmed = to.trim();
|
|
17
16
|
|
|
18
17
|
if (trimmed.startsWith("group:")) {
|
|
19
18
|
const withoutPrefix = trimmed.slice("group:".length);
|
|
20
|
-
const
|
|
21
|
-
if (
|
|
22
|
-
const groupId = withoutPrefix.slice(0,
|
|
19
|
+
const pipeIdx = withoutPrefix.indexOf("|");
|
|
20
|
+
if (pipeIdx !== -1) {
|
|
21
|
+
const groupId = withoutPrefix.slice(0, pipeIdx);
|
|
23
22
|
const mentionIds = withoutPrefix
|
|
24
|
-
.slice(
|
|
23
|
+
.slice(pipeIdx + 1)
|
|
25
24
|
.split(",")
|
|
26
25
|
.map((s) => s.trim())
|
|
27
26
|
.filter(Boolean);
|
|
@@ -53,27 +52,22 @@ export function looksLikeSocketChatTargetId(s: string): boolean {
|
|
|
53
52
|
}
|
|
54
53
|
|
|
55
54
|
/**
|
|
56
|
-
*
|
|
55
|
+
* 构建文字消息 payload(纯函数,不发送)
|
|
57
56
|
*/
|
|
58
|
-
export function
|
|
57
|
+
export function buildSocketChatTextPayload(
|
|
59
58
|
to: string,
|
|
60
59
|
text: string,
|
|
61
|
-
opts
|
|
60
|
+
opts?: { mentionIds?: string[] },
|
|
62
61
|
): SocketChatOutboundPayload {
|
|
63
62
|
const base = parseSocketChatTarget(to);
|
|
64
|
-
const mentionIds =
|
|
65
|
-
|
|
66
|
-
return {
|
|
67
|
-
...base,
|
|
68
|
-
mentionIds: mentionIds?.length ? mentionIds : undefined,
|
|
69
|
-
messages: [{ type: 1, content: text }],
|
|
70
|
-
};
|
|
63
|
+
const mentionIds = opts?.mentionIds?.length ? opts.mentionIds : undefined;
|
|
64
|
+
return { ...base, messages: [{ type: 1, content: text }], ...(mentionIds ? { mentionIds } : {}) };
|
|
71
65
|
}
|
|
72
66
|
|
|
73
67
|
/**
|
|
74
68
|
* 构建图片发送 payload(可附带文字 caption)
|
|
75
69
|
*/
|
|
76
|
-
export function
|
|
70
|
+
export function buildSocketChatMediaPayload(
|
|
77
71
|
to: string,
|
|
78
72
|
imageUrl: string,
|
|
79
73
|
caption?: string,
|
|
@@ -110,66 +104,34 @@ export async function sendSocketChatMessage(params: {
|
|
|
110
104
|
}
|
|
111
105
|
|
|
112
106
|
// ---------------------------------------------------------------------------
|
|
113
|
-
//
|
|
107
|
+
// Shared outbound helper used by channel.ts and inbound.ts
|
|
114
108
|
// ---------------------------------------------------------------------------
|
|
115
109
|
|
|
116
110
|
/**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* 通过注册表查找当前账号的活跃 MQTT client 发送消息:
|
|
120
|
-
* - sendText:发送纯文字
|
|
121
|
-
* - sendMedia:优先发图片(type:2),无 mediaUrl 时退化为纯文字
|
|
122
|
-
* - resolveTarget:规范化目标地址(strip socket-chat: 前缀)
|
|
111
|
+
* 通过活跃的 MQTT client 发送文字消息到指定目标
|
|
123
112
|
*/
|
|
124
|
-
export
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
|
138
|
-
const client = getActiveMqttClient(resolvedAccountId);
|
|
139
|
-
const mqttConfig = getActiveMqttConfig(resolvedAccountId);
|
|
140
|
-
|
|
141
|
-
if (!client || !mqttConfig) {
|
|
142
|
-
throw new Error(
|
|
143
|
-
`[socket-chat] No active MQTT connection for account "${resolvedAccountId}". ` +
|
|
113
|
+
export async function sendSocketChatText(params: {
|
|
114
|
+
to: string;
|
|
115
|
+
text: string;
|
|
116
|
+
accountId?: string;
|
|
117
|
+
}): Promise<{ channel: string; messageId: string }> {
|
|
118
|
+
const { to, text } = params;
|
|
119
|
+
const resolvedAccountId = params.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
120
|
+
const client = getActiveMqttClient(resolvedAccountId);
|
|
121
|
+
const mqttConfig = getActiveMqttConfig(resolvedAccountId);
|
|
122
|
+
|
|
123
|
+
if (!client || !mqttConfig) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`[socket-chat] No active MQTT connection for account "${resolvedAccountId}". ` +
|
|
144
126
|
"Is the gateway running?",
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const payload = buildTextPayload(to, text);
|
|
149
|
-
const result = await sendSocketChatMessage({ mqttClient: client, mqttConfig, payload });
|
|
150
|
-
return { channel: "socket-chat", messageId: result.messageId };
|
|
151
|
-
},
|
|
152
|
-
|
|
153
|
-
sendMedia: async ({ to, text, mediaUrl, accountId }) => {
|
|
154
|
-
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
|
155
|
-
const client = getActiveMqttClient(resolvedAccountId);
|
|
156
|
-
const mqttConfig = getActiveMqttConfig(resolvedAccountId);
|
|
157
|
-
|
|
158
|
-
if (!client || !mqttConfig) {
|
|
159
|
-
throw new Error(
|
|
160
|
-
`[socket-chat] No active MQTT connection for account "${resolvedAccountId}".`,
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// 有图片 URL 时发图片(可附带 caption),否则退化为纯文字
|
|
165
|
-
if (mediaUrl) {
|
|
166
|
-
const payload = buildMediaPayload(to, mediaUrl, text);
|
|
167
|
-
const result = await sendSocketChatMessage({ mqttClient: client, mqttConfig, payload });
|
|
168
|
-
return { channel: "socket-chat", messageId: result.messageId };
|
|
169
|
-
}
|
|
127
|
+
);
|
|
128
|
+
}
|
|
170
129
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
};
|
|
130
|
+
const base = parseSocketChatTarget(to);
|
|
131
|
+
const payload: SocketChatOutboundPayload = {
|
|
132
|
+
...base,
|
|
133
|
+
messages: [{ type: 1, content: text }],
|
|
134
|
+
};
|
|
135
|
+
const result = await sendSocketChatMessage({ mqttClient: client, mqttConfig, payload });
|
|
136
|
+
return { channel: "socket-chat", messageId: result.messageId };
|
|
137
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Internal runtime barrel for socket-chat extension.
|
|
2
|
+
// All SDK subpath imports are centralised here.
|
|
3
|
+
// Production files import from "./runtime-api.js" — never directly from "openclaw/plugin-sdk/*".
|
|
4
|
+
|
|
5
|
+
export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
|
6
|
+
export type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload";
|
|
7
|
+
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
|
8
|
+
export { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
|
9
|
+
export type {
|
|
10
|
+
ChannelAccountSnapshot,
|
|
11
|
+
ChannelStatusIssue,
|
|
12
|
+
} from "openclaw/plugin-sdk/status-helpers";
|
|
13
|
+
|
|
14
|
+
export { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
|
15
|
+
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
|
16
|
+
export { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
|
|
17
|
+
export { dispatchInboundReplyWithBase } from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
|
18
|
+
export {
|
|
19
|
+
deliverFormattedTextWithAttachments,
|
|
20
|
+
buildMediaPayload,
|
|
21
|
+
} from "openclaw/plugin-sdk/reply-payload";
|
|
22
|
+
export { resolveChannelMediaMaxBytes, detectMime } from "openclaw/plugin-sdk/media-runtime";
|
|
23
|
+
export { resolveAllowlistMatchByCandidates } from "openclaw/plugin-sdk/allow-from";
|
|
24
|
+
export {
|
|
25
|
+
buildBaseChannelStatusSummary,
|
|
26
|
+
buildBaseAccountStatusSnapshot,
|
|
27
|
+
collectStatusIssuesFromLastError,
|
|
28
|
+
} from "openclaw/plugin-sdk/status-helpers";
|
package/src/runtime.ts
CHANGED
|
@@ -1,14 +1,10 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
|
2
|
+
import type { PluginRuntime } from "./runtime-api.js";
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
const {
|
|
5
|
+
setRuntime: setSocketChatRuntime,
|
|
6
|
+
getRuntime: getSocketChatRuntime,
|
|
7
|
+
clearRuntime: clearSocketChatRuntime,
|
|
8
|
+
} = createPluginRuntimeStore<PluginRuntime>("socket-chat runtime not initialized");
|
|
4
9
|
|
|
5
|
-
export
|
|
6
|
-
runtime = next;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function getSocketChatRuntime(): PluginRuntime {
|
|
10
|
-
if (!runtime) {
|
|
11
|
-
throw new Error("socket-chat runtime not initialized");
|
|
12
|
-
}
|
|
13
|
-
return runtime;
|
|
14
|
-
}
|
|
10
|
+
export { setSocketChatRuntime, getSocketChatRuntime, clearSocketChatRuntime };
|