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