@marshulll/wecom-dual 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/README.en.md +98 -0
- package/README.md +98 -0
- package/README.zh.md +98 -0
- package/docs/INSTALL.md +103 -0
- package/docs/wecom.config.example.json +19 -0
- package/docs/wecom.config.full.example.json +55 -0
- package/package.json +59 -0
- package/wecom/index.ts +23 -0
- package/wecom/openclaw.plugin.json +9 -0
- package/wecom/package.json +45 -0
- package/wecom/src/accounts.ts +136 -0
- package/wecom/src/channel.ts +221 -0
- package/wecom/src/commands.ts +96 -0
- package/wecom/src/config-schema.ts +84 -0
- package/wecom/src/crypto.ts +129 -0
- package/wecom/src/format.ts +58 -0
- package/wecom/src/monitor.ts +72 -0
- package/wecom/src/runtime.ts +14 -0
- package/wecom/src/types.ts +140 -0
- package/wecom/src/wecom-api.ts +366 -0
- package/wecom/src/wecom-app.ts +635 -0
- package/wecom/src/wecom-bot.ts +645 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export const WECOM_TEXT_BYTE_LIMIT = 2000;
|
|
2
|
+
|
|
3
|
+
export function markdownToWecomText(markdown: string): string {
|
|
4
|
+
if (!markdown) return markdown;
|
|
5
|
+
|
|
6
|
+
let text = markdown;
|
|
7
|
+
|
|
8
|
+
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => {
|
|
9
|
+
const lines = String(code).trim().split("\n").map((line) => ` ${line}`).join("\n");
|
|
10
|
+
return lang ? `[${lang}]\n${lines}` : lines;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
text = text.replace(/`([^`]+)`/g, "$1");
|
|
14
|
+
text = text.replace(/^### (.+)$/gm, "▸ $1");
|
|
15
|
+
text = text.replace(/^## (.+)$/gm, "■ $1");
|
|
16
|
+
text = text.replace(/^# (.+)$/gm, "◆ $1");
|
|
17
|
+
|
|
18
|
+
text = text.replace(/\*\*\*([^*]+)\*\*\*/g, "$1");
|
|
19
|
+
text = text.replace(/\*\*([^*]+)\*\*/g, "$1");
|
|
20
|
+
text = text.replace(/\*([^*]+)\*/g, "$1");
|
|
21
|
+
text = text.replace(/___([^_]+)___/g, "$1");
|
|
22
|
+
text = text.replace(/__([^_]+)__/g, "$1");
|
|
23
|
+
text = text.replace(/_([^_]+)_/g, "$1");
|
|
24
|
+
|
|
25
|
+
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)");
|
|
26
|
+
text = text.replace(/^[\*\-] /gm, "• ");
|
|
27
|
+
text = text.replace(/^[-*_]{3,}$/gm, "────────────");
|
|
28
|
+
text = text.replace(/!\[([^\]]*)\]\([^)]+\)/g, "[图片: $1]");
|
|
29
|
+
|
|
30
|
+
text = text.replace(/\n{3,}/g, "\n\n");
|
|
31
|
+
|
|
32
|
+
return text.trim();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getByteLength(str: string): number {
|
|
36
|
+
return Buffer.byteLength(str, "utf8");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function splitWecomText(text: string, byteLimit = WECOM_TEXT_BYTE_LIMIT): string[] {
|
|
40
|
+
if (!text) return [""];
|
|
41
|
+
if (getByteLength(text) <= byteLimit) return [text];
|
|
42
|
+
|
|
43
|
+
const chunks: string[] = [];
|
|
44
|
+
let current = "";
|
|
45
|
+
|
|
46
|
+
for (const ch of text) {
|
|
47
|
+
const next = current + ch;
|
|
48
|
+
if (getByteLength(next) > byteLimit) {
|
|
49
|
+
if (current) chunks.push(current);
|
|
50
|
+
current = ch;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
current = next;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (current) chunks.push(current);
|
|
57
|
+
return chunks.length > 0 ? chunks : [text];
|
|
58
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
|
|
3
|
+
import type { ClawdbotConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
4
|
+
|
|
5
|
+
import type { ResolvedWecomAccount } from "./types.js";
|
|
6
|
+
import { handleWecomAppWebhook } from "./wecom-app.js";
|
|
7
|
+
import { handleWecomBotWebhook } from "./wecom-bot.js";
|
|
8
|
+
|
|
9
|
+
export type WecomRuntimeEnv = {
|
|
10
|
+
log?: (message: string) => void;
|
|
11
|
+
error?: (message: string) => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type WecomWebhookTarget = {
|
|
15
|
+
account: ResolvedWecomAccount;
|
|
16
|
+
config: ClawdbotConfig;
|
|
17
|
+
runtime: WecomRuntimeEnv;
|
|
18
|
+
core: PluginRuntime;
|
|
19
|
+
path: string;
|
|
20
|
+
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const webhookTargets = new Map<string, WecomWebhookTarget[]>();
|
|
24
|
+
|
|
25
|
+
function normalizeWebhookPath(raw: string): string {
|
|
26
|
+
const trimmed = raw.trim();
|
|
27
|
+
if (!trimmed) return "/";
|
|
28
|
+
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
29
|
+
if (withSlash.length > 1 && withSlash.endsWith("/")) return withSlash.slice(0, -1);
|
|
30
|
+
return withSlash;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolvePath(req: IncomingMessage): string {
|
|
34
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
35
|
+
return normalizeWebhookPath(url.pathname || "/");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function registerWecomWebhookTarget(target: WecomWebhookTarget): () => void {
|
|
39
|
+
const key = normalizeWebhookPath(target.path);
|
|
40
|
+
const normalizedTarget = { ...target, path: key };
|
|
41
|
+
const existing = webhookTargets.get(key) ?? [];
|
|
42
|
+
const next = [...existing, normalizedTarget];
|
|
43
|
+
webhookTargets.set(key, next);
|
|
44
|
+
return () => {
|
|
45
|
+
const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
|
|
46
|
+
if (updated.length > 0) webhookTargets.set(key, updated);
|
|
47
|
+
else webhookTargets.delete(key);
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function handleWecomWebhookRequest(
|
|
52
|
+
req: IncomingMessage,
|
|
53
|
+
res: ServerResponse,
|
|
54
|
+
): Promise<boolean> {
|
|
55
|
+
const path = resolvePath(req);
|
|
56
|
+
const targets = webhookTargets.get(path);
|
|
57
|
+
if (!targets || targets.length === 0) return false;
|
|
58
|
+
|
|
59
|
+
// Prefer account-level mode. If both, we attempt bot first (JSON) then app (XML).
|
|
60
|
+
// Concrete routing is implemented in handlers.
|
|
61
|
+
const botHandled = await handleWecomBotWebhook({ req, res, targets });
|
|
62
|
+
if (botHandled) return true;
|
|
63
|
+
|
|
64
|
+
const appHandled = await handleWecomAppWebhook({ req, res, targets });
|
|
65
|
+
if (appHandled) return true;
|
|
66
|
+
|
|
67
|
+
// Fallback: not a recognized request for this plugin.
|
|
68
|
+
res.statusCode = 400;
|
|
69
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
70
|
+
res.end("unsupported wecom webhook request");
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
let runtime: PluginRuntime | null = null;
|
|
4
|
+
|
|
5
|
+
export function setWecomRuntime(next: PluginRuntime): void {
|
|
6
|
+
runtime = next;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getWecomRuntime(): PluginRuntime {
|
|
10
|
+
if (!runtime) {
|
|
11
|
+
throw new Error("WeCom runtime not initialized");
|
|
12
|
+
}
|
|
13
|
+
return runtime;
|
|
14
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
export type WecomMode = "bot" | "app" | "both";
|
|
2
|
+
|
|
3
|
+
export type WecomDmConfig = {
|
|
4
|
+
policy?: "pairing" | "allowlist" | "open" | "disabled";
|
|
5
|
+
allowFrom?: Array<string | number>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type WecomBotConfig = {
|
|
9
|
+
token?: string;
|
|
10
|
+
encodingAESKey?: string;
|
|
11
|
+
receiveId?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type WecomAppConfig = {
|
|
15
|
+
corpId?: string;
|
|
16
|
+
corpSecret?: string;
|
|
17
|
+
agentId?: string | number;
|
|
18
|
+
callbackToken?: string;
|
|
19
|
+
callbackAesKey?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type WecomAccountConfig = {
|
|
23
|
+
name?: string;
|
|
24
|
+
enabled?: boolean;
|
|
25
|
+
mode?: WecomMode;
|
|
26
|
+
|
|
27
|
+
// Shared settings
|
|
28
|
+
webhookPath?: string;
|
|
29
|
+
welcomeText?: string;
|
|
30
|
+
dm?: WecomDmConfig;
|
|
31
|
+
|
|
32
|
+
// Bot API (intelligent bot) settings
|
|
33
|
+
token?: string;
|
|
34
|
+
encodingAESKey?: string;
|
|
35
|
+
receiveId?: string;
|
|
36
|
+
|
|
37
|
+
// Internal app settings
|
|
38
|
+
corpId?: string;
|
|
39
|
+
corpSecret?: string;
|
|
40
|
+
agentId?: string | number;
|
|
41
|
+
callbackToken?: string;
|
|
42
|
+
callbackAesKey?: string;
|
|
43
|
+
|
|
44
|
+
// Media handling
|
|
45
|
+
media?: {
|
|
46
|
+
tempDir?: string;
|
|
47
|
+
retentionHours?: number;
|
|
48
|
+
cleanupOnStart?: boolean;
|
|
49
|
+
maxBytes?: number;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Network behavior
|
|
53
|
+
network?: {
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
retries?: number;
|
|
56
|
+
retryDelayMs?: number;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// If true (default), bot mode can bridge media via app send APIs.
|
|
60
|
+
botMediaBridge?: boolean;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type WecomConfig = WecomAccountConfig & {
|
|
64
|
+
defaultAccount?: string;
|
|
65
|
+
accounts?: Record<string, WecomAccountConfig>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type ResolvedWecomAccount = {
|
|
69
|
+
accountId: string;
|
|
70
|
+
name?: string;
|
|
71
|
+
enabled: boolean;
|
|
72
|
+
configured: boolean;
|
|
73
|
+
mode: WecomMode;
|
|
74
|
+
config: WecomAccountConfig;
|
|
75
|
+
|
|
76
|
+
// Bot API
|
|
77
|
+
token?: string;
|
|
78
|
+
encodingAESKey?: string;
|
|
79
|
+
receiveId: string;
|
|
80
|
+
|
|
81
|
+
// Internal app
|
|
82
|
+
corpId?: string;
|
|
83
|
+
corpSecret?: string;
|
|
84
|
+
agentId?: number;
|
|
85
|
+
callbackToken?: string;
|
|
86
|
+
callbackAesKey?: string;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type WecomInboundBase = {
|
|
90
|
+
msgid?: string;
|
|
91
|
+
aibotid?: string;
|
|
92
|
+
chattype?: "single" | "group";
|
|
93
|
+
chatid?: string;
|
|
94
|
+
response_url?: string;
|
|
95
|
+
from?: { userid?: string; corpid?: string };
|
|
96
|
+
msgtype?: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export type WecomInboundText = WecomInboundBase & {
|
|
100
|
+
msgtype: "text";
|
|
101
|
+
text?: { content?: string };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type WecomInboundVoice = WecomInboundBase & {
|
|
105
|
+
msgtype: "voice";
|
|
106
|
+
voice?: { content?: string };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export type WecomInboundStreamRefresh = WecomInboundBase & {
|
|
110
|
+
msgtype: "stream";
|
|
111
|
+
stream?: { id?: string };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export type WecomInboundEvent = WecomInboundBase & {
|
|
115
|
+
msgtype: "event";
|
|
116
|
+
create_time?: number;
|
|
117
|
+
event?: {
|
|
118
|
+
eventtype?: string;
|
|
119
|
+
[key: string]: unknown;
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export type WecomInboundMessage =
|
|
124
|
+
| WecomInboundText
|
|
125
|
+
| WecomInboundVoice
|
|
126
|
+
| WecomInboundStreamRefresh
|
|
127
|
+
| WecomInboundEvent
|
|
128
|
+
| (WecomInboundBase & Record<string, unknown>);
|
|
129
|
+
|
|
130
|
+
export type WecomNormalizedMessage = {
|
|
131
|
+
id?: string;
|
|
132
|
+
type: "text" | "image" | "voice" | "file" | "video" | "link" | "event" | "unknown";
|
|
133
|
+
text?: string;
|
|
134
|
+
mediaId?: string;
|
|
135
|
+
mediaUrl?: string;
|
|
136
|
+
chatId?: string;
|
|
137
|
+
userId?: string;
|
|
138
|
+
isGroup?: boolean;
|
|
139
|
+
raw?: unknown;
|
|
140
|
+
};
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { splitWecomText } from "./format.js";
|
|
2
|
+
import type { ResolvedWecomAccount } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export type WecomTokenState = {
|
|
5
|
+
token: string | null;
|
|
6
|
+
expiresAt: number;
|
|
7
|
+
refreshPromise: Promise<string> | null;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
class RateLimiter {
|
|
11
|
+
private maxConcurrent: number;
|
|
12
|
+
private minInterval: number;
|
|
13
|
+
private running: number;
|
|
14
|
+
private queue: Array<{ fn: () => Promise<any>; resolve: (v: any) => void; reject: (e: any) => void }>;
|
|
15
|
+
private lastExecution: number;
|
|
16
|
+
|
|
17
|
+
constructor({ maxConcurrent = 3, minInterval = 200 } = {}) {
|
|
18
|
+
this.maxConcurrent = maxConcurrent;
|
|
19
|
+
this.minInterval = minInterval;
|
|
20
|
+
this.running = 0;
|
|
21
|
+
this.queue = [];
|
|
22
|
+
this.lastExecution = 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
execute<T>(fn: () => Promise<T>): Promise<T> {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
this.queue.push({ fn, resolve, reject });
|
|
28
|
+
this.processQueue();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private async processQueue(): Promise<void> {
|
|
33
|
+
if (this.running >= this.maxConcurrent || this.queue.length === 0) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
const waitTime = Math.max(0, this.lastExecution + this.minInterval - now);
|
|
39
|
+
|
|
40
|
+
if (waitTime > 0) {
|
|
41
|
+
setTimeout(() => this.processQueue(), waitTime);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
this.running += 1;
|
|
46
|
+
this.lastExecution = Date.now();
|
|
47
|
+
|
|
48
|
+
const { fn, resolve, reject } = this.queue.shift()!;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const result = await fn();
|
|
52
|
+
resolve(result);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
reject(err);
|
|
55
|
+
} finally {
|
|
56
|
+
this.running -= 1;
|
|
57
|
+
this.processQueue();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const accessTokenCaches = new Map<string, WecomTokenState>();
|
|
63
|
+
const apiLimiter = new RateLimiter({ maxConcurrent: 3, minInterval: 200 });
|
|
64
|
+
|
|
65
|
+
function ensureAppConfig(account: ResolvedWecomAccount): { corpId: string; corpSecret: string; agentId: number } {
|
|
66
|
+
const corpId = account.corpId ?? "";
|
|
67
|
+
const corpSecret = account.corpSecret ?? "";
|
|
68
|
+
const agentId = account.agentId ?? 0;
|
|
69
|
+
if (!corpId || !corpSecret || !agentId) {
|
|
70
|
+
throw new Error("WeCom app not configured (corpId/corpSecret/agentId required)");
|
|
71
|
+
}
|
|
72
|
+
return { corpId, corpSecret, agentId };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function resolveNetworkConfig(account: ResolvedWecomAccount): { timeoutMs: number; retries: number; retryDelayMs: number } {
|
|
76
|
+
const cfg = account.config.network ?? {};
|
|
77
|
+
const timeoutMs = typeof cfg.timeoutMs === "number" && cfg.timeoutMs > 0 ? cfg.timeoutMs : 15000;
|
|
78
|
+
const retries = typeof cfg.retries === "number" && cfg.retries >= 0 ? cfg.retries : 2;
|
|
79
|
+
const retryDelayMs = typeof cfg.retryDelayMs === "number" && cfg.retryDelayMs >= 0 ? cfg.retryDelayMs : 300;
|
|
80
|
+
return { timeoutMs, retries, retryDelayMs };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function sleep(ms: number): Promise<void> {
|
|
84
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function fetchWithRetry(account: ResolvedWecomAccount, input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
|
88
|
+
const { timeoutMs, retries, retryDelayMs } = resolveNetworkConfig(account);
|
|
89
|
+
let attempt = 0;
|
|
90
|
+
let lastErr: unknown;
|
|
91
|
+
while (attempt <= retries) {
|
|
92
|
+
const controller = new AbortController();
|
|
93
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
94
|
+
try {
|
|
95
|
+
const res = await apiLimiter.execute(() => fetch(input, { ...init, signal: controller.signal }));
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
return res;
|
|
98
|
+
} catch (err) {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
lastErr = err;
|
|
101
|
+
if (attempt >= retries) break;
|
|
102
|
+
await sleep(retryDelayMs * Math.max(1, attempt + 1));
|
|
103
|
+
}
|
|
104
|
+
attempt += 1;
|
|
105
|
+
}
|
|
106
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function getWecomAccessToken(account: ResolvedWecomAccount): Promise<string> {
|
|
110
|
+
const { corpId, corpSecret } = ensureAppConfig(account);
|
|
111
|
+
const cacheKey = corpId;
|
|
112
|
+
let cache = accessTokenCaches.get(cacheKey);
|
|
113
|
+
|
|
114
|
+
if (!cache) {
|
|
115
|
+
cache = { token: null, expiresAt: 0, refreshPromise: null };
|
|
116
|
+
accessTokenCaches.set(cacheKey, cache);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const now = Date.now();
|
|
120
|
+
if (cache.token && cache.expiresAt > now + 60000) {
|
|
121
|
+
return cache.token;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (cache.refreshPromise) {
|
|
125
|
+
return cache.refreshPromise;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
cache.refreshPromise = (async () => {
|
|
129
|
+
try {
|
|
130
|
+
const tokenUrl = `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${encodeURIComponent(corpId)}&corpsecret=${encodeURIComponent(corpSecret)}`;
|
|
131
|
+
const tokenRes = await fetchWithRetry(account, tokenUrl);
|
|
132
|
+
const tokenJson = await tokenRes.json();
|
|
133
|
+
if (!tokenJson?.access_token) {
|
|
134
|
+
throw new Error(`WeCom gettoken failed: ${JSON.stringify(tokenJson)}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
cache.token = tokenJson.access_token;
|
|
138
|
+
cache.expiresAt = Date.now() + (tokenJson.expires_in || 7200) * 1000;
|
|
139
|
+
|
|
140
|
+
return cache.token;
|
|
141
|
+
} finally {
|
|
142
|
+
cache.refreshPromise = null;
|
|
143
|
+
}
|
|
144
|
+
})();
|
|
145
|
+
|
|
146
|
+
return cache.refreshPromise;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function sendWecomTextSingle(params: {
|
|
150
|
+
account: ResolvedWecomAccount;
|
|
151
|
+
toUser: string;
|
|
152
|
+
chatId?: string;
|
|
153
|
+
text: string;
|
|
154
|
+
}): Promise<void> {
|
|
155
|
+
const { account, toUser, chatId, text } = params;
|
|
156
|
+
const { agentId } = ensureAppConfig(account);
|
|
157
|
+
const accessToken = await getWecomAccessToken(account);
|
|
158
|
+
const useChat = Boolean(chatId);
|
|
159
|
+
const sendUrl = useChat
|
|
160
|
+
? `https://qyapi.weixin.qq.com/cgi-bin/appchat/send?access_token=${encodeURIComponent(accessToken)}`
|
|
161
|
+
: `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${encodeURIComponent(accessToken)}`;
|
|
162
|
+
|
|
163
|
+
const body = useChat
|
|
164
|
+
? { chatid: chatId, msgtype: "text", text: { content: text } }
|
|
165
|
+
: { touser: toUser, msgtype: "text", agentid: agentId, text: { content: text } };
|
|
166
|
+
|
|
167
|
+
const sendRes = await fetchWithRetry(account, sendUrl, {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: { "Content-Type": "application/json" },
|
|
170
|
+
body: JSON.stringify(body),
|
|
171
|
+
});
|
|
172
|
+
const sendJson = await sendRes.json();
|
|
173
|
+
if (sendJson?.errcode !== 0) {
|
|
174
|
+
throw new Error(`WeCom message/send failed: ${JSON.stringify(sendJson)}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function sendWecomText(params: {
|
|
179
|
+
account: ResolvedWecomAccount;
|
|
180
|
+
toUser: string;
|
|
181
|
+
chatId?: string;
|
|
182
|
+
text: string;
|
|
183
|
+
}): Promise<void> {
|
|
184
|
+
const { account, toUser, chatId, text } = params;
|
|
185
|
+
const chunks = splitWecomText(text);
|
|
186
|
+
for (const chunk of chunks) {
|
|
187
|
+
if (!chunk) continue;
|
|
188
|
+
await sendWecomTextSingle({ account, toUser, chatId, text: chunk });
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function uploadWecomMedia(params: {
|
|
193
|
+
account: ResolvedWecomAccount;
|
|
194
|
+
type: "image" | "voice" | "video" | "file";
|
|
195
|
+
buffer: Buffer;
|
|
196
|
+
filename: string;
|
|
197
|
+
}): Promise<string> {
|
|
198
|
+
const { account, type, buffer, filename } = params;
|
|
199
|
+
const accessToken = await getWecomAccessToken(account);
|
|
200
|
+
const uploadUrl = `https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=${encodeURIComponent(accessToken)}&type=${encodeURIComponent(type)}`;
|
|
201
|
+
|
|
202
|
+
const form = new FormData();
|
|
203
|
+
form.append("media", new Blob([buffer]), filename);
|
|
204
|
+
|
|
205
|
+
const res = await fetchWithRetry(account, uploadUrl, {
|
|
206
|
+
method: "POST",
|
|
207
|
+
body: form,
|
|
208
|
+
});
|
|
209
|
+
const json = await res.json();
|
|
210
|
+
if (!json?.media_id) {
|
|
211
|
+
throw new Error(`WeCom media upload failed: ${JSON.stringify(json)}`);
|
|
212
|
+
}
|
|
213
|
+
return json.media_id;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function sendWecomImage(params: {
|
|
217
|
+
account: ResolvedWecomAccount;
|
|
218
|
+
toUser: string;
|
|
219
|
+
chatId?: string;
|
|
220
|
+
mediaId: string;
|
|
221
|
+
}): Promise<void> {
|
|
222
|
+
const { account, toUser, chatId, mediaId } = params;
|
|
223
|
+
const { agentId } = ensureAppConfig(account);
|
|
224
|
+
const accessToken = await getWecomAccessToken(account);
|
|
225
|
+
const useChat = Boolean(chatId);
|
|
226
|
+
const sendUrl = useChat
|
|
227
|
+
? `https://qyapi.weixin.qq.com/cgi-bin/appchat/send?access_token=${encodeURIComponent(accessToken)}`
|
|
228
|
+
: `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${encodeURIComponent(accessToken)}`;
|
|
229
|
+
|
|
230
|
+
const body = useChat
|
|
231
|
+
? { chatid: chatId, msgtype: "image", image: { media_id: mediaId } }
|
|
232
|
+
: { touser: toUser, msgtype: "image", agentid: agentId, image: { media_id: mediaId } };
|
|
233
|
+
|
|
234
|
+
const sendRes = await fetchWithRetry(account, sendUrl, {
|
|
235
|
+
method: "POST",
|
|
236
|
+
headers: { "Content-Type": "application/json" },
|
|
237
|
+
body: JSON.stringify(body),
|
|
238
|
+
});
|
|
239
|
+
const sendJson = await sendRes.json();
|
|
240
|
+
if (sendJson?.errcode !== 0) {
|
|
241
|
+
throw new Error(`WeCom image send failed: ${JSON.stringify(sendJson)}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function sendWecomVoice(params: {
|
|
246
|
+
account: ResolvedWecomAccount;
|
|
247
|
+
toUser: string;
|
|
248
|
+
chatId?: string;
|
|
249
|
+
mediaId: string;
|
|
250
|
+
}): Promise<void> {
|
|
251
|
+
const { account, toUser, chatId, mediaId } = params;
|
|
252
|
+
const { agentId } = ensureAppConfig(account);
|
|
253
|
+
const accessToken = await getWecomAccessToken(account);
|
|
254
|
+
const useChat = Boolean(chatId);
|
|
255
|
+
const sendUrl = useChat
|
|
256
|
+
? `https://qyapi.weixin.qq.com/cgi-bin/appchat/send?access_token=${encodeURIComponent(accessToken)}`
|
|
257
|
+
: `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${encodeURIComponent(accessToken)}`;
|
|
258
|
+
|
|
259
|
+
const body = useChat
|
|
260
|
+
? { chatid: chatId, msgtype: "voice", voice: { media_id: mediaId } }
|
|
261
|
+
: { touser: toUser, msgtype: "voice", agentid: agentId, voice: { media_id: mediaId } };
|
|
262
|
+
|
|
263
|
+
const sendRes = await fetchWithRetry(account, sendUrl, {
|
|
264
|
+
method: "POST",
|
|
265
|
+
headers: { "Content-Type": "application/json" },
|
|
266
|
+
body: JSON.stringify(body),
|
|
267
|
+
});
|
|
268
|
+
const sendJson = await sendRes.json();
|
|
269
|
+
if (sendJson?.errcode !== 0) {
|
|
270
|
+
throw new Error(`WeCom voice send failed: ${JSON.stringify(sendJson)}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function sendWecomVideo(params: {
|
|
275
|
+
account: ResolvedWecomAccount;
|
|
276
|
+
toUser: string;
|
|
277
|
+
chatId?: string;
|
|
278
|
+
mediaId: string;
|
|
279
|
+
title?: string;
|
|
280
|
+
description?: string;
|
|
281
|
+
}): Promise<void> {
|
|
282
|
+
const { account, toUser, chatId, mediaId, title, description } = params;
|
|
283
|
+
const { agentId } = ensureAppConfig(account);
|
|
284
|
+
const accessToken = await getWecomAccessToken(account);
|
|
285
|
+
const useChat = Boolean(chatId);
|
|
286
|
+
const sendUrl = useChat
|
|
287
|
+
? `https://qyapi.weixin.qq.com/cgi-bin/appchat/send?access_token=${encodeURIComponent(accessToken)}`
|
|
288
|
+
: `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${encodeURIComponent(accessToken)}`;
|
|
289
|
+
|
|
290
|
+
const video = { media_id: mediaId, title: title ?? "Video", description: description ?? "" };
|
|
291
|
+
const body = useChat
|
|
292
|
+
? { chatid: chatId, msgtype: "video", video }
|
|
293
|
+
: { touser: toUser, msgtype: "video", agentid: agentId, video };
|
|
294
|
+
|
|
295
|
+
const sendRes = await fetchWithRetry(account, sendUrl, {
|
|
296
|
+
method: "POST",
|
|
297
|
+
headers: { "Content-Type": "application/json" },
|
|
298
|
+
body: JSON.stringify(body),
|
|
299
|
+
});
|
|
300
|
+
const sendJson = await sendRes.json();
|
|
301
|
+
if (sendJson?.errcode !== 0) {
|
|
302
|
+
throw new Error(`WeCom video send failed: ${JSON.stringify(sendJson)}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function sendWecomFile(params: {
|
|
307
|
+
account: ResolvedWecomAccount;
|
|
308
|
+
toUser: string;
|
|
309
|
+
chatId?: string;
|
|
310
|
+
mediaId: string;
|
|
311
|
+
}): Promise<void> {
|
|
312
|
+
const { account, toUser, chatId, mediaId } = params;
|
|
313
|
+
const { agentId } = ensureAppConfig(account);
|
|
314
|
+
const accessToken = await getWecomAccessToken(account);
|
|
315
|
+
const useChat = Boolean(chatId);
|
|
316
|
+
const sendUrl = useChat
|
|
317
|
+
? `https://qyapi.weixin.qq.com/cgi-bin/appchat/send?access_token=${encodeURIComponent(accessToken)}`
|
|
318
|
+
: `https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${encodeURIComponent(accessToken)}`;
|
|
319
|
+
|
|
320
|
+
const body = useChat
|
|
321
|
+
? { chatid: chatId, msgtype: "file", file: { media_id: mediaId } }
|
|
322
|
+
: { touser: toUser, msgtype: "file", agentid: agentId, file: { media_id: mediaId } };
|
|
323
|
+
|
|
324
|
+
const sendRes = await fetchWithRetry(account, sendUrl, {
|
|
325
|
+
method: "POST",
|
|
326
|
+
headers: { "Content-Type": "application/json" },
|
|
327
|
+
body: JSON.stringify(body),
|
|
328
|
+
});
|
|
329
|
+
const sendJson = await sendRes.json();
|
|
330
|
+
if (sendJson?.errcode !== 0) {
|
|
331
|
+
throw new Error(`WeCom file send failed: ${JSON.stringify(sendJson)}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export async function downloadWecomMedia(params: {
|
|
336
|
+
account: ResolvedWecomAccount;
|
|
337
|
+
mediaId: string;
|
|
338
|
+
}): Promise<{ buffer: Buffer; contentType: string } > {
|
|
339
|
+
const { account, mediaId } = params;
|
|
340
|
+
const accessToken = await getWecomAccessToken(account);
|
|
341
|
+
const mediaUrl = `https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token=${encodeURIComponent(accessToken)}&media_id=${encodeURIComponent(mediaId)}`;
|
|
342
|
+
|
|
343
|
+
const res = await fetchWithRetry(account, mediaUrl);
|
|
344
|
+
if (!res.ok) {
|
|
345
|
+
throw new Error(`Failed to download media: ${res.status}`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const contentType = res.headers.get("content-type") || "";
|
|
349
|
+
if (contentType.includes("application/json")) {
|
|
350
|
+
const json = await res.json();
|
|
351
|
+
throw new Error(`WeCom media download failed: ${JSON.stringify(json)}`);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
355
|
+
return { buffer, contentType };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export async function fetchMediaFromUrl(url: string, account?: ResolvedWecomAccount): Promise<{ buffer: Buffer; contentType: string } > {
|
|
359
|
+
const res = account ? await fetchWithRetry(account, url) : await fetch(url);
|
|
360
|
+
if (!res.ok) {
|
|
361
|
+
throw new Error(`Failed to fetch media from URL: ${res.status}`);
|
|
362
|
+
}
|
|
363
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
364
|
+
const contentType = res.headers.get("content-type") || "application/octet-stream";
|
|
365
|
+
return { buffer, contentType };
|
|
366
|
+
}
|