@openclaw-channel/socket-chat 1.0.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.
@@ -0,0 +1,176 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildMediaPayload,
4
+ buildTextPayload,
5
+ looksLikeSocketChatTargetId,
6
+ normalizeSocketChatTarget,
7
+ parseSocketChatTarget,
8
+ } from "./outbound.js";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // parseSocketChatTarget
12
+ // ---------------------------------------------------------------------------
13
+
14
+ describe("parseSocketChatTarget", () => {
15
+ it("parses a direct contact ID", () => {
16
+ const result = parseSocketChatTarget("wxid_abc123");
17
+ expect(result).toEqual({ isGroup: false, contactId: "wxid_abc123" });
18
+ });
19
+
20
+ it("parses a group target without mentions", () => {
21
+ const result = parseSocketChatTarget("group:roomid_xxx");
22
+ expect(result).toEqual({ isGroup: true, groupId: "roomid_xxx" });
23
+ });
24
+
25
+ it("parses a group target with single mention", () => {
26
+ const result = parseSocketChatTarget("group:roomid_xxx@wxid_a");
27
+ expect(result).toEqual({ isGroup: true, groupId: "roomid_xxx", mentionIds: ["wxid_a"] });
28
+ });
29
+
30
+ it("parses a group target with multiple mentions", () => {
31
+ const result = parseSocketChatTarget("group:roomid_xxx@wxid_a,wxid_b,wxid_c");
32
+ expect(result).toEqual({
33
+ isGroup: true,
34
+ groupId: "roomid_xxx",
35
+ mentionIds: ["wxid_a", "wxid_b", "wxid_c"],
36
+ });
37
+ });
38
+
39
+ it("trims whitespace from target string", () => {
40
+ const result = parseSocketChatTarget(" wxid_trim ");
41
+ expect(result).toEqual({ isGroup: false, contactId: "wxid_trim" });
42
+ });
43
+
44
+ it("filters empty mention ids", () => {
45
+ const result = parseSocketChatTarget("group:roomid@wxid_a,,wxid_b");
46
+ expect(result.isGroup).toBe(true);
47
+ if (result.isGroup) {
48
+ expect(result.mentionIds).toEqual(["wxid_a", "wxid_b"]);
49
+ }
50
+ });
51
+ });
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // normalizeSocketChatTarget
55
+ // ---------------------------------------------------------------------------
56
+
57
+ describe("normalizeSocketChatTarget", () => {
58
+ it("returns the target unchanged for native IDs", () => {
59
+ expect(normalizeSocketChatTarget("wxid_abc")).toBe("wxid_abc");
60
+ expect(normalizeSocketChatTarget("group:roomid_xxx")).toBe("group:roomid_xxx");
61
+ });
62
+
63
+ it("strips socket-chat: prefix (case-insensitive)", () => {
64
+ expect(normalizeSocketChatTarget("socket-chat:wxid_abc")).toBe("wxid_abc");
65
+ expect(normalizeSocketChatTarget("Socket-Chat:wxid_abc")).toBe("wxid_abc");
66
+ expect(normalizeSocketChatTarget("SOCKET-CHAT:wxid_abc")).toBe("wxid_abc");
67
+ });
68
+
69
+ it("returns undefined for empty/whitespace-only strings", () => {
70
+ expect(normalizeSocketChatTarget("")).toBeUndefined();
71
+ expect(normalizeSocketChatTarget(" ")).toBeUndefined();
72
+ });
73
+
74
+ it("trims surrounding whitespace", () => {
75
+ expect(normalizeSocketChatTarget(" wxid_abc ")).toBe("wxid_abc");
76
+ });
77
+ });
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // looksLikeSocketChatTargetId
81
+ // ---------------------------------------------------------------------------
82
+
83
+ describe("looksLikeSocketChatTargetId", () => {
84
+ it("recognizes wxid_ prefix", () => {
85
+ expect(looksLikeSocketChatTargetId("wxid_abc123")).toBe(true);
86
+ });
87
+
88
+ it("recognizes roomid_ prefix", () => {
89
+ expect(looksLikeSocketChatTargetId("roomid_xyz")).toBe(true);
90
+ });
91
+
92
+ it("recognizes group: prefix", () => {
93
+ expect(looksLikeSocketChatTargetId("group:roomid_xxx")).toBe(true);
94
+ });
95
+
96
+ it("rejects arbitrary strings", () => {
97
+ expect(looksLikeSocketChatTargetId("alice")).toBe(false);
98
+ expect(looksLikeSocketChatTargetId("user@example.com")).toBe(false);
99
+ expect(looksLikeSocketChatTargetId("")).toBe(false);
100
+ });
101
+
102
+ it("trims leading whitespace before checking", () => {
103
+ expect(looksLikeSocketChatTargetId(" wxid_abc")).toBe(true);
104
+ });
105
+ });
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // buildTextPayload
109
+ // ---------------------------------------------------------------------------
110
+
111
+ describe("buildTextPayload", () => {
112
+ it("builds a DM text payload", () => {
113
+ const payload = buildTextPayload("wxid_abc", "hello");
114
+ expect(payload.isGroup).toBe(false);
115
+ expect(payload.contactId).toBe("wxid_abc");
116
+ expect(payload.messages).toEqual([{ type: 1, content: "hello" }]);
117
+ expect(payload.mentionIds).toBeUndefined();
118
+ });
119
+
120
+ it("builds a group text payload", () => {
121
+ const payload = buildTextPayload("group:roomid_xxx", "hi group");
122
+ expect(payload.isGroup).toBe(true);
123
+ expect(payload.groupId).toBe("roomid_xxx");
124
+ expect(payload.messages).toEqual([{ type: 1, content: "hi group" }]);
125
+ });
126
+
127
+ it("extracts mentions from group target string", () => {
128
+ const payload = buildTextPayload("group:roomid_xxx@wxid_a,wxid_b", "hi");
129
+ expect(payload.mentionIds).toEqual(["wxid_a", "wxid_b"]);
130
+ });
131
+
132
+ it("allows explicit override of mentionIds", () => {
133
+ const payload = buildTextPayload("group:roomid_xxx", "hi", { mentionIds: ["wxid_override"] });
134
+ expect(payload.mentionIds).toEqual(["wxid_override"]);
135
+ });
136
+
137
+ it("sets mentionIds to undefined when empty", () => {
138
+ const payload = buildTextPayload("group:roomid_xxx", "hi", { mentionIds: [] });
139
+ expect(payload.mentionIds).toBeUndefined();
140
+ });
141
+ });
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // buildMediaPayload
145
+ // ---------------------------------------------------------------------------
146
+
147
+ describe("buildMediaPayload", () => {
148
+ it("builds a media-only payload without caption", () => {
149
+ const payload = buildMediaPayload("wxid_abc", "https://img.example.com/photo.jpg");
150
+ expect(payload.isGroup).toBe(false);
151
+ expect(payload.messages).toEqual([
152
+ { type: 2, url: "https://img.example.com/photo.jpg" },
153
+ ]);
154
+ });
155
+
156
+ it("includes caption text before image when provided", () => {
157
+ const payload = buildMediaPayload("wxid_abc", "https://img.example.com/photo.jpg", "Look at this");
158
+ expect(payload.messages).toEqual([
159
+ { type: 1, content: "Look at this" },
160
+ { type: 2, url: "https://img.example.com/photo.jpg" },
161
+ ]);
162
+ });
163
+
164
+ it("skips empty caption", () => {
165
+ const payload = buildMediaPayload("wxid_abc", "https://img.example.com/photo.jpg", " ");
166
+ expect(payload.messages).toEqual([
167
+ { type: 2, url: "https://img.example.com/photo.jpg" },
168
+ ]);
169
+ });
170
+
171
+ it("builds group media payload", () => {
172
+ const payload = buildMediaPayload("group:roomid_xxx", "https://img.example.com/photo.jpg");
173
+ expect(payload.isGroup).toBe(true);
174
+ expect(payload.groupId).toBe("roomid_xxx");
175
+ });
176
+ });
@@ -0,0 +1,175 @@
1
+ import type { MqttClient } from "mqtt";
2
+ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
3
+ import { DEFAULT_ACCOUNT_ID } from "./config.js";
4
+ import { getActiveMqttClient, getActiveMqttConfig } from "./mqtt-client.js";
5
+ import type { SocketChatMqttConfig, SocketChatOutboundPayload } from "./types.js";
6
+
7
+ /**
8
+ * 解析 outbound "to" 字符串,得到发送目标
9
+ *
10
+ * 格式约定:
11
+ * - 私聊:contactId,例如 "wxid_abc123"
12
+ * - 群聊:以 "group:" 前缀,例如 "group:roomid_xxx"
13
+ * - 群聊带 @:以 "group:roomid_xxx@wxid_a,wxid_b"(@ 后面逗号分隔 mentionIds)
14
+ */
15
+ export function parseSocketChatTarget(to: string): Omit<SocketChatOutboundPayload, "messages"> {
16
+ const trimmed = to.trim();
17
+
18
+ if (trimmed.startsWith("group:")) {
19
+ const withoutPrefix = trimmed.slice("group:".length);
20
+ const atIdx = withoutPrefix.indexOf("@");
21
+ if (atIdx !== -1) {
22
+ const groupId = withoutPrefix.slice(0, atIdx);
23
+ const mentionIds = withoutPrefix
24
+ .slice(atIdx + 1)
25
+ .split(",")
26
+ .map((s) => s.trim())
27
+ .filter(Boolean);
28
+ return { isGroup: true, groupId, mentionIds };
29
+ }
30
+ return { isGroup: true, groupId: withoutPrefix };
31
+ }
32
+
33
+ return { isGroup: false, contactId: trimmed };
34
+ }
35
+
36
+ /**
37
+ * 规范化 outbound 目标字符串(去掉前后空格,去掉 socket-chat: 前缀)
38
+ */
39
+ export function normalizeSocketChatTarget(raw: string): string | undefined {
40
+ const trimmed = raw
41
+ .trim()
42
+ .replace(/^socket-chat:/i, "")
43
+ .trim();
44
+ return trimmed || undefined;
45
+ }
46
+
47
+ /**
48
+ * 判断字符串是否像一个 socket-chat 原生 ID
49
+ * wxid_xxx / roomid_xxx 格式,或带 group: 前缀
50
+ */
51
+ export function looksLikeSocketChatTargetId(s: string): boolean {
52
+ return /^(wxid_|roomid_|group:)/.test(s.trim());
53
+ }
54
+
55
+ /**
56
+ * 构建纯文本发送 payload
57
+ */
58
+ export function buildTextPayload(
59
+ to: string,
60
+ text: string,
61
+ opts: { mentionIds?: string[] } = {},
62
+ ): SocketChatOutboundPayload {
63
+ const base = parseSocketChatTarget(to);
64
+ const mentionIds =
65
+ opts.mentionIds ?? (base.isGroup ? base.mentionIds : undefined);
66
+ return {
67
+ ...base,
68
+ mentionIds: mentionIds?.length ? mentionIds : undefined,
69
+ messages: [{ type: 1, content: text }],
70
+ };
71
+ }
72
+
73
+ /**
74
+ * 构建图片发送 payload(可附带文字 caption)
75
+ */
76
+ export function buildMediaPayload(
77
+ to: string,
78
+ imageUrl: string,
79
+ caption?: string,
80
+ ): SocketChatOutboundPayload {
81
+ const base = parseSocketChatTarget(to);
82
+ const messages: SocketChatOutboundPayload["messages"] = [];
83
+ if (caption?.trim()) {
84
+ messages.push({ type: 1, content: caption });
85
+ }
86
+ messages.push({ type: 2, url: imageUrl });
87
+ return { ...base, messages };
88
+ }
89
+
90
+ /**
91
+ * 通过已连接的 MQTT client 发送出站消息
92
+ */
93
+ export async function sendSocketChatMessage(params: {
94
+ mqttClient: MqttClient;
95
+ mqttConfig: SocketChatMqttConfig;
96
+ payload: SocketChatOutboundPayload;
97
+ }): Promise<{ messageId: string }> {
98
+ const { mqttClient, mqttConfig, payload } = params;
99
+ return new Promise((resolve, reject) => {
100
+ const body = JSON.stringify(payload);
101
+ mqttClient.publish(mqttConfig.sendTopic, body, { qos: 1 }, (err?: Error) => {
102
+ if (err) {
103
+ reject(new Error(`MQTT publish failed: ${err.message}`));
104
+ return;
105
+ }
106
+ // 平台不返回 messageId,用本地时间戳生成一个
107
+ resolve({ messageId: `sc-${Date.now()}` });
108
+ });
109
+ });
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // ChannelOutboundAdapter 实现
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /**
117
+ * socket-chat 出站适配器。
118
+ *
119
+ * 通过注册表查找当前账号的活跃 MQTT client 发送消息:
120
+ * - sendText:发送纯文字
121
+ * - sendMedia:优先发图片(type:2),无 mediaUrl 时退化为纯文字
122
+ * - resolveTarget:规范化目标地址(strip socket-chat: 前缀)
123
+ */
124
+ export const socketChatOutbound: ChannelOutboundAdapter = {
125
+ deliveryMode: "direct",
126
+ textChunkLimit: 4096,
127
+
128
+ resolveTarget: ({ to }) => {
129
+ const normalized = to ? normalizeSocketChatTarget(to) : undefined;
130
+ if (!normalized) {
131
+ return { ok: false, error: new Error(`Invalid socket-chat target: "${to}"`) };
132
+ }
133
+ return { ok: true, to: normalized };
134
+ },
135
+
136
+ sendText: async ({ to, text, accountId }) => {
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}". ` +
144
+ "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
+ }
170
+
171
+ const payload = buildTextPayload(to, text);
172
+ const result = await sendSocketChatMessage({ mqttClient: client, mqttConfig, payload });
173
+ return { channel: "socket-chat", messageId: result.messageId };
174
+ },
175
+ };
package/src/probe.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { fetchMqttConfig } from "./api.js";
2
+ import type { ResolvedSocketChatAccount } from "./config.js";
3
+
4
+ export type SocketChatProbeResult = {
5
+ ok: boolean;
6
+ error?: string;
7
+ host?: string;
8
+ port?: string;
9
+ robotId?: string;
10
+ userId?: string;
11
+ reciveTopic?: string;
12
+ sendTopic?: string;
13
+ };
14
+
15
+ /**
16
+ * 探测账号连通性:
17
+ * 1. 调用 GET /api/openclaw/chat/config 验证 API key 有效性
18
+ * 2. 返回获取到的连接信息摘要
19
+ */
20
+ export async function probeSocketChatAccount(params: {
21
+ account: ResolvedSocketChatAccount;
22
+ timeoutMs: number;
23
+ }): Promise<SocketChatProbeResult> {
24
+ const { account, timeoutMs } = params;
25
+
26
+ if (!account.apiKey || !account.apiBaseUrl) {
27
+ return { ok: false, error: "apiKey or apiBaseUrl not configured" };
28
+ }
29
+
30
+ try {
31
+ const config = await fetchMqttConfig({
32
+ apiBaseUrl: account.apiBaseUrl,
33
+ apiKey: account.apiKey,
34
+ timeoutMs,
35
+ });
36
+ return {
37
+ ok: true,
38
+ host: config.host,
39
+ port: config.port,
40
+ robotId: config.robotId,
41
+ userId: config.userId,
42
+ reciveTopic: config.reciveTopic,
43
+ sendTopic: config.sendTopic,
44
+ };
45
+ } catch (err) {
46
+ const message = err instanceof Error ? err.message : String(err);
47
+ return { ok: false, error: message };
48
+ }
49
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk";
2
+
3
+ let runtime: PluginRuntime | null = null;
4
+
5
+ export function setSocketChatRuntime(next: PluginRuntime): void {
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
+ }
package/src/types.ts ADDED
@@ -0,0 +1,98 @@
1
+ // Socket Chat inbound/outbound 消息类型定义
2
+
3
+ /**
4
+ * MQTT 收到的入站消息格式(reciveTopic)
5
+ */
6
+ export type SocketChatInboundMessage = {
7
+ /** 消息文字内容 */
8
+ content: string;
9
+ /** 机器人 ID(robotId,即本 bot 账号标识) */
10
+ robotId: string;
11
+ /** 发送者 ID */
12
+ senderId: string;
13
+ /** 发送者昵称 */
14
+ senderName: string;
15
+ /** 是否是群消息 */
16
+ isGroup: boolean;
17
+ /** 群 ID(isGroup=true 时存在) */
18
+ groupId?: string;
19
+ /** 群名称(isGroup=true 时存在) */
20
+ groupName?: string;
21
+ /** 11 位时间戳(毫秒) */
22
+ timestamp: number;
23
+ /** 消息唯一 ID */
24
+ messageId: string;
25
+ /** 消息类型:文字 / 图片 / 视频 / 文件 / 语音 / 名片 / h5链接 / 视频号 / 位置 / 历史记录 */
26
+ type?: string;
27
+ /** 媒体文件 URL(图片/视频/文件/语音等,OSS 链接或 base64) */
28
+ url?: string;
29
+ /** 额外媒体元数据(视频号、名片、h5链接等结构化信息) */
30
+ mediaInfo?: Record<string, unknown>;
31
+ };
32
+
33
+ /**
34
+ * 单条发送消息体
35
+ * type: 1=文字, 2=纯图片
36
+ */
37
+ export type SocketChatOutboundMessage =
38
+ | { type: 1; content: string }
39
+ | { type: 2; url: string };
40
+
41
+ /**
42
+ * MQTT 发送的出站消息格式(sendTopic)
43
+ */
44
+ export type SocketChatOutboundPayload = {
45
+ /** 是否发给群 */
46
+ isGroup: boolean;
47
+ /** 群 ID(isGroup=true 时传) */
48
+ groupId?: string;
49
+ /** 私聊用户 ID(isGroup=false 时传) */
50
+ contactId?: string;
51
+ /** 群组中 @提及的用户 ID 列表 */
52
+ mentionIds?: string[];
53
+ /** 消息列表(支持多条) */
54
+ messages: SocketChatOutboundMessage[];
55
+ };
56
+
57
+ /**
58
+ * 从 GET /api/openclaw/chat/config 获取的 MQTT 连接配置
59
+ */
60
+ export type SocketChatMqttConfig = {
61
+ host: string;
62
+ port: string;
63
+ username: string;
64
+ password: string;
65
+ clientId: string;
66
+ reciveTopic: string;
67
+ sendTopic: string;
68
+ /** 机器人自身的 ID(用于识别是否是自己发出的消息) */
69
+ robotId: string;
70
+ /** 当前用户/账号 ID */
71
+ userId: string;
72
+ };
73
+
74
+ /**
75
+ * 出站目标解析结果
76
+ */
77
+ export type SocketChatTarget =
78
+ | { isGroup: false; contactId: string }
79
+ | { isGroup: true; groupId: string; mentionIds?: string[] };
80
+
81
+ /**
82
+ * MQTT 运行时状态 patch(传给 ctx.setStatus)
83
+ */
84
+ export type SocketChatStatusPatch = {
85
+ accountId?: string;
86
+ running?: boolean;
87
+ connected?: boolean;
88
+ lastConnectedAt?: number | null;
89
+ lastDisconnect?: string | null;
90
+ lastEventAt?: number | null;
91
+ lastStartAt?: number | null;
92
+ lastStopAt?: number | null;
93
+ lastError?: string | null;
94
+ reconnectAttempts?: number;
95
+ lastInboundAt?: number | null;
96
+ lastOutboundAt?: number | null;
97
+ mode?: string;
98
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "./dist",
10
+ "declaration": true,
11
+ "declarationMap": true,
12
+ "sourceMap": true,
13
+ "lib": ["ES2022", "DOM"]
14
+ },
15
+ "include": ["index.ts", "src/**/*.ts"],
16
+ "exclude": ["node_modules", "dist"]
17
+ }
@@ -0,0 +1,25 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { defineConfig } from "vitest/config";
4
+
5
+ const dir = path.dirname(fileURLToPath(import.meta.url));
6
+ const openclawSrc = path.join(dir, "..", "openclaw", "src");
7
+
8
+ export default defineConfig({
9
+ resolve: {
10
+ alias: [
11
+ // Point directly to the allowlist-match module to avoid pulling in the
12
+ // entire plugin-sdk dependency tree (which includes json5 and other
13
+ // modules that fail to load outside the openclaw workspace).
14
+ {
15
+ find: "openclaw/plugin-sdk",
16
+ replacement: path.join(dir, "src", "__sdk-stub__.ts"),
17
+ },
18
+ ],
19
+ },
20
+ test: {
21
+ include: ["src/**/*.test.ts", "src/**/*.integration.test.ts"],
22
+ environment: "node",
23
+ testTimeout: 15_000,
24
+ },
25
+ });