@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.
@@ -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
+ }