@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.
- package/index.ts +36 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +66 -0
- package/src/accounts.ts +53 -0
- package/src/auth.ts +104 -0
- package/src/bot.ts +285 -0
- package/src/channel.ts +209 -0
- package/src/client.ts +167 -0
- package/src/config-schema.ts +50 -0
- package/src/crypto.ts +44 -0
- package/src/monitor.ts +215 -0
- package/src/outbound.ts +72 -0
- package/src/policy.ts +60 -0
- package/src/probe.ts +44 -0
- package/src/reply-dispatcher.ts +130 -0
- package/src/runtime.ts +27 -0
- package/src/send.ts +129 -0
- package/src/targets.ts +25 -0
- package/src/types.ts +158 -0
package/src/channel.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { ChannelPlugin } from "openclaw/plugin-sdk";
|
|
2
|
+
import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk";
|
|
3
|
+
import type { ResolvedPopoOaAccount, PopoOaConfig } from "./types.js";
|
|
4
|
+
import { resolvePopoOaAccount, resolvePopoOaCredentials } from "./accounts.js";
|
|
5
|
+
import { popoOaOutbound } from "./outbound.js";
|
|
6
|
+
import { probePopoOa } from "./probe.js";
|
|
7
|
+
import { normalizePopoOaTarget, looksLikePopoOaId } from "./targets.js";
|
|
8
|
+
import { sendTextPopoOa } from "./send.js";
|
|
9
|
+
|
|
10
|
+
const meta = {
|
|
11
|
+
id: "popo-oa",
|
|
12
|
+
label: "POPO 服务号",
|
|
13
|
+
selectionLabel: "POPO 服务号 (网易)",
|
|
14
|
+
docsPath: "/channels/popo-oa",
|
|
15
|
+
docsLabel: "popo-oa",
|
|
16
|
+
blurb: "POPO Official Account messaging via Open API.",
|
|
17
|
+
aliases: [],
|
|
18
|
+
order: 82,
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export const popoOaPlugin: ChannelPlugin<ResolvedPopoOaAccount> = {
|
|
22
|
+
id: "popo-oa",
|
|
23
|
+
meta: {
|
|
24
|
+
...meta,
|
|
25
|
+
},
|
|
26
|
+
pairing: {
|
|
27
|
+
idLabel: "openid",
|
|
28
|
+
normalizeAllowEntry: (entry) => entry.replace(/^(popo-oa|user):/i, ""),
|
|
29
|
+
notifyApproval: async ({ cfg, id }) => {
|
|
30
|
+
const account = resolvePopoOaAccount({ cfg });
|
|
31
|
+
await sendTextPopoOa({
|
|
32
|
+
cfg: account,
|
|
33
|
+
to: id,
|
|
34
|
+
text: PAIRING_APPROVED_MESSAGE,
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
capabilities: {
|
|
39
|
+
chatTypes: ["direct"], // POPO OA only supports direct messages
|
|
40
|
+
polls: false,
|
|
41
|
+
threads: false,
|
|
42
|
+
media: false, // Limited media support compared to Native API
|
|
43
|
+
reactions: false,
|
|
44
|
+
edit: false,
|
|
45
|
+
reply: false,
|
|
46
|
+
},
|
|
47
|
+
agentPrompt: {
|
|
48
|
+
messageToolHints: () => [
|
|
49
|
+
"- POPO 服务号 targeting: omit `target` to reply to the current conversation (auto-inferred). Explicit targets: `user:OPENID`.",
|
|
50
|
+
"- POPO 服务号 uses OpenID (not email) as user identifier.",
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
reload: { configPrefixes: ["channels.popo-oa"] },
|
|
54
|
+
configSchema: {
|
|
55
|
+
schema: {
|
|
56
|
+
type: "object",
|
|
57
|
+
additionalProperties: false,
|
|
58
|
+
properties: {
|
|
59
|
+
enabled: { type: "boolean" },
|
|
60
|
+
systemPrompt: { type: "string" },
|
|
61
|
+
appId: { type: "string" },
|
|
62
|
+
appSecret: { type: "string" },
|
|
63
|
+
token: { type: "string" },
|
|
64
|
+
server: { type: "string" },
|
|
65
|
+
webhookPath: { type: "string" },
|
|
66
|
+
dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
|
|
67
|
+
allowFrom: { type: "array", items: { type: "string" } },
|
|
68
|
+
historyLimit: { type: "integer", minimum: 0 },
|
|
69
|
+
dmHistoryLimit: { type: "integer", minimum: 0 },
|
|
70
|
+
textChunkLimit: { type: "integer", minimum: 1 },
|
|
71
|
+
chunkMode: { type: "string", enum: ["length", "newline"] },
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
config: {
|
|
76
|
+
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
|
77
|
+
resolveAccount: (cfg) => resolvePopoOaAccount({ cfg }),
|
|
78
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
79
|
+
setAccountEnabled: ({ cfg, enabled }) => ({
|
|
80
|
+
...cfg,
|
|
81
|
+
channels: {
|
|
82
|
+
...cfg.channels,
|
|
83
|
+
"popo-oa": {
|
|
84
|
+
...cfg.channels?.["popo-oa"],
|
|
85
|
+
enabled,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
deleteAccount: ({ cfg }) => {
|
|
90
|
+
const next = { ...cfg };
|
|
91
|
+
const nextChannels = { ...cfg.channels };
|
|
92
|
+
delete (nextChannels as Record<string, unknown>)["popo-oa"];
|
|
93
|
+
if (Object.keys(nextChannels).length > 0) {
|
|
94
|
+
next.channels = nextChannels;
|
|
95
|
+
} else {
|
|
96
|
+
delete next.channels;
|
|
97
|
+
}
|
|
98
|
+
return next;
|
|
99
|
+
},
|
|
100
|
+
isConfigured: (_account, cfg) =>
|
|
101
|
+
Boolean(resolvePopoOaCredentials(cfg.channels?.["popo-oa"] as PopoOaConfig | undefined)),
|
|
102
|
+
describeAccount: (account) => ({
|
|
103
|
+
accountId: account.id,
|
|
104
|
+
enabled: account.cfg.enabled !== false,
|
|
105
|
+
configured: Boolean(resolvePopoOaCredentials(account.cfg)),
|
|
106
|
+
}),
|
|
107
|
+
resolveAllowFrom: ({ cfg }) =>
|
|
108
|
+
(cfg.channels?.["popo-oa"] as PopoOaConfig | undefined)?.allowFrom ?? [],
|
|
109
|
+
formatAllowFrom: ({ allowFrom }) =>
|
|
110
|
+
allowFrom
|
|
111
|
+
.map((entry) => String(entry).trim())
|
|
112
|
+
.filter(Boolean),
|
|
113
|
+
},
|
|
114
|
+
security: {
|
|
115
|
+
collectWarnings: ({ cfg }) => {
|
|
116
|
+
const popoCfg = cfg.channels?.["popo-oa"] as PopoOaConfig | undefined;
|
|
117
|
+
const dmPolicy = popoCfg?.dmPolicy ?? "pairing";
|
|
118
|
+
if (dmPolicy !== "open") return [];
|
|
119
|
+
return [
|
|
120
|
+
`- POPO 服务号: dmPolicy="open" allows any user to interact. Set channels.popo-oa.dmPolicy="allowlist" + channels.popo-oa.allowFrom to restrict users.`,
|
|
121
|
+
];
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
setup: {
|
|
125
|
+
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
126
|
+
applyAccountConfig: ({ cfg }) => ({
|
|
127
|
+
...cfg,
|
|
128
|
+
channels: {
|
|
129
|
+
...cfg.channels,
|
|
130
|
+
"popo-oa": {
|
|
131
|
+
...cfg.channels?.["popo-oa"],
|
|
132
|
+
enabled: true,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
}),
|
|
136
|
+
},
|
|
137
|
+
messaging: {
|
|
138
|
+
normalizeTarget: normalizePopoOaTarget,
|
|
139
|
+
targetResolver: {
|
|
140
|
+
looksLikeId: looksLikePopoOaId,
|
|
141
|
+
hint: "<openid|user:OPENID>",
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
outbound: popoOaOutbound,
|
|
145
|
+
actions: {
|
|
146
|
+
listActions: ({ cfg }) => {
|
|
147
|
+
const enabled = cfg.channels?.["popo-oa"]?.enabled !== false;
|
|
148
|
+
if (!enabled) return [];
|
|
149
|
+
return ["send"];
|
|
150
|
+
},
|
|
151
|
+
supportsCards: () => false, // POPO OA doesn't support cards like Native API
|
|
152
|
+
handleAction: async (ctx) => {
|
|
153
|
+
const { action, params, cfg } = ctx;
|
|
154
|
+
|
|
155
|
+
if (action === "send") {
|
|
156
|
+
const to =
|
|
157
|
+
typeof params.to === "string"
|
|
158
|
+
? params.to.trim()
|
|
159
|
+
: typeof params.target === "string"
|
|
160
|
+
? params.target.trim()
|
|
161
|
+
: "";
|
|
162
|
+
const text =
|
|
163
|
+
typeof params.text === "string"
|
|
164
|
+
? params.text
|
|
165
|
+
: typeof params.message === "string"
|
|
166
|
+
? params.message
|
|
167
|
+
: "";
|
|
168
|
+
|
|
169
|
+
if (!to) {
|
|
170
|
+
return {
|
|
171
|
+
isError: true,
|
|
172
|
+
content: [{ type: "text", text: "Send requires a target (to)." }],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const account = resolvePopoOaAccount({ cfg });
|
|
177
|
+
const result = await sendTextPopoOa({ cfg: account, to, text });
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
content: [
|
|
181
|
+
{
|
|
182
|
+
type: "text",
|
|
183
|
+
text: JSON.stringify({
|
|
184
|
+
ok: result.code === 0,
|
|
185
|
+
channel: "popo-oa",
|
|
186
|
+
messageId: result.data?.msgId,
|
|
187
|
+
}),
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
isError: true,
|
|
195
|
+
content: [{ type: "text", text: `Unknown action: ${action}` }],
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
health: {
|
|
200
|
+
check: async ({ cfg }) => {
|
|
201
|
+
const popoCfg = cfg.channels?.["popo-oa"] as PopoOaConfig | undefined;
|
|
202
|
+
const result = await probePopoOa(popoCfg);
|
|
203
|
+
return {
|
|
204
|
+
ok: result.ok,
|
|
205
|
+
message: result.error,
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
};
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { getAccessToken, clearAccessToken } from "./auth.js";
|
|
2
|
+
import type {
|
|
3
|
+
ResolvedPopoOaAccount,
|
|
4
|
+
PopoOaSendMessage,
|
|
5
|
+
SendMessageResponse,
|
|
6
|
+
} from "./types.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* HTTP client for POPO OA API.
|
|
10
|
+
*/
|
|
11
|
+
export class PopoOaClient {
|
|
12
|
+
private account: ResolvedPopoOaAccount;
|
|
13
|
+
|
|
14
|
+
constructor(account: ResolvedPopoOaAccount) {
|
|
15
|
+
this.account = account;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Make an authenticated API request.
|
|
20
|
+
*/
|
|
21
|
+
async request<T>({
|
|
22
|
+
path,
|
|
23
|
+
method = "GET",
|
|
24
|
+
body,
|
|
25
|
+
query,
|
|
26
|
+
}: {
|
|
27
|
+
path: string;
|
|
28
|
+
method?: "GET" | "POST" | "PUT" | "DELETE";
|
|
29
|
+
body?: unknown;
|
|
30
|
+
query?: Record<string, string>;
|
|
31
|
+
}): Promise<T> {
|
|
32
|
+
const accessToken = await getAccessToken({
|
|
33
|
+
appId: this.account.appId,
|
|
34
|
+
appSecret: this.account.appSecret,
|
|
35
|
+
server: this.account.server,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const url = new URL(path, this.account.server);
|
|
39
|
+
url.searchParams.set("access_token", accessToken);
|
|
40
|
+
|
|
41
|
+
if (query) {
|
|
42
|
+
for (const [key, value] of Object.entries(query)) {
|
|
43
|
+
url.searchParams.set(key, value);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const response = await fetch(url.toString(), {
|
|
48
|
+
method,
|
|
49
|
+
headers: {
|
|
50
|
+
"Content-Type": "application/json",
|
|
51
|
+
},
|
|
52
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Handle token expiration
|
|
56
|
+
if (response.status === 401) {
|
|
57
|
+
clearAccessToken(this.account.appId);
|
|
58
|
+
// Retry once with new token
|
|
59
|
+
return this.request({ path, method, body, query });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
const text = await response.text();
|
|
64
|
+
throw new Error(
|
|
65
|
+
`POPO OA API error: ${response.status} ${response.statusText} - ${text}`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const data = await response.json();
|
|
70
|
+
return data as T;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Send a message to a user.
|
|
75
|
+
*/
|
|
76
|
+
async sendMessage(message: PopoOaSendMessage): Promise<SendMessageResponse> {
|
|
77
|
+
return this.request<SendMessageResponse>({
|
|
78
|
+
path: "/open/api/v1/message/send",
|
|
79
|
+
method: "POST",
|
|
80
|
+
body: message,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Send a text message.
|
|
86
|
+
*/
|
|
87
|
+
async sendText(openid: string, content: string): Promise<SendMessageResponse> {
|
|
88
|
+
return this.sendMessage({
|
|
89
|
+
openid,
|
|
90
|
+
type: "text",
|
|
91
|
+
body: { content },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Send an image message.
|
|
97
|
+
*/
|
|
98
|
+
async sendImage(openid: string, mediaId: string): Promise<SendMessageResponse> {
|
|
99
|
+
return this.sendMessage({
|
|
100
|
+
openid,
|
|
101
|
+
type: "image",
|
|
102
|
+
body: { mediaId },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Send a news (article) message.
|
|
108
|
+
*/
|
|
109
|
+
async sendNews(
|
|
110
|
+
openid: string,
|
|
111
|
+
articles: Array<{
|
|
112
|
+
title: string;
|
|
113
|
+
description: string;
|
|
114
|
+
url: string;
|
|
115
|
+
picurl?: string;
|
|
116
|
+
}>
|
|
117
|
+
): Promise<SendMessageResponse> {
|
|
118
|
+
return this.sendMessage({
|
|
119
|
+
openid,
|
|
120
|
+
type: "news",
|
|
121
|
+
body: { articles },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Send a list/select message.
|
|
127
|
+
*/
|
|
128
|
+
async sendList(
|
|
129
|
+
openid: string,
|
|
130
|
+
header: string,
|
|
131
|
+
items: Array<{
|
|
132
|
+
title: string;
|
|
133
|
+
description?: string;
|
|
134
|
+
url?: string;
|
|
135
|
+
}>
|
|
136
|
+
): Promise<SendMessageResponse> {
|
|
137
|
+
return this.sendMessage({
|
|
138
|
+
openid,
|
|
139
|
+
type: "list",
|
|
140
|
+
body: { header, items },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Send a button message.
|
|
146
|
+
*/
|
|
147
|
+
async sendButton(
|
|
148
|
+
openid: string,
|
|
149
|
+
content: string,
|
|
150
|
+
buttons: Array<{ key: string; value: string }>
|
|
151
|
+
): Promise<SendMessageResponse> {
|
|
152
|
+
return this.sendMessage({
|
|
153
|
+
openid,
|
|
154
|
+
type: "button",
|
|
155
|
+
body: { content, buttons },
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Create a client for an account.
|
|
162
|
+
*/
|
|
163
|
+
export function createPopoOaClient(
|
|
164
|
+
account: ResolvedPopoOaAccount
|
|
165
|
+
): PopoOaClient {
|
|
166
|
+
return new PopoOaClient(account);
|
|
167
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export { z };
|
|
3
|
+
|
|
4
|
+
const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
|
|
5
|
+
|
|
6
|
+
const DmConfigSchema = z
|
|
7
|
+
.object({
|
|
8
|
+
enabled: z.boolean().optional(),
|
|
9
|
+
systemPrompt: z.string().optional(),
|
|
10
|
+
})
|
|
11
|
+
.strict()
|
|
12
|
+
.optional();
|
|
13
|
+
|
|
14
|
+
export const PopoOaConfigSchema = z
|
|
15
|
+
.object({
|
|
16
|
+
enabled: z.boolean().optional(),
|
|
17
|
+
systemPrompt: z.string().optional(),
|
|
18
|
+
appId: z.string().optional(), // 服务号 AppID
|
|
19
|
+
appSecret: z.string().optional(), // 服务号 AppSecret
|
|
20
|
+
token: z.string().optional(), // 用于签名验证的 Token
|
|
21
|
+
server: z
|
|
22
|
+
.string()
|
|
23
|
+
.optional()
|
|
24
|
+
.default("https://open.popo.netease.com"),
|
|
25
|
+
webhookPath: z.string().optional().default("/popo-oa/events"),
|
|
26
|
+
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
|
27
|
+
allowFrom: z.array(z.string()).optional(), // OpenID 白名单
|
|
28
|
+
historyLimit: z.number().int().min(0).optional(),
|
|
29
|
+
dmHistoryLimit: z.number().int().min(0).optional(),
|
|
30
|
+
dms: z.record(z.string(), DmConfigSchema).optional(),
|
|
31
|
+
textChunkLimit: z.number().int().positive().optional(),
|
|
32
|
+
chunkMode: z.enum(["length", "newline"]).optional(),
|
|
33
|
+
})
|
|
34
|
+
.strict()
|
|
35
|
+
.superRefine((value, ctx) => {
|
|
36
|
+
if (value.dmPolicy === "open") {
|
|
37
|
+
const allowFrom = value.allowFrom ?? [];
|
|
38
|
+
const hasWildcard = allowFrom.some((entry) => entry.trim() === "*");
|
|
39
|
+
if (!hasWildcard) {
|
|
40
|
+
ctx.addIssue({
|
|
41
|
+
code: z.ZodIssueCode.custom,
|
|
42
|
+
path: ["allowFrom"],
|
|
43
|
+
message:
|
|
44
|
+
'channels.popo-oa.dmPolicy="open" requires channels.popo-oa.allowFrom to include "*"',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export type PopoOaConfig = z.infer<typeof PopoOaConfigSchema>;
|
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Verify POPO OA webhook signature using SHA1.
|
|
5
|
+
* Signature = SHA1(token + timestamp + nonce) with sorted params
|
|
6
|
+
*/
|
|
7
|
+
export function verifySignature(
|
|
8
|
+
token: string,
|
|
9
|
+
timestamp: string,
|
|
10
|
+
nonce: string,
|
|
11
|
+
signature: string
|
|
12
|
+
): boolean {
|
|
13
|
+
// Sort token, timestamp, nonce alphabetically and join
|
|
14
|
+
const str = [token, timestamp, nonce].sort().join("");
|
|
15
|
+
const computed = crypto.createHash("sha1").update(str).digest("hex");
|
|
16
|
+
return computed.toLowerCase() === signature.toLowerCase();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generate signature for webhook verification.
|
|
21
|
+
* Used when constructing verification responses.
|
|
22
|
+
*/
|
|
23
|
+
export function generateSignature(
|
|
24
|
+
token: string,
|
|
25
|
+
timestamp: string,
|
|
26
|
+
nonce: string
|
|
27
|
+
): string {
|
|
28
|
+
const str = [token, timestamp, nonce].sort().join("");
|
|
29
|
+
return crypto.createHash("sha1").update(str).digest("hex");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Generate a random nonce for webhook verification.
|
|
34
|
+
*/
|
|
35
|
+
export function generateNonce(): string {
|
|
36
|
+
return crypto.randomBytes(16).toString("hex");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Get current timestamp in seconds.
|
|
41
|
+
*/
|
|
42
|
+
export function getTimestamp(): string {
|
|
43
|
+
return Math.floor(Date.now() / 1000).toString();
|
|
44
|
+
}
|
package/src/monitor.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
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 { PopoOaConfig } from "./types.js";
|
|
5
|
+
import { resolvePopoOaCredentials } from "./accounts.js";
|
|
6
|
+
import { verifySignature } from "./crypto.js";
|
|
7
|
+
import { handlePopoOaMessage, type PopoOaMessageEvent, buildPassiveReply } from "./bot.js";
|
|
8
|
+
import { probePopoOa } from "./probe.js";
|
|
9
|
+
|
|
10
|
+
export type MonitorPopoOaOpts = {
|
|
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 monitorPopoOaProvider(opts: MonitorPopoOaOpts = {}): Promise<void> {
|
|
28
|
+
const cfg = opts.config;
|
|
29
|
+
if (!cfg) {
|
|
30
|
+
throw new Error("Config is required for POPO OA monitor");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const popoCfg = cfg.channels?.["popo-oa"] as PopoOaConfig | undefined;
|
|
34
|
+
const creds = resolvePopoOaCredentials(popoCfg);
|
|
35
|
+
if (!creds) {
|
|
36
|
+
throw new Error("POPO OA credentials not configured (appId, appSecret, token 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 probePopoOa(popoCfg);
|
|
44
|
+
if (!probeResult.ok) {
|
|
45
|
+
throw new Error(`POPO OA probe failed: ${probeResult.error}`);
|
|
46
|
+
}
|
|
47
|
+
log(`popo-oa: credentials verified for appId ${probeResult.appId}`);
|
|
48
|
+
|
|
49
|
+
const webhookPath = popoCfg?.webhookPath?.trim() || "/popo-oa/events";
|
|
50
|
+
const chatHistories = new Map<string, HistoryEntry[]>();
|
|
51
|
+
|
|
52
|
+
// Track approved users (for pairing policy)
|
|
53
|
+
const approvedUsers = new Set<string>();
|
|
54
|
+
|
|
55
|
+
// Normalize path
|
|
56
|
+
const normalizedPath = normalizePluginHttpPath(webhookPath, "/popo-oa/events") ?? "/popo-oa/events";
|
|
57
|
+
|
|
58
|
+
// Register HTTP route to gateway
|
|
59
|
+
const unregisterHttp = registerPluginHttpRoute({
|
|
60
|
+
path: normalizedPath,
|
|
61
|
+
pluginId: "popo-oa",
|
|
62
|
+
accountId: opts.accountId,
|
|
63
|
+
log: (msg: string) => log(msg),
|
|
64
|
+
handler: async (req, res) => {
|
|
65
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
66
|
+
log(`popo-oa: received ${req.method} request to ${url.pathname}`);
|
|
67
|
+
|
|
68
|
+
// Handle CORS preflight
|
|
69
|
+
if (req.method === "OPTIONS") {
|
|
70
|
+
res.writeHead(200, {
|
|
71
|
+
"Access-Control-Allow-Origin": "*",
|
|
72
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
73
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
74
|
+
});
|
|
75
|
+
res.end();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Handle URL validation (GET request) - POPO OA webhook verification
|
|
80
|
+
if (req.method === "GET") {
|
|
81
|
+
const signature = url.searchParams.get("signature");
|
|
82
|
+
const timestamp = url.searchParams.get("timestamp");
|
|
83
|
+
const nonce = url.searchParams.get("nonce");
|
|
84
|
+
const echostr = url.searchParams.get("echostr");
|
|
85
|
+
|
|
86
|
+
log(`popo-oa: URL validation attempt - signature=${signature?.slice(0, 10)}..., timestamp=${timestamp}, nonce=${nonce?.slice(0, 10)}...`);
|
|
87
|
+
|
|
88
|
+
if (signature && timestamp && nonce && echostr) {
|
|
89
|
+
const valid = verifySignature(
|
|
90
|
+
creds.token,
|
|
91
|
+
timestamp,
|
|
92
|
+
nonce,
|
|
93
|
+
signature
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
if (valid) {
|
|
97
|
+
log(`popo-oa: URL validation successful`);
|
|
98
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
99
|
+
res.end(echostr);
|
|
100
|
+
return;
|
|
101
|
+
} else {
|
|
102
|
+
log(`popo-oa: signature verification failed`);
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
log(`popo-oa: missing required parameters for validation`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
res.writeHead(403);
|
|
109
|
+
res.end("Invalid validation request");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Handle webhook event (POST request)
|
|
114
|
+
if (req.method === "POST") {
|
|
115
|
+
try {
|
|
116
|
+
const body = await readRequestBody(req);
|
|
117
|
+
|
|
118
|
+
// Verify signature for POST requests too
|
|
119
|
+
const signature = url.searchParams.get("signature");
|
|
120
|
+
const timestamp = url.searchParams.get("timestamp");
|
|
121
|
+
const nonce = url.searchParams.get("nonce");
|
|
122
|
+
|
|
123
|
+
if (signature && timestamp && nonce) {
|
|
124
|
+
const valid = verifySignature(
|
|
125
|
+
creds.token,
|
|
126
|
+
timestamp,
|
|
127
|
+
nonce,
|
|
128
|
+
signature
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
if (!valid) {
|
|
132
|
+
log(`popo-oa: invalid signature in webhook event`);
|
|
133
|
+
res.writeHead(403);
|
|
134
|
+
res.end("Invalid signature");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
log(`popo-oa: received XML message body length=${body.length}`);
|
|
140
|
+
|
|
141
|
+
const event: PopoOaMessageEvent = {
|
|
142
|
+
xml: {
|
|
143
|
+
ToUserName: "",
|
|
144
|
+
FromUserName: "",
|
|
145
|
+
CreateTime: String(Date.now()),
|
|
146
|
+
MsgType: "text",
|
|
147
|
+
},
|
|
148
|
+
rawBody: body,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// Process message
|
|
152
|
+
const passiveReply = await handlePopoOaMessage({
|
|
153
|
+
cfg,
|
|
154
|
+
event,
|
|
155
|
+
runtime: opts.runtime,
|
|
156
|
+
approvedUsers,
|
|
157
|
+
chatHistories,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// If there's a passive reply, return it in the response
|
|
161
|
+
if (passiveReply) {
|
|
162
|
+
const xml = buildPassiveReply({
|
|
163
|
+
toUser: event.xml.FromUserName,
|
|
164
|
+
fromUser: event.xml.ToUserName,
|
|
165
|
+
content: passiveReply,
|
|
166
|
+
});
|
|
167
|
+
res.writeHead(200, { "Content-Type": "application/xml" });
|
|
168
|
+
res.end(xml);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Return success response
|
|
173
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
174
|
+
res.end("success");
|
|
175
|
+
} catch (err) {
|
|
176
|
+
error(`popo-oa: error processing webhook: ${String(err)}`);
|
|
177
|
+
res.writeHead(500);
|
|
178
|
+
res.end("Internal Server Error");
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
res.writeHead(405);
|
|
184
|
+
res.end("Method Not Allowed");
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
log(`popo-oa: registered webhook handler at ${normalizedPath}`);
|
|
189
|
+
|
|
190
|
+
// Handle abort signal
|
|
191
|
+
const stopHandler = () => {
|
|
192
|
+
log("popo-oa: stopping provider");
|
|
193
|
+
unregisterHttp();
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
if (opts.abortSignal?.aborted) {
|
|
197
|
+
stopHandler();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
opts.abortSignal?.addEventListener("abort", stopHandler, { once: true });
|
|
202
|
+
|
|
203
|
+
// Keep promise pending until abort
|
|
204
|
+
return new Promise((resolve) => {
|
|
205
|
+
const handler = () => {
|
|
206
|
+
stopHandler();
|
|
207
|
+
resolve();
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
if (opts.abortSignal) {
|
|
211
|
+
opts.abortSignal.removeEventListener("abort", stopHandler);
|
|
212
|
+
opts.abortSignal.addEventListener("abort", handler, { once: true });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|