@yanhaidao/wecom 2.3.190 → 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 +7 -1
- package/changelog/v2.3.26.md +21 -0
- package/package.json +2 -2
- package/src/agent/handler.ts +2 -0
- package/src/app/account-runtime.ts +5 -1
- package/src/app/index.ts +116 -0
- package/src/channel.ts +1 -1
- 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.ts +1 -1
- package/src/outbound.test.ts +404 -2
- package/src/outbound.ts +81 -6
- 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 +2 -2
- package/src/transport/bot-ws/media.ts +1 -1
- package/src/transport/bot-ws/reply.test.ts +1 -1
- package/src/transport/bot-ws/reply.ts +8 -3
- 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
|
@@ -96,14 +96,33 @@ jobs:
|
|
|
96
96
|
id: notes
|
|
97
97
|
run: |
|
|
98
98
|
set -euo pipefail
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
VERSION="${{ steps.meta.outputs.version }}"
|
|
100
|
+
CHANGELOG_DIR="changelog"
|
|
101
|
+
CHANGELOG_FILE=""
|
|
102
|
+
CANDIDATES=("v${VERSION}.md")
|
|
103
|
+
|
|
104
|
+
IFS='.' read -r major minor patch <<< "${VERSION}"
|
|
105
|
+
if [[ -n "${major:-}" && -n "${minor:-}" && -n "${patch:-}" && "${patch}" =~ ^[0-9]+$ && ${#patch} -gt 2 ]]; then
|
|
106
|
+
trimmed_patch="${patch%?}"
|
|
107
|
+
CANDIDATES+=("v${major}.${minor}.${trimmed_patch}.md")
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
echo "Release notes candidates:"
|
|
111
|
+
for candidate in "${CANDIDATES[@]}"; do
|
|
112
|
+
path="${CHANGELOG_DIR}/${candidate}"
|
|
113
|
+
echo " - ${path}"
|
|
114
|
+
if [[ -z "${CHANGELOG_FILE}" && -f "${path}" ]]; then
|
|
115
|
+
CHANGELOG_FILE="${path}"
|
|
116
|
+
fi
|
|
117
|
+
done
|
|
118
|
+
|
|
119
|
+
if [[ -n "${CHANGELOG_FILE}" ]]; then
|
|
101
120
|
echo "Using changelog file: ${CHANGELOG_FILE}"
|
|
102
121
|
cp "${CHANGELOG_FILE}" /tmp/release-body.md
|
|
103
122
|
else
|
|
104
|
-
echo "
|
|
123
|
+
echo "No matching changelog file found; using fallback notes."
|
|
105
124
|
{
|
|
106
|
-
echo "# @yanhaidao/wecom v${
|
|
125
|
+
echo "# @yanhaidao/wecom v${VERSION}"
|
|
107
126
|
echo
|
|
108
127
|
if [ "${{ steps.npm_check.outputs.exists }}" = "true" ]; then
|
|
109
128
|
echo "- npm publish skipped: version already exists."
|
package/README.md
CHANGED
|
@@ -161,7 +161,13 @@
|
|
|
161
161
|
## 📋 最近更新 (Changelog摘要)
|
|
162
162
|
|
|
163
163
|
> 项目保持高频迭代,全面对齐甚至超越企业真实业务诉求。
|
|
164
|
-
> **为保持精简,以下仅展示近期
|
|
164
|
+
> **为保持精简,以下仅展示近期 5 次重要更新,完整历史版本(含全部 `v2.2.x`)请前往 [changelog/ 目录](./changelog/) 查阅。**
|
|
165
|
+
|
|
166
|
+
#### 📌 v2.3.26(2026-03-26)
|
|
167
|
+
- **[重要修复] 升级 OpenClaw 后不再乱报错** 🔧 修复了新版 OpenClaw 下 `wecom` 插件容易出现的 `is not a function` 一类启动/运行错误。
|
|
168
|
+
- **[回复更稳] Agent 和 Bot WS 不再乱串** ↔️ 现在是谁收到消息,就尽量由谁来回复,不再容易出现“在 Agent 里说话,结果 Bot WS 回你”的情况。
|
|
169
|
+
- **[体验修复] Bot WS 发图后不再多冒一条 `Done...`** 🖼 之前常见表现是:`正在思考` -> 图片 -> 又多一条完成提示。现在最终收尾会尽量接回原来的回复链路。
|
|
170
|
+
- **[占位符修复] 不会一直卡在“正在思考...”** ⏳ 如果图片或文本已经发出去了,占位符会更自然地结束,不会继续无意义地刷屏。
|
|
165
171
|
|
|
166
172
|
#### 📌 v2.3.19(2026-03-19)
|
|
167
173
|
- **[重要修复] Bot WS 现在也真正走 `dynamicAgents`** 🧭 之前同样开启动态路由时,不同消息链路的行为并不完全一致:Webhook / Agent 能按用户、群聊隔离,Bot WebSocket 却可能重新落回主 Agent。现在 WS 运行时也执行同样的动态路由逻辑,会话隔离终于统一了。
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# OpenClaw WeCom 插件 v2.3.26 变更简报
|
|
2
|
+
|
|
3
|
+
> [!TIP]
|
|
4
|
+
> 这一版主要就是把最近最烦人的几类问题修顺了:升级后不再乱报错,Agent 和 Bot WS 不再乱串回复,Bot WS 发图片后不再多冒一条 `Done...`,占位符也不会一直卡着不收。
|
|
5
|
+
|
|
6
|
+
## 2026-03-26(v2.3.26)
|
|
7
|
+
- 修复了 OpenClaw 升级后 `wecom` 插件报 `is not a function` 的问题。之前是 SDK 导入路径没跟着新版调整,这一版已经统一修好。
|
|
8
|
+
- 修复了 Agent 和 Bot WS 串回复的问题。现在是谁收到消息,就尽量由谁来回复,不再出现“在 Agent 里说话,结果 Bot WS 回你”这种情况。
|
|
9
|
+
- 修复了 Bot WS 会话里发图片后的收尾问题。之前常见表现是:`正在思考` -> 图片 -> 又多一条 `Done!...`。现在最终文字会尽量接回原来的回复链路,不再额外多发一条完成提示。
|
|
10
|
+
- 修复了 `message` 工具和 Bot WS 占位符不同步的问题。现在如果图片或文本已经发出去了,占位符不会还在那里一直刷“正在思考...”。
|
|
11
|
+
- 顺手加强了“全局安装的 OpenClaw + 本地 wecom 插件源码”这种混合运行方式下的稳定性,开发时不容易再出现一边生效、一边不生效的割裂情况。
|
|
12
|
+
|
|
13
|
+
## 升级后你会感受到
|
|
14
|
+
- 升级 OpenClaw 后,`wecom` 插件不再因为导入问题直接报错。
|
|
15
|
+
- Agent 和 Bot WS 同时开着时,回复会更稳地留在正确的对话里。
|
|
16
|
+
- Bot WS 发图、发文件后,回复看起来更像一条完整对话,不会再碎成好几段。
|
|
17
|
+
- 占位符会更自然地结束,不会一直停在“正在思考...”。
|
|
18
|
+
|
|
19
|
+
## 升级提示
|
|
20
|
+
- 执行 `openclaw plugins update wecom` 即可升级到 `v2.3.26`。
|
|
21
|
+
- 如果你是“全局 `openclaw` + 本地 `extensions/wecom` 源码”在跑,升级后记得完整重启 gateway。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yanhaidao/wecom",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.260",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw 企业微信(WeCom)插件,默认 Bot WebSocket,支持加密媒体解密、Agent 主动发消息与多账号接入",
|
|
6
6
|
"repository": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"undici": "^7.20.0"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"openclaw": "
|
|
48
|
+
"openclaw": "^2026.3.23-2"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@types/node": "^25.2.0",
|
package/src/agent/handler.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime";
|
|
2
3
|
import type { ResolvedRuntimeAccount } from "../config/runtime-config.js";
|
|
3
4
|
import { WecomAuditLog } from "../observability/audit-log.js";
|
|
4
5
|
import { WecomStatusRegistry } from "../observability/status-registry.js";
|
|
@@ -107,6 +108,9 @@ export class WecomAccountRuntime {
|
|
|
107
108
|
);
|
|
108
109
|
await replyHandle.fail?.(error);
|
|
109
110
|
},
|
|
111
|
+
markExternalActivity: () => {
|
|
112
|
+
replyHandle.markExternalActivity?.();
|
|
113
|
+
},
|
|
110
114
|
};
|
|
111
115
|
|
|
112
116
|
try {
|
package/src/app/index.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
2
|
import { clearWecomSourceAccount } from "../runtime/source-registry.js";
|
|
3
3
|
import { WecomAccountRuntime } from "./account-runtime.js";
|
|
4
|
+
import type { ReplyHandle } from "../types/index.js";
|
|
4
5
|
|
|
5
6
|
let runtime: PluginRuntime | null = null;
|
|
6
7
|
const runtimes = new Map<string, WecomAccountRuntime>();
|
|
7
8
|
const botWsPushHandles = new Map<string, BotWsPushHandle>();
|
|
9
|
+
const activeBotWsReplyHandlesBySession = new Map<string, ReplyHandle>();
|
|
10
|
+
const activeBotWsReplyHandlesByPeer = new Map<string, ReplyHandle>();
|
|
8
11
|
|
|
9
12
|
export type BotWsPushHandle = {
|
|
10
13
|
isConnected: () => boolean;
|
|
@@ -29,6 +32,28 @@ export type BotWsPushHandle = {
|
|
|
29
32
|
}>;
|
|
30
33
|
};
|
|
31
34
|
|
|
35
|
+
function normalizeOptional(value: string | null | undefined): string | undefined {
|
|
36
|
+
const trimmed = String(value ?? "").trim();
|
|
37
|
+
return trimmed || undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizePeerId(value: string | null | undefined): string | undefined {
|
|
41
|
+
const trimmed = normalizeOptional(value);
|
|
42
|
+
return trimmed ? trimmed.toLowerCase() : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildSessionHandleKey(accountId: string, sessionKey: string): string {
|
|
46
|
+
return `${accountId}::session::${sessionKey}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildPeerHandleKey(
|
|
50
|
+
accountId: string,
|
|
51
|
+
peerKind: "direct" | "group",
|
|
52
|
+
peerId: string,
|
|
53
|
+
): string {
|
|
54
|
+
return `${accountId}::peer::${peerKind}::${peerId}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
32
57
|
export function setWecomRuntime(next: PluginRuntime): void {
|
|
33
58
|
runtime = next;
|
|
34
59
|
}
|
|
@@ -61,6 +86,87 @@ export function getBotWsPushHandle(accountId: string): BotWsPushHandle | undefin
|
|
|
61
86
|
return botWsPushHandles.get(accountId);
|
|
62
87
|
}
|
|
63
88
|
|
|
89
|
+
export function registerActiveBotWsReplyHandle(params: {
|
|
90
|
+
accountId: string;
|
|
91
|
+
sessionKey?: string | null;
|
|
92
|
+
peerKind?: "direct" | "group" | null;
|
|
93
|
+
peerId?: string | null;
|
|
94
|
+
handle: ReplyHandle;
|
|
95
|
+
}): void {
|
|
96
|
+
const accountId = normalizeOptional(params.accountId);
|
|
97
|
+
const sessionKey = normalizeOptional(params.sessionKey);
|
|
98
|
+
const peerId = normalizePeerId(params.peerId);
|
|
99
|
+
if (!accountId) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (sessionKey) {
|
|
103
|
+
activeBotWsReplyHandlesBySession.set(buildSessionHandleKey(accountId, sessionKey), params.handle);
|
|
104
|
+
}
|
|
105
|
+
if ((params.peerKind === "direct" || params.peerKind === "group") && peerId) {
|
|
106
|
+
activeBotWsReplyHandlesByPeer.set(
|
|
107
|
+
buildPeerHandleKey(accountId, params.peerKind, peerId),
|
|
108
|
+
params.handle,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getActiveBotWsReplyHandle(params: {
|
|
114
|
+
accountId: string;
|
|
115
|
+
sessionKey?: string | null;
|
|
116
|
+
peerKind?: "direct" | "group" | null;
|
|
117
|
+
peerId?: string | null;
|
|
118
|
+
}): ReplyHandle | undefined {
|
|
119
|
+
const accountId = normalizeOptional(params.accountId);
|
|
120
|
+
const sessionKey = normalizeOptional(params.sessionKey);
|
|
121
|
+
const peerId = normalizePeerId(params.peerId);
|
|
122
|
+
if (!accountId) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
if (sessionKey) {
|
|
126
|
+
const handle = activeBotWsReplyHandlesBySession.get(
|
|
127
|
+
buildSessionHandleKey(accountId, sessionKey),
|
|
128
|
+
);
|
|
129
|
+
if (handle) {
|
|
130
|
+
return handle;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if ((params.peerKind === "direct" || params.peerKind === "group") && peerId) {
|
|
134
|
+
return activeBotWsReplyHandlesByPeer.get(
|
|
135
|
+
buildPeerHandleKey(accountId, params.peerKind, peerId),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function unregisterActiveBotWsReplyHandle(params: {
|
|
142
|
+
accountId: string;
|
|
143
|
+
sessionKey?: string | null;
|
|
144
|
+
peerKind?: "direct" | "group" | null;
|
|
145
|
+
peerId?: string | null;
|
|
146
|
+
handle?: ReplyHandle;
|
|
147
|
+
}): void {
|
|
148
|
+
const accountId = normalizeOptional(params.accountId);
|
|
149
|
+
const sessionKey = normalizeOptional(params.sessionKey);
|
|
150
|
+
const peerId = normalizePeerId(params.peerId);
|
|
151
|
+
if (!accountId) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (sessionKey) {
|
|
155
|
+
const key = buildSessionHandleKey(accountId, sessionKey);
|
|
156
|
+
const current = activeBotWsReplyHandlesBySession.get(key);
|
|
157
|
+
if (!params.handle || current === params.handle) {
|
|
158
|
+
activeBotWsReplyHandlesBySession.delete(key);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if ((params.peerKind === "direct" || params.peerKind === "group") && peerId) {
|
|
162
|
+
const key = buildPeerHandleKey(accountId, params.peerKind, peerId);
|
|
163
|
+
const current = activeBotWsReplyHandlesByPeer.get(key);
|
|
164
|
+
if (!params.handle || current === params.handle) {
|
|
165
|
+
activeBotWsReplyHandlesByPeer.delete(key);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
64
170
|
export function unregisterBotWsPushHandle(accountId: string): void {
|
|
65
171
|
botWsPushHandles.delete(accountId);
|
|
66
172
|
}
|
|
@@ -68,6 +174,16 @@ export function unregisterBotWsPushHandle(accountId: string): void {
|
|
|
68
174
|
export function unregisterAccountRuntime(accountId: string): void {
|
|
69
175
|
runtimes.delete(accountId);
|
|
70
176
|
botWsPushHandles.delete(accountId);
|
|
177
|
+
for (const key of activeBotWsReplyHandlesBySession.keys()) {
|
|
178
|
+
if (key.startsWith(`${accountId}::`)) {
|
|
179
|
+
activeBotWsReplyHandlesBySession.delete(key);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
for (const key of activeBotWsReplyHandlesByPeer.keys()) {
|
|
183
|
+
if (key.startsWith(`${accountId}::`)) {
|
|
184
|
+
activeBotWsReplyHandlesByPeer.delete(key);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
71
187
|
clearWecomSourceAccount(accountId);
|
|
72
188
|
console.log(`[wecom-runtime] unregister account=${accountId}`);
|
|
73
189
|
}
|
package/src/channel.ts
CHANGED
package/src/config/media.test.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
-
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk";
|
|
4
|
+
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/infra-runtime";
|
|
5
5
|
import { resolveWecomMediaMaxBytes, resolveWecomMergedMediaLocalRoots } from "./media.js";
|
|
6
6
|
|
|
7
7
|
describe("resolveWecomMergedMediaLocalRoots", () => {
|
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.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
|
|