@shenhh/popo-oa 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.
@@ -0,0 +1,72 @@
1
+ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk";
2
+ import { getPopoOaRuntime } from "./runtime.js";
3
+ import { sendTextPopoOa, sendNewsPopoOa, sendImagePopoOa, sendListPopoOa, sendButtonPopoOa } from "./send.js";
4
+ import { resolvePopoOaAccount } from "./accounts.js";
5
+
6
+ export const popoOaOutbound: ChannelOutboundAdapter = {
7
+ deliveryMode: "direct",
8
+ chunker: (text, limit) => getPopoOaRuntime()?.channel.text.chunkMarkdownText(text, limit) ?? [text],
9
+ chunkerMode: "markdown",
10
+ textChunkLimit: 2000,
11
+ sendText: async ({ cfg, to, text }) => {
12
+ const account = resolvePopoOaAccount({ cfg });
13
+ const result = await sendTextPopoOa({ cfg: account, to, text });
14
+ return { channel: "popo-oa", ...result };
15
+ },
16
+ sendMedia: async ({ cfg, to, text, mediaUrl }) => {
17
+ const account = resolvePopoOaAccount({ cfg });
18
+
19
+ // Send text first if provided
20
+ if (text?.trim()) {
21
+ await sendTextPopoOa({ cfg: account, to, text });
22
+ }
23
+
24
+ // POPO OA doesn't support direct media upload in the same way
25
+ // Send as link if URL provided
26
+ if (mediaUrl) {
27
+ const fallbackText = `📎 ${mediaUrl}`;
28
+ const result = await sendTextPopoOa({ cfg: account, to, text: fallbackText });
29
+ return { channel: "popo-oa", ...result };
30
+ }
31
+
32
+ // No media URL, just return text result
33
+ const result = await sendTextPopoOa({ cfg: account, to, text: text ?? "" });
34
+ return { channel: "popo-oa", ...result };
35
+ },
36
+ sendCard: async ({ cfg, to, card }) => {
37
+ const account = resolvePopoOaAccount({ cfg });
38
+
39
+ // POPO OA supports news (article) messages which are similar to cards
40
+ const { title, description, url, picurl } = card as {
41
+ title?: string;
42
+ description?: string;
43
+ url?: string;
44
+ picurl?: string;
45
+ };
46
+
47
+ if (title && url) {
48
+ const result = await sendNewsPopoOa({
49
+ cfg: account,
50
+ to,
51
+ articles: [{
52
+ title,
53
+ description: description ?? "",
54
+ url,
55
+ picurl,
56
+ }],
57
+ });
58
+ return { channel: "popo-oa", ...result };
59
+ }
60
+
61
+ // Fallback to text
62
+ const result = await sendTextPopoOa({
63
+ cfg: account,
64
+ to,
65
+ text: `${title ?? "Card"}\n${description ?? ""}\n${url ?? ""}`,
66
+ });
67
+ return { channel: "popo-oa", ...result };
68
+ },
69
+ // POPO OA doesn't support streaming cards in the same way as Native API
70
+ createStreamCard: undefined,
71
+ updateStreamCard: undefined,
72
+ };
package/src/policy.ts ADDED
@@ -0,0 +1,60 @@
1
+ import type { PopoOaConfig } from "./config-schema.js";
2
+
3
+ /**
4
+ * Check if a user is allowed to interact with the bot.
5
+ */
6
+ export function isUserAllowed({
7
+ cfg,
8
+ openid,
9
+ approvedUsers,
10
+ }: {
11
+ cfg: PopoOaConfig;
12
+ openid: string;
13
+ approvedUsers: Set<string>;
14
+ }): boolean {
15
+ const policy = cfg.dmPolicy ?? "pairing";
16
+
17
+ switch (policy) {
18
+ case "open":
19
+ return true;
20
+ case "pairing":
21
+ return approvedUsers.has(openid);
22
+ case "allowlist": {
23
+ const allowList = cfg.allowFrom ?? [];
24
+ return allowList.includes(openid);
25
+ }
26
+ default:
27
+ return false;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Get system prompt for a specific user DM.
33
+ */
34
+ export function getDmSystemPrompt({
35
+ cfg,
36
+ openid,
37
+ }: {
38
+ cfg: PopoOaConfig;
39
+ openid: string;
40
+ }): string | undefined {
41
+ const dmConfig = cfg.dms?.[openid];
42
+ return dmConfig?.systemPrompt ?? cfg.systemPrompt;
43
+ }
44
+
45
+ /**
46
+ * Check if DM is enabled for a specific user.
47
+ */
48
+ export function isDmEnabled({
49
+ cfg,
50
+ openid,
51
+ }: {
52
+ cfg: PopoOaConfig;
53
+ openid: string;
54
+ }): boolean {
55
+ const dmConfig = cfg.dms?.[openid];
56
+ if (dmConfig?.enabled !== undefined) {
57
+ return dmConfig.enabled;
58
+ }
59
+ return cfg.enabled !== false;
60
+ }
package/src/probe.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { PopoOaConfig } from "./types.js";
2
+ import { getAccessToken, clearAccessToken } from "./auth.js";
3
+
4
+ export interface ProbeResult {
5
+ ok: boolean;
6
+ appId?: string;
7
+ error?: string;
8
+ }
9
+
10
+ /**
11
+ * Probe POPO OA configuration by attempting to get an access token.
12
+ */
13
+ export async function probePopoOa(cfg?: PopoOaConfig): Promise<ProbeResult> {
14
+ if (!cfg) {
15
+ return { ok: false, error: "Configuration not found" };
16
+ }
17
+
18
+ const appId = cfg.appId?.trim();
19
+ const appSecret = cfg.appSecret?.trim();
20
+
21
+ if (!appId || !appSecret) {
22
+ return { ok: false, error: "Missing appId or appSecret" };
23
+ }
24
+
25
+ try {
26
+ // Clear any cached token to force a fresh request
27
+ clearAccessToken(appId);
28
+
29
+ // Try to get an access token
30
+ await getAccessToken({
31
+ appId,
32
+ appSecret,
33
+ server: cfg.server ?? "https://open.popo.netease.com",
34
+ });
35
+
36
+ return { ok: true, appId };
37
+ } catch (err) {
38
+ return {
39
+ ok: false,
40
+ appId,
41
+ error: err instanceof Error ? err.message : String(err),
42
+ };
43
+ }
44
+ }
@@ -0,0 +1,130 @@
1
+ import {
2
+ createReplyPrefixContext,
3
+ createTypingCallbacks,
4
+ logTypingFailure,
5
+ type ClawdbotConfig,
6
+ type RuntimeEnv,
7
+ type ReplyPayload,
8
+ } from "openclaw/plugin-sdk";
9
+ import { getPopoOaRuntime } from "./runtime.js";
10
+ import { sendTextPopoOa } from "./send.js";
11
+ import type { PopoOaConfig } from "./types.js";
12
+ import { resolvePopoOaAccount } from "./accounts.js";
13
+
14
+ export type CreatePopoOaReplyDispatcherParams = {
15
+ cfg: ClawdbotConfig;
16
+ agentId: string;
17
+ runtime: RuntimeEnv;
18
+ openid: string;
19
+ };
20
+
21
+ export function createPopoOaReplyDispatcher(params: CreatePopoOaReplyDispatcherParams) {
22
+ const core = getPopoOaRuntime();
23
+ const { cfg, agentId, openid } = params;
24
+
25
+ const prefixContext = createReplyPrefixContext({
26
+ cfg,
27
+ agentId,
28
+ });
29
+
30
+ // Track whether any tool results have been sent
31
+ let hasToolResults = false;
32
+ let toolResultCount = 0;
33
+
34
+ // POPO OA doesn't have a native typing indicator API
35
+ const typingCallbacks = createTypingCallbacks({
36
+ start: async () => {
37
+ // No-op for POPO OA
38
+ },
39
+ stop: async () => {
40
+ // No-op for POPO OA
41
+ },
42
+ onStartError: (err) => {
43
+ logTypingFailure({
44
+ log: (message) => params.runtime.log?.(message),
45
+ channel: "popo-oa",
46
+ action: "start",
47
+ error: err,
48
+ });
49
+ },
50
+ onStopError: (err) => {
51
+ logTypingFailure({
52
+ log: (message) => params.runtime.log?.(message),
53
+ channel: "popo-oa",
54
+ action: "stop",
55
+ error: err,
56
+ });
57
+ },
58
+ });
59
+
60
+ const textChunkLimit = core.channel.text.resolveTextChunkLimit({
61
+ cfg,
62
+ channel: "popo-oa",
63
+ defaultLimit: 2000,
64
+ });
65
+ const chunkMode = core.channel.text.resolveChunkMode(cfg, "popo-oa");
66
+
67
+ const popoCfg = cfg.channels?.["popo-oa"] as PopoOaConfig | undefined;
68
+
69
+ const { dispatcher, replyOptions, markDispatchIdle } =
70
+ core.channel.reply.createReplyDispatcherWithTyping({
71
+ responsePrefix: prefixContext.responsePrefix,
72
+ responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
73
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
74
+ onReplyStart: typingCallbacks.onReplyStart,
75
+ deliver: async (payload: ReplyPayload) => {
76
+ params.runtime.log?.(`popo-oa deliver called: text=${payload.text?.slice(0, 100)}`);
77
+ const text = payload.text ?? "";
78
+
79
+ // Build tool execution indicator if tools were used
80
+ let fullText = text;
81
+ if (hasToolResults) {
82
+ const toolIndicator = toolResultCount === 1
83
+ ? "🛠️ **使用了 1 个工具**\n\n"
84
+ : `🛠️ **使用了 ${toolResultCount} 个工具**\n\n`;
85
+ fullText = toolIndicator + text;
86
+ // Reset after including in message
87
+ hasToolResults = false;
88
+ toolResultCount = 0;
89
+ }
90
+
91
+ if (!fullText.trim()) {
92
+ params.runtime.log?.(`popo-oa deliver: empty text, skipping`);
93
+ return;
94
+ }
95
+
96
+ const chunks = core.channel.text.chunkTextWithMode(fullText, textChunkLimit, chunkMode);
97
+ params.runtime.log?.(`popo-oa deliver: sending ${chunks.length} chunks to ${openid}`);
98
+
99
+ const account = resolvePopoOaAccount({ cfg });
100
+
101
+ for (const chunk of chunks) {
102
+ await sendTextPopoOa({
103
+ cfg: account,
104
+ to: openid,
105
+ text: chunk,
106
+ });
107
+ }
108
+ },
109
+ onError: (err, info) => {
110
+ params.runtime.error?.(`popo-oa ${info.kind} reply failed: ${String(err)}`);
111
+ typingCallbacks.onIdle?.();
112
+ },
113
+ onIdle: typingCallbacks.onIdle,
114
+ });
115
+
116
+ return {
117
+ dispatcher,
118
+ replyOptions: {
119
+ ...replyOptions,
120
+ onModelSelected: prefixContext.onModelSelected,
121
+ // Track tool results as they are executed
122
+ onToolResult: (_payload: { text?: string; mediaUrls?: string[] }) => {
123
+ hasToolResults = true;
124
+ toolResultCount++;
125
+ params.runtime.log?.(`popo-oa: tracked tool result #${toolResultCount}`);
126
+ },
127
+ },
128
+ markDispatchIdle,
129
+ };
130
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,27 @@
1
+ import type { RuntimeApi } from "openclaw/plugin-sdk";
2
+
3
+ let runtime: RuntimeApi | undefined;
4
+
5
+ /**
6
+ * Set the runtime API for POPO OA plugin.
7
+ */
8
+ export function setPopoOaRuntime(api: RuntimeApi): void {
9
+ runtime = api;
10
+ }
11
+
12
+ /**
13
+ * Get the runtime API.
14
+ */
15
+ export function getPopoOaRuntime(): RuntimeApi | undefined {
16
+ return runtime;
17
+ }
18
+
19
+ /**
20
+ * Require runtime API (throws if not set).
21
+ */
22
+ export function requirePopoOaRuntime(): RuntimeApi {
23
+ if (!runtime) {
24
+ throw new Error("POPO OA runtime not initialized");
25
+ }
26
+ return runtime;
27
+ }
package/src/send.ts ADDED
@@ -0,0 +1,129 @@
1
+ import type { ResolvedPopoOaAccount, PopoOaSendMessage, SendMessageResponse } from "./types.js";
2
+ import { createPopoOaClient } from "./client.js";
3
+ import { resolvePopoOaAccount } from "./accounts.js";
4
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk";
5
+
6
+ /**
7
+ * Send a message to a user via POPO OA.
8
+ */
9
+ export async function sendMessagePopoOa({
10
+ cfg,
11
+ to,
12
+ message,
13
+ }: {
14
+ cfg: ResolvedPopoOaAccount;
15
+ to: string;
16
+ message: PopoOaSendMessage;
17
+ }): Promise<SendMessageResponse> {
18
+ const client = createPopoOaClient(cfg);
19
+ return client.sendMessage(message);
20
+ }
21
+
22
+ /**
23
+ * Send a text message to a user.
24
+ */
25
+ export async function sendTextPopoOa({
26
+ cfg,
27
+ to,
28
+ text,
29
+ }: {
30
+ cfg: ResolvedPopoOaAccount;
31
+ to: string;
32
+ text: string;
33
+ }): Promise<SendMessageResponse> {
34
+ const client = createPopoOaClient(cfg);
35
+ return client.sendText(to, text);
36
+ }
37
+
38
+ /**
39
+ * Send an image message to a user.
40
+ */
41
+ export async function sendImagePopoOa({
42
+ cfg,
43
+ to,
44
+ mediaId,
45
+ }: {
46
+ cfg: ResolvedPopoOaAccount;
47
+ to: string;
48
+ mediaId: string;
49
+ }): Promise<SendMessageResponse> {
50
+ const client = createPopoOaClient(cfg);
51
+ return client.sendImage(to, mediaId);
52
+ }
53
+
54
+ /**
55
+ * Send a news (article) message to a user.
56
+ */
57
+ export async function sendNewsPopoOa({
58
+ cfg,
59
+ to,
60
+ articles,
61
+ }: {
62
+ cfg: ResolvedPopoOaAccount;
63
+ to: string;
64
+ articles: Array<{
65
+ title: string;
66
+ description: string;
67
+ url: string;
68
+ picurl?: string;
69
+ }>;
70
+ }): Promise<SendMessageResponse> {
71
+ const client = createPopoOaClient(cfg);
72
+ return client.sendNews(to, articles);
73
+ }
74
+
75
+ /**
76
+ * Send a list/select message to a user.
77
+ */
78
+ export async function sendListPopoOa({
79
+ cfg,
80
+ to,
81
+ header,
82
+ items,
83
+ }: {
84
+ cfg: ResolvedPopoOaAccount;
85
+ to: string;
86
+ header: string;
87
+ items: Array<{
88
+ title: string;
89
+ description?: string;
90
+ url?: string;
91
+ }>;
92
+ }): Promise<SendMessageResponse> {
93
+ const client = createPopoOaClient(cfg);
94
+ return client.sendList(to, header, items);
95
+ }
96
+
97
+ /**
98
+ * Send a button message to a user.
99
+ */
100
+ export async function sendButtonPopoOa({
101
+ cfg,
102
+ to,
103
+ content,
104
+ buttons,
105
+ }: {
106
+ cfg: ResolvedPopoOaAccount;
107
+ to: string;
108
+ content: string;
109
+ buttons: Array<{ key: string; value: string }>;
110
+ }): Promise<SendMessageResponse> {
111
+ const client = createPopoOaClient(cfg);
112
+ return client.sendButton(to, content, buttons);
113
+ }
114
+
115
+ /**
116
+ * Send message using config (for internal use).
117
+ */
118
+ export async function sendPopoOaMessageFromConfig({
119
+ config,
120
+ to,
121
+ message,
122
+ }: {
123
+ config: ClawdbotConfig;
124
+ to: string;
125
+ message: PopoOaSendMessage;
126
+ }): Promise<SendMessageResponse> {
127
+ const account = resolvePopoOaAccount({ cfg: config });
128
+ return sendMessagePopoOa({ cfg: account, to, message });
129
+ }
package/src/targets.ts ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Normalize POPO OA target.
3
+ * POPO OA uses OpenID as user identifier.
4
+ */
5
+ export function normalizePopoOaTarget(target: string): string {
6
+ // Remove any prefix if present
7
+ return target.replace(/^(popo-oa|user):/i, "");
8
+ }
9
+
10
+ /**
11
+ * Check if a string looks like a POPO OA OpenID.
12
+ * OpenIDs are typically UUID-like strings.
13
+ */
14
+ export function looksLikePopoOaId(id: string): boolean {
15
+ // OpenID is typically a UUID or similar format
16
+ // Basic check: non-empty string without @ symbol (not an email)
17
+ return id.length > 0 && !id.includes("@");
18
+ }
19
+
20
+ /**
21
+ * Format target for POPO OA.
22
+ */
23
+ export function formatPopoOaTarget(openid: string): string {
24
+ return `popo-oa:${openid}`;
25
+ }
package/src/types.ts ADDED
@@ -0,0 +1,158 @@
1
+ import type { PopoOaConfig as ImportedPopoOaConfig } from "./config-schema.js";
2
+
3
+ // Re-export for use in other modules
4
+ export type PopoOaConfig = ImportedPopoOaConfig;
5
+
6
+ /**
7
+ * Resolved POPO OA account configuration
8
+ */
9
+ export interface ResolvedPopoOaAccount {
10
+ id: string; // appId
11
+ appId: string;
12
+ appSecret: string;
13
+ token: string;
14
+ server: string;
15
+ webhookPath: string;
16
+ cfg: PopoOaConfig;
17
+ }
18
+
19
+ /**
20
+ * Access token response from POPO OA API
21
+ */
22
+ export interface AccessTokenResponse {
23
+ accessToken: string;
24
+ expiresIn: number; // seconds
25
+ }
26
+
27
+ /**
28
+ * Cached access token with expiration
29
+ */
30
+ export interface CachedAccessToken {
31
+ token: string;
32
+ expiresAt: number; // timestamp in ms
33
+ }
34
+
35
+ /**
36
+ * XML message received from POPO OA webhook
37
+ */
38
+ export interface PopoOaXmlMessage {
39
+ ToUserName: string; // 服务号 ID
40
+ FromUserName: string; // 用户 OpenID
41
+ CreateTime: string;
42
+ MsgType: "text" | "image" | "voice" | "video" | "location" | "link" | "event";
43
+ MsgId?: string;
44
+ // Text message
45
+ Content?: string;
46
+ // Image message
47
+ PicUrl?: string;
48
+ MediaId?: string;
49
+ // Event
50
+ Event?:
51
+ | "subscribe"
52
+ | "unsubscribe"
53
+ | "CLICK"
54
+ | "VIEW"
55
+ | "BUTTON_CLICK"
56
+ | "TEMPLATE_CLICK";
57
+ EventKey?: string;
58
+ }
59
+
60
+ /**
61
+ * Text message to send
62
+ */
63
+ export interface PopoOaTextMessage {
64
+ openid: string;
65
+ type: "text";
66
+ body: {
67
+ content: string;
68
+ };
69
+ }
70
+
71
+ /**
72
+ * Image message to send
73
+ */
74
+ export interface PopoOaImageMessage {
75
+ openid: string;
76
+ type: "image";
77
+ body: {
78
+ mediaId: string;
79
+ };
80
+ }
81
+
82
+ /**
83
+ * News (article) message to send
84
+ */
85
+ export interface PopoOaNewsMessage {
86
+ openid: string;
87
+ type: "news";
88
+ body: {
89
+ articles: Array<{
90
+ title: string;
91
+ description: string;
92
+ url: string;
93
+ picurl?: string;
94
+ }>;
95
+ };
96
+ }
97
+
98
+ /**
99
+ * List/select message to send
100
+ */
101
+ export interface PopoOaListMessage {
102
+ openid: string;
103
+ type: "list";
104
+ body: {
105
+ header: string;
106
+ items: Array<{
107
+ title: string;
108
+ description?: string;
109
+ url?: string;
110
+ }>;
111
+ };
112
+ }
113
+
114
+ /**
115
+ * Button message to send
116
+ */
117
+ export interface PopoOaButtonMessage {
118
+ openid: string;
119
+ type: "button";
120
+ body: {
121
+ content: string;
122
+ buttons: Array<{
123
+ key: string;
124
+ value: string;
125
+ }>;
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Union type for all sendable messages
131
+ */
132
+ export type PopoOaSendMessage =
133
+ | PopoOaTextMessage
134
+ | PopoOaImageMessage
135
+ | PopoOaNewsMessage
136
+ | PopoOaListMessage
137
+ | PopoOaButtonMessage;
138
+
139
+ /**
140
+ * Send message response
141
+ */
142
+ export interface SendMessageResponse {
143
+ code: number;
144
+ message?: string;
145
+ data?: {
146
+ msgId?: string;
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Webhook verification params
152
+ */
153
+ export interface WebhookVerifyParams {
154
+ signature: string;
155
+ timestamp: string;
156
+ nonce: string;
157
+ echostr: string;
158
+ }