@zlr_236/popo 0.0.1

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/src/monitor.ts ADDED
@@ -0,0 +1,236 @@
1
+ import http from "http";
2
+ import { registerPluginHttpRoute, normalizePluginHttpPath } from "openclaw/plugin-sdk";
3
+ import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
4
+ import type { PopoConfig } from "./types.js";
5
+ import { resolvePopoCredentials } from "./accounts.js";
6
+ import { verifySignature, decryptMessage, encryptMessage } from "./crypto.js";
7
+ import { handlePopoMessage, type PopoMessageEvent } from "./bot.js";
8
+ import { probePopo } from "./probe.js";
9
+
10
+ export type MonitorPopoOpts = {
11
+ config?: ClawdbotConfig;
12
+ runtime?: RuntimeEnv;
13
+ abortSignal?: AbortSignal;
14
+ accountId?: string;
15
+ };
16
+
17
+ // Helper function to read request body
18
+ function readRequestBody(req: http.IncomingMessage): Promise<string> {
19
+ return new Promise((resolve, reject) => {
20
+ const chunks: Buffer[] = [];
21
+ req.on("data", (chunk) => chunks.push(chunk));
22
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
23
+ req.on("error", reject);
24
+ });
25
+ }
26
+
27
+ export async function monitorPopoProvider(opts: MonitorPopoOpts = {}): Promise<void> {
28
+ const cfg = opts.config;
29
+ if (!cfg) {
30
+ throw new Error("Config is required for POPO monitor");
31
+ }
32
+
33
+ const popoCfg = cfg.channels?.popo as PopoConfig | undefined;
34
+ const creds = resolvePopoCredentials(popoCfg);
35
+ if (!creds) {
36
+ throw new Error("POPO credentials not configured (appKey, appSecret required)");
37
+ }
38
+
39
+ const log = opts.runtime?.log ?? console.log;
40
+ const error = opts.runtime?.error ?? console.error;
41
+
42
+ // Verify credentials by getting a token
43
+ const probeResult = await probePopo(popoCfg);
44
+ if (!probeResult.ok) {
45
+ throw new Error(`POPO probe failed: ${probeResult.error}`);
46
+ }
47
+ log(`popo: credentials verified for appKey ${probeResult.appKey}`);
48
+
49
+ const webhookPath = popoCfg?.webhookPath?.trim() || "/popo/events";
50
+ const chatHistories = new Map<string, HistoryEntry[]>();
51
+
52
+ // Normalize path
53
+ const normalizedPath = normalizePluginHttpPath(webhookPath, "/popo/events") ?? "/popo/events";
54
+
55
+ // Register HTTP route to gateway
56
+ const unregisterHttp = registerPluginHttpRoute({
57
+ path: normalizedPath,
58
+ pluginId: "popo",
59
+ accountId: opts.accountId,
60
+ log: (msg: string) => log(msg),
61
+ handler: async (req, res) => {
62
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
63
+ log(`popo: received ${req.method} request to ${url.pathname}`);
64
+
65
+ // Handle CORS preflight
66
+ if (req.method === "OPTIONS") {
67
+ res.writeHead(200, {
68
+ "Access-Control-Allow-Origin": "*",
69
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
70
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
71
+ });
72
+ res.end();
73
+ return;
74
+ }
75
+
76
+ // Handle URL validation (GET request)
77
+ if (req.method === "GET") {
78
+ const nonce = url.searchParams.get("nonce");
79
+ const timestamp = url.searchParams.get("timestamp");
80
+ const signature = url.searchParams.get("signature");
81
+
82
+ log(`popo: URL validation attempt - nonce=${nonce}, timestamp=${timestamp}, signature=${signature}`);
83
+
84
+ if (nonce && timestamp && signature && creds.token) {
85
+ const valid = verifySignature({
86
+ token: creds.token,
87
+ nonce,
88
+ timestamp,
89
+ signature,
90
+ });
91
+
92
+ if (valid) {
93
+ log(`popo: URL validation successful`);
94
+ res.writeHead(200, { "Content-Type": "text/plain" });
95
+ res.end(nonce);
96
+ return;
97
+ } else {
98
+ log(`popo: signature verification failed`);
99
+ }
100
+ } else {
101
+ log(`popo: missing required parameters for validation`);
102
+ }
103
+
104
+ res.writeHead(400);
105
+ res.end("Invalid validation request");
106
+ return;
107
+ }
108
+
109
+ // Handle webhook event (POST request)
110
+ if (req.method === "POST") {
111
+ try {
112
+ const body = await readRequestBody(req);
113
+ const payload = JSON.parse(body);
114
+
115
+ // Check for encrypted payload
116
+ let eventData: unknown;
117
+ if (payload.encrypt && creds.aesKey) {
118
+ // Verify signature first
119
+ const { nonce, timestamp, signature } = payload;
120
+ if (nonce && timestamp && signature && creds.token) {
121
+ const valid = verifySignature({
122
+ token: creds.token,
123
+ nonce,
124
+ timestamp,
125
+ signature,
126
+ });
127
+
128
+ if (!valid) {
129
+ log(`popo: invalid signature in webhook event`);
130
+ res.writeHead(403);
131
+ res.end("Invalid signature");
132
+ return;
133
+ }
134
+ }
135
+
136
+ // Decrypt the message
137
+ const decrypted = decryptMessage(payload.encrypt, creds.aesKey);
138
+ eventData = JSON.parse(decrypted);
139
+ } else {
140
+ eventData = payload;
141
+ }
142
+
143
+ const event = eventData as { eventType?: string };
144
+
145
+ // Handle valid_url event
146
+ if (event.eventType === "valid_url") {
147
+ log(`popo: received valid_url event`);
148
+ const response = { eventType: "valid_url" };
149
+
150
+ if (creds.aesKey) {
151
+ const encrypted = encryptMessage(JSON.stringify(response), creds.aesKey);
152
+ res.writeHead(200, { "Content-Type": "application/json" });
153
+ res.end(JSON.stringify({ encrypt: encrypted }));
154
+ } else {
155
+ res.writeHead(200, { "Content-Type": "application/json" });
156
+ res.end(JSON.stringify(response));
157
+ }
158
+ return;
159
+ }
160
+
161
+ // Handle message events
162
+ if (
163
+ event.eventType === "IM_P2P_TO_ROBOT_MSG" ||
164
+ event.eventType === "IM_CHAT_TO_ROBOT_AT_MSG"
165
+ ) {
166
+ const messageEvent = eventData as PopoMessageEvent;
167
+ log(`popo: received ${event.eventType} event`);
168
+
169
+ // Process message asynchronously
170
+ handlePopoMessage({
171
+ cfg,
172
+ event: messageEvent,
173
+ runtime: opts.runtime,
174
+ chatHistories,
175
+ }).catch((err) => {
176
+ error(`popo: error handling message: ${String(err)}`);
177
+ });
178
+ }
179
+
180
+ // Handle ACTION events (card interactions)
181
+ if (event.eventType === "ACTION") {
182
+ log(`popo: received ACTION event`);
183
+ // TODO: Implement card action handling if needed
184
+ }
185
+
186
+ // Return success response
187
+ const successResponse = { success: true };
188
+ if (creds.aesKey) {
189
+ const encrypted = encryptMessage(JSON.stringify(successResponse), creds.aesKey);
190
+ res.writeHead(200, { "Content-Type": "application/json" });
191
+ res.end(JSON.stringify({ encrypt: encrypted }));
192
+ } else {
193
+ res.writeHead(200, { "Content-Type": "application/json" });
194
+ res.end(JSON.stringify(successResponse));
195
+ }
196
+ } catch (err) {
197
+ error(`popo: error processing webhook: ${String(err)}`);
198
+ res.writeHead(500);
199
+ res.end("Internal Server Error");
200
+ }
201
+ return;
202
+ }
203
+
204
+ res.writeHead(405);
205
+ res.end("Method Not Allowed");
206
+ },
207
+ });
208
+
209
+ log(`popo: registered webhook handler at ${normalizedPath}`);
210
+
211
+ // Handle abort signal
212
+ const stopHandler = () => {
213
+ log("popo: stopping provider");
214
+ unregisterHttp();
215
+ };
216
+
217
+ if (opts.abortSignal?.aborted) {
218
+ stopHandler();
219
+ return;
220
+ }
221
+
222
+ opts.abortSignal?.addEventListener("abort", stopHandler, { once: true });
223
+
224
+ // Keep promise pending until abort
225
+ return new Promise((resolve) => {
226
+ const handler = () => {
227
+ stopHandler();
228
+ resolve();
229
+ };
230
+
231
+ if (opts.abortSignal) {
232
+ opts.abortSignal.removeEventListener("abort", stopHandler);
233
+ opts.abortSignal.addEventListener("abort", handler, { once: true });
234
+ }
235
+ });
236
+ }
@@ -0,0 +1,133 @@
1
+ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
2
+ import { getPopoRuntime } from "./runtime.js";
3
+ import { sendMessagePopo, sendCardPopo, createStreamCardPopo, updateStreamCardPopo } from "./send.js";
4
+ import { sendMediaPopo } from "./media.js";
5
+
6
+ export const popoOutbound: ChannelOutboundAdapter = {
7
+ deliveryMode: "direct",
8
+ chunker: (text, limit) => getPopoRuntime().channel.text.chunkMarkdownText(text, limit),
9
+ chunkerMode: "markdown",
10
+ textChunkLimit: 4000,
11
+ sendText: async ({ cfg, to, text }) => {
12
+ const result = await sendMessagePopo({ cfg, to, text });
13
+ return { channel: "popo", ...result };
14
+ },
15
+ sendMedia: async ({ cfg, to, text, mediaUrl }) => {
16
+ // Send text first if provided
17
+ if (text?.trim()) {
18
+ await sendMessagePopo({ cfg, to, text });
19
+ }
20
+
21
+ // Upload and send media if URL provided
22
+ if (mediaUrl) {
23
+ try {
24
+ const result = await sendMediaPopo({ cfg, to, mediaUrl });
25
+ return { channel: "popo", ...result };
26
+ } catch (err) {
27
+ // Log the error for debugging
28
+ console.error(`[popo] sendMediaPopo failed:`, err);
29
+ // Fallback to URL link if upload fails
30
+ const fallbackText = `📎 ${mediaUrl}`;
31
+ const result = await sendMessagePopo({ cfg, to, text: fallbackText });
32
+ return { channel: "popo", ...result };
33
+ }
34
+ }
35
+
36
+ // No media URL, just return text result
37
+ const result = await sendMessagePopo({ cfg, to, text: text ?? "" });
38
+ return { channel: "popo", ...result };
39
+ },
40
+
41
+ sendCard: async ({ cfg, to, card }) => {
42
+ const {
43
+ templateUuid,
44
+ instanceUuid,
45
+ callBackConfigKey,
46
+ publicVariableMap,
47
+ batchPrivateVariableMap,
48
+ options,
49
+ } = card as {
50
+ templateUuid: string;
51
+ instanceUuid: string;
52
+ callBackConfigKey?: string;
53
+ publicVariableMap?: Record<string, unknown>;
54
+ batchPrivateVariableMap?: Record<string, Record<string, unknown>>;
55
+ options?: import("./send.js").PopoCardOptions;
56
+ };
57
+ const result = await sendCardPopo({
58
+ cfg,
59
+ to,
60
+ templateUuid,
61
+ instanceUuid,
62
+ callBackConfigKey,
63
+ publicVariableMap,
64
+ batchPrivateVariableMap,
65
+ options,
66
+ });
67
+ return { channel: "popo", ...result };
68
+ },
69
+
70
+ // Streaming card support
71
+ createStreamCard: async ({ cfg, to, card }) => {
72
+ const {
73
+ templateUuid,
74
+ instanceUuid,
75
+ robotAccount,
76
+ fromUser,
77
+ sessionType,
78
+ callbackKey,
79
+ initialContent,
80
+ } = card as {
81
+ templateUuid: string;
82
+ instanceUuid: string;
83
+ robotAccount: string;
84
+ fromUser?: string;
85
+ sessionType?: number;
86
+ callbackKey?: string;
87
+ initialContent?: string;
88
+ };
89
+ const result = await createStreamCardPopo({
90
+ cfg,
91
+ to,
92
+ templateUuid,
93
+ instanceUuid,
94
+ robotAccount,
95
+ fromUser,
96
+ sessionType,
97
+ callbackKey,
98
+ initialContent,
99
+ });
100
+ return { channel: "popo", success: result.success, instanceUuid: result.instanceUuid };
101
+ },
102
+
103
+ updateStreamCard: async ({ cfg, card }) => {
104
+ const {
105
+ templateUuid,
106
+ instanceUuid,
107
+ content,
108
+ sequence,
109
+ isFinalize,
110
+ isError,
111
+ streamKey,
112
+ } = card as {
113
+ templateUuid: string;
114
+ instanceUuid: string;
115
+ content: string;
116
+ sequence: number;
117
+ isFinalize?: boolean;
118
+ isError?: boolean;
119
+ streamKey?: string;
120
+ };
121
+ const result = await updateStreamCardPopo({
122
+ cfg,
123
+ templateUuid,
124
+ instanceUuid,
125
+ content,
126
+ sequence,
127
+ isFinalize,
128
+ isError,
129
+ streamKey,
130
+ });
131
+ return { channel: "popo", success: result.success };
132
+ },
133
+ };
package/src/policy.ts ADDED
@@ -0,0 +1,93 @@
1
+ import type { ChannelGroupContext, GroupToolPolicyConfig } from "openclaw/plugin-sdk";
2
+ import type { PopoConfig } from "./types.js";
3
+ import type { PopoGroupConfig } from "./config-schema.js";
4
+
5
+ export type PopoAllowlistMatch = {
6
+ allowed: boolean;
7
+ matchKey?: string;
8
+ matchSource?: "wildcard" | "id" | "name";
9
+ };
10
+
11
+ export function resolvePopoAllowlistMatch(params: {
12
+ allowFrom: Array<string | number>;
13
+ senderId: string;
14
+ senderName?: string | null;
15
+ }): PopoAllowlistMatch {
16
+ const allowFrom = params.allowFrom
17
+ .map((entry) => String(entry).trim().toLowerCase())
18
+ .filter(Boolean);
19
+
20
+ if (allowFrom.length === 0) return { allowed: false };
21
+ if (allowFrom.includes("*")) {
22
+ return { allowed: true, matchKey: "*", matchSource: "wildcard" };
23
+ }
24
+
25
+ const senderId = params.senderId.toLowerCase();
26
+ if (allowFrom.includes(senderId)) {
27
+ return { allowed: true, matchKey: senderId, matchSource: "id" };
28
+ }
29
+
30
+ const senderName = params.senderName?.toLowerCase();
31
+ if (senderName && allowFrom.includes(senderName)) {
32
+ return { allowed: true, matchKey: senderName, matchSource: "name" };
33
+ }
34
+
35
+ return { allowed: false };
36
+ }
37
+
38
+ export function resolvePopoGroupConfig(params: {
39
+ cfg?: PopoConfig;
40
+ groupId?: string | null;
41
+ }): PopoGroupConfig | undefined {
42
+ const groups = params.cfg?.groups ?? {};
43
+ const groupId = params.groupId?.trim();
44
+ if (!groupId) return undefined;
45
+
46
+ const direct = groups[groupId] as PopoGroupConfig | undefined;
47
+ if (direct) return direct;
48
+
49
+ const lowered = groupId.toLowerCase();
50
+ const matchKey = Object.keys(groups).find((key) => key.toLowerCase() === lowered);
51
+ return matchKey ? (groups[matchKey] as PopoGroupConfig | undefined) : undefined;
52
+ }
53
+
54
+ export function resolvePopoGroupToolPolicy(
55
+ params: ChannelGroupContext
56
+ ): GroupToolPolicyConfig | undefined {
57
+ const cfg = params.cfg.channels?.popo as PopoConfig | undefined;
58
+ if (!cfg) return undefined;
59
+
60
+ const groupConfig = resolvePopoGroupConfig({
61
+ cfg,
62
+ groupId: params.groupId,
63
+ });
64
+
65
+ return groupConfig?.tools;
66
+ }
67
+
68
+ export function isPopoGroupAllowed(params: {
69
+ groupPolicy: "open" | "allowlist" | "disabled";
70
+ allowFrom: Array<string | number>;
71
+ senderId: string;
72
+ senderName?: string | null;
73
+ }): boolean {
74
+ const { groupPolicy } = params;
75
+ if (groupPolicy === "disabled") return false;
76
+ if (groupPolicy === "open") return true;
77
+ return resolvePopoAllowlistMatch(params).allowed;
78
+ }
79
+
80
+ export function resolvePopoReplyPolicy(params: {
81
+ isDirectMessage: boolean;
82
+ globalConfig?: PopoConfig;
83
+ groupConfig?: PopoGroupConfig;
84
+ }): { requireMention: boolean } {
85
+ if (params.isDirectMessage) {
86
+ return { requireMention: false };
87
+ }
88
+
89
+ const requireMention =
90
+ params.groupConfig?.requireMention ?? params.globalConfig?.requireMention ?? true;
91
+
92
+ return { requireMention };
93
+ }
package/src/probe.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { PopoConfig, PopoProbeResult } from "./types.js";
2
+ import { resolvePopoCredentials } from "./accounts.js";
3
+ import { getAccessToken } from "./auth.js";
4
+
5
+ export async function probePopo(cfg?: PopoConfig): Promise<PopoProbeResult> {
6
+ const creds = resolvePopoCredentials(cfg);
7
+ if (!creds) {
8
+ return {
9
+ ok: false,
10
+ error: "missing credentials (appKey, appSecret)",
11
+ };
12
+ }
13
+
14
+ try {
15
+ // Try to get an access token to verify credentials
16
+ await getAccessToken(cfg!);
17
+
18
+ return {
19
+ ok: true,
20
+ appKey: creds.appKey,
21
+ };
22
+ } catch (err) {
23
+ return {
24
+ ok: false,
25
+ appKey: creds.appKey,
26
+ error: err instanceof Error ? err.message : String(err),
27
+ };
28
+ }
29
+ }
@@ -0,0 +1,141 @@
1
+ import {
2
+ createReplyPrefixContext,
3
+ createTypingCallbacks,
4
+ logTypingFailure,
5
+ type ClawdbotConfig,
6
+ type RuntimeEnv,
7
+ type ReplyPayload,
8
+ } from "openclaw/plugin-sdk";
9
+ import { getPopoRuntime } from "./runtime.js";
10
+ import { sendMessagePopo, sendRichTextPopo, textToRichTextContent } from "./send.js";
11
+ import type { PopoConfig } from "./types.js";
12
+
13
+ export type CreatePopoReplyDispatcherParams = {
14
+ cfg: ClawdbotConfig;
15
+ agentId: string;
16
+ runtime: RuntimeEnv;
17
+ sessionId: string;
18
+ };
19
+
20
+ export function createPopoReplyDispatcher(params: CreatePopoReplyDispatcherParams) {
21
+ const core = getPopoRuntime();
22
+ const { cfg, agentId, sessionId } = params;
23
+
24
+ const prefixContext = createReplyPrefixContext({
25
+ cfg,
26
+ agentId,
27
+ });
28
+
29
+ // Track whether any tool results have been sent
30
+ let hasToolResults = false;
31
+ let toolResultCount = 0;
32
+
33
+ // POPO doesn't have a native typing indicator API
34
+ // We could potentially use emoji reactions but skip for now
35
+ const typingCallbacks = createTypingCallbacks({
36
+ start: async () => {
37
+ // No-op for POPO
38
+ },
39
+ stop: async () => {
40
+ // No-op for POPO
41
+ },
42
+ onStartError: (err) => {
43
+ logTypingFailure({
44
+ log: (message) => params.runtime.log?.(message),
45
+ channel: "popo",
46
+ action: "start",
47
+ error: err,
48
+ });
49
+ },
50
+ onStopError: (err) => {
51
+ logTypingFailure({
52
+ log: (message) => params.runtime.log?.(message),
53
+ channel: "popo",
54
+ action: "stop",
55
+ error: err,
56
+ });
57
+ },
58
+ });
59
+
60
+ const textChunkLimit = core.channel.text.resolveTextChunkLimit({
61
+ cfg,
62
+ channel: "popo",
63
+ defaultLimit: 4000,
64
+ });
65
+ const chunkMode = core.channel.text.resolveChunkMode(cfg, "popo");
66
+
67
+ const { dispatcher, replyOptions, markDispatchIdle } =
68
+ core.channel.reply.createReplyDispatcherWithTyping({
69
+ responsePrefix: prefixContext.responsePrefix,
70
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
71
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
72
+ onReplyStart: typingCallbacks.onReplyStart,
73
+ deliver: async (payload: ReplyPayload) => {
74
+ params.runtime.log?.(`popo deliver called: text=${payload.text?.slice(0, 100)}`);
75
+ const text = payload.text ?? "";
76
+
77
+ // Check render mode: raw (default) or rich_text
78
+ const popoCfg = cfg.channels?.popo as PopoConfig | undefined;
79
+ const renderMode = popoCfg?.renderMode ?? "raw";
80
+
81
+ // Build tool execution indicator if tools were used
82
+ let fullText = text;
83
+ if (hasToolResults) {
84
+ const toolIndicator = toolResultCount === 1
85
+ ? "🛠️ **使用了 1 个工具**\n\n"
86
+ : `🛠️ **使用了 ${toolResultCount} 个工具**\n\n`;
87
+ fullText = toolIndicator + text;
88
+ // Reset after including in message
89
+ hasToolResults = false;
90
+ toolResultCount = 0;
91
+ }
92
+
93
+ if (!fullText.trim()) {
94
+ params.runtime.log?.(`popo deliver: empty text, skipping`);
95
+ return;
96
+ }
97
+
98
+ const chunks = core.channel.text.chunkTextWithMode(fullText, textChunkLimit, chunkMode);
99
+ params.runtime.log?.(`popo deliver: sending ${chunks.length} chunks to ${sessionId}`);
100
+
101
+ for (const chunk of chunks) {
102
+ if (renderMode === "rich_text") {
103
+ // Rich text mode
104
+ const content = textToRichTextContent(chunk);
105
+ await sendRichTextPopo({
106
+ cfg,
107
+ to: sessionId,
108
+ content,
109
+ });
110
+ } else {
111
+ // Raw text mode (default)
112
+ await sendMessagePopo({
113
+ cfg,
114
+ to: sessionId,
115
+ text: chunk,
116
+ });
117
+ }
118
+ }
119
+ },
120
+ onError: (err, info) => {
121
+ params.runtime.error?.(`popo ${info.kind} reply failed: ${String(err)}`);
122
+ typingCallbacks.onIdle?.();
123
+ },
124
+ onIdle: typingCallbacks.onIdle,
125
+ });
126
+
127
+ return {
128
+ dispatcher,
129
+ replyOptions: {
130
+ ...replyOptions,
131
+ onModelSelected: prefixContext.onModelSelected,
132
+ // Track tool results as they are executed
133
+ onToolResult: (_payload: { text?: string; mediaUrls?: string[] }) => {
134
+ hasToolResults = true;
135
+ toolResultCount++;
136
+ params.runtime.log?.(`popo: tracked tool result #${toolResultCount}`);
137
+ },
138
+ },
139
+ markDispatchIdle,
140
+ };
141
+ }
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 setPopoRuntime(next: PluginRuntime) {
6
+ runtime = next;
7
+ }
8
+
9
+ export function getPopoRuntime(): PluginRuntime {
10
+ if (!runtime) {
11
+ throw new Error("POPO runtime not initialized");
12
+ }
13
+ return runtime;
14
+ }