@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.
- package/DEVNOTES.md +219 -0
- package/README.md +215 -0
- package/index.ts +17 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +44 -0
- package/src/__sdk-stub__.ts +12 -0
- package/src/api.ts +95 -0
- package/src/channel.ts +395 -0
- package/src/config-schema.test.ts +90 -0
- package/src/config-schema.ts +58 -0
- package/src/config.test.ts +318 -0
- package/src/config.ts +218 -0
- package/src/inbound.test.ts +679 -0
- package/src/inbound.ts +344 -0
- package/src/mqtt-client.ts +274 -0
- package/src/outbound.test.ts +176 -0
- package/src/outbound.ts +175 -0
- package/src/probe.ts +49 -0
- package/src/runtime.ts +14 -0
- package/src/types.ts +98 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +25 -0
|
@@ -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
|
+
});
|
package/src/outbound.ts
ADDED
|
@@ -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
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -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
|
+
});
|