@shenhh/popo 0.1.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/index.ts +23 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +66 -0
- package/skills/popo-card/SKILL.md +169 -0
- package/skills/popo-group/SKILL.md +115 -0
- package/skills/popo-msg/SKILL.md +105 -0
- package/src/accounts.ts +52 -0
- package/src/auth.ts +151 -0
- package/src/bot.ts +365 -0
- package/src/channel.ts +203 -0
- package/src/client.ts +111 -0
- package/src/config-schema.ts +79 -0
- package/src/crypto.ts +69 -0
- package/src/media.ts +299 -0
- package/src/monitor.ts +241 -0
- package/src/outbound.ts +40 -0
- package/src/policy.ts +93 -0
- package/src/probe.ts +29 -0
- package/src/reply-dispatcher.ts +118 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +169 -0
- package/src/targets.ts +68 -0
- package/src/types.ts +48 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export { z };
|
|
3
|
+
|
|
4
|
+
const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
|
|
5
|
+
const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
|
|
6
|
+
|
|
7
|
+
const ToolPolicySchema = z
|
|
8
|
+
.object({
|
|
9
|
+
allow: z.array(z.string()).optional(),
|
|
10
|
+
deny: z.array(z.string()).optional(),
|
|
11
|
+
})
|
|
12
|
+
.strict()
|
|
13
|
+
.optional();
|
|
14
|
+
|
|
15
|
+
const DmConfigSchema = z
|
|
16
|
+
.object({
|
|
17
|
+
enabled: z.boolean().optional(),
|
|
18
|
+
systemPrompt: z.string().optional(),
|
|
19
|
+
})
|
|
20
|
+
.strict()
|
|
21
|
+
.optional();
|
|
22
|
+
|
|
23
|
+
// Message render mode: raw (default) = plain text, rich_text = POPO rich text format
|
|
24
|
+
const RenderModeSchema = z.enum(["raw", "rich_text"]).optional();
|
|
25
|
+
|
|
26
|
+
export const PopoGroupSchema = z
|
|
27
|
+
.object({
|
|
28
|
+
requireMention: z.boolean().optional(),
|
|
29
|
+
tools: ToolPolicySchema,
|
|
30
|
+
skills: z.array(z.string()).optional(),
|
|
31
|
+
enabled: z.boolean().optional(),
|
|
32
|
+
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
33
|
+
systemPrompt: z.string().optional(),
|
|
34
|
+
})
|
|
35
|
+
.strict();
|
|
36
|
+
|
|
37
|
+
export type PopoGroupConfig = z.infer<typeof PopoGroupSchema>;
|
|
38
|
+
|
|
39
|
+
export const PopoConfigSchema = z
|
|
40
|
+
.object({
|
|
41
|
+
enabled: z.boolean().optional(),
|
|
42
|
+
appKey: z.string().optional(),
|
|
43
|
+
appSecret: z.string().optional(),
|
|
44
|
+
token: z.string().optional(), // Token for signature verification
|
|
45
|
+
aesKey: z.string().optional(), // 32-char AES key for encryption
|
|
46
|
+
server: z
|
|
47
|
+
.string()
|
|
48
|
+
.optional()
|
|
49
|
+
.default("https://open.popo.netease.com/open-apis/robots/v1"),
|
|
50
|
+
webhookPath: z.string().optional().default("/popo/events"),
|
|
51
|
+
webhookPort: z.number().int().positive().optional(),
|
|
52
|
+
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
|
53
|
+
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
54
|
+
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
|
55
|
+
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
|
56
|
+
requireMention: z.boolean().optional().default(true),
|
|
57
|
+
groups: z.record(z.string(), PopoGroupSchema.optional()).optional(),
|
|
58
|
+
historyLimit: z.number().int().min(0).optional(),
|
|
59
|
+
dmHistoryLimit: z.number().int().min(0).optional(),
|
|
60
|
+
dms: z.record(z.string(), DmConfigSchema).optional(),
|
|
61
|
+
textChunkLimit: z.number().int().positive().optional(),
|
|
62
|
+
chunkMode: z.enum(["length", "newline"]).optional(),
|
|
63
|
+
mediaMaxMb: z.number().positive().optional().default(20), // 20MB max
|
|
64
|
+
renderMode: RenderModeSchema, // raw = plain text (default), rich_text = POPO rich text
|
|
65
|
+
})
|
|
66
|
+
.strict()
|
|
67
|
+
.superRefine((value, ctx) => {
|
|
68
|
+
if (value.dmPolicy === "open") {
|
|
69
|
+
const allowFrom = value.allowFrom ?? [];
|
|
70
|
+
const hasWildcard = allowFrom.some((entry) => String(entry).trim() === "*");
|
|
71
|
+
if (!hasWildcard) {
|
|
72
|
+
ctx.addIssue({
|
|
73
|
+
code: z.ZodIssueCode.custom,
|
|
74
|
+
path: ["allowFrom"],
|
|
75
|
+
message: 'channels.popo.dmPolicy="open" requires channels.popo.allowFrom to include "*"',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Verify POPO webhook signature.
|
|
5
|
+
* Signature = SHA256(token + nonce + timestamp)
|
|
6
|
+
*/
|
|
7
|
+
export function verifySignature(params: {
|
|
8
|
+
token: string;
|
|
9
|
+
nonce: string;
|
|
10
|
+
timestamp: string;
|
|
11
|
+
signature: string;
|
|
12
|
+
}): boolean {
|
|
13
|
+
const { token, nonce, timestamp, signature } = params;
|
|
14
|
+
const data = token + nonce + timestamp;
|
|
15
|
+
const computed = crypto.createHash("sha256").update(data).digest("hex");
|
|
16
|
+
return computed.toLowerCase() === signature.toLowerCase();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Decrypt POPO encrypted message using AES-CBC.
|
|
21
|
+
* Key derivation: Base64 decode(aesKey + "=") -> 32 bytes, first 16 bytes = IV
|
|
22
|
+
*/
|
|
23
|
+
export function decryptMessage(encrypt: string, aesKey: string): string {
|
|
24
|
+
// POPO uses Base64(aesKey + "=") as the key source
|
|
25
|
+
const keyBuffer = Buffer.from(aesKey + "=", "base64");
|
|
26
|
+
if (keyBuffer.length < 32) {
|
|
27
|
+
throw new Error("Invalid AES key length");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// First 16 bytes as IV, full 32 bytes as key
|
|
31
|
+
const iv = keyBuffer.subarray(0, 16);
|
|
32
|
+
const key = keyBuffer.subarray(0, 32);
|
|
33
|
+
|
|
34
|
+
// Decrypt the base64-encoded ciphertext
|
|
35
|
+
const ciphertext = Buffer.from(encrypt, "base64");
|
|
36
|
+
const decipher = crypto.createDecipheriv("aes-256-cbc", key, iv);
|
|
37
|
+
let decrypted = decipher.update(ciphertext);
|
|
38
|
+
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
|
39
|
+
|
|
40
|
+
// Remove PKCS7 padding and parse as UTF-8
|
|
41
|
+
return decrypted.toString("utf8");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Encrypt POPO response message using AES-CBC.
|
|
46
|
+
* Used to encrypt the "success" response.
|
|
47
|
+
*/
|
|
48
|
+
export function encryptMessage(plaintext: string, aesKey: string): string {
|
|
49
|
+
const keyBuffer = Buffer.from(aesKey + "=", "base64");
|
|
50
|
+
if (keyBuffer.length < 32) {
|
|
51
|
+
throw new Error("Invalid AES key length");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const iv = keyBuffer.subarray(0, 16);
|
|
55
|
+
const key = keyBuffer.subarray(0, 32);
|
|
56
|
+
|
|
57
|
+
const cipher = crypto.createCipheriv("aes-256-cbc", key, iv);
|
|
58
|
+
let encrypted = cipher.update(plaintext, "utf8");
|
|
59
|
+
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
|
60
|
+
|
|
61
|
+
return encrypted.toString("base64");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Generate a random nonce for webhook responses.
|
|
66
|
+
*/
|
|
67
|
+
export function generateNonce(): string {
|
|
68
|
+
return crypto.randomBytes(16).toString("hex");
|
|
69
|
+
}
|
package/src/media.ts
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import type { PopoConfig, PopoSendResult } from "./types.js";
|
|
3
|
+
import { popoRequest, popoUploadRequest, popoDownloadRequest } from "./client.js";
|
|
4
|
+
import { normalizePopoTarget, detectReceiverType } from "./targets.js";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import fs from "fs";
|
|
7
|
+
|
|
8
|
+
export type DownloadFileResult = {
|
|
9
|
+
buffer: Buffer;
|
|
10
|
+
contentType?: string;
|
|
11
|
+
fileName?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Download a file from POPO using fileId.
|
|
16
|
+
*/
|
|
17
|
+
export async function downloadFilePopo(params: {
|
|
18
|
+
cfg: ClawdbotConfig;
|
|
19
|
+
fileId: string;
|
|
20
|
+
}): Promise<DownloadFileResult> {
|
|
21
|
+
const { cfg, fileId } = params;
|
|
22
|
+
const popoCfg = cfg.channels?.popo as PopoConfig | undefined;
|
|
23
|
+
if (!popoCfg) {
|
|
24
|
+
throw new Error("POPO channel not configured");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const result = await popoDownloadRequest({
|
|
28
|
+
cfg: popoCfg,
|
|
29
|
+
path: `/im/file/download?fileId=${encodeURIComponent(fileId)}`,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
buffer: result.buffer,
|
|
34
|
+
contentType: result.contentType,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type UploadFileResult = {
|
|
39
|
+
fileId: string;
|
|
40
|
+
fileName: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Upload a file to POPO.
|
|
45
|
+
*/
|
|
46
|
+
export async function uploadFilePopo(params: {
|
|
47
|
+
cfg: ClawdbotConfig;
|
|
48
|
+
file: Buffer | string;
|
|
49
|
+
fileName: string;
|
|
50
|
+
fileType?: string;
|
|
51
|
+
}): Promise<UploadFileResult> {
|
|
52
|
+
const { cfg, file, fileName, fileType } = params;
|
|
53
|
+
const popoCfg = cfg.channels?.popo as PopoConfig | undefined;
|
|
54
|
+
if (!popoCfg) {
|
|
55
|
+
throw new Error("POPO channel not configured");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const formData = new FormData();
|
|
59
|
+
|
|
60
|
+
let fileBuffer: Buffer;
|
|
61
|
+
if (typeof file === "string") {
|
|
62
|
+
fileBuffer = fs.readFileSync(file);
|
|
63
|
+
} else {
|
|
64
|
+
fileBuffer = file;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const blob = new Blob([fileBuffer as unknown as ArrayBuffer], { type: fileType || "application/octet-stream" });
|
|
68
|
+
formData.append("file", blob, fileName);
|
|
69
|
+
|
|
70
|
+
const response = await popoUploadRequest<{ fileId: string; fileName: string }>({
|
|
71
|
+
cfg: popoCfg,
|
|
72
|
+
path: "/im/file/upload",
|
|
73
|
+
formData,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (response.code !== 200 || !response.result) {
|
|
77
|
+
throw new Error(`POPO file upload failed: ${response.message || "unknown error"}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
fileId: response.result.fileId,
|
|
82
|
+
fileName: response.result.fileName,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Upload an image to POPO.
|
|
88
|
+
*/
|
|
89
|
+
export async function uploadImagePopo(params: {
|
|
90
|
+
cfg: ClawdbotConfig;
|
|
91
|
+
image: Buffer | string;
|
|
92
|
+
fileName?: string;
|
|
93
|
+
}): Promise<UploadFileResult> {
|
|
94
|
+
const { cfg, image, fileName } = params;
|
|
95
|
+
|
|
96
|
+
let actualFileName = fileName ?? "image.png";
|
|
97
|
+
let buffer: Buffer;
|
|
98
|
+
|
|
99
|
+
if (typeof image === "string") {
|
|
100
|
+
buffer = fs.readFileSync(image);
|
|
101
|
+
if (!fileName) {
|
|
102
|
+
actualFileName = path.basename(image);
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
buffer = image;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Detect content type from extension
|
|
109
|
+
const ext = path.extname(actualFileName).toLowerCase();
|
|
110
|
+
const contentTypes: Record<string, string> = {
|
|
111
|
+
".jpg": "image/jpeg",
|
|
112
|
+
".jpeg": "image/jpeg",
|
|
113
|
+
".png": "image/png",
|
|
114
|
+
".gif": "image/gif",
|
|
115
|
+
".webp": "image/webp",
|
|
116
|
+
".bmp": "image/bmp",
|
|
117
|
+
};
|
|
118
|
+
const contentType = contentTypes[ext] || "image/png";
|
|
119
|
+
|
|
120
|
+
return uploadFilePopo({
|
|
121
|
+
cfg,
|
|
122
|
+
file: buffer,
|
|
123
|
+
fileName: actualFileName,
|
|
124
|
+
fileType: contentType,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Send an image message using a fileId.
|
|
130
|
+
*/
|
|
131
|
+
export async function sendImagePopo(params: {
|
|
132
|
+
cfg: ClawdbotConfig;
|
|
133
|
+
to: string;
|
|
134
|
+
fileId: string;
|
|
135
|
+
}): Promise<PopoSendResult> {
|
|
136
|
+
const { cfg, to, fileId } = params;
|
|
137
|
+
const popoCfg = cfg.channels?.popo as PopoConfig | undefined;
|
|
138
|
+
if (!popoCfg) {
|
|
139
|
+
throw new Error("POPO channel not configured");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const receiver = normalizePopoTarget(to);
|
|
143
|
+
if (!receiver) {
|
|
144
|
+
throw new Error(`Invalid POPO target: ${to}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const receiverType = detectReceiverType(receiver);
|
|
148
|
+
const receiverKey = receiverType === "email" ? "receiver" : "groupId";
|
|
149
|
+
|
|
150
|
+
const response = await popoRequest<{ msgId?: string }>({
|
|
151
|
+
cfg: popoCfg,
|
|
152
|
+
method: "POST",
|
|
153
|
+
path: "/im/send-msg",
|
|
154
|
+
body: {
|
|
155
|
+
[receiverKey]: receiver,
|
|
156
|
+
msgType: "image",
|
|
157
|
+
message: { fileId },
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (response.code !== 200) {
|
|
162
|
+
throw new Error(`POPO image send failed: ${response.message || `code ${response.code}`}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
messageId: response.result?.msgId,
|
|
167
|
+
sessionId: receiver,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Send a file message using a fileId.
|
|
173
|
+
*/
|
|
174
|
+
export async function sendFilePopo(params: {
|
|
175
|
+
cfg: ClawdbotConfig;
|
|
176
|
+
to: string;
|
|
177
|
+
fileId: string;
|
|
178
|
+
}): Promise<PopoSendResult> {
|
|
179
|
+
const { cfg, to, fileId } = params;
|
|
180
|
+
const popoCfg = cfg.channels?.popo as PopoConfig | undefined;
|
|
181
|
+
if (!popoCfg) {
|
|
182
|
+
throw new Error("POPO channel not configured");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const receiver = normalizePopoTarget(to);
|
|
186
|
+
if (!receiver) {
|
|
187
|
+
throw new Error(`Invalid POPO target: ${to}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const receiverType = detectReceiverType(receiver);
|
|
191
|
+
const receiverKey = receiverType === "email" ? "receiver" : "groupId";
|
|
192
|
+
|
|
193
|
+
const response = await popoRequest<{ msgId?: string }>({
|
|
194
|
+
cfg: popoCfg,
|
|
195
|
+
method: "POST",
|
|
196
|
+
path: "/im/send-msg",
|
|
197
|
+
body: {
|
|
198
|
+
[receiverKey]: receiver,
|
|
199
|
+
msgType: "file",
|
|
200
|
+
message: { fileId },
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (response.code !== 200) {
|
|
205
|
+
throw new Error(`POPO file send failed: ${response.message || `code ${response.code}`}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
messageId: response.result?.msgId,
|
|
210
|
+
sessionId: receiver,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Helper to detect file type from extension.
|
|
216
|
+
*/
|
|
217
|
+
export function detectFileType(fileName: string): "image" | "audio" | "file" {
|
|
218
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
219
|
+
const imageExts = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"];
|
|
220
|
+
const audioExts = [".mp3", ".wav", ".ogg", ".opus", ".m4a", ".aac"];
|
|
221
|
+
|
|
222
|
+
if (imageExts.includes(ext)) return "image";
|
|
223
|
+
if (audioExts.includes(ext)) return "audio";
|
|
224
|
+
return "file";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Upload and send media (image or file) from URL, local path, or buffer.
|
|
229
|
+
*/
|
|
230
|
+
export async function sendMediaPopo(params: {
|
|
231
|
+
cfg: ClawdbotConfig;
|
|
232
|
+
to: string;
|
|
233
|
+
mediaUrl?: string;
|
|
234
|
+
mediaBuffer?: Buffer;
|
|
235
|
+
fileName?: string;
|
|
236
|
+
}): Promise<PopoSendResult> {
|
|
237
|
+
const { cfg, to, mediaUrl, mediaBuffer, fileName } = params;
|
|
238
|
+
|
|
239
|
+
let buffer: Buffer;
|
|
240
|
+
let name: string;
|
|
241
|
+
|
|
242
|
+
if (mediaBuffer) {
|
|
243
|
+
buffer = mediaBuffer;
|
|
244
|
+
name = fileName ?? "file";
|
|
245
|
+
} else if (mediaUrl) {
|
|
246
|
+
if (isLocalPath(mediaUrl)) {
|
|
247
|
+
// Local file path - read directly
|
|
248
|
+
const filePath = mediaUrl.startsWith("~")
|
|
249
|
+
? mediaUrl.replace("~", process.env.HOME ?? "")
|
|
250
|
+
: mediaUrl.replace("file://", "");
|
|
251
|
+
|
|
252
|
+
if (!fs.existsSync(filePath)) {
|
|
253
|
+
throw new Error(`Local file not found: ${filePath}`);
|
|
254
|
+
}
|
|
255
|
+
buffer = fs.readFileSync(filePath);
|
|
256
|
+
name = fileName ?? path.basename(filePath);
|
|
257
|
+
} else {
|
|
258
|
+
// Remote URL - fetch
|
|
259
|
+
const response = await fetch(mediaUrl);
|
|
260
|
+
if (!response.ok) {
|
|
261
|
+
throw new Error(`Failed to fetch media from URL: ${response.status}`);
|
|
262
|
+
}
|
|
263
|
+
buffer = Buffer.from(await response.arrayBuffer());
|
|
264
|
+
name = fileName ?? (path.basename(new URL(mediaUrl).pathname) || "file");
|
|
265
|
+
}
|
|
266
|
+
} else {
|
|
267
|
+
throw new Error("Either mediaUrl or mediaBuffer must be provided");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Upload the file
|
|
271
|
+
const fileType = detectFileType(name);
|
|
272
|
+
const uploadResult = await uploadFilePopo({
|
|
273
|
+
cfg,
|
|
274
|
+
file: buffer,
|
|
275
|
+
fileName: name,
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Send based on file type
|
|
279
|
+
if (fileType === "image") {
|
|
280
|
+
return sendImagePopo({ cfg, to, fileId: uploadResult.fileId });
|
|
281
|
+
} else {
|
|
282
|
+
return sendFilePopo({ cfg, to, fileId: uploadResult.fileId });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Check if a string is a local file path (not a URL).
|
|
288
|
+
*/
|
|
289
|
+
function isLocalPath(urlOrPath: string): boolean {
|
|
290
|
+
if (urlOrPath.startsWith("/") || urlOrPath.startsWith("~") || /^[a-zA-Z]:/.test(urlOrPath)) {
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
const url = new URL(urlOrPath);
|
|
295
|
+
return url.protocol === "file:";
|
|
296
|
+
} catch {
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
}
|
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import http from "http";
|
|
2
|
+
import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { PopoConfig } from "./types.js";
|
|
4
|
+
import { resolvePopoCredentials } from "./accounts.js";
|
|
5
|
+
import { verifySignature, decryptMessage, encryptMessage } from "./crypto.js";
|
|
6
|
+
import { handlePopoMessage, type PopoMessageEvent } from "./bot.js";
|
|
7
|
+
import { probePopo } from "./probe.js";
|
|
8
|
+
|
|
9
|
+
export type MonitorPopoOpts = {
|
|
10
|
+
config?: ClawdbotConfig;
|
|
11
|
+
runtime?: RuntimeEnv;
|
|
12
|
+
abortSignal?: AbortSignal;
|
|
13
|
+
accountId?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
let currentServer: http.Server | null = null;
|
|
17
|
+
|
|
18
|
+
export async function monitorPopoProvider(opts: MonitorPopoOpts = {}): Promise<void> {
|
|
19
|
+
const cfg = opts.config;
|
|
20
|
+
if (!cfg) {
|
|
21
|
+
throw new Error("Config is required for POPO monitor");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const popoCfg = cfg.channels?.popo as PopoConfig | undefined;
|
|
25
|
+
const creds = resolvePopoCredentials(popoCfg);
|
|
26
|
+
if (!creds) {
|
|
27
|
+
throw new Error("POPO credentials not configured (appKey, appSecret required)");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const log = opts.runtime?.log ?? console.log;
|
|
31
|
+
const error = opts.runtime?.error ?? console.error;
|
|
32
|
+
|
|
33
|
+
// Verify credentials by getting a token
|
|
34
|
+
const probeResult = await probePopo(popoCfg);
|
|
35
|
+
if (!probeResult.ok) {
|
|
36
|
+
throw new Error(`POPO probe failed: ${probeResult.error}`);
|
|
37
|
+
}
|
|
38
|
+
log(`popo: credentials verified for appKey ${probeResult.appKey}`);
|
|
39
|
+
|
|
40
|
+
const webhookPath = popoCfg?.webhookPath ?? "/popo/events";
|
|
41
|
+
const webhookPort = popoCfg?.webhookPort ?? 3001;
|
|
42
|
+
const chatHistories = new Map<string, HistoryEntry[]>();
|
|
43
|
+
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const server = http.createServer(async (req, res) => {
|
|
46
|
+
// Handle CORS preflight
|
|
47
|
+
if (req.method === "OPTIONS") {
|
|
48
|
+
res.writeHead(200, {
|
|
49
|
+
"Access-Control-Allow-Origin": "*",
|
|
50
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
51
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
52
|
+
});
|
|
53
|
+
res.end();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
58
|
+
|
|
59
|
+
// Only handle the webhook path
|
|
60
|
+
if (url.pathname !== webhookPath) {
|
|
61
|
+
res.writeHead(404);
|
|
62
|
+
res.end("Not Found");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Handle URL validation (GET request)
|
|
67
|
+
if (req.method === "GET") {
|
|
68
|
+
const nonce = url.searchParams.get("nonce");
|
|
69
|
+
const timestamp = url.searchParams.get("timestamp");
|
|
70
|
+
const signature = url.searchParams.get("signature");
|
|
71
|
+
|
|
72
|
+
if (nonce && timestamp && signature && creds.token) {
|
|
73
|
+
const valid = verifySignature({
|
|
74
|
+
token: creds.token,
|
|
75
|
+
nonce,
|
|
76
|
+
timestamp,
|
|
77
|
+
signature,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (valid) {
|
|
81
|
+
log(`popo: URL validation successful`);
|
|
82
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
83
|
+
res.end(nonce);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
res.writeHead(400);
|
|
89
|
+
res.end("Invalid validation request");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Handle webhook event (POST request)
|
|
94
|
+
if (req.method === "POST") {
|
|
95
|
+
try {
|
|
96
|
+
const body = await readRequestBody(req);
|
|
97
|
+
const payload = JSON.parse(body);
|
|
98
|
+
|
|
99
|
+
// Check for encrypted payload
|
|
100
|
+
let eventData: unknown;
|
|
101
|
+
if (payload.encrypt && creds.aesKey) {
|
|
102
|
+
// Verify signature first
|
|
103
|
+
const { nonce, timestamp, signature } = payload;
|
|
104
|
+
if (nonce && timestamp && signature && creds.token) {
|
|
105
|
+
const valid = verifySignature({
|
|
106
|
+
token: creds.token,
|
|
107
|
+
nonce,
|
|
108
|
+
timestamp,
|
|
109
|
+
signature,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (!valid) {
|
|
113
|
+
log(`popo: invalid signature in webhook event`);
|
|
114
|
+
res.writeHead(403);
|
|
115
|
+
res.end("Invalid signature");
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Decrypt the message
|
|
121
|
+
const decrypted = decryptMessage(payload.encrypt, creds.aesKey);
|
|
122
|
+
eventData = JSON.parse(decrypted);
|
|
123
|
+
} else {
|
|
124
|
+
eventData = payload;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const event = eventData as { eventType?: string };
|
|
128
|
+
|
|
129
|
+
// Handle valid_url event (inline URL validation)
|
|
130
|
+
if (event.eventType === "valid_url") {
|
|
131
|
+
log(`popo: received valid_url event`);
|
|
132
|
+
const response = { eventType: "valid_url" };
|
|
133
|
+
|
|
134
|
+
if (creds.aesKey) {
|
|
135
|
+
const encrypted = encryptMessage(JSON.stringify(response), creds.aesKey);
|
|
136
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
137
|
+
res.end(JSON.stringify({ encrypt: encrypted }));
|
|
138
|
+
} else {
|
|
139
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
140
|
+
res.end(JSON.stringify(response));
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Handle message events
|
|
146
|
+
if (
|
|
147
|
+
event.eventType === "IM_P2P_TO_ROBOT_MSG" ||
|
|
148
|
+
event.eventType === "IM_CHAT_TO_ROBOT_AT_MSG"
|
|
149
|
+
) {
|
|
150
|
+
const messageEvent = eventData as PopoMessageEvent;
|
|
151
|
+
log(`popo: received ${event.eventType} event`);
|
|
152
|
+
|
|
153
|
+
// Process message asynchronously
|
|
154
|
+
handlePopoMessage({
|
|
155
|
+
cfg,
|
|
156
|
+
event: messageEvent,
|
|
157
|
+
runtime: opts.runtime,
|
|
158
|
+
chatHistories,
|
|
159
|
+
}).catch((err) => {
|
|
160
|
+
error(`popo: error handling message: ${String(err)}`);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Handle ACTION events (card interactions)
|
|
165
|
+
if (event.eventType === "ACTION") {
|
|
166
|
+
log(`popo: received ACTION event`);
|
|
167
|
+
// TODO: Implement card action handling if needed
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Return success response
|
|
171
|
+
const successResponse = { success: true };
|
|
172
|
+
if (creds.aesKey) {
|
|
173
|
+
const encrypted = encryptMessage(JSON.stringify(successResponse), creds.aesKey);
|
|
174
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
175
|
+
res.end(JSON.stringify({ encrypt: encrypted }));
|
|
176
|
+
} else {
|
|
177
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
178
|
+
res.end(JSON.stringify(successResponse));
|
|
179
|
+
}
|
|
180
|
+
} catch (err) {
|
|
181
|
+
error(`popo: error processing webhook: ${String(err)}`);
|
|
182
|
+
res.writeHead(500);
|
|
183
|
+
res.end("Internal Server Error");
|
|
184
|
+
}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
res.writeHead(405);
|
|
189
|
+
res.end("Method Not Allowed");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
currentServer = server;
|
|
193
|
+
|
|
194
|
+
const cleanup = () => {
|
|
195
|
+
if (currentServer === server) {
|
|
196
|
+
server.close();
|
|
197
|
+
currentServer = null;
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const handleAbort = () => {
|
|
202
|
+
log("popo: abort signal received, stopping webhook server");
|
|
203
|
+
cleanup();
|
|
204
|
+
resolve();
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
if (opts.abortSignal?.aborted) {
|
|
208
|
+
cleanup();
|
|
209
|
+
resolve();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
opts.abortSignal?.addEventListener("abort", handleAbort, { once: true });
|
|
214
|
+
|
|
215
|
+
server.on("error", (err) => {
|
|
216
|
+
cleanup();
|
|
217
|
+
opts.abortSignal?.removeEventListener("abort", handleAbort);
|
|
218
|
+
reject(err);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
server.listen(webhookPort, () => {
|
|
222
|
+
log(`popo: webhook server started on port ${webhookPort}, path ${webhookPath}`);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function readRequestBody(req: http.IncomingMessage): Promise<string> {
|
|
228
|
+
return new Promise((resolve, reject) => {
|
|
229
|
+
const chunks: Buffer[] = [];
|
|
230
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
231
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
232
|
+
req.on("error", reject);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function stopPopoMonitor(): void {
|
|
237
|
+
if (currentServer) {
|
|
238
|
+
currentServer.close();
|
|
239
|
+
currentServer = null;
|
|
240
|
+
}
|
|
241
|
+
}
|