@botcord/daemon 0.2.36 → 0.2.37
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/dist/config.d.ts +29 -0
- package/dist/config.js +27 -0
- package/dist/daemon-config-map.d.ts +3 -0
- package/dist/daemon-config-map.js +30 -0
- package/dist/daemon.d.ts +15 -1
- package/dist/daemon.js +56 -11
- package/dist/gateway/channels/botcord.js +44 -0
- package/dist/gateway/channels/http-types.d.ts +19 -0
- package/dist/gateway/channels/http-types.js +1 -0
- package/dist/gateway/channels/index.d.ts +5 -0
- package/dist/gateway/channels/index.js +5 -0
- package/dist/gateway/channels/login-session.d.ts +83 -0
- package/dist/gateway/channels/login-session.js +99 -0
- package/dist/gateway/channels/secret-store.d.ts +21 -0
- package/dist/gateway/channels/secret-store.js +75 -0
- package/dist/gateway/channels/state-store.d.ts +60 -0
- package/dist/gateway/channels/state-store.js +173 -0
- package/dist/gateway/channels/telegram.d.ts +31 -0
- package/dist/gateway/channels/telegram.js +371 -0
- package/dist/gateway/channels/text-split.d.ts +13 -0
- package/dist/gateway/channels/text-split.js +33 -0
- package/dist/gateway/channels/url-guard.d.ts +18 -0
- package/dist/gateway/channels/url-guard.js +53 -0
- package/dist/gateway/channels/wechat-http.d.ts +18 -0
- package/dist/gateway/channels/wechat-http.js +28 -0
- package/dist/gateway/channels/wechat-login.d.ts +36 -0
- package/dist/gateway/channels/wechat-login.js +62 -0
- package/dist/gateway/channels/wechat.d.ts +40 -0
- package/dist/gateway/channels/wechat.js +472 -0
- package/dist/gateway/runtimes/openclaw-acp.js +211 -6
- package/dist/gateway/types.d.ts +10 -0
- package/dist/gateway-control.d.ts +53 -0
- package/dist/gateway-control.js +638 -0
- package/dist/provision.d.ts +7 -0
- package/dist/provision.js +255 -5
- package/package.json +1 -1
- package/src/__tests__/gateway-control.test.ts +499 -0
- package/src/__tests__/openclaw-acp.test.ts +63 -0
- package/src/__tests__/provision.test.ts +179 -0
- package/src/__tests__/secret-store.test.ts +70 -0
- package/src/__tests__/state-store.test.ts +119 -0
- package/src/__tests__/third-party-gateway.test.ts +126 -0
- package/src/__tests__/url-guard.test.ts +85 -0
- package/src/__tests__/wechat-channel.test.ts +1134 -0
- package/src/config.ts +71 -0
- package/src/daemon-config-map.ts +24 -0
- package/src/daemon.ts +70 -11
- package/src/gateway/__tests__/botcord-channel.test.ts +1 -1
- package/src/gateway/__tests__/telegram-channel.test.ts +555 -0
- package/src/gateway/channels/botcord.ts +39 -0
- package/src/gateway/channels/http-types.ts +22 -0
- package/src/gateway/channels/index.ts +22 -0
- package/src/gateway/channels/login-session.ts +135 -0
- package/src/gateway/channels/secret-store.ts +100 -0
- package/src/gateway/channels/state-store.ts +213 -0
- package/src/gateway/channels/telegram.ts +469 -0
- package/src/gateway/channels/text-split.ts +29 -0
- package/src/gateway/channels/url-guard.ts +55 -0
- package/src/gateway/channels/wechat-http.ts +35 -0
- package/src/gateway/channels/wechat-login.ts +90 -0
- package/src/gateway/channels/wechat.ts +572 -0
- package/src/gateway/runtimes/openclaw-acp.ts +211 -7
- package/src/gateway/types.ts +10 -0
- package/src/gateway-control.ts +709 -0
- package/src/provision.ts +336 -5
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelAdapter,
|
|
3
|
+
ChannelSendContext,
|
|
4
|
+
ChannelSendResult,
|
|
5
|
+
ChannelStartContext,
|
|
6
|
+
ChannelStatusSnapshot,
|
|
7
|
+
ChannelStopContext,
|
|
8
|
+
ChannelTypingContext,
|
|
9
|
+
GatewayInboundEnvelope,
|
|
10
|
+
GatewayInboundMessage,
|
|
11
|
+
} from "../types.js";
|
|
12
|
+
import { sanitizeUntrustedContent } from "./sanitize.js";
|
|
13
|
+
import { GatewayStateStore } from "./state-store.js";
|
|
14
|
+
import { loadGatewaySecret } from "./secret-store.js";
|
|
15
|
+
import { splitText } from "./text-split.js";
|
|
16
|
+
import { wechatHeaders, WECHAT_BASE_INFO, type FetchLike } from "./wechat-http.js";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
|
|
19
|
+
const DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Replace every occurrence of `token` in `input` with `"[REDACTED]"`.
|
|
23
|
+
* No-ops when token is falsy (not yet loaded).
|
|
24
|
+
*/
|
|
25
|
+
function redactSecret(input: string, token: string | undefined): string {
|
|
26
|
+
if (!token || !input) return input;
|
|
27
|
+
return input.split(token).join("[REDACTED]");
|
|
28
|
+
}
|
|
29
|
+
const DEFAULT_SPLIT_AT = 1800;
|
|
30
|
+
/** iLink server holds getupdates ≤35s; allow slack on the client timeout. */
|
|
31
|
+
const POLL_TIMEOUT_S = 60;
|
|
32
|
+
const POLL_BACKOFF_MS = 3000;
|
|
33
|
+
const TRANSIENT_BACKOFF_MS = 1000;
|
|
34
|
+
const WECHAT_PROVIDER = "wechat" as const;
|
|
35
|
+
/** Trace -> context_token cache TTL. Doc recommends 30 minutes. */
|
|
36
|
+
const TRACE_CONTEXT_TTL_MS = 30 * 60 * 1000;
|
|
37
|
+
const TRACE_CONTEXT_SWEEP_MS = 5 * 60 * 1000;
|
|
38
|
+
/** W1: hard cap on the traceContexts map to prevent unbounded growth. */
|
|
39
|
+
const TRACE_CONTEXT_MAX = 5000;
|
|
40
|
+
|
|
41
|
+
/** Options accepted by {@link createWechatChannel}. */
|
|
42
|
+
export interface WechatChannelOptions {
|
|
43
|
+
id: string;
|
|
44
|
+
accountId: string;
|
|
45
|
+
/** iLink bot token. When omitted, the adapter loads it from the secret-store on start. */
|
|
46
|
+
botToken?: string;
|
|
47
|
+
baseUrl?: string;
|
|
48
|
+
/** Empty / missing list = default-deny (per security doc §"入站白名单"). */
|
|
49
|
+
allowedSenderIds?: string[];
|
|
50
|
+
splitAt?: number;
|
|
51
|
+
secretFile?: string;
|
|
52
|
+
stateFile?: string;
|
|
53
|
+
/** Test hook: override `globalThis.fetch`. */
|
|
54
|
+
fetchImpl?: FetchLike;
|
|
55
|
+
/** Test hook: synchronous state writes (`debounceMs: 0`). */
|
|
56
|
+
stateDebounceMs?: number;
|
|
57
|
+
/** Test hook: override Date.now() for trace cache TTL assertions. */
|
|
58
|
+
now?: () => number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface WechatSecret {
|
|
62
|
+
botToken?: string;
|
|
63
|
+
[key: string]: unknown;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface WechatItem {
|
|
67
|
+
type?: number;
|
|
68
|
+
text_item?: { text?: string };
|
|
69
|
+
[k: string]: unknown;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface WechatInboundMsg {
|
|
73
|
+
message_type?: number;
|
|
74
|
+
message_state?: number;
|
|
75
|
+
from_user_id?: string;
|
|
76
|
+
to_user_id?: string;
|
|
77
|
+
context_token?: string;
|
|
78
|
+
client_id?: unknown;
|
|
79
|
+
item_list?: WechatItem[];
|
|
80
|
+
[k: string]: unknown;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface WechatGetUpdatesResp {
|
|
84
|
+
ret?: number;
|
|
85
|
+
get_updates_buf?: string;
|
|
86
|
+
msgs?: WechatInboundMsg[];
|
|
87
|
+
[k: string]: unknown;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface WechatGenericResp {
|
|
91
|
+
ret?: number;
|
|
92
|
+
[k: string]: unknown;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface TraceContext {
|
|
96
|
+
contextToken: string;
|
|
97
|
+
fromUserId: string;
|
|
98
|
+
updatedAt: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* WeChat (iLink Bot API) channel adapter.
|
|
103
|
+
*
|
|
104
|
+
* - long-polls `POST /ilink/bot/getupdates` (cursor = `get_updates_buf`,
|
|
105
|
+
* persisted via state-store)
|
|
106
|
+
* - normalizes `message_type === 1` text into a `GatewayInboundEnvelope`
|
|
107
|
+
* with `conversation.id = "wechat:user:${fromUserId}"` and trace id
|
|
108
|
+
* `wechat:${fromUserId}:${receivedAt}` (or `client_id` when present)
|
|
109
|
+
* - per-trace cache binds `traceId → context_token`; `send()` looks up by
|
|
110
|
+
* `GatewayOutboundMessage.traceId` and rejects if missing/expired
|
|
111
|
+
* (no conversation-level fallback — see doc §"WeChat channel adapter")
|
|
112
|
+
* - `send()` splits long replies at `splitAt` (default 1800), preferring
|
|
113
|
+
* newline boundaries; `typing()` caches the per-user `typing_ticket`
|
|
114
|
+
* fetched via `getconfig`.
|
|
115
|
+
*
|
|
116
|
+
* Allowlist is default-deny: an empty (or missing) `allowedSenderIds` rejects
|
|
117
|
+
* every inbound message.
|
|
118
|
+
*/
|
|
119
|
+
export function createWechatChannel(opts: WechatChannelOptions): ChannelAdapter {
|
|
120
|
+
const channelType = WECHAT_PROVIDER;
|
|
121
|
+
const baseUrl = (opts.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
122
|
+
const splitAt = opts.splitAt && opts.splitAt > 0 ? opts.splitAt : DEFAULT_SPLIT_AT;
|
|
123
|
+
const allowedSenderIds = new Set((opts.allowedSenderIds ?? []).map((s) => String(s)));
|
|
124
|
+
const fetchImpl: FetchLike =
|
|
125
|
+
opts.fetchImpl ?? ((globalThis.fetch as unknown) as FetchLike);
|
|
126
|
+
const now: () => number = opts.now ?? (() => Date.now());
|
|
127
|
+
|
|
128
|
+
let botToken: string | undefined = opts.botToken;
|
|
129
|
+
let stateStore: GatewayStateStore | null = null;
|
|
130
|
+
let stopCallback: (() => void) | null = null;
|
|
131
|
+
let sweepTimer: NodeJS.Timeout | null = null;
|
|
132
|
+
let started = false;
|
|
133
|
+
// W11: captured during start() so send() can push lastSendAt to the
|
|
134
|
+
// gateway-tracked snapshot, not just the local statusSnapshot.
|
|
135
|
+
let liveSetStatus:
|
|
136
|
+
| ((patch: Partial<ChannelStatusSnapshot>) => void)
|
|
137
|
+
| null = null;
|
|
138
|
+
|
|
139
|
+
const traceContexts = new Map<string, TraceContext>();
|
|
140
|
+
/** typing_ticket cache keyed by ilink user id. */
|
|
141
|
+
const typingTickets = new Map<string, string>();
|
|
142
|
+
|
|
143
|
+
let statusSnapshot: ChannelStatusSnapshot = {
|
|
144
|
+
channel: opts.id,
|
|
145
|
+
accountId: opts.accountId,
|
|
146
|
+
running: false,
|
|
147
|
+
connected: false,
|
|
148
|
+
reconnectAttempts: 0,
|
|
149
|
+
lastError: null,
|
|
150
|
+
provider: WECHAT_PROVIDER,
|
|
151
|
+
authorized: false,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
function ensureState(): GatewayStateStore {
|
|
155
|
+
if (!stateStore) {
|
|
156
|
+
stateStore = new GatewayStateStore(opts.id, {
|
|
157
|
+
...(opts.stateFile ? { override: opts.stateFile } : {}),
|
|
158
|
+
...(opts.stateDebounceMs !== undefined
|
|
159
|
+
? { debounceMs: opts.stateDebounceMs }
|
|
160
|
+
: {}),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return stateStore;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function loadTokenFromSecretIfNeeded(): string | undefined {
|
|
167
|
+
if (botToken) return botToken;
|
|
168
|
+
const secret = loadGatewaySecret<WechatSecret>(opts.id, opts.secretFile);
|
|
169
|
+
if (secret && typeof secret.botToken === "string" && secret.botToken.length > 0) {
|
|
170
|
+
botToken = secret.botToken;
|
|
171
|
+
}
|
|
172
|
+
return botToken;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function pruneTraceContexts(): void {
|
|
176
|
+
const cutoff = now() - TRACE_CONTEXT_TTL_MS;
|
|
177
|
+
for (const [k, v] of traceContexts) {
|
|
178
|
+
if (v.updatedAt < cutoff) traceContexts.delete(k);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function rememberTrace(traceId: string, ctx: TraceContext): void {
|
|
183
|
+
// W1: prune oldest entry by updatedAt when cap is reached.
|
|
184
|
+
if (traceContexts.size >= TRACE_CONTEXT_MAX) {
|
|
185
|
+
let oldestKey: string | undefined;
|
|
186
|
+
let oldestAt = Infinity;
|
|
187
|
+
for (const [k, v] of traceContexts) {
|
|
188
|
+
if (v.updatedAt < oldestAt) {
|
|
189
|
+
oldestAt = v.updatedAt;
|
|
190
|
+
oldestKey = k;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (oldestKey !== undefined) traceContexts.delete(oldestKey);
|
|
194
|
+
}
|
|
195
|
+
traceContexts.set(traceId, ctx);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function lookupTrace(traceId: string | null | undefined): TraceContext | null {
|
|
199
|
+
if (!traceId) return null;
|
|
200
|
+
const hit = traceContexts.get(traceId);
|
|
201
|
+
if (!hit) return null;
|
|
202
|
+
if (now() - hit.updatedAt > TRACE_CONTEXT_TTL_MS) {
|
|
203
|
+
traceContexts.delete(traceId);
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
return hit;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function callApi<T = WechatGenericResp>(
|
|
210
|
+
path: string,
|
|
211
|
+
body: Record<string, unknown>,
|
|
212
|
+
timeoutMs: number,
|
|
213
|
+
): Promise<T> {
|
|
214
|
+
if (!botToken) throw new Error("wechat bot token not loaded");
|
|
215
|
+
const url = `${baseUrl}/${path.replace(/^\/+/, "")}`;
|
|
216
|
+
const payload = { ...body, base_info: { ...WECHAT_BASE_INFO } };
|
|
217
|
+
// C2: enforce per-call timeout via AbortSignal.timeout — matches telegram.ts.
|
|
218
|
+
const init = {
|
|
219
|
+
method: "POST",
|
|
220
|
+
headers: wechatHeaders(botToken),
|
|
221
|
+
body: JSON.stringify(payload),
|
|
222
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
223
|
+
};
|
|
224
|
+
const resp = await fetchImpl(url, init);
|
|
225
|
+
const raw = await resp.text();
|
|
226
|
+
if (!raw) return {} as T;
|
|
227
|
+
try {
|
|
228
|
+
return JSON.parse(raw) as T;
|
|
229
|
+
} catch {
|
|
230
|
+
return {} as T;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function extractText(msg: WechatInboundMsg): string {
|
|
235
|
+
const parts: string[] = [];
|
|
236
|
+
for (const item of msg.item_list ?? []) {
|
|
237
|
+
if (item?.type === 1) {
|
|
238
|
+
const t = item.text_item?.text;
|
|
239
|
+
if (typeof t === "string" && t.length > 0) parts.push(t);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return parts.join("\n").trim();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function normalizeInbound(msg: WechatInboundMsg): GatewayInboundMessage | null {
|
|
246
|
+
if (msg.message_type !== 1) return null;
|
|
247
|
+
const fromUid = typeof msg.from_user_id === "string" ? msg.from_user_id : "";
|
|
248
|
+
const contextToken = typeof msg.context_token === "string" ? msg.context_token : "";
|
|
249
|
+
if (!fromUid || !contextToken) return null;
|
|
250
|
+
const text = extractText(msg);
|
|
251
|
+
if (!text) return null;
|
|
252
|
+
if (!allowedSenderIds.has(fromUid)) return null;
|
|
253
|
+
|
|
254
|
+
const sanitized = sanitizeUntrustedContent(text);
|
|
255
|
+
const receivedAt = now();
|
|
256
|
+
// W10: append randomUUID() to the fallback so two messages received in
|
|
257
|
+
// the same millisecond can't collide. Trace id below already does this.
|
|
258
|
+
const messageId =
|
|
259
|
+
typeof msg.client_id === "string" && msg.client_id.length > 0
|
|
260
|
+
? msg.client_id
|
|
261
|
+
: `wechat:${fromUid}:${receivedAt}:${randomUUID()}`;
|
|
262
|
+
// Trace id MUST be unique per inbound so the per-trace context cache
|
|
263
|
+
// does not collide when the same user sends two messages back-to-back.
|
|
264
|
+
const traceId = `wechat:${fromUid}:${receivedAt}:${randomUUID()}`;
|
|
265
|
+
|
|
266
|
+
rememberTrace(traceId, {
|
|
267
|
+
contextToken,
|
|
268
|
+
fromUserId: fromUid,
|
|
269
|
+
updatedAt: receivedAt,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
return {
|
|
273
|
+
id: messageId,
|
|
274
|
+
channel: opts.id,
|
|
275
|
+
accountId: opts.accountId,
|
|
276
|
+
conversation: {
|
|
277
|
+
id: `wechat:user:${fromUid}`,
|
|
278
|
+
kind: "direct",
|
|
279
|
+
},
|
|
280
|
+
sender: {
|
|
281
|
+
id: fromUid,
|
|
282
|
+
kind: "user",
|
|
283
|
+
},
|
|
284
|
+
text: sanitized,
|
|
285
|
+
raw: msg,
|
|
286
|
+
replyTo: null,
|
|
287
|
+
mentioned: false,
|
|
288
|
+
receivedAt,
|
|
289
|
+
// streamable: false — iLink has no message-edit / native streaming.
|
|
290
|
+
trace: { id: traceId, streamable: false },
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function pollLoop(ctx: ChannelStartContext): Promise<void> {
|
|
295
|
+
const { abortSignal, log, emit, setStatus } = ctx;
|
|
296
|
+
liveSetStatus = setStatus;
|
|
297
|
+
const state = ensureState();
|
|
298
|
+
|
|
299
|
+
function markStatus(patch: Partial<ChannelStatusSnapshot>) {
|
|
300
|
+
statusSnapshot = { ...statusSnapshot, ...patch };
|
|
301
|
+
setStatus(patch);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!loadTokenFromSecretIfNeeded()) {
|
|
305
|
+
// W2: ensure stop() is a clean no-op even though pollLoop never armed
|
|
306
|
+
// its inner stopCallback. The next `upsert_gateway` (which forwards
|
|
307
|
+
// the freshly-confirmed bot token via secret-store) will rebuild the
|
|
308
|
+
// channel — there is no in-process retry timer here on purpose.
|
|
309
|
+
stopCallback = () => {};
|
|
310
|
+
markStatus({
|
|
311
|
+
running: false,
|
|
312
|
+
connected: false,
|
|
313
|
+
authorized: false,
|
|
314
|
+
lastError: "missing_secret",
|
|
315
|
+
});
|
|
316
|
+
log.error("wechat missing bot token", { gatewayId: opts.id });
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
let updatesBuf: string = state.getCursor() ?? "";
|
|
321
|
+
|
|
322
|
+
// W3: do NOT report `authorized: true` until the first getupdates call
|
|
323
|
+
// returns ret === 0. Mark only the loop as starting so test_gateway and
|
|
324
|
+
// the dashboard see the in-progress state instead of a false positive.
|
|
325
|
+
markStatus({
|
|
326
|
+
running: true,
|
|
327
|
+
connected: false,
|
|
328
|
+
authorized: false,
|
|
329
|
+
reconnectAttempts: 0,
|
|
330
|
+
lastError: null,
|
|
331
|
+
lastStartAt: Date.now(),
|
|
332
|
+
});
|
|
333
|
+
log.info("wechat poll loop starting", {
|
|
334
|
+
gatewayId: opts.id,
|
|
335
|
+
hasCursor: updatesBuf.length > 0,
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
let stopped = false;
|
|
339
|
+
const onAbort = () => {
|
|
340
|
+
stopped = true;
|
|
341
|
+
};
|
|
342
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
343
|
+
stopCallback = () => {
|
|
344
|
+
stopped = true;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
sweepTimer = setInterval(() => {
|
|
348
|
+
try {
|
|
349
|
+
pruneTraceContexts();
|
|
350
|
+
} catch {
|
|
351
|
+
// best-effort
|
|
352
|
+
}
|
|
353
|
+
}, TRACE_CONTEXT_SWEEP_MS);
|
|
354
|
+
if (typeof sweepTimer.unref === "function") sweepTimer.unref();
|
|
355
|
+
|
|
356
|
+
let firstPollOk = false;
|
|
357
|
+
while (!stopped && !abortSignal.aborted) {
|
|
358
|
+
try {
|
|
359
|
+
const resp = await callApi<WechatGetUpdatesResp>(
|
|
360
|
+
"ilink/bot/getupdates",
|
|
361
|
+
{ get_updates_buf: updatesBuf },
|
|
362
|
+
(POLL_TIMEOUT_S + 10) * 1000,
|
|
363
|
+
);
|
|
364
|
+
markStatus({ lastPollAt: Date.now() });
|
|
365
|
+
// W3: a successful response (`ret === 0`) is the only signal we
|
|
366
|
+
// have that the bot token actually authenticates. Promote the
|
|
367
|
+
// channel to authorized only on that boundary.
|
|
368
|
+
if (!firstPollOk && resp.ret === 0) {
|
|
369
|
+
firstPollOk = true;
|
|
370
|
+
markStatus({ connected: true, authorized: true });
|
|
371
|
+
}
|
|
372
|
+
const msgs = Array.isArray(resp.msgs) ? resp.msgs : [];
|
|
373
|
+
// W4: persist the cursor only AFTER all emits return cleanly. If
|
|
374
|
+
// any emit throws, leave updatesBuf and the on-disk cursor alone
|
|
375
|
+
// so the same batch retries on the next poll.
|
|
376
|
+
const nextBuf =
|
|
377
|
+
typeof resp.get_updates_buf === "string" ? resp.get_updates_buf : updatesBuf;
|
|
378
|
+
if (msgs.length === 0) {
|
|
379
|
+
if (nextBuf !== updatesBuf) {
|
|
380
|
+
updatesBuf = nextBuf;
|
|
381
|
+
state.update({ cursor: updatesBuf });
|
|
382
|
+
}
|
|
383
|
+
// Yield a macrotask so abort signals fire even when the test fetch
|
|
384
|
+
// stub resolves synchronously (real iLink getupdates blocks ≤35s).
|
|
385
|
+
await sleep(0, abortSignal);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let emitFailed = false;
|
|
390
|
+
for (const msg of msgs) {
|
|
391
|
+
const normalized = normalizeInbound(msg);
|
|
392
|
+
if (!normalized) continue;
|
|
393
|
+
markStatus({ lastInboundAt: Date.now() });
|
|
394
|
+
const envelope: GatewayInboundEnvelope = { message: normalized };
|
|
395
|
+
try {
|
|
396
|
+
await emit(envelope);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
emitFailed = true;
|
|
399
|
+
log.error("wechat emit threw — leaving cursor unchanged", {
|
|
400
|
+
err: redactSecret(String(err), botToken),
|
|
401
|
+
});
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (!emitFailed && nextBuf !== updatesBuf) {
|
|
406
|
+
updatesBuf = nextBuf;
|
|
407
|
+
state.update({ cursor: updatesBuf });
|
|
408
|
+
}
|
|
409
|
+
} catch (err) {
|
|
410
|
+
if (stopped || abortSignal.aborted) break;
|
|
411
|
+
const name = (err as Error)?.name ?? "";
|
|
412
|
+
if (name === "AbortError" || name === "TimeoutError") {
|
|
413
|
+
log.warn("wechat poll transient", { name });
|
|
414
|
+
await sleep(TRANSIENT_BACKOFF_MS, abortSignal);
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const errStr = redactSecret(String(err), botToken);
|
|
418
|
+
log.error("wechat poll failed", { err: errStr });
|
|
419
|
+
markStatus({ lastError: errStr });
|
|
420
|
+
await sleep(POLL_BACKOFF_MS, abortSignal);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (sweepTimer) {
|
|
425
|
+
clearInterval(sweepTimer);
|
|
426
|
+
sweepTimer = null;
|
|
427
|
+
}
|
|
428
|
+
markStatus({
|
|
429
|
+
running: false,
|
|
430
|
+
connected: false,
|
|
431
|
+
lastStopAt: Date.now(),
|
|
432
|
+
});
|
|
433
|
+
try {
|
|
434
|
+
state.flush();
|
|
435
|
+
} catch (e) {
|
|
436
|
+
log.warn("state-flush-on-stop failed", { error: String(e) });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function getTypingTicket(userId: string, contextToken: string): Promise<string> {
|
|
441
|
+
const cached = typingTickets.get(userId);
|
|
442
|
+
if (cached) return cached;
|
|
443
|
+
try {
|
|
444
|
+
const resp = await callApi<{ ret?: number; typing_ticket?: string }>(
|
|
445
|
+
"ilink/bot/getconfig",
|
|
446
|
+
{ ilink_user_id: userId, context_token: contextToken },
|
|
447
|
+
10_000,
|
|
448
|
+
);
|
|
449
|
+
const ticket = typeof resp.typing_ticket === "string" ? resp.typing_ticket : "";
|
|
450
|
+
if (ticket) typingTickets.set(userId, ticket);
|
|
451
|
+
return ticket;
|
|
452
|
+
} catch {
|
|
453
|
+
return "";
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const adapter: ChannelAdapter = {
|
|
458
|
+
id: opts.id,
|
|
459
|
+
type: channelType,
|
|
460
|
+
|
|
461
|
+
async start(ctx: ChannelStartContext): Promise<void> {
|
|
462
|
+
if (started) throw new Error("already started");
|
|
463
|
+
started = true;
|
|
464
|
+
await pollLoop(ctx);
|
|
465
|
+
},
|
|
466
|
+
|
|
467
|
+
async stop(_ctx: ChannelStopContext): Promise<void> {
|
|
468
|
+
if (stopCallback) {
|
|
469
|
+
stopCallback();
|
|
470
|
+
stopCallback = null;
|
|
471
|
+
}
|
|
472
|
+
if (sweepTimer) {
|
|
473
|
+
clearInterval(sweepTimer);
|
|
474
|
+
sweepTimer = null;
|
|
475
|
+
}
|
|
476
|
+
try {
|
|
477
|
+
stateStore?.flush();
|
|
478
|
+
} catch (e) {
|
|
479
|
+
// W7: log flush failures at stop — previously swallowed silently.
|
|
480
|
+
// No ctx.log here; use console to avoid import cycle.
|
|
481
|
+
console.warn("[wechat] state-flush-on-stop failed", String(e));
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
|
|
485
|
+
async send(ctx: ChannelSendContext): Promise<ChannelSendResult> {
|
|
486
|
+
const { message, log } = ctx;
|
|
487
|
+
if (!loadTokenFromSecretIfNeeded()) {
|
|
488
|
+
throw new Error("wechat bot token not loaded");
|
|
489
|
+
}
|
|
490
|
+
const trace = lookupTrace(message.traceId);
|
|
491
|
+
if (!trace) {
|
|
492
|
+
throw new Error(
|
|
493
|
+
`wechat send: no context_token for traceId=${message.traceId ?? "<missing>"}` +
|
|
494
|
+
` (expired or never bound — daemon does not support unsolicited replies)`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const chunks = splitText(message.text, splitAt);
|
|
499
|
+
let lastClientId: string | null = null;
|
|
500
|
+
for (const chunk of chunks) {
|
|
501
|
+
const clientId = `botcord-${randomUUID()}`;
|
|
502
|
+
const body = {
|
|
503
|
+
msg: {
|
|
504
|
+
from_user_id: "",
|
|
505
|
+
to_user_id: trace.fromUserId,
|
|
506
|
+
client_id: clientId,
|
|
507
|
+
message_type: 2, // BOT → user
|
|
508
|
+
message_state: 2, // FINISH
|
|
509
|
+
context_token: trace.contextToken,
|
|
510
|
+
item_list: [{ type: 1, text_item: { text: chunk } }],
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
const resp = await callApi<WechatGenericResp>("ilink/bot/sendmessage", body, 15_000);
|
|
514
|
+
if (resp.ret !== 0 && resp.ret !== undefined) {
|
|
515
|
+
log.warn("wechat sendmessage non-zero ret", { ret: resp.ret });
|
|
516
|
+
throw new Error(redactSecret(`wechat sendmessage failed: ret=${resp.ret}`, botToken));
|
|
517
|
+
}
|
|
518
|
+
lastClientId = clientId;
|
|
519
|
+
}
|
|
520
|
+
const sendAt = Date.now();
|
|
521
|
+
statusSnapshot = { ...statusSnapshot, lastSendAt: sendAt };
|
|
522
|
+
// W11: push to the gateway snapshot too — the dashboard reads this.
|
|
523
|
+
if (liveSetStatus) liveSetStatus({ lastSendAt: sendAt });
|
|
524
|
+
return { providerMessageId: lastClientId };
|
|
525
|
+
},
|
|
526
|
+
|
|
527
|
+
async typing(ctx: ChannelTypingContext): Promise<void> {
|
|
528
|
+
if (!loadTokenFromSecretIfNeeded()) return;
|
|
529
|
+
const trace = lookupTrace(ctx.traceId);
|
|
530
|
+
if (!trace) return;
|
|
531
|
+
try {
|
|
532
|
+
const ticket = await getTypingTicket(trace.fromUserId, trace.contextToken);
|
|
533
|
+
if (!ticket) return;
|
|
534
|
+
await callApi(
|
|
535
|
+
"ilink/bot/sendtyping",
|
|
536
|
+
{
|
|
537
|
+
ilink_user_id: trace.fromUserId,
|
|
538
|
+
typing_ticket: ticket,
|
|
539
|
+
status: 1,
|
|
540
|
+
},
|
|
541
|
+
10_000,
|
|
542
|
+
);
|
|
543
|
+
} catch (err) {
|
|
544
|
+
ctx.log.warn("wechat typing failed", { err: redactSecret(String(err), botToken) });
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
|
|
548
|
+
status(): ChannelStatusSnapshot {
|
|
549
|
+
return { ...statusSnapshot };
|
|
550
|
+
},
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
return adapter;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
557
|
+
return new Promise((resolve) => {
|
|
558
|
+
if (signal?.aborted) {
|
|
559
|
+
resolve();
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const timer = setTimeout(() => {
|
|
563
|
+
signal?.removeEventListener("abort", onAbort);
|
|
564
|
+
resolve();
|
|
565
|
+
}, ms);
|
|
566
|
+
const onAbort = () => {
|
|
567
|
+
clearTimeout(timer);
|
|
568
|
+
resolve();
|
|
569
|
+
};
|
|
570
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
571
|
+
});
|
|
572
|
+
}
|