@yanhaidao/wecom 2.3.180 → 2.3.260
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 +87 -2
- package/SKILLS_DOC.md +272 -120
- package/changelog/v2.3.19.md +73 -0
- package/changelog/v2.3.26.md +21 -0
- package/package.json +2 -2
- package/src/agent/handler.ts +5 -3
- package/src/app/account-runtime.ts +5 -1
- package/src/app/index.ts +117 -0
- package/src/capability/bot/stream-orchestrator.ts +1 -1
- package/src/capability/doc/client.ts +228 -9
- package/src/capability/doc/tool.ts +14 -7
- package/src/channel.ts +1 -1
- package/src/config/index.ts +7 -1
- package/src/config/media.test.ts +113 -0
- package/src/config/media.ts +130 -5
- package/src/config/schema.ts +3 -0
- package/src/context-store.ts +264 -0
- package/src/onboarding.ts +1 -1
- package/src/outbound.test.ts +565 -5
- package/src/outbound.ts +94 -7
- package/src/runtime/dispatcher.ts +24 -5
- package/src/runtime/routing-bridge.test.ts +115 -0
- package/src/runtime/routing-bridge.ts +26 -1
- 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-webhook/inbound-normalizer.ts +4 -4
- package/src/transport/bot-ws/media.test.ts +44 -0
- package/src/transport/bot-ws/media.ts +7 -4
- package/src/transport/bot-ws/reply.test.ts +131 -1
- package/src/transport/bot-ws/reply.ts +15 -3
- package/src/transport/bot-ws/sdk-adapter.ts +2 -1
- package/src/transport/http/registry.ts +1 -1
- package/src/types/config.ts +3 -0
- package/src/types/runtime.ts +1 -0
- package/src/wecom_msg_adapter/markdown_adapter.ts +331 -0
|
@@ -1189,11 +1189,12 @@ export function registerWecomDocTools(api: OpenClawPluginApi) {
|
|
|
1189
1189
|
});
|
|
1190
1190
|
}
|
|
1191
1191
|
case "smartsheet_add_records": {
|
|
1192
|
-
const result = await docClient.
|
|
1192
|
+
const result = await docClient.smartTableAddRecords({
|
|
1193
1193
|
agent: account,
|
|
1194
1194
|
docId: params.docId,
|
|
1195
|
-
|
|
1196
|
-
|
|
1195
|
+
sheetId: params.sheetId,
|
|
1196
|
+
records: params.records,
|
|
1197
|
+
key_type: params.key_type,
|
|
1197
1198
|
});
|
|
1198
1199
|
return buildToolResult({
|
|
1199
1200
|
ok: true,
|
|
@@ -1205,11 +1206,11 @@ export function registerWecomDocTools(api: OpenClawPluginApi) {
|
|
|
1205
1206
|
});
|
|
1206
1207
|
}
|
|
1207
1208
|
case "smartsheet_update_records": {
|
|
1208
|
-
const result = await docClient.
|
|
1209
|
+
const result = await docClient.smartTableUpdateRecords({
|
|
1209
1210
|
agent: account,
|
|
1210
1211
|
docId: params.docId,
|
|
1211
|
-
|
|
1212
|
-
|
|
1212
|
+
sheetId: params.sheetId,
|
|
1213
|
+
records: params.records,
|
|
1213
1214
|
});
|
|
1214
1215
|
return buildToolResult({
|
|
1215
1216
|
ok: true,
|
|
@@ -1350,7 +1351,13 @@ export function registerWecomDocTools(api: OpenClawPluginApi) {
|
|
|
1350
1351
|
});
|
|
1351
1352
|
}
|
|
1352
1353
|
case "smartsheet_add_fields": {
|
|
1353
|
-
const result = await docClient.smartTableAddFields({
|
|
1354
|
+
const result = await docClient.smartTableAddFields({
|
|
1355
|
+
agent: account,
|
|
1356
|
+
docId: params.docId,
|
|
1357
|
+
sheetId: params.sheetId,
|
|
1358
|
+
fields: params.fields,
|
|
1359
|
+
autoCleanupDefaultField: params.autoCleanupDefaultField !== false, // Default true
|
|
1360
|
+
});
|
|
1354
1361
|
return buildToolResult({
|
|
1355
1362
|
ok: true,
|
|
1356
1363
|
action,
|
package/src/channel.ts
CHANGED
package/src/config/index.ts
CHANGED
|
@@ -12,5 +12,11 @@ export {
|
|
|
12
12
|
export { resolveWecomRuntimeAccount, resolveWecomRuntimeConfig, type ResolvedRuntimeAccount, type ResolvedRuntimeConfig } from "./runtime-config.js";
|
|
13
13
|
export { resolveDerivedPath, resolveDerivedPathSummary } from "./derived-paths.js";
|
|
14
14
|
export { resolveWecomEgressProxyUrl, resolveWecomEgressProxyUrlFromNetwork } from "./network.js";
|
|
15
|
-
export {
|
|
15
|
+
export {
|
|
16
|
+
DEFAULT_WECOM_MEDIA_MAX_BYTES,
|
|
17
|
+
getWecomDefaultMediaLocalRoots,
|
|
18
|
+
resolveWecomConfiguredMediaLocalRoots,
|
|
19
|
+
resolveWecomMediaMaxBytes,
|
|
20
|
+
resolveWecomMergedMediaLocalRoots,
|
|
21
|
+
} from "./media.js";
|
|
16
22
|
export { resolveWecomFailClosedOnDefaultRoute, shouldRejectWecomDefaultRoute } from "./routing.js";
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";
|
|
5
|
+
import { resolveWecomMediaMaxBytes, resolveWecomMergedMediaLocalRoots } from "./media.js";
|
|
6
|
+
|
|
7
|
+
describe("resolveWecomMergedMediaLocalRoots", () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
vi.unstubAllEnvs();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("merges defaults with configured local roots", () => {
|
|
13
|
+
vi.stubEnv("OPENCLAW_STATE_DIR", "/tmp/wecom-state");
|
|
14
|
+
|
|
15
|
+
const roots = resolveWecomMergedMediaLocalRoots({
|
|
16
|
+
cfg: {
|
|
17
|
+
channels: {
|
|
18
|
+
wecom: {
|
|
19
|
+
media: {
|
|
20
|
+
localRoots: ["~/Downloads", "/tmp/custom-root"],
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
} as never,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(roots).toEqual(
|
|
28
|
+
expect.arrayContaining([
|
|
29
|
+
path.resolve(resolvePreferredOpenClawTmpDir()),
|
|
30
|
+
"/tmp/wecom-state",
|
|
31
|
+
"/tmp/wecom-state/media",
|
|
32
|
+
"/tmp/wecom-state/agents",
|
|
33
|
+
"/tmp/wecom-state/workspace",
|
|
34
|
+
"/tmp/wecom-state/sandboxes",
|
|
35
|
+
path.resolve(os.homedir(), "Desktop"),
|
|
36
|
+
path.resolve(os.homedir(), "Documents"),
|
|
37
|
+
path.resolve(os.homedir(), "Downloads"),
|
|
38
|
+
path.resolve(os.homedir(), "Movies"),
|
|
39
|
+
path.resolve(os.homedir(), "Pictures"),
|
|
40
|
+
"/tmp/custom-root",
|
|
41
|
+
]),
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("keeps defaults, base roots, and configured roots without duplicates", () => {
|
|
46
|
+
vi.stubEnv("OPENCLAW_STATE_DIR", "/tmp/wecom-state");
|
|
47
|
+
|
|
48
|
+
const roots = resolveWecomMergedMediaLocalRoots({
|
|
49
|
+
cfg: {
|
|
50
|
+
channels: {
|
|
51
|
+
wecom: {
|
|
52
|
+
media: {
|
|
53
|
+
localRoots: ["/tmp/agent-root", "/tmp/downloads"],
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
} as never,
|
|
58
|
+
baseRoots: ["/tmp/agent-root", "/tmp/workspace-agent"],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(roots).toEqual(
|
|
62
|
+
expect.arrayContaining([
|
|
63
|
+
path.resolve(resolvePreferredOpenClawTmpDir()),
|
|
64
|
+
"/tmp/wecom-state",
|
|
65
|
+
"/tmp/workspace-agent",
|
|
66
|
+
"/tmp/agent-root",
|
|
67
|
+
"/tmp/downloads",
|
|
68
|
+
]),
|
|
69
|
+
);
|
|
70
|
+
expect(roots.filter((root) => root === "/tmp/agent-root")).toHaveLength(1);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("resolveWecomMediaMaxBytes", () => {
|
|
75
|
+
it("prefers account mediaMaxMb over channel and agent defaults", () => {
|
|
76
|
+
expect(
|
|
77
|
+
resolveWecomMediaMaxBytes(
|
|
78
|
+
{
|
|
79
|
+
agents: {
|
|
80
|
+
defaults: {
|
|
81
|
+
mediaMaxMb: 12,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
channels: {
|
|
85
|
+
wecom: {
|
|
86
|
+
mediaMaxMb: 24,
|
|
87
|
+
accounts: {
|
|
88
|
+
ops: {
|
|
89
|
+
mediaMaxMb: 32,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
} as never,
|
|
95
|
+
"ops",
|
|
96
|
+
),
|
|
97
|
+
).toBe(32 * 1024 * 1024);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("falls back to legacy channels.wecom.media.maxBytes when mediaMaxMb is unset", () => {
|
|
101
|
+
expect(
|
|
102
|
+
resolveWecomMediaMaxBytes({
|
|
103
|
+
channels: {
|
|
104
|
+
wecom: {
|
|
105
|
+
media: {
|
|
106
|
+
maxBytes: 15 * 1024 * 1024,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
} as never),
|
|
111
|
+
).toBe(15 * 1024 * 1024);
|
|
112
|
+
});
|
|
113
|
+
});
|
package/src/config/media.ts
CHANGED
|
@@ -1,14 +1,139 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
1
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";
|
|
2
6
|
|
|
3
7
|
// 默认给一个相对“够用”的上限(80MB),避免视频/较大文件频繁触发失败。
|
|
4
8
|
// 仍保留上限以防止恶意大文件把进程内存打爆(下载实现会读入内存再保存)。
|
|
5
9
|
export const DEFAULT_WECOM_MEDIA_MAX_BYTES = 80 * 1024 * 1024;
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
function parsePositiveNumber(value: unknown): number | undefined {
|
|
12
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
13
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
return parsed;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function resolveStateDirForWecomMedia(): string {
|
|
20
|
+
const stateOverride =
|
|
21
|
+
process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
|
|
22
|
+
if (stateOverride) {
|
|
23
|
+
return stateOverride;
|
|
24
|
+
}
|
|
25
|
+
return path.join(os.homedir(), ".openclaw");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeWecomLocalRoot(root: string): string | undefined {
|
|
29
|
+
const trimmed = root.trim();
|
|
30
|
+
if (!trimmed) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
return path.resolve(trimmed.replace(/^~(?=\/|$)/, os.homedir()));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getWecomCommonUserMediaLocalRoots(): readonly string[] {
|
|
37
|
+
const home = os.homedir();
|
|
38
|
+
return [
|
|
39
|
+
path.join(home, "Desktop"),
|
|
40
|
+
path.join(home, "Documents"),
|
|
41
|
+
path.join(home, "Downloads"),
|
|
42
|
+
path.join(home, "Movies"),
|
|
43
|
+
path.join(home, "Pictures"),
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getWecomDefaultMediaLocalRoots(): readonly string[] {
|
|
48
|
+
const stateDir = path.resolve(resolveStateDirForWecomMedia());
|
|
49
|
+
return [
|
|
50
|
+
path.resolve(resolvePreferredOpenClawTmpDir()),
|
|
51
|
+
stateDir,
|
|
52
|
+
path.join(stateDir, "media"),
|
|
53
|
+
path.join(stateDir, "agents"),
|
|
54
|
+
path.join(stateDir, "workspace"),
|
|
55
|
+
path.join(stateDir, "sandboxes"),
|
|
56
|
+
...getWecomCommonUserMediaLocalRoots(),
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function resolveWecomConfiguredMediaLocalRoots(cfg: OpenClawConfig): readonly string[] {
|
|
61
|
+
const rawWecom = cfg.channels?.wecom as
|
|
62
|
+
| {
|
|
63
|
+
media?: { localRoots?: unknown };
|
|
64
|
+
mediaLocalRoots?: unknown;
|
|
65
|
+
}
|
|
66
|
+
| undefined;
|
|
67
|
+
const configured = Array.isArray(rawWecom?.media?.localRoots)
|
|
68
|
+
? rawWecom.media.localRoots
|
|
69
|
+
: Array.isArray(rawWecom?.mediaLocalRoots)
|
|
70
|
+
? rawWecom.mediaLocalRoots
|
|
71
|
+
: [];
|
|
72
|
+
return configured
|
|
73
|
+
.filter((root): root is string => typeof root === "string")
|
|
74
|
+
.map(normalizeWecomLocalRoot)
|
|
75
|
+
.filter((root): root is string => Boolean(root));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function resolveWecomMergedMediaLocalRoots(params: {
|
|
79
|
+
cfg: OpenClawConfig;
|
|
80
|
+
baseRoots?: readonly string[];
|
|
81
|
+
}): readonly string[] {
|
|
82
|
+
const merged: string[] = [];
|
|
83
|
+
const seen = new Set<string>();
|
|
84
|
+
const pushRoot = (root: string) => {
|
|
85
|
+
const normalized = normalizeWecomLocalRoot(root);
|
|
86
|
+
if (!normalized || seen.has(normalized)) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
seen.add(normalized);
|
|
90
|
+
merged.push(normalized);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
for (const root of getWecomDefaultMediaLocalRoots()) {
|
|
94
|
+
pushRoot(root);
|
|
95
|
+
}
|
|
96
|
+
for (const root of params.baseRoots ?? []) {
|
|
97
|
+
pushRoot(root);
|
|
98
|
+
}
|
|
99
|
+
for (const root of resolveWecomConfiguredMediaLocalRoots(params.cfg)) {
|
|
100
|
+
pushRoot(root);
|
|
101
|
+
}
|
|
102
|
+
return merged;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resolveLegacyWecomMediaMaxBytes(cfg: OpenClawConfig): number | undefined {
|
|
8
106
|
const raw = (cfg.channels?.wecom as any)?.media?.maxBytes;
|
|
9
|
-
const
|
|
10
|
-
if (
|
|
11
|
-
return Math.floor(
|
|
107
|
+
const bytes = parsePositiveNumber(raw);
|
|
108
|
+
if (bytes) {
|
|
109
|
+
return Math.floor(bytes);
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function resolveWecomMediaMaxBytes(
|
|
115
|
+
cfg: OpenClawConfig,
|
|
116
|
+
accountId?: string | null,
|
|
117
|
+
): number {
|
|
118
|
+
const mediaMaxBytes = resolveChannelMediaMaxBytes({
|
|
119
|
+
cfg,
|
|
120
|
+
accountId,
|
|
121
|
+
resolveChannelLimitMb: ({ cfg, accountId }) => {
|
|
122
|
+
const wecom = cfg.channels?.wecom as
|
|
123
|
+
| {
|
|
124
|
+
mediaMaxMb?: unknown;
|
|
125
|
+
accounts?: Record<string, { mediaMaxMb?: unknown }>;
|
|
126
|
+
}
|
|
127
|
+
| undefined;
|
|
128
|
+
const accountLimitMb = parsePositiveNumber(wecom?.accounts?.[accountId]?.mediaMaxMb);
|
|
129
|
+
if (accountLimitMb) {
|
|
130
|
+
return accountLimitMb;
|
|
131
|
+
}
|
|
132
|
+
return parsePositiveNumber(wecom?.mediaMaxMb);
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
if (mediaMaxBytes) {
|
|
136
|
+
return mediaMaxBytes;
|
|
12
137
|
}
|
|
13
|
-
return DEFAULT_WECOM_MEDIA_MAX_BYTES;
|
|
138
|
+
return resolveLegacyWecomMediaMaxBytes(cfg) ?? DEFAULT_WECOM_MEDIA_MAX_BYTES;
|
|
14
139
|
}
|
package/src/config/schema.ts
CHANGED
|
@@ -8,6 +8,7 @@ export interface MediaConfig {
|
|
|
8
8
|
retentionHours?: number;
|
|
9
9
|
cleanupOnStart?: boolean;
|
|
10
10
|
maxBytes?: number;
|
|
11
|
+
localRoots?: string[];
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export interface NetworkConfig {
|
|
@@ -61,12 +62,14 @@ export interface DynamicAgentsConfig {
|
|
|
61
62
|
export interface AccountConfig {
|
|
62
63
|
enabled?: boolean;
|
|
63
64
|
name?: string;
|
|
65
|
+
mediaMaxMb?: number;
|
|
64
66
|
bot?: BotConfig;
|
|
65
67
|
agent?: AgentConfig;
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
export interface WecomConfigInput {
|
|
69
71
|
enabled?: boolean;
|
|
72
|
+
mediaMaxMb?: number;
|
|
70
73
|
bot?: BotConfig;
|
|
71
74
|
agent?: AgentConfig;
|
|
72
75
|
accounts?: Record<string, AccountConfig>;
|
|
@@ -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.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type {
|
|
|
8
8
|
OpenClawConfig,
|
|
9
9
|
WizardPrompter,
|
|
10
10
|
} from "openclaw/plugin-sdk";
|
|
11
|
-
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
|
|
11
|
+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
|
12
12
|
import { listWecomAccountIds, resolveDefaultWecomAccountId, resolveWecomAccount, resolveWecomAccounts } from "./config/index.js";
|
|
13
13
|
import type { WecomConfig, WecomBotConfig, WecomAgentConfig, WecomDmConfig, WecomAccountConfig } from "./types/index.js";
|
|
14
14
|
|