@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/index.ts +53 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +66 -0
- package/skills/popo-admin/SKILL.md +195 -0
- package/skills/popo-card/SKILL.md +571 -0
- package/skills/popo-group/SKILL.md +292 -0
- package/skills/popo-msg/SKILL.md +360 -0
- package/skills/popo-team/SKILL.md +296 -0
- package/src/accounts.ts +52 -0
- package/src/auth.ts +151 -0
- package/src/bot.ts +394 -0
- package/src/channel.ts +839 -0
- package/src/client.ts +118 -0
- package/src/config-schema.ts +79 -0
- package/src/crypto.ts +67 -0
- package/src/media.ts +623 -0
- package/src/monitor.ts +236 -0
- package/src/outbound.ts +133 -0
- package/src/policy.ts +93 -0
- package/src/probe.ts +29 -0
- package/src/reply-dispatcher.ts +141 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +430 -0
- package/src/targets.ts +68 -0
- package/src/team.ts +506 -0
- package/src/types.ts +48 -0
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
|
+
}
|
package/src/outbound.ts
ADDED
|
@@ -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
|
+
}
|