@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/client.ts ADDED
@@ -0,0 +1,118 @@
1
+ import type { PopoConfig } from "./types.js";
2
+ import { resolvePopoCredentials } from "./accounts.js";
3
+ import { getAccessToken } from "./auth.js";
4
+
5
+ export type PopoApiResponse<T = unknown> = {
6
+ errcode: number;
7
+ errmsg: string;
8
+ data?: T;
9
+ };
10
+
11
+ /**
12
+ * Make an authenticated API request to POPO.
13
+ */
14
+ export async function popoRequest<T = unknown>(params: {
15
+ cfg: PopoConfig;
16
+ method: "GET" | "POST" | "PUT" | "DELETE";
17
+ path: string;
18
+ body?: unknown;
19
+ query?: Record<string, string>;
20
+ }): Promise<PopoApiResponse<T>> {
21
+ const { cfg, method, path, body, query } = params;
22
+ const creds = resolvePopoCredentials(cfg);
23
+ if (!creds) {
24
+ throw new Error("POPO credentials not configured");
25
+ }
26
+
27
+ const accessToken = await getAccessToken(cfg);
28
+ let url = `${creds.server}${path}`;
29
+
30
+ // Add query parameters if provided
31
+ if (query && Object.keys(query).length > 0) {
32
+ const queryString = new URLSearchParams(query).toString();
33
+ url = `${url}?${queryString}`;
34
+ }
35
+
36
+ const headers: Record<string, string> = {
37
+ "Open-Access-Token": accessToken,
38
+ "Content-Type": "application/json",
39
+ };
40
+
41
+ const response = await fetch(url, {
42
+ method,
43
+ headers,
44
+ body: body ? JSON.stringify(body) : undefined,
45
+ });
46
+
47
+ if (!response.ok) {
48
+ throw new Error(`POPO API request failed: ${response.status} ${response.statusText}`);
49
+ }
50
+
51
+ return (await response.json()) as PopoApiResponse<T>;
52
+ }
53
+
54
+ /**
55
+ * Make an authenticated multipart form request to POPO (for file uploads).
56
+ */
57
+ export async function popoUploadRequest<T = unknown>(params: {
58
+ cfg: PopoConfig;
59
+ path: string;
60
+ formData: FormData;
61
+ }): Promise<PopoApiResponse<T>> {
62
+ const { cfg, path, formData } = params;
63
+ const creds = resolvePopoCredentials(cfg);
64
+ if (!creds) {
65
+ throw new Error("POPO credentials not configured");
66
+ }
67
+
68
+ const accessToken = await getAccessToken(cfg);
69
+ const url = `${creds.server}${path}`;
70
+
71
+ const response = await fetch(url, {
72
+ method: "POST",
73
+ headers: {
74
+ "Open-Access-Token": accessToken,
75
+ // Don't set Content-Type for FormData - fetch will set it with boundary
76
+ },
77
+ body: formData,
78
+ });
79
+
80
+ if (!response.ok) {
81
+ throw new Error(`POPO upload request failed: ${response.status} ${response.statusText}`);
82
+ }
83
+
84
+ return (await response.json()) as PopoApiResponse<T>;
85
+ }
86
+
87
+ /**
88
+ * Download a file from POPO.
89
+ */
90
+ export async function popoDownloadRequest(params: {
91
+ cfg: PopoConfig;
92
+ path: string;
93
+ }): Promise<{ buffer: Buffer; contentType?: string }> {
94
+ const { cfg, path } = params;
95
+ const creds = resolvePopoCredentials(cfg);
96
+ if (!creds) {
97
+ throw new Error("POPO credentials not configured");
98
+ }
99
+
100
+ const accessToken = await getAccessToken(cfg);
101
+ const url = `${creds.server}${path}`;
102
+
103
+ const response = await fetch(url, {
104
+ method: "GET",
105
+ headers: {
106
+ "Open-Access-Token": accessToken,
107
+ },
108
+ });
109
+
110
+ if (!response.ok) {
111
+ throw new Error(`POPO download request failed: ${response.status} ${response.statusText}`);
112
+ }
113
+
114
+ const buffer = Buffer.from(await response.arrayBuffer());
115
+ const contentType = response.headers.get("content-type") || undefined;
116
+
117
+ return { buffer, contentType };
118
+ }
@@ -0,0 +1,79 @@
1
+ import { z } from "zod";
2
+ export { z };
3
+
4
+ const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
5
+ const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
6
+
7
+ const ToolPolicySchema = z
8
+ .object({
9
+ allow: z.array(z.string()).optional(),
10
+ deny: z.array(z.string()).optional(),
11
+ })
12
+ .strict()
13
+ .optional();
14
+
15
+ const DmConfigSchema = z
16
+ .object({
17
+ enabled: z.boolean().optional(),
18
+ systemPrompt: z.string().optional(),
19
+ })
20
+ .strict()
21
+ .optional();
22
+
23
+ // Message render mode: raw (default) = plain text, rich_text = POPO rich text format
24
+ const RenderModeSchema = z.enum(["raw", "rich_text"]).optional();
25
+
26
+ export const PopoGroupSchema = z
27
+ .object({
28
+ requireMention: z.boolean().optional(),
29
+ tools: ToolPolicySchema,
30
+ skills: z.array(z.string()).optional(),
31
+ enabled: z.boolean().optional(),
32
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
33
+ systemPrompt: z.string().optional(),
34
+ })
35
+ .strict();
36
+
37
+ export type PopoGroupConfig = z.infer<typeof PopoGroupSchema>;
38
+
39
+ export const PopoConfigSchema = z
40
+ .object({
41
+ enabled: z.boolean().optional(),
42
+ systemPrompt: z.string().optional(),
43
+ appKey: z.string().optional(),
44
+ appSecret: z.string().optional(),
45
+ token: z.string().optional(), // Token for signature verification
46
+ aesKey: z.string().optional(), // 32-char AES key for encryption
47
+ server: z
48
+ .string()
49
+ .optional()
50
+ .default("https://open.popo.netease.com"),
51
+ webhookPath: z.string().optional().default("/popo/events"),
52
+ dmPolicy: DmPolicySchema.optional().default("pairing"),
53
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
54
+ groupPolicy: GroupPolicySchema.optional().default("allowlist"),
55
+ groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
56
+ requireMention: z.boolean().optional().default(true),
57
+ groups: z.record(z.string(), PopoGroupSchema.optional()).optional(),
58
+ historyLimit: z.number().int().min(0).optional(),
59
+ dmHistoryLimit: z.number().int().min(0).optional(),
60
+ dms: z.record(z.string(), DmConfigSchema).optional(),
61
+ textChunkLimit: z.number().int().positive().optional(),
62
+ chunkMode: z.enum(["length", "newline"]).optional(),
63
+ mediaMaxMb: z.number().positive().optional().default(20), // 20MB max
64
+ renderMode: RenderModeSchema, // raw = plain text (default), rich_text = POPO rich text
65
+ })
66
+ .strict()
67
+ .superRefine((value, ctx) => {
68
+ if (value.dmPolicy === "open") {
69
+ const allowFrom = value.allowFrom ?? [];
70
+ const hasWildcard = allowFrom.some((entry) => String(entry).trim() === "*");
71
+ if (!hasWildcard) {
72
+ ctx.addIssue({
73
+ code: z.ZodIssueCode.custom,
74
+ path: ["allowFrom"],
75
+ message: 'channels.popo.dmPolicy="open" requires channels.popo.allowFrom to include "*"',
76
+ });
77
+ }
78
+ }
79
+ });
package/src/crypto.ts ADDED
@@ -0,0 +1,67 @@
1
+ import crypto from "crypto";
2
+
3
+ /**
4
+ * Verify POPO webhook signature.
5
+ * Signature = SHA256(token + nonce + timestamp)
6
+ */
7
+ export function verifySignature(params: {
8
+ token: string;
9
+ nonce: string;
10
+ timestamp: string;
11
+ signature: string;
12
+ }): boolean {
13
+ const { token, nonce, timestamp, signature } = params;
14
+ const data = [token, nonce, timestamp].sort().join("");
15
+ const computed = crypto.createHash("sha256").update(data).digest("hex");
16
+ return computed.toLowerCase() === signature.toLowerCase();
17
+ }
18
+
19
+ /**
20
+ * Decrypt POPO encrypted message using AES-CBC.
21
+ * Key derivation: aesKey is 32-char string, first 16 chars = key, last 16 chars = IV
22
+ */
23
+ export function decryptMessage(encrypt: string, aesKey: string): string {
24
+ if (aesKey.length !== 32) {
25
+ throw new Error("AES key must be 32 characters");
26
+ }
27
+
28
+ // First 16 chars as key, last 16 chars as IV (AES-128-CBC)
29
+ // This matches Python: self.key = aes_key[:16].encode('utf-8')
30
+ const key = Buffer.from(aesKey.substring(0, 16), "utf8");
31
+ const iv = Buffer.from(aesKey.substring(16, 32), "utf8");
32
+
33
+ // Decrypt the base64-encoded ciphertext
34
+ const ciphertext = Buffer.from(encrypt, "base64");
35
+ const decipher = crypto.createDecipheriv("aes-128-cbc", key, iv);
36
+ let decrypted = decipher.update(ciphertext);
37
+ decrypted = Buffer.concat([decrypted, decipher.final()]);
38
+
39
+ // Remove PKCS7 padding and parse as UTF-8
40
+ return decrypted.toString("utf8");
41
+ }
42
+
43
+ /**
44
+ * Encrypt POPO response message using AES-CBC.
45
+ * Used to encrypt the "success" response.
46
+ */
47
+ export function encryptMessage(plaintext: string, aesKey: string): string {
48
+ if (aesKey.length !== 32) {
49
+ throw new Error("AES key must be 32 characters");
50
+ }
51
+
52
+ const key = Buffer.from(aesKey.substring(0, 16), "utf8");
53
+ const iv = Buffer.from(aesKey.substring(16, 32), "utf8");
54
+
55
+ const cipher = crypto.createCipheriv("aes-128-cbc", key, iv);
56
+ let encrypted = cipher.update(plaintext, "utf8");
57
+ encrypted = Buffer.concat([encrypted, cipher.final()]);
58
+
59
+ return encrypted.toString("base64");
60
+ }
61
+
62
+ /**
63
+ * Generate a random nonce for webhook responses.
64
+ */
65
+ export function generateNonce(): string {
66
+ return crypto.randomBytes(16).toString("hex");
67
+ }