@yanhaidao/wecom 2.3.190 → 2.3.270
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/.github/workflows/release.yml +23 -4
- package/README.md +17 -6
- package/changelog/v2.3.26.md +21 -0
- package/changelog/v2.3.27.md +33 -0
- package/index.test.ts +5 -1
- package/package.json +17 -17
- package/src/agent/handler.ts +2 -0
- package/src/app/account-runtime.ts +5 -1
- package/src/app/index.ts +120 -1
- package/src/capability/mcp/tool.ts +7 -3
- package/src/channel.meta.test.ts +4 -0
- package/src/channel.ts +30 -60
- package/src/config/media.test.ts +1 -1
- package/src/config/media.ts +3 -5
- package/src/context-store.ts +264 -0
- package/src/onboarding.test.ts +42 -24
- package/src/onboarding.ts +598 -553
- package/src/outbound.test.ts +404 -2
- package/src/outbound.ts +96 -15
- package/src/runtime/dispatcher.ts +24 -5
- package/src/runtime/session-manager.test.ts +135 -0
- package/src/runtime/session-manager.ts +40 -8
- package/src/runtime/source-registry.ts +79 -0
- package/src/runtime.ts +3 -0
- package/src/target.ts +20 -8
- package/src/transport/bot-ws/media.test.ts +9 -9
- package/src/transport/bot-ws/media.ts +51 -2
- package/src/transport/bot-ws/reply.test.ts +1 -1
- package/src/transport/bot-ws/reply.ts +8 -3
- package/src/transport/bot-ws/sdk-adapter.ts +6 -6
- package/src/transport/http/registry.ts +1 -1
- package/src/types/runtime.ts +1 -0
- package/src/wecom_msg_adapter/markdown_adapter.ts +331 -0
package/src/config/media.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
type OpenClawConfig,
|
|
7
|
-
} from "openclaw/plugin-sdk";
|
|
3
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
4
|
+
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";
|
|
5
|
+
import { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
|
|
8
6
|
|
|
9
7
|
// 默认给一个相对“够用”的上限(80MB),避免视频/较大文件频繁触发失败。
|
|
10
8
|
// 仍保留上限以防止恶意大文件把进程内存打爆(下载实现会读入内存再保存)。
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context store for WeCom Bot WS proactive push.
|
|
3
|
+
*
|
|
4
|
+
* Similar to Weixin's contextToken mechanism, we need to track:
|
|
5
|
+
* - Which accountId has active sessions with which peerId
|
|
6
|
+
* - The contextToken for routing outbound messages
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from "node:crypto";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
// Simple logger
|
|
14
|
+
const logger = {
|
|
15
|
+
info: (...args: unknown[]) => console.log('[wecom-context]', ...args),
|
|
16
|
+
warn: (...args: unknown[]) => console.warn('[wecom-context]', ...args),
|
|
17
|
+
debug: (...args: unknown[]) => process.env.DEBUG && console.log('[wecom-context]', ...args),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type PeerKind = "direct" | "group";
|
|
21
|
+
|
|
22
|
+
type StoredPeerContext = {
|
|
23
|
+
contextToken: string;
|
|
24
|
+
peerKind: PeerKind;
|
|
25
|
+
lastSeen: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type ResolvedPeerContext = StoredPeerContext & {
|
|
29
|
+
accountId: string;
|
|
30
|
+
peerId: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// In-memory store: accountId -> peerId -> context info
|
|
34
|
+
const peerContextStore = new Map<string, Map<string, StoredPeerContext>>();
|
|
35
|
+
|
|
36
|
+
// Reverse lookup: peerId -> accountId (for routing outbound)
|
|
37
|
+
const peerToAccountMap = new Map<string, string>();
|
|
38
|
+
const contextTokenToPeerMap = new Map<string, ResolvedPeerContext>();
|
|
39
|
+
|
|
40
|
+
function resolveStateDir(): string {
|
|
41
|
+
return process.env.OPENCLAW_STATE_DIR || path.join(process.env.HOME || "/tmp", ".openclaw");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function resolveContextFilePath(accountId: string): string {
|
|
45
|
+
return path.join(
|
|
46
|
+
resolveStateDir(),
|
|
47
|
+
"wecom",
|
|
48
|
+
"context",
|
|
49
|
+
`${accountId}.json`
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Persist peer contexts for an account to disk */
|
|
54
|
+
function persistContexts(accountId: string): void {
|
|
55
|
+
const peerMap = peerContextStore.get(accountId);
|
|
56
|
+
if (!peerMap) return;
|
|
57
|
+
|
|
58
|
+
const data: Record<string, StoredPeerContext> = {};
|
|
59
|
+
for (const [peerId, info] of peerMap) {
|
|
60
|
+
data[peerId] = info;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const filePath = resolveContextFilePath(accountId);
|
|
64
|
+
try {
|
|
65
|
+
const dir = path.dirname(filePath);
|
|
66
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
67
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 0), "utf-8");
|
|
68
|
+
} catch (err) {
|
|
69
|
+
logger.warn?.(`persistContexts: failed to write ${filePath}: ${String(err)}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizeContextToken(value: unknown): string | undefined {
|
|
74
|
+
const token = typeof value === "string" ? value.trim() : "";
|
|
75
|
+
return token || undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizePeerKind(value: unknown): PeerKind {
|
|
79
|
+
return value === "group" ? "group" : "direct";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function registerPeerContext(accountId: string, peerId: string, info: StoredPeerContext): void {
|
|
83
|
+
let peerMap = peerContextStore.get(accountId);
|
|
84
|
+
if (!peerMap) {
|
|
85
|
+
peerMap = new Map();
|
|
86
|
+
peerContextStore.set(accountId, peerMap);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const previous = peerMap.get(peerId);
|
|
90
|
+
if (previous?.contextToken && previous.contextToken !== info.contextToken) {
|
|
91
|
+
contextTokenToPeerMap.delete(previous.contextToken);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
peerMap.set(peerId, info);
|
|
95
|
+
peerToAccountMap.set(peerId, accountId);
|
|
96
|
+
contextTokenToPeerMap.set(info.contextToken, {
|
|
97
|
+
accountId,
|
|
98
|
+
peerId,
|
|
99
|
+
...info,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function resolveStoredPeerContext(
|
|
104
|
+
accountId: string,
|
|
105
|
+
peerId: string,
|
|
106
|
+
params: {
|
|
107
|
+
contextToken?: string;
|
|
108
|
+
peerKind?: PeerKind;
|
|
109
|
+
lastSeen?: number;
|
|
110
|
+
},
|
|
111
|
+
): StoredPeerContext {
|
|
112
|
+
const existing = peerContextStore.get(accountId)?.get(peerId);
|
|
113
|
+
return {
|
|
114
|
+
contextToken:
|
|
115
|
+
normalizeContextToken(params.contextToken) ??
|
|
116
|
+
existing?.contextToken ??
|
|
117
|
+
randomUUID(),
|
|
118
|
+
peerKind: params.peerKind ?? existing?.peerKind ?? "direct",
|
|
119
|
+
lastSeen: params.lastSeen ?? Date.now(),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Restore persisted peer contexts for an account */
|
|
124
|
+
export function restorePeerContexts(accountId: string): void {
|
|
125
|
+
const filePath = resolveContextFilePath(accountId);
|
|
126
|
+
try {
|
|
127
|
+
if (!fs.existsSync(filePath)) return;
|
|
128
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
129
|
+
const data = JSON.parse(raw) as Record<
|
|
130
|
+
string,
|
|
131
|
+
{
|
|
132
|
+
contextToken?: string;
|
|
133
|
+
peerKind?: string;
|
|
134
|
+
lastSeen?: number;
|
|
135
|
+
}
|
|
136
|
+
>;
|
|
137
|
+
|
|
138
|
+
const peerMap = new Map<string, StoredPeerContext>();
|
|
139
|
+
let count = 0;
|
|
140
|
+
let mutated = false;
|
|
141
|
+
for (const [peerId, info] of Object.entries(data)) {
|
|
142
|
+
const normalized: StoredPeerContext = {
|
|
143
|
+
contextToken: normalizeContextToken(info?.contextToken) ?? randomUUID(),
|
|
144
|
+
peerKind: normalizePeerKind(info?.peerKind),
|
|
145
|
+
lastSeen:
|
|
146
|
+
typeof info?.lastSeen === "number" && Number.isFinite(info.lastSeen)
|
|
147
|
+
? info.lastSeen
|
|
148
|
+
: Date.now(),
|
|
149
|
+
};
|
|
150
|
+
peerMap.set(peerId, normalized);
|
|
151
|
+
peerToAccountMap.set(peerId, accountId);
|
|
152
|
+
contextTokenToPeerMap.set(normalized.contextToken, {
|
|
153
|
+
accountId,
|
|
154
|
+
peerId,
|
|
155
|
+
...normalized,
|
|
156
|
+
});
|
|
157
|
+
if (
|
|
158
|
+
normalized.contextToken !== info?.contextToken ||
|
|
159
|
+
normalized.peerKind !== info?.peerKind ||
|
|
160
|
+
normalized.lastSeen !== info?.lastSeen
|
|
161
|
+
) {
|
|
162
|
+
mutated = true;
|
|
163
|
+
}
|
|
164
|
+
count++;
|
|
165
|
+
}
|
|
166
|
+
peerContextStore.set(accountId, peerMap);
|
|
167
|
+
if (mutated) {
|
|
168
|
+
persistContexts(accountId);
|
|
169
|
+
}
|
|
170
|
+
logger.info?.(`restorePeerContexts: restored ${count} peers for account=${accountId}`);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
logger.warn?.(`restorePeerContexts: failed to read ${filePath}: ${String(err)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Store context for a peer (called on inbound message) */
|
|
177
|
+
export function setPeerContext(
|
|
178
|
+
accountId: string,
|
|
179
|
+
peerId: string,
|
|
180
|
+
options?: {
|
|
181
|
+
contextToken?: string;
|
|
182
|
+
peerKind?: PeerKind;
|
|
183
|
+
lastSeen?: number;
|
|
184
|
+
},
|
|
185
|
+
): string {
|
|
186
|
+
const resolved = resolveStoredPeerContext(accountId, peerId, options ?? {});
|
|
187
|
+
registerPeerContext(accountId, peerId, resolved);
|
|
188
|
+
|
|
189
|
+
// Persist to disk (debounced would be better, but simple for now)
|
|
190
|
+
persistContexts(accountId);
|
|
191
|
+
|
|
192
|
+
logger.debug?.(
|
|
193
|
+
`setPeerContext: accountId=${accountId} peerId=${peerId} token=${resolved.contextToken} kind=${resolved.peerKind}`,
|
|
194
|
+
);
|
|
195
|
+
return resolved.contextToken;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Get the accountId that has an active session with a peer */
|
|
199
|
+
export function getAccountIdByPeer(peerId: string): string | undefined {
|
|
200
|
+
return peerToAccountMap.get(peerId);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Get the most recent peerId for an account (for proactive push) */
|
|
204
|
+
export function getRecentPeerForAccount(accountId: string, maxAgeMs = 30 * 60 * 1000): string | undefined {
|
|
205
|
+
const peerMap = peerContextStore.get(accountId);
|
|
206
|
+
if (!peerMap) return undefined;
|
|
207
|
+
|
|
208
|
+
let mostRecent: { peerId: string; lastSeen: number } | undefined;
|
|
209
|
+
|
|
210
|
+
for (const [peerId, info] of peerMap) {
|
|
211
|
+
if (Date.now() - info.lastSeen > maxAgeMs) continue;
|
|
212
|
+
if (!mostRecent || info.lastSeen > mostRecent.lastSeen) {
|
|
213
|
+
mostRecent = { peerId, lastSeen: info.lastSeen };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return mostRecent?.peerId;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Get context token for a peer */
|
|
221
|
+
export function getPeerContextToken(accountId: string, peerId: string): string | undefined {
|
|
222
|
+
const peerMap = peerContextStore.get(accountId);
|
|
223
|
+
return peerMap?.get(peerId)?.contextToken;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Resolve a peer context from a context token. */
|
|
227
|
+
export function getPeerContextByToken(contextToken: string): ResolvedPeerContext | undefined {
|
|
228
|
+
return contextTokenToPeerMap.get(contextToken);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Resolve accountId from a context token. */
|
|
232
|
+
export function getAccountIdByContextToken(contextToken: string): string | undefined {
|
|
233
|
+
return contextTokenToPeerMap.get(contextToken)?.accountId;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Check if we have an active session for routing */
|
|
237
|
+
export function hasActiveSession(accountId: string, peerId: string, maxAgeMs = 30 * 60 * 1000): boolean {
|
|
238
|
+
const peerMap = peerContextStore.get(accountId);
|
|
239
|
+
if (!peerMap) return false;
|
|
240
|
+
|
|
241
|
+
const info = peerMap.get(peerId);
|
|
242
|
+
if (!info) return false;
|
|
243
|
+
|
|
244
|
+
return Date.now() - info.lastSeen < maxAgeMs;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Clear all contexts for an account */
|
|
248
|
+
export function clearPeerContexts(accountId: string): void {
|
|
249
|
+
const peerMap = peerContextStore.get(accountId);
|
|
250
|
+
if (peerMap) {
|
|
251
|
+
for (const [peerId, info] of peerMap) {
|
|
252
|
+
peerToAccountMap.delete(peerId);
|
|
253
|
+
contextTokenToPeerMap.delete(info.contextToken);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
peerContextStore.delete(accountId);
|
|
257
|
+
|
|
258
|
+
const filePath = resolveContextFilePath(accountId);
|
|
259
|
+
try {
|
|
260
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
logger.warn?.(`clearPeerContexts: failed to remove ${filePath}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
package/src/onboarding.test.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk";
|
|
2
2
|
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
|
3
3
|
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
import {
|
|
4
|
+
import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
|
|
5
|
+
import { wecomPlugin } from "./channel.js";
|
|
6
|
+
|
|
7
|
+
const wecomSetupAdapter = buildChannelSetupWizardAdapterFromSetupWizard({
|
|
8
|
+
plugin: wecomPlugin,
|
|
9
|
+
wizard: wecomPlugin.setupWizard!,
|
|
10
|
+
});
|
|
5
11
|
|
|
6
12
|
function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
|
|
7
13
|
return {
|
|
@@ -54,7 +60,7 @@ describe("wecom onboarding", () => {
|
|
|
54
60
|
}) as WizardPrompter["text"],
|
|
55
61
|
});
|
|
56
62
|
|
|
57
|
-
const result = await
|
|
63
|
+
const result = await wecomSetupAdapter.configure({
|
|
58
64
|
cfg: {} as OpenClawConfig,
|
|
59
65
|
runtime: createRuntime(),
|
|
60
66
|
prompter,
|
|
@@ -76,8 +82,12 @@ describe("wecom onboarding", () => {
|
|
|
76
82
|
const noteText = (prompter.note as ReturnType<typeof vi.fn>).mock.calls
|
|
77
83
|
.map(([message]) => String(message))
|
|
78
84
|
.join("\n");
|
|
79
|
-
expect(noteText).toContain(
|
|
80
|
-
|
|
85
|
+
expect(noteText).toContain(
|
|
86
|
+
"YanHaidao/wecom 是企业微信官方推荐三方插件,功能强大,适合直接落生产环境。",
|
|
87
|
+
);
|
|
88
|
+
expect(noteText).toContain(
|
|
89
|
+
"默认就是 Bot WebSocket 模式,配置简单,无需域名,普通用户也能快速接入。",
|
|
90
|
+
);
|
|
81
91
|
expect(noteText).toContain("支持主动发消息,定时任务、异常提醒、工作流通知都可直接落地。");
|
|
82
92
|
});
|
|
83
93
|
|
|
@@ -131,7 +141,7 @@ describe("wecom onboarding", () => {
|
|
|
131
141
|
},
|
|
132
142
|
};
|
|
133
143
|
|
|
134
|
-
const result = await
|
|
144
|
+
const result = await wecomSetupAdapter.configure({
|
|
135
145
|
cfg: initialCfg,
|
|
136
146
|
runtime: createRuntime(),
|
|
137
147
|
prompter,
|
|
@@ -155,7 +165,7 @@ describe("wecom onboarding", () => {
|
|
|
155
165
|
});
|
|
156
166
|
|
|
157
167
|
it("reports chinese status copy for channel selection", async () => {
|
|
158
|
-
const status = await
|
|
168
|
+
const status = await wecomSetupAdapter.getStatus({
|
|
159
169
|
cfg: {} as OpenClawConfig,
|
|
160
170
|
options: {},
|
|
161
171
|
accountOverrides: {},
|
|
@@ -199,7 +209,7 @@ describe("wecom onboarding", () => {
|
|
|
199
209
|
}) as WizardPrompter["text"],
|
|
200
210
|
});
|
|
201
211
|
|
|
202
|
-
const result = await
|
|
212
|
+
const result = await wecomSetupAdapter.configure({
|
|
203
213
|
cfg: {} as OpenClawConfig,
|
|
204
214
|
runtime: createRuntime(),
|
|
205
215
|
prompter,
|
|
@@ -214,25 +224,33 @@ describe("wecom onboarding", () => {
|
|
|
214
224
|
.map(([message]) => String(message))
|
|
215
225
|
.join("\n");
|
|
216
226
|
expect(noteText).toContain("接入标识已规范化为:haidao");
|
|
217
|
-
expect(
|
|
227
|
+
expect(wecomSetupAdapter.dmPolicy).toBeUndefined();
|
|
218
228
|
});
|
|
219
229
|
|
|
220
230
|
it("offers default account selection when config has no accounts", async () => {
|
|
221
231
|
const prompter = createPrompter({
|
|
222
|
-
select: vi.fn(
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
232
|
+
select: vi.fn(
|
|
233
|
+
async ({
|
|
234
|
+
message,
|
|
235
|
+
options,
|
|
236
|
+
}: {
|
|
237
|
+
message: string;
|
|
238
|
+
options: Array<{ value: string; label: string }>;
|
|
239
|
+
}) => {
|
|
240
|
+
if (message === "请选择企业微信接入标识(英文):") {
|
|
241
|
+
expect(options.map((option) => option.value)).toEqual(["default", "__new__"]);
|
|
242
|
+
expect(options[0]?.label).toBe("default(默认标识)");
|
|
243
|
+
return "default";
|
|
244
|
+
}
|
|
245
|
+
if (message === "请选择您要配置的接入模式:") {
|
|
246
|
+
return "bot";
|
|
247
|
+
}
|
|
248
|
+
if (message === "请选择私聊 (DM) 访问策略:") {
|
|
249
|
+
return "pairing";
|
|
250
|
+
}
|
|
251
|
+
throw new Error(`Unexpected select prompt: ${message}`);
|
|
252
|
+
},
|
|
253
|
+
) as WizardPrompter["select"],
|
|
236
254
|
text: vi.fn(async ({ message }: { message: string }) => {
|
|
237
255
|
if (message === "请输入 BotId(机器人 ID):") {
|
|
238
256
|
return "bot-id-default";
|
|
@@ -250,7 +268,7 @@ describe("wecom onboarding", () => {
|
|
|
250
268
|
}) as WizardPrompter["text"],
|
|
251
269
|
});
|
|
252
270
|
|
|
253
|
-
const result = await
|
|
271
|
+
const result = await wecomSetupAdapter.configure({
|
|
254
272
|
cfg: {} as OpenClawConfig,
|
|
255
273
|
runtime: createRuntime(),
|
|
256
274
|
prompter,
|
|
@@ -301,7 +319,7 @@ describe("wecom onboarding", () => {
|
|
|
301
319
|
}) as WizardPrompter["text"],
|
|
302
320
|
});
|
|
303
321
|
|
|
304
|
-
const result = await
|
|
322
|
+
const result = await wecomSetupAdapter.configure({
|
|
305
323
|
cfg: {} as OpenClawConfig,
|
|
306
324
|
runtime: createRuntime(),
|
|
307
325
|
prompter,
|