@sidleo3/dsh-chat-weixin 0.0.4

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.
@@ -0,0 +1,146 @@
1
+ /**
2
+ * 微信账号的运行状态存储。
3
+ *
4
+ * 沿用 dsh-im 的 `accounts/<botId>/state.json`:会话绑定、已处理消息 id、
5
+ * 长轮询游标(`getUpdatesBuf`)与 per-user 的 `context_token`。
6
+ * 会话绑定在启动时交给 hub 的会话桥 `adopt()` 接管,升级不丢会话。
7
+ *
8
+ * @module dsh-chat-weixin/state-store
9
+ */
10
+
11
+ const MAX_SEEN = 1_000;
12
+ const MAX_CONTEXT_TOKENS = 200;
13
+
14
+ function isPlainObject(value) {
15
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
16
+ }
17
+
18
+ function normalizeDocument(value) {
19
+ const source = isPlainObject(value) ? value : {};
20
+ const sessions = {};
21
+ if (isPlainObject(source.sessions)) {
22
+ for (const [key, sessionId] of Object.entries(source.sessions)) {
23
+ if (typeof sessionId === 'string' && sessionId) sessions[key] = sessionId;
24
+ }
25
+ }
26
+ const seenMessageIds = Array.isArray(source.seenMessageIds)
27
+ ? source.seenMessageIds.filter((id) => typeof id === 'string' && id).slice(-MAX_SEEN)
28
+ : [];
29
+ const contextTokens = {};
30
+ if (isPlainObject(source.contextTokens)) {
31
+ for (const [userId, token] of Object.entries(source.contextTokens).slice(-MAX_CONTEXT_TOKENS)) {
32
+ if (typeof token === 'string' && token) contextTokens[userId] = token;
33
+ }
34
+ }
35
+ const lastError = isPlainObject(source.lastError) && typeof source.lastError.message === 'string'
36
+ ? { message: source.lastError.message, at: source.lastError.at ?? null }
37
+ : null;
38
+ return {
39
+ version: 1,
40
+ sessions,
41
+ seenMessageIds,
42
+ contextTokens,
43
+ lastError,
44
+ getUpdatesBuf: typeof source.getUpdatesBuf === 'string' ? source.getUpdatesBuf : '',
45
+ };
46
+ }
47
+
48
+ /**
49
+ * 创建状态存储。
50
+ *
51
+ * @param options - { path, createJsonStore }。
52
+ * @returns 状态 API。
53
+ */
54
+ export function createWeixinStateStore({ path, createJsonStore }) {
55
+ if (typeof createJsonStore !== 'function') {
56
+ throw new TypeError('微信状态存储需要 hub 提供的 createJsonStore。');
57
+ }
58
+ const store = createJsonStore({
59
+ path,
60
+ normalize: normalizeDocument,
61
+ empty: () => ({
62
+ version: 1, sessions: {}, seenMessageIds: [], contextTokens: {}, getUpdatesBuf: '',
63
+ }),
64
+ label: '微信账号状态',
65
+ });
66
+
67
+ return {
68
+ path,
69
+ ready: () => store.ready(),
70
+
71
+ /** @returns 旧实现的会话绑定(交给 hub 的会话桥 adopt)。 */
72
+ sessions() {
73
+ return Object.freeze({ ...(store.snapshot().sessions ?? {}) });
74
+ },
75
+
76
+ /** @returns 长轮询游标。 */
77
+ getUpdatesBuf() {
78
+ return store.snapshot().getUpdatesBuf ?? '';
79
+ },
80
+
81
+ /** 记录长轮询游标(每轮都会变,写入串行且失败不阻塞收消息)。 */
82
+ async saveGetUpdatesBuf(value) {
83
+ if (typeof value !== 'string' || value === store.snapshot().getUpdatesBuf) return;
84
+ await store.update((current) => ({ ...current, getUpdatesBuf: value }));
85
+ },
86
+
87
+ /** 某个用户最近一次的 context_token(回复时要原样带回)。 */
88
+ contextToken(userId) {
89
+ return store.snapshot().contextTokens?.[userId];
90
+ },
91
+
92
+ /** 记录 context_token。 */
93
+ async rememberContextToken(userId, token) {
94
+ if (typeof userId !== 'string' || !userId) return;
95
+ if (typeof token !== 'string' || !token) return;
96
+ if (store.snapshot().contextTokens?.[userId] === token) return;
97
+ await store.update((current) => {
98
+ const contextTokens = { ...(current.contextTokens ?? {}) };
99
+ delete contextTokens[userId];
100
+ contextTokens[userId] = token;
101
+ const keys = Object.keys(contextTokens);
102
+ for (const stale of keys.slice(0, Math.max(0, keys.length - MAX_CONTEXT_TOKENS))) {
103
+ delete contextTokens[stale];
104
+ }
105
+ return { ...current, contextTokens };
106
+ });
107
+ },
108
+
109
+ /**
110
+ * 去重:第一次见到返回 true。
111
+ *
112
+ * @param id - 平台消息 id。
113
+ */
114
+ markSeen(id) {
115
+ if (typeof id !== 'string' || !id) return true;
116
+ const current = store.snapshot();
117
+ if (current.seenMessageIds.includes(id)) return false;
118
+ const seenMessageIds = [...current.seenMessageIds, id].slice(-MAX_SEEN);
119
+ void store.update((doc) => ({ ...doc, seenMessageIds })).catch(() => {
120
+ // 去重集合的落盘失败不影响本轮处理;下次启动可能重复处理一条消息。
121
+ });
122
+ return true;
123
+ },
124
+
125
+ /** 等待已排队的写入落定(停机前调用)。 */
126
+ async flush() {
127
+ await store.flush();
128
+ },
129
+
130
+ /**
131
+ * 记下最近一次处理失败。
132
+ *
133
+ * 目的很直接:出问题时**不需要用户去翻终端**——直接读 state.json 就能看到
134
+ * 最后一条错误的原文与时间。
135
+ *
136
+ * @param message - 错误原文。
137
+ */
138
+ async recordFailure(message) {
139
+ const text = typeof message === 'string' ? message.slice(0, 500) : String(message).slice(0, 500);
140
+ await store.update((current) => ({
141
+ ...current,
142
+ lastError: { message: text, at: new Date().toISOString() },
143
+ })).catch(() => undefined);
144
+ },
145
+ };
146
+ }