@openclaw-channel/socket-chat 1.0.6 → 1.0.8
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/channel-api.ts +3 -0
- package/index.ts +11 -12
- package/package.json +3 -3
- 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 +25 -16
- package/src/outbound.ts +31 -69
- 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
|
// ---------------------------------------------------------------------------
|
|
@@ -104,12 +113,12 @@ describe("looksLikeSocketChatTargetId", () => {
|
|
|
104
113
|
});
|
|
105
114
|
|
|
106
115
|
// ---------------------------------------------------------------------------
|
|
107
|
-
//
|
|
116
|
+
// buildSocketChatTextPayload
|
|
108
117
|
// ---------------------------------------------------------------------------
|
|
109
118
|
|
|
110
|
-
describe("
|
|
119
|
+
describe("buildSocketChatTextPayload", () => {
|
|
111
120
|
it("builds a DM text payload", () => {
|
|
112
|
-
const payload =
|
|
121
|
+
const payload = buildSocketChatTextPayload("wxid_abc", "hello");
|
|
113
122
|
expect(payload.isGroup).toBe(false);
|
|
114
123
|
expect(payload.contactId).toBe("wxid_abc");
|
|
115
124
|
expect(payload.messages).toEqual([{ type: 1, content: "hello" }]);
|
|
@@ -117,35 +126,35 @@ describe("buildTextPayload", () => {
|
|
|
117
126
|
});
|
|
118
127
|
|
|
119
128
|
it("builds a group text payload", () => {
|
|
120
|
-
const payload =
|
|
129
|
+
const payload = buildSocketChatTextPayload("group:17581395450@chatroom", "hi group");
|
|
121
130
|
expect(payload.isGroup).toBe(true);
|
|
122
131
|
expect(payload.groupId).toBe("17581395450@chatroom");
|
|
123
132
|
expect(payload.messages).toEqual([{ type: 1, content: "hi group" }]);
|
|
124
133
|
});
|
|
125
134
|
|
|
126
135
|
it("extracts mentions from group target string", () => {
|
|
127
|
-
const payload =
|
|
136
|
+
const payload = buildSocketChatTextPayload("group:17581395450@chatroom|wxid_a,wxid_b", "hi");
|
|
128
137
|
expect(payload.mentionIds).toEqual(["wxid_a", "wxid_b"]);
|
|
129
138
|
});
|
|
130
139
|
|
|
131
140
|
it("allows explicit override of mentionIds", () => {
|
|
132
|
-
const payload =
|
|
141
|
+
const payload = buildSocketChatTextPayload("group:17581395450@chatroom", "hi", { mentionIds: ["wxid_override"] });
|
|
133
142
|
expect(payload.mentionIds).toEqual(["wxid_override"]);
|
|
134
143
|
});
|
|
135
144
|
|
|
136
145
|
it("sets mentionIds to undefined when empty", () => {
|
|
137
|
-
const payload =
|
|
146
|
+
const payload = buildSocketChatTextPayload("group:17581395450@chatroom", "hi", { mentionIds: [] });
|
|
138
147
|
expect(payload.mentionIds).toBeUndefined();
|
|
139
148
|
});
|
|
140
149
|
});
|
|
141
150
|
|
|
142
151
|
// ---------------------------------------------------------------------------
|
|
143
|
-
//
|
|
152
|
+
// buildSocketChatMediaPayload
|
|
144
153
|
// ---------------------------------------------------------------------------
|
|
145
154
|
|
|
146
|
-
describe("
|
|
155
|
+
describe("buildSocketChatMediaPayload", () => {
|
|
147
156
|
it("builds a media-only payload without caption", () => {
|
|
148
|
-
const payload =
|
|
157
|
+
const payload = buildSocketChatMediaPayload("wxid_abc", "https://img.example.com/photo.jpg");
|
|
149
158
|
expect(payload.isGroup).toBe(false);
|
|
150
159
|
expect(payload.messages).toEqual([
|
|
151
160
|
{ type: 2, url: "https://img.example.com/photo.jpg" },
|
|
@@ -153,7 +162,7 @@ describe("buildMediaPayload", () => {
|
|
|
153
162
|
});
|
|
154
163
|
|
|
155
164
|
it("includes caption text before image when provided", () => {
|
|
156
|
-
const payload =
|
|
165
|
+
const payload = buildSocketChatMediaPayload("wxid_abc", "https://img.example.com/photo.jpg", "Look at this");
|
|
157
166
|
expect(payload.messages).toEqual([
|
|
158
167
|
{ type: 1, content: "Look at this" },
|
|
159
168
|
{ type: 2, url: "https://img.example.com/photo.jpg" },
|
|
@@ -161,14 +170,14 @@ describe("buildMediaPayload", () => {
|
|
|
161
170
|
});
|
|
162
171
|
|
|
163
172
|
it("skips empty caption", () => {
|
|
164
|
-
const payload =
|
|
173
|
+
const payload = buildSocketChatMediaPayload("wxid_abc", "https://img.example.com/photo.jpg", " ");
|
|
165
174
|
expect(payload.messages).toEqual([
|
|
166
175
|
{ type: 2, url: "https://img.example.com/photo.jpg" },
|
|
167
176
|
]);
|
|
168
177
|
});
|
|
169
178
|
|
|
170
179
|
it("builds group media payload", () => {
|
|
171
|
-
const payload =
|
|
180
|
+
const payload = buildSocketChatMediaPayload("group:17581395450@chatroom", "https://img.example.com/photo.jpg");
|
|
172
181
|
expect(payload.isGroup).toBe(true);
|
|
173
182
|
expect(payload.groupId).toBe("17581395450@chatroom");
|
|
174
183
|
});
|
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";
|
|
@@ -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 };
|
package/tsconfig.json
CHANGED
package/vitest.config.ts
CHANGED
|
@@ -4,17 +4,23 @@ import { defineConfig } from "vitest/config";
|
|
|
4
4
|
|
|
5
5
|
const dir = path.dirname(fileURLToPath(import.meta.url));
|
|
6
6
|
const openclawSrc = path.join(dir, "..", "openclaw", "src");
|
|
7
|
+
const sdkSrc = path.join(openclawSrc, "plugin-sdk");
|
|
7
8
|
|
|
8
9
|
export default defineConfig({
|
|
9
10
|
resolve: {
|
|
10
11
|
alias: [
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
{
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
},
|
|
12
|
+
// Per-subpath aliases must come BEFORE the broad "openclaw/plugin-sdk" fallback.
|
|
13
|
+
{ find: "openclaw/plugin-sdk/runtime-store", replacement: path.join(sdkSrc, "runtime-store.ts") },
|
|
14
|
+
{ find: "openclaw/plugin-sdk/channel-pairing", replacement: path.join(sdkSrc, "channel-pairing.ts") },
|
|
15
|
+
{ find: "openclaw/plugin-sdk/channel-lifecycle", replacement: path.join(sdkSrc, "channel-lifecycle.ts") },
|
|
16
|
+
{ find: "openclaw/plugin-sdk/inbound-reply-dispatch", replacement: path.join(sdkSrc, "inbound-reply-dispatch.ts") },
|
|
17
|
+
{ find: "openclaw/plugin-sdk/reply-payload", replacement: path.join(sdkSrc, "reply-payload.ts") },
|
|
18
|
+
{ find: "openclaw/plugin-sdk/media-runtime", replacement: path.join(sdkSrc, "media-runtime.ts") },
|
|
19
|
+
{ find: "openclaw/plugin-sdk/allow-from", replacement: path.join(sdkSrc, "allow-from.ts") },
|
|
20
|
+
{ find: "openclaw/plugin-sdk/status-helpers", replacement: path.join(sdkSrc, "status-helpers.ts") },
|
|
21
|
+
{ find: "openclaw/plugin-sdk/channel-core", replacement: path.join(sdkSrc, "channel-core.ts") },
|
|
22
|
+
// Broad fallback for any remaining "openclaw/plugin-sdk" (main barrel) imports.
|
|
23
|
+
{ find: "openclaw/plugin-sdk", replacement: path.join(dir, "src", "__sdk-stub__.ts") },
|
|
18
24
|
],
|
|
19
25
|
},
|
|
20
26
|
test: {
|