@lanyingim/lanying 1.0.0
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/README.md +105 -0
- package/index.ts +17 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +15 -0
- package/src/channel.ts +1028 -0
- package/src/lanying-im-sdk/floo-3.0.0.js +2 -0
- package/src/runtime.ts +14 -0
- package/src/types.ts +44 -0
- package/tsconfig.json +15 -0
package/src/channel.ts
ADDED
|
@@ -0,0 +1,1028 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import {
|
|
8
|
+
formatPairingApproveHint,
|
|
9
|
+
type ChannelPlugin,
|
|
10
|
+
type OpenClawConfig,
|
|
11
|
+
} from "openclaw/plugin-sdk";
|
|
12
|
+
import { getLanyingRuntime } from "./runtime.js";
|
|
13
|
+
import {
|
|
14
|
+
LANYING_CHANNEL_ID,
|
|
15
|
+
LANYING_DEFAULT_ACCOUNT_ID,
|
|
16
|
+
type LanyingChannelConfig,
|
|
17
|
+
type LanyingInboundEvent,
|
|
18
|
+
type LanyingMessageTarget,
|
|
19
|
+
type ResolvedLanyingAccount,
|
|
20
|
+
} from "./types.js";
|
|
21
|
+
|
|
22
|
+
type FlooFactory = (options: Record<string, unknown>) => LanyingImClient;
|
|
23
|
+
|
|
24
|
+
type LanyingImClient = {
|
|
25
|
+
login: (params: {
|
|
26
|
+
name?: string;
|
|
27
|
+
password: string;
|
|
28
|
+
}) => Promise<unknown>;
|
|
29
|
+
on: (
|
|
30
|
+
eventOrMap: string | Record<string, (...args: unknown[]) => void>,
|
|
31
|
+
cb?: (...args: unknown[]) => void,
|
|
32
|
+
) => unknown;
|
|
33
|
+
off?: (
|
|
34
|
+
eventOrMap: string | Record<string, (...args: unknown[]) => void>,
|
|
35
|
+
cb?: (...args: unknown[]) => void,
|
|
36
|
+
) => unknown;
|
|
37
|
+
disConnect?: () => unknown;
|
|
38
|
+
logout?: () => unknown;
|
|
39
|
+
isReady?: () => boolean;
|
|
40
|
+
isLogin?: () => boolean;
|
|
41
|
+
listen: (
|
|
42
|
+
eventOrMap: string | Record<string, (...args: unknown[]) => void>,
|
|
43
|
+
cb?: (...args: unknown[]) => void,
|
|
44
|
+
) => unknown;
|
|
45
|
+
sysManage: {
|
|
46
|
+
sendRosterMessage: (params: {
|
|
47
|
+
type: string;
|
|
48
|
+
uid: string;
|
|
49
|
+
content: string;
|
|
50
|
+
attachment?: unknown;
|
|
51
|
+
}) => Promise<unknown>;
|
|
52
|
+
sendGroupMessage: (params: {
|
|
53
|
+
type: string;
|
|
54
|
+
gid: string;
|
|
55
|
+
content: string;
|
|
56
|
+
attachment?: unknown;
|
|
57
|
+
}) => Promise<unknown>;
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const meta = {
|
|
62
|
+
id: LANYING_CHANNEL_ID,
|
|
63
|
+
label: "Lanying",
|
|
64
|
+
selectionLabel: "Lanying IM",
|
|
65
|
+
detailLabel: "Lanying IM",
|
|
66
|
+
docsPath: "/channels/lanying",
|
|
67
|
+
docsLabel: "lanying",
|
|
68
|
+
blurb: "Lanying IM channel for OpenClaw.",
|
|
69
|
+
order: 90,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const require = createRequire(import.meta.url);
|
|
73
|
+
const sdkModulePath = path.resolve(
|
|
74
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
75
|
+
"./lanying-im-sdk/floo-3.0.0.js",
|
|
76
|
+
);
|
|
77
|
+
let cachedFlooFactory: FlooFactory | null = null;
|
|
78
|
+
const READY_TIMEOUT_MS = 15_000;
|
|
79
|
+
const READY_POLL_MS = 250;
|
|
80
|
+
const RECONNECT_BASE_DELAY_MS = 2_000;
|
|
81
|
+
const RECONNECT_MAX_DELAY_MS = 30_000;
|
|
82
|
+
|
|
83
|
+
class NodeXmlHttpRequest {
|
|
84
|
+
readyState = 0;
|
|
85
|
+
status = 0;
|
|
86
|
+
statusText = "";
|
|
87
|
+
responseType = "";
|
|
88
|
+
response: unknown = null;
|
|
89
|
+
responseText = "";
|
|
90
|
+
timeout = 0;
|
|
91
|
+
withCredentials = false;
|
|
92
|
+
onreadystatechange: ((this: any, ev: any) => any) | null = null;
|
|
93
|
+
onloadend: ((this: any, ev: any) => any) | null = null;
|
|
94
|
+
onerror: ((this: any, ev: any) => any) | null = null;
|
|
95
|
+
ontimeout: ((this: any, ev: any) => any) | null = null;
|
|
96
|
+
onabort: ((this: any, ev: any) => any) | null = null;
|
|
97
|
+
private method = "GET";
|
|
98
|
+
private url = "";
|
|
99
|
+
private requestHeaders = new Headers();
|
|
100
|
+
private responseHeaders = "";
|
|
101
|
+
private aborted = false;
|
|
102
|
+
private abortController: AbortController | null = null;
|
|
103
|
+
|
|
104
|
+
open(method: string, url: string): void {
|
|
105
|
+
this.method = method;
|
|
106
|
+
this.url = url;
|
|
107
|
+
this.readyState = 1;
|
|
108
|
+
this.emitReadyStateChange();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
setRequestHeader(name: string, value: string): void {
|
|
112
|
+
this.requestHeaders.set(name, value);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getAllResponseHeaders(): string {
|
|
116
|
+
return this.responseHeaders;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
abort(): void {
|
|
120
|
+
this.aborted = true;
|
|
121
|
+
this.abortController?.abort();
|
|
122
|
+
this.readyState = 4;
|
|
123
|
+
this.emitReadyStateChange();
|
|
124
|
+
this.emit("onabort");
|
|
125
|
+
this.emit("onloadend");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
send(data?: BodyInit | null): void {
|
|
129
|
+
this.abortController = new AbortController();
|
|
130
|
+
const timeoutId =
|
|
131
|
+
this.timeout > 0
|
|
132
|
+
? setTimeout(() => {
|
|
133
|
+
this.abortController?.abort();
|
|
134
|
+
this.emit("ontimeout");
|
|
135
|
+
this.emit("onloadend");
|
|
136
|
+
}, this.timeout)
|
|
137
|
+
: null;
|
|
138
|
+
|
|
139
|
+
fetch(this.url, {
|
|
140
|
+
method: this.method,
|
|
141
|
+
headers: this.requestHeaders,
|
|
142
|
+
body: this.method === "GET" || this.method === "HEAD" ? undefined : data ?? undefined,
|
|
143
|
+
signal: this.abortController.signal,
|
|
144
|
+
})
|
|
145
|
+
.then(async (res) => {
|
|
146
|
+
if (this.aborted) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
this.status = res.status;
|
|
150
|
+
this.statusText = res.statusText;
|
|
151
|
+
this.responseHeaders = "";
|
|
152
|
+
res.headers.forEach((value, key) => {
|
|
153
|
+
this.responseHeaders += `${key}: ${value}\r\n`;
|
|
154
|
+
});
|
|
155
|
+
const text = await res.text();
|
|
156
|
+
this.responseText = text;
|
|
157
|
+
this.response = text;
|
|
158
|
+
this.readyState = 4;
|
|
159
|
+
this.emitReadyStateChange();
|
|
160
|
+
this.emit("onloadend");
|
|
161
|
+
})
|
|
162
|
+
.catch((err) => {
|
|
163
|
+
if (this.aborted) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
logWarn("xhr polyfill fetch error", err);
|
|
167
|
+
this.readyState = 4;
|
|
168
|
+
this.emitReadyStateChange();
|
|
169
|
+
this.emit("onerror");
|
|
170
|
+
this.emit("onloadend");
|
|
171
|
+
})
|
|
172
|
+
.finally(() => {
|
|
173
|
+
if (timeoutId) {
|
|
174
|
+
clearTimeout(timeoutId);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private emitReadyStateChange(): void {
|
|
180
|
+
this.onreadystatechange?.call(this, { type: "readystatechange" });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private emit(
|
|
184
|
+
key: "onloadend" | "onerror" | "ontimeout" | "onabort",
|
|
185
|
+
): void {
|
|
186
|
+
const fn = this[key];
|
|
187
|
+
fn?.call(this, { type: key });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function ensureXmlHttpRequestPolyfill(): void {
|
|
192
|
+
if (typeof (globalThis as { XMLHttpRequest?: unknown }).XMLHttpRequest !== "undefined") {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
(globalThis as { XMLHttpRequest: typeof NodeXmlHttpRequest }).XMLHttpRequest =
|
|
196
|
+
NodeXmlHttpRequest;
|
|
197
|
+
logDebug("installed XMLHttpRequest polyfill for lanying sdk");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function logDebug(message: string, data?: unknown): void {
|
|
201
|
+
if (data === undefined) {
|
|
202
|
+
console.log(`[lanying] ${message}`);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
console.log(`[lanying] ${message}`, data);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function logWarn(message: string, data?: unknown): void {
|
|
209
|
+
if (data === undefined) {
|
|
210
|
+
console.warn(`[lanying] ${message}`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
console.warn(`[lanying] ${message}`, data);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function logError(message: string, err?: unknown): void {
|
|
217
|
+
if (err === undefined) {
|
|
218
|
+
console.error(`[lanying] ${message}`);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
console.error(`[lanying] ${message}`, err);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function sleep(ms: number): Promise<void> {
|
|
225
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function loadFlooFactory(): FlooFactory {
|
|
229
|
+
if (cachedFlooFactory) {
|
|
230
|
+
return cachedFlooFactory;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
ensureXmlHttpRequestPolyfill();
|
|
234
|
+
logDebug("loading lanying sdk", { sdkModulePath });
|
|
235
|
+
const code = readFileSync(sdkModulePath, "utf8");
|
|
236
|
+
const hash = createHash("sha1").update(code).digest("hex").slice(0, 12);
|
|
237
|
+
const runtimeCjsDir = path.join(os.tmpdir(), "openclaw-lanying-sdk");
|
|
238
|
+
const runtimeCjsPath = path.join(runtimeCjsDir, `floo-3.0.0-${hash}.cjs`);
|
|
239
|
+
|
|
240
|
+
if (!existsSync(runtimeCjsDir)) {
|
|
241
|
+
mkdirSync(runtimeCjsDir, { recursive: true });
|
|
242
|
+
}
|
|
243
|
+
if (!existsSync(runtimeCjsPath)) {
|
|
244
|
+
copyFileSync(sdkModulePath, runtimeCjsPath);
|
|
245
|
+
logDebug("copied sdk to cjs runtime path", { runtimeCjsPath });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const sdk = require(runtimeCjsPath) as Record<string, unknown>;
|
|
249
|
+
const floo =
|
|
250
|
+
typeof sdk.flooim === "function"
|
|
251
|
+
? sdk.flooim
|
|
252
|
+
: typeof sdk.default === "function"
|
|
253
|
+
? sdk.default
|
|
254
|
+
: sdk;
|
|
255
|
+
|
|
256
|
+
if (typeof floo !== "function") {
|
|
257
|
+
throw new Error("Invalid Lanying SDK export: flooim factory not found");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
cachedFlooFactory = floo as FlooFactory;
|
|
261
|
+
logDebug("lanying sdk loaded");
|
|
262
|
+
return cachedFlooFactory;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function pickId(value: unknown): string {
|
|
266
|
+
if (typeof value === "string" || typeof value === "number") {
|
|
267
|
+
return String(value);
|
|
268
|
+
}
|
|
269
|
+
if (value && typeof value === "object") {
|
|
270
|
+
const uid = (value as { uid?: unknown }).uid;
|
|
271
|
+
if (typeof uid === "string" || typeof uid === "number") {
|
|
272
|
+
return String(uid);
|
|
273
|
+
}
|
|
274
|
+
const id = (value as { id?: unknown }).id;
|
|
275
|
+
if (typeof id === "string" || typeof id === "number") {
|
|
276
|
+
return String(id);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return "";
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function extractText(event: LanyingInboundEvent): string {
|
|
283
|
+
const eventAny = event as Record<string, unknown>;
|
|
284
|
+
const meta = (eventAny.meta ?? eventAny) as Record<string, unknown>;
|
|
285
|
+
const candidates: unknown[] = [
|
|
286
|
+
event.msg,
|
|
287
|
+
event.text,
|
|
288
|
+
event.content,
|
|
289
|
+
event.payload?.msg,
|
|
290
|
+
event.payload?.text,
|
|
291
|
+
event.payload?.content,
|
|
292
|
+
(event as { body?: unknown }).body,
|
|
293
|
+
(event as { message?: unknown }).message,
|
|
294
|
+
(event as { data?: unknown }).data,
|
|
295
|
+
meta.content,
|
|
296
|
+
(meta.payload as Record<string, unknown> | undefined)?.content,
|
|
297
|
+
(meta.payload as Record<string, unknown> | undefined)?.text,
|
|
298
|
+
];
|
|
299
|
+
|
|
300
|
+
for (const item of candidates) {
|
|
301
|
+
if (item == null) {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (typeof item === "string") {
|
|
305
|
+
const trimmed = item.trim();
|
|
306
|
+
if (!trimmed) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
310
|
+
try {
|
|
311
|
+
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
|
|
312
|
+
const parsedText = [parsed.msg, parsed.text, parsed.content].find(
|
|
313
|
+
(x) => typeof x === "string" && x.trim().length > 0,
|
|
314
|
+
);
|
|
315
|
+
if (typeof parsedText === "string") {
|
|
316
|
+
return parsedText;
|
|
317
|
+
}
|
|
318
|
+
} catch {
|
|
319
|
+
// Keep raw string fallback.
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return trimmed;
|
|
323
|
+
}
|
|
324
|
+
if (typeof item === "object") {
|
|
325
|
+
const asObj = item as Record<string, unknown>;
|
|
326
|
+
const nested = [asObj.msg, asObj.text, asObj.content].find(
|
|
327
|
+
(x) => typeof x === "string" && x.trim().length > 0,
|
|
328
|
+
);
|
|
329
|
+
if (typeof nested === "string") {
|
|
330
|
+
return nested;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return "";
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function normalizeTarget(raw: string): LanyingMessageTarget | null {
|
|
339
|
+
const trimmed = raw.trim();
|
|
340
|
+
if (!trimmed) {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const normalized = trimmed.replace(/^lanying:/i, "");
|
|
345
|
+
if (/^(group|g):/i.test(normalized)) {
|
|
346
|
+
return { kind: "group", id: normalized.replace(/^(group|g):/i, "").trim() };
|
|
347
|
+
}
|
|
348
|
+
if (/^(user|u):/i.test(normalized)) {
|
|
349
|
+
return { kind: "user", id: normalized.replace(/^(user|u):/i, "").trim() };
|
|
350
|
+
}
|
|
351
|
+
return { kind: "user", id: normalized };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function sanitizeAccountForLog(account: ResolvedLanyingAccount): Record<string, unknown> {
|
|
355
|
+
return {
|
|
356
|
+
accountId: account.accountId,
|
|
357
|
+
enabled: account.enabled,
|
|
358
|
+
configured: account.configured,
|
|
359
|
+
appId: account.appId ? `${account.appId.slice(0, 4)}***` : "",
|
|
360
|
+
username: account.username,
|
|
361
|
+
dmPolicy: account.dmPolicy,
|
|
362
|
+
allowFromCount: account.allowFrom.length,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function resolveLanyingConfig(cfg: OpenClawConfig): LanyingChannelConfig {
|
|
367
|
+
const channels = cfg?.channels as Record<string, unknown> | undefined;
|
|
368
|
+
const raw = channels?.[LANYING_CHANNEL_ID];
|
|
369
|
+
if (!raw || typeof raw !== "object") {
|
|
370
|
+
return {};
|
|
371
|
+
}
|
|
372
|
+
return raw as LanyingChannelConfig;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function resolveLanyingAccount(cfg: OpenClawConfig): ResolvedLanyingAccount {
|
|
376
|
+
const channelCfg = resolveLanyingConfig(cfg);
|
|
377
|
+
const appIdRaw = channelCfg.app_id ?? "";
|
|
378
|
+
const usernameRaw = channelCfg.username ?? "";
|
|
379
|
+
const passwordRaw = channelCfg.password ?? "";
|
|
380
|
+
|
|
381
|
+
const appId = String(appIdRaw).trim();
|
|
382
|
+
const username = String(usernameRaw).trim();
|
|
383
|
+
const password = String(passwordRaw).trim();
|
|
384
|
+
const enabled = channelCfg.enabled !== false;
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
accountId: LANYING_DEFAULT_ACCOUNT_ID,
|
|
388
|
+
enabled,
|
|
389
|
+
configured: Boolean(enabled && appId && username && password),
|
|
390
|
+
appId,
|
|
391
|
+
username,
|
|
392
|
+
password,
|
|
393
|
+
dmPolicy: channelCfg.dmPolicy ?? "pairing",
|
|
394
|
+
allowFrom: (channelCfg.allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean),
|
|
395
|
+
defaultTo: channelCfg.defaultTo?.trim() || undefined,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
class LanyingSession {
|
|
400
|
+
private client: LanyingImClient | null = null;
|
|
401
|
+
private accountKey: string | null = null;
|
|
402
|
+
private loginPromise: Promise<void> | null = null;
|
|
403
|
+
private reconnectPromise: Promise<void> | null = null;
|
|
404
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
405
|
+
private reconnectAttempts = 0;
|
|
406
|
+
private listenersBound = false;
|
|
407
|
+
private shuttingDown = false;
|
|
408
|
+
private selfId = "";
|
|
409
|
+
private lastConfig?: ResolvedLanyingAccount;
|
|
410
|
+
|
|
411
|
+
private currentConfigKey(account: ResolvedLanyingAccount): string {
|
|
412
|
+
return `${account.appId}::${account.username}::${account.password}`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
private async createClient(account: ResolvedLanyingAccount): Promise<LanyingImClient> {
|
|
416
|
+
const flooFactory = loadFlooFactory();
|
|
417
|
+
const im = flooFactory({
|
|
418
|
+
appid: account.appId,
|
|
419
|
+
ws: true,
|
|
420
|
+
autoLogin: false,
|
|
421
|
+
logLevel: "off",
|
|
422
|
+
});
|
|
423
|
+
logDebug("im client created", { appId: `${account.appId.slice(0, 4)}***` });
|
|
424
|
+
return im;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private bindListeners(account: ResolvedLanyingAccount): void {
|
|
428
|
+
if (!this.client || this.listenersBound) {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
this.listenersBound = true;
|
|
432
|
+
logDebug("binding inbound listeners");
|
|
433
|
+
const onDirect = (name: string, event: unknown) => {
|
|
434
|
+
logDebug(`inbound event: ${name}`, event);
|
|
435
|
+
void this.onInbound(event as LanyingInboundEvent, "direct", account);
|
|
436
|
+
};
|
|
437
|
+
const onGroup = (name: string, event: unknown) => {
|
|
438
|
+
logDebug(`inbound event: ${name}`, event);
|
|
439
|
+
logDebug("skip group event (direct-only mode)", { name });
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
// Subscribe only to documented public events from floo-web types.
|
|
443
|
+
this.client.listen({
|
|
444
|
+
onRosterMessage: (event: unknown) => onDirect("onRosterMessage", event),
|
|
445
|
+
onRosterMessageContentAppend: (event: unknown) =>
|
|
446
|
+
onDirect("onRosterMessageContentAppend", event),
|
|
447
|
+
onRosterMessageReplace: (event: unknown) => onDirect("onRosterMessageReplace", event),
|
|
448
|
+
onRosterRTCMessage: (event: unknown) => onDirect("onRosterRTCMessage", event),
|
|
449
|
+
onGroupMessage: (event: unknown) => onGroup("onGroupMessage", event),
|
|
450
|
+
onGroupMessageContentAppend: (event: unknown) =>
|
|
451
|
+
onGroup("onGroupMessageContentAppend", event),
|
|
452
|
+
onGroupMessageReplace: (event: unknown) => onGroup("onGroupMessageReplace", event),
|
|
453
|
+
onMentionMessage: (event: unknown) => onGroup("onMentionMessage", event),
|
|
454
|
+
onReceiveHistoryMsg: (event: unknown) => logDebug("onReceiveHistoryMsg event", event),
|
|
455
|
+
onMessageStatusChanged: (event: unknown) => logDebug("onMessageStatusChanged event", event),
|
|
456
|
+
onSendingMessageStatusChanged: (event: unknown) => {
|
|
457
|
+
logDebug("onSendingMessageStatusChanged event", event);
|
|
458
|
+
const evt = event as Record<string, unknown>;
|
|
459
|
+
const msg = (evt.message ?? {}) as Record<string, unknown>;
|
|
460
|
+
const sender = pickId(msg.from) || pickId(evt.from);
|
|
461
|
+
if (sender && sender !== this.selfId) {
|
|
462
|
+
this.selfId = sender;
|
|
463
|
+
logDebug("learned selfId from sending status event", { selfId: this.selfId });
|
|
464
|
+
}
|
|
465
|
+
},
|
|
466
|
+
onUnreadChange: (event: unknown) => logDebug("onUnreadChange event", event),
|
|
467
|
+
onRosterListUpdate: (event: unknown) => logDebug("onRosterListUpdate event", event),
|
|
468
|
+
onGroupListUpdate: (event: unknown) => logDebug("onGroupListUpdate event", event),
|
|
469
|
+
onGroupMemberChanged: (event: unknown) => logDebug("onGroupMemberChanged event", event),
|
|
470
|
+
loginSuccess: (event: unknown) => logDebug("loginSuccess event", event),
|
|
471
|
+
loginFail: (event: unknown) => {
|
|
472
|
+
logWarn("loginFail event", event);
|
|
473
|
+
this.scheduleReconnect("loginFail");
|
|
474
|
+
},
|
|
475
|
+
messageNormal: (event: unknown) => logDebug("messageNormal event", event),
|
|
476
|
+
flooNotice: (event: unknown) => {
|
|
477
|
+
logDebug("flooNotice event", event);
|
|
478
|
+
},
|
|
479
|
+
flooError: (event: unknown) => {
|
|
480
|
+
logWarn("flooError event", event);
|
|
481
|
+
this.scheduleReconnect("flooError");
|
|
482
|
+
},
|
|
483
|
+
reconnect: (event: unknown) => {
|
|
484
|
+
logWarn("reconnect event", event);
|
|
485
|
+
this.scheduleReconnect("reconnect");
|
|
486
|
+
},
|
|
487
|
+
disconnected: (event: unknown) => {
|
|
488
|
+
logWarn("disconnected", event);
|
|
489
|
+
this.scheduleReconnect("disconnected");
|
|
490
|
+
},
|
|
491
|
+
connected: (event: unknown) => {
|
|
492
|
+
logDebug("connected", event);
|
|
493
|
+
this.resetReconnectState("connected");
|
|
494
|
+
},
|
|
495
|
+
auth: (event: unknown) => {
|
|
496
|
+
logDebug("auth event", event);
|
|
497
|
+
},
|
|
498
|
+
message: (event: unknown) => {
|
|
499
|
+
logDebug("generic message event", event);
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private resetReconnectState(reason: string): void {
|
|
505
|
+
if (this.reconnectTimer) {
|
|
506
|
+
clearTimeout(this.reconnectTimer);
|
|
507
|
+
this.reconnectTimer = null;
|
|
508
|
+
}
|
|
509
|
+
this.reconnectAttempts = 0;
|
|
510
|
+
this.reconnectPromise = null;
|
|
511
|
+
logDebug("reconnect state reset", { reason });
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private scheduleReconnect(trigger: string): void {
|
|
515
|
+
if (this.shuttingDown) {
|
|
516
|
+
logDebug("skip reconnect: session is shutting down", { trigger });
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (!this.client) {
|
|
520
|
+
logDebug("skip reconnect: client missing", { trigger });
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (!this.lastConfig || !this.lastConfig.enabled || !this.lastConfig.configured) {
|
|
524
|
+
logWarn("skip reconnect: account config unavailable", { trigger });
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
if (this.loginPromise || this.reconnectPromise) {
|
|
528
|
+
logDebug("skip reconnect: login/reconnect in progress", { trigger });
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
if (this.client.isLogin?.()) {
|
|
532
|
+
logDebug("skip reconnect: already logged in", { trigger });
|
|
533
|
+
this.resetReconnectState("already_logged_in");
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (this.reconnectTimer) {
|
|
537
|
+
logDebug("skip reconnect: timer already scheduled", { trigger });
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const exp = Math.min(this.reconnectAttempts, 6);
|
|
542
|
+
const delay = Math.min(RECONNECT_MAX_DELAY_MS, RECONNECT_BASE_DELAY_MS * 2 ** exp);
|
|
543
|
+
this.reconnectAttempts += 1;
|
|
544
|
+
logWarn("schedule reconnect", {
|
|
545
|
+
trigger,
|
|
546
|
+
attempt: this.reconnectAttempts,
|
|
547
|
+
delayMs: delay,
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
this.reconnectTimer = setTimeout(() => {
|
|
551
|
+
this.reconnectTimer = null;
|
|
552
|
+
const run = (async () => {
|
|
553
|
+
const cfg = this.lastConfig;
|
|
554
|
+
if (!cfg || this.shuttingDown) {
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
try {
|
|
558
|
+
logWarn("reconnect attempt start", {
|
|
559
|
+
attempt: this.reconnectAttempts,
|
|
560
|
+
username: cfg.username,
|
|
561
|
+
});
|
|
562
|
+
await this.ensureReady(cfg);
|
|
563
|
+
logWarn("reconnect attempt success", {
|
|
564
|
+
attempt: this.reconnectAttempts,
|
|
565
|
+
username: cfg.username,
|
|
566
|
+
});
|
|
567
|
+
this.resetReconnectState("reconnect_success");
|
|
568
|
+
} catch (err) {
|
|
569
|
+
logError("reconnect attempt failed", err);
|
|
570
|
+
this.reconnectPromise = null;
|
|
571
|
+
this.scheduleReconnect("reconnect_failed");
|
|
572
|
+
}
|
|
573
|
+
})();
|
|
574
|
+
this.reconnectPromise = run;
|
|
575
|
+
void run.finally(() => {
|
|
576
|
+
if (this.reconnectPromise === run) {
|
|
577
|
+
this.reconnectPromise = null;
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
}, delay);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private async onInbound(
|
|
584
|
+
event: LanyingInboundEvent,
|
|
585
|
+
mode: "direct" | "group",
|
|
586
|
+
account: ResolvedLanyingAccount,
|
|
587
|
+
): Promise<void> {
|
|
588
|
+
try {
|
|
589
|
+
if (mode !== "direct") {
|
|
590
|
+
logDebug("skip non-direct inbound", { mode });
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const eventAny = event as Record<string, unknown>;
|
|
594
|
+
const meta = (eventAny.meta ?? eventAny) as Record<string, unknown>;
|
|
595
|
+
const isHistoryRaw = (eventAny.isHistory ?? meta.isHistory) as unknown;
|
|
596
|
+
const isHistory =
|
|
597
|
+
isHistoryRaw === true || isHistoryRaw === "true" || isHistoryRaw === 1 || isHistoryRaw === "1";
|
|
598
|
+
if (isHistory) {
|
|
599
|
+
logDebug("skip history inbound event", {
|
|
600
|
+
mode,
|
|
601
|
+
id: pickId(eventAny.id ?? meta.id),
|
|
602
|
+
});
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const senderId =
|
|
606
|
+
pickId(event.from) ||
|
|
607
|
+
pickId(event.sender_id) ||
|
|
608
|
+
pickId((event as { sender?: unknown }).sender) ||
|
|
609
|
+
pickId((event as { uid?: unknown }).uid) ||
|
|
610
|
+
pickId(meta.from);
|
|
611
|
+
|
|
612
|
+
const body = extractText(event);
|
|
613
|
+
if (!body) {
|
|
614
|
+
logDebug("skip empty inbound", { mode, eventType: event.type });
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const toId =
|
|
619
|
+
pickId(event.to) ||
|
|
620
|
+
pickId((event as { to_id?: unknown }).to_id) ||
|
|
621
|
+
pickId(meta.to) ||
|
|
622
|
+
pickId(meta.uid) ||
|
|
623
|
+
pickId(meta.xid);
|
|
624
|
+
const directPeer =
|
|
625
|
+
pickId(event.from) ||
|
|
626
|
+
pickId((event as { to_id?: unknown }).to_id) ||
|
|
627
|
+
pickId((event as { xid?: unknown }).xid) ||
|
|
628
|
+
toId;
|
|
629
|
+
const targetId = directPeer;
|
|
630
|
+
if (!targetId) {
|
|
631
|
+
logWarn("inbound message missing target id", { mode, event });
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Learn selfId from inbound direct message destination when unknown.
|
|
636
|
+
if (mode === "direct" && !this.selfId && toId) {
|
|
637
|
+
this.selfId = toId;
|
|
638
|
+
logDebug("learned selfId from inbound to field", { selfId: this.selfId });
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (senderId && toId && senderId === toId) {
|
|
642
|
+
logDebug("skip loopback message (from === to)", { senderId, toId, targetId });
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (senderId && this.selfId && senderId === this.selfId) {
|
|
647
|
+
logDebug("skip self/multi-device sync message", { senderId, toId, targetId });
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
logDebug("inbound message", {
|
|
652
|
+
mode,
|
|
653
|
+
senderId,
|
|
654
|
+
toId,
|
|
655
|
+
targetId,
|
|
656
|
+
bodyPreview: body.slice(0, 80),
|
|
657
|
+
keys: Object.keys(meta),
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
const runtime = getLanyingRuntime();
|
|
661
|
+
const cfg = await runtime.config.loadConfig();
|
|
662
|
+
const messageSid =
|
|
663
|
+
pickId(eventAny.id ?? meta.id) ||
|
|
664
|
+
pickId((eventAny as { message_id?: unknown }).message_id) ||
|
|
665
|
+
"";
|
|
666
|
+
const timestampNum = Number(
|
|
667
|
+
eventAny.timestamp ?? meta.timestamp ?? (eventAny as { ts?: unknown }).ts ?? Date.now(),
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
const result = await runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
671
|
+
ctx: {
|
|
672
|
+
Body: body,
|
|
673
|
+
From: senderId || targetId,
|
|
674
|
+
To: toId || account.username,
|
|
675
|
+
SessionKey: targetId,
|
|
676
|
+
AccountId: account.accountId,
|
|
677
|
+
MessageSid: messageSid || undefined,
|
|
678
|
+
Timestamp: Number.isFinite(timestampNum) ? timestampNum : Date.now(),
|
|
679
|
+
OriginatingChannel: LANYING_CHANNEL_ID as any,
|
|
680
|
+
OriginatingTo: targetId,
|
|
681
|
+
ChatType: mode,
|
|
682
|
+
Provider: LANYING_CHANNEL_ID,
|
|
683
|
+
Surface: LANYING_CHANNEL_ID,
|
|
684
|
+
SenderId: senderId || undefined,
|
|
685
|
+
SenderName: senderId || undefined,
|
|
686
|
+
},
|
|
687
|
+
cfg,
|
|
688
|
+
dispatcherOptions: {
|
|
689
|
+
deliver: async (payload: { text?: string; body?: string }) => {
|
|
690
|
+
const response = payload?.text ?? payload?.body ?? "";
|
|
691
|
+
if (!response.trim()) {
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
await this.sendText(
|
|
695
|
+
{
|
|
696
|
+
kind: "user",
|
|
697
|
+
id: targetId,
|
|
698
|
+
},
|
|
699
|
+
response,
|
|
700
|
+
account,
|
|
701
|
+
);
|
|
702
|
+
},
|
|
703
|
+
onError: (err: unknown, info: { kind: "tool" | "block" | "final" }) => {
|
|
704
|
+
logError(`reply dispatcher send failed (kind=${info.kind})`, err);
|
|
705
|
+
},
|
|
706
|
+
onSkip: (
|
|
707
|
+
payload: { text?: string; body?: string },
|
|
708
|
+
info: { kind: "tool" | "block" | "final"; reason: string },
|
|
709
|
+
) => {
|
|
710
|
+
logDebug(`reply dispatcher skipped payload (kind=${info.kind}, reason=${info.reason})`, {
|
|
711
|
+
textPreview: (payload.text ?? payload.body ?? "").slice(0, 80),
|
|
712
|
+
});
|
|
713
|
+
},
|
|
714
|
+
},
|
|
715
|
+
});
|
|
716
|
+
logDebug("reply dispatcher result", result);
|
|
717
|
+
} catch (err) {
|
|
718
|
+
logError("failed to process inbound message", err);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async ensureReady(account: ResolvedLanyingAccount): Promise<void> {
|
|
723
|
+
if (!account.configured) {
|
|
724
|
+
throw new Error("Lanying account is not configured (enabled/app_id/username/password).");
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const nextKey = this.currentConfigKey(account);
|
|
728
|
+
const needNewClient = !this.client || !this.accountKey || this.accountKey !== nextKey;
|
|
729
|
+
|
|
730
|
+
if (needNewClient) {
|
|
731
|
+
await this.shutdown();
|
|
732
|
+
this.shuttingDown = false;
|
|
733
|
+
this.client = await this.createClient(account);
|
|
734
|
+
this.accountKey = nextKey;
|
|
735
|
+
this.lastConfig = account;
|
|
736
|
+
this.bindListeners(account);
|
|
737
|
+
} else if (!this.listenersBound) {
|
|
738
|
+
this.bindListeners(account);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
this.lastConfig = account;
|
|
742
|
+
if (this.client?.isLogin?.()) {
|
|
743
|
+
this.resetReconnectState("already_logged_in_before_ensure");
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (this.loginPromise) {
|
|
748
|
+
await this.loginPromise;
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
this.loginPromise = (async () => {
|
|
753
|
+
if (!this.client) {
|
|
754
|
+
throw new Error("Lanying client not initialized");
|
|
755
|
+
}
|
|
756
|
+
logDebug("attempting login", { username: account.username });
|
|
757
|
+
const result = await this.client.login({
|
|
758
|
+
name: account.username,
|
|
759
|
+
password: account.password,
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
const resultRecord =
|
|
763
|
+
result && typeof result === "object" ? (result as Record<string, unknown>) : undefined;
|
|
764
|
+
this.selfId = pickId(resultRecord?.uid ?? resultRecord?.user_id ?? resultRecord?.username);
|
|
765
|
+
logDebug("login success", {
|
|
766
|
+
username: account.username,
|
|
767
|
+
selfId: this.selfId || undefined,
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
771
|
+
while (Date.now() < deadline) {
|
|
772
|
+
const ready = Boolean(this.client?.isReady?.());
|
|
773
|
+
const loggedIn = Boolean(this.client?.isLogin?.());
|
|
774
|
+
if (loggedIn) {
|
|
775
|
+
logDebug("sdk ready", { ready, loggedIn });
|
|
776
|
+
this.resetReconnectState("login_success");
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
await sleep(READY_POLL_MS);
|
|
780
|
+
}
|
|
781
|
+
throw new Error("Lanying SDK not logged in after login timeout");
|
|
782
|
+
})();
|
|
783
|
+
|
|
784
|
+
try {
|
|
785
|
+
await this.loginPromise;
|
|
786
|
+
} catch (err) {
|
|
787
|
+
logError("login failed", err);
|
|
788
|
+
this.loginPromise = null;
|
|
789
|
+
this.scheduleReconnect("ensureReady_login_failed");
|
|
790
|
+
throw err;
|
|
791
|
+
} finally {
|
|
792
|
+
this.loginPromise = null;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
async sendText(
|
|
797
|
+
target: LanyingMessageTarget,
|
|
798
|
+
text: string,
|
|
799
|
+
account?: ResolvedLanyingAccount,
|
|
800
|
+
): Promise<unknown> {
|
|
801
|
+
const cfgToUse = account ?? this.lastConfig;
|
|
802
|
+
if (!cfgToUse) {
|
|
803
|
+
throw new Error("Lanying session has no account context");
|
|
804
|
+
}
|
|
805
|
+
await this.ensureReady(cfgToUse);
|
|
806
|
+
if (!this.client) {
|
|
807
|
+
throw new Error("Lanying client is not ready");
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
const payload = {
|
|
811
|
+
type: "text",
|
|
812
|
+
content: text,
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
logDebug("sending message", {
|
|
816
|
+
kind: target.kind,
|
|
817
|
+
id: target.id,
|
|
818
|
+
textPreview: text.slice(0, 80),
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
if (target.kind === "group") {
|
|
822
|
+
return await this.client.sysManage.sendGroupMessage({
|
|
823
|
+
...payload,
|
|
824
|
+
gid: target.id,
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
return await this.client.sysManage.sendRosterMessage({
|
|
828
|
+
...payload,
|
|
829
|
+
uid: target.id,
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
async shutdown(): Promise<void> {
|
|
834
|
+
this.shuttingDown = true;
|
|
835
|
+
if (this.reconnectTimer) {
|
|
836
|
+
clearTimeout(this.reconnectTimer);
|
|
837
|
+
this.reconnectTimer = null;
|
|
838
|
+
}
|
|
839
|
+
this.reconnectPromise = null;
|
|
840
|
+
this.reconnectAttempts = 0;
|
|
841
|
+
if (!this.client) {
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
logDebug("shutting down lanying session");
|
|
845
|
+
try {
|
|
846
|
+
this.client.disConnect?.();
|
|
847
|
+
} catch (err) {
|
|
848
|
+
logWarn("disConnect failed during shutdown", err);
|
|
849
|
+
}
|
|
850
|
+
try {
|
|
851
|
+
this.client.logout?.();
|
|
852
|
+
} catch (err) {
|
|
853
|
+
logWarn("logout failed during shutdown", err);
|
|
854
|
+
}
|
|
855
|
+
this.client = null;
|
|
856
|
+
this.accountKey = null;
|
|
857
|
+
this.listenersBound = false;
|
|
858
|
+
this.loginPromise = null;
|
|
859
|
+
this.selfId = "";
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const session = new LanyingSession();
|
|
864
|
+
|
|
865
|
+
export const lanyingPlugin: ChannelPlugin<ResolvedLanyingAccount> = {
|
|
866
|
+
id: LANYING_CHANNEL_ID,
|
|
867
|
+
meta,
|
|
868
|
+
capabilities: {
|
|
869
|
+
chatTypes: ["direct", "group"],
|
|
870
|
+
media: false,
|
|
871
|
+
reactions: false,
|
|
872
|
+
threads: false,
|
|
873
|
+
blockStreaming: false,
|
|
874
|
+
},
|
|
875
|
+
reload: { configPrefixes: ["channels.lanying"] },
|
|
876
|
+
config: {
|
|
877
|
+
listAccountIds: () => [LANYING_DEFAULT_ACCOUNT_ID],
|
|
878
|
+
resolveAccount: (cfg) => resolveLanyingAccount(cfg),
|
|
879
|
+
defaultAccountId: () => LANYING_DEFAULT_ACCOUNT_ID,
|
|
880
|
+
isConfigured: (account) => account.configured,
|
|
881
|
+
describeAccount: (account) => ({
|
|
882
|
+
accountId: account.accountId,
|
|
883
|
+
enabled: account.enabled,
|
|
884
|
+
configured: account.configured,
|
|
885
|
+
dmPolicy: account.dmPolicy,
|
|
886
|
+
}),
|
|
887
|
+
resolveAllowFrom: ({ cfg }) => resolveLanyingAccount(cfg).allowFrom,
|
|
888
|
+
formatAllowFrom: ({ allowFrom }) =>
|
|
889
|
+
allowFrom.map((entry) => String(entry).trim()).filter(Boolean),
|
|
890
|
+
resolveDefaultTo: ({ cfg }) => resolveLanyingAccount(cfg).defaultTo,
|
|
891
|
+
},
|
|
892
|
+
pairing: {
|
|
893
|
+
idLabel: "lanyingUserId",
|
|
894
|
+
normalizeAllowEntry: (entry) => entry.replace(/^lanying:/i, "").trim(),
|
|
895
|
+
notifyApproval: async ({ cfg, id }) => {
|
|
896
|
+
const account = resolveLanyingAccount(cfg);
|
|
897
|
+
if (!account.configured || !account.enabled) {
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
await session.sendText(
|
|
901
|
+
{ kind: "user", id: String(id) },
|
|
902
|
+
"OpenClaw: your access has been approved.",
|
|
903
|
+
account,
|
|
904
|
+
);
|
|
905
|
+
},
|
|
906
|
+
},
|
|
907
|
+
security: {
|
|
908
|
+
resolveDmPolicy: ({ account }) => ({
|
|
909
|
+
policy: account.dmPolicy ?? "pairing",
|
|
910
|
+
allowFrom: account.allowFrom ?? [],
|
|
911
|
+
policyPath: "channels.lanying.dmPolicy",
|
|
912
|
+
allowFromPath: "channels.lanying.allowFrom",
|
|
913
|
+
approveHint: formatPairingApproveHint("lanying"),
|
|
914
|
+
normalizeEntry: (raw) => raw.replace(/^lanying:/i, "").trim(),
|
|
915
|
+
}),
|
|
916
|
+
collectWarnings: ({ account }) => {
|
|
917
|
+
if (account.enabled && !account.configured) {
|
|
918
|
+
return [
|
|
919
|
+
'- Lanying is enabled but not configured. Set channels.lanying.app_id, channels.lanying.username, channels.lanying.password.',
|
|
920
|
+
];
|
|
921
|
+
}
|
|
922
|
+
return [];
|
|
923
|
+
},
|
|
924
|
+
},
|
|
925
|
+
messaging: {
|
|
926
|
+
normalizeTarget: (raw) => normalizeTarget(raw)?.id,
|
|
927
|
+
targetResolver: {
|
|
928
|
+
looksLikeId: (raw) => {
|
|
929
|
+
const normalized = normalizeTarget(raw);
|
|
930
|
+
return Boolean(normalized?.id);
|
|
931
|
+
},
|
|
932
|
+
hint: "<userId|group:groupId>",
|
|
933
|
+
},
|
|
934
|
+
},
|
|
935
|
+
outbound: {
|
|
936
|
+
deliveryMode: "direct",
|
|
937
|
+
textChunkLimit: 2000,
|
|
938
|
+
sendText: async ({ cfg, to, text }) => {
|
|
939
|
+
const account = resolveLanyingAccount(cfg);
|
|
940
|
+
logDebug("outbound sendText requested", {
|
|
941
|
+
to,
|
|
942
|
+
account: sanitizeAccountForLog(account),
|
|
943
|
+
});
|
|
944
|
+
if (!account.enabled) {
|
|
945
|
+
throw new Error("Lanying channel is disabled.");
|
|
946
|
+
}
|
|
947
|
+
const target = normalizeTarget(to);
|
|
948
|
+
if (!target || !target.id) {
|
|
949
|
+
throw new Error(`Invalid Lanying target: ${to}`);
|
|
950
|
+
}
|
|
951
|
+
const messageId = await session.sendText(target, text, account);
|
|
952
|
+
return {
|
|
953
|
+
channel: LANYING_CHANNEL_ID,
|
|
954
|
+
messageId: String(messageId ?? ""),
|
|
955
|
+
chatId: target.id,
|
|
956
|
+
};
|
|
957
|
+
},
|
|
958
|
+
},
|
|
959
|
+
auth: {
|
|
960
|
+
login: async ({ cfg }) => {
|
|
961
|
+
const account = resolveLanyingAccount(cfg);
|
|
962
|
+
logDebug("auth.login called", { account: sanitizeAccountForLog(account) });
|
|
963
|
+
if (!account.enabled) {
|
|
964
|
+
throw new Error("Lanying channel is disabled.");
|
|
965
|
+
}
|
|
966
|
+
await session.ensureReady(account);
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
gateway: {
|
|
970
|
+
startAccount: async (ctx) => {
|
|
971
|
+
const account = resolveLanyingAccount(ctx.cfg);
|
|
972
|
+
if (!account.enabled) {
|
|
973
|
+
ctx.log?.info?.("[lanying] account disabled, skip startup");
|
|
974
|
+
return { stop: () => {} };
|
|
975
|
+
}
|
|
976
|
+
if (!account.configured) {
|
|
977
|
+
const reason = "Lanying is not configured (app_id/username/password)";
|
|
978
|
+
ctx.log?.warn?.(`[lanying] ${reason}`);
|
|
979
|
+
throw new Error(reason);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
ctx.log?.info?.(`[lanying] starting account ${ctx.accountId}`);
|
|
983
|
+
ctx.log?.debug?.(
|
|
984
|
+
`[lanying] resolved account: ${JSON.stringify(sanitizeAccountForLog(account))}`,
|
|
985
|
+
);
|
|
986
|
+
ctx.setStatus({
|
|
987
|
+
accountId: ctx.accountId,
|
|
988
|
+
enabled: account.enabled,
|
|
989
|
+
configured: account.configured,
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
await session.ensureReady(account);
|
|
993
|
+
ctx.setStatus({
|
|
994
|
+
accountId: ctx.accountId,
|
|
995
|
+
enabled: account.enabled,
|
|
996
|
+
configured: account.configured,
|
|
997
|
+
running: true,
|
|
998
|
+
connected: true,
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
ctx.abortSignal.addEventListener(
|
|
1002
|
+
"abort",
|
|
1003
|
+
() => {
|
|
1004
|
+
void session.shutdown();
|
|
1005
|
+
},
|
|
1006
|
+
{ once: true },
|
|
1007
|
+
);
|
|
1008
|
+
|
|
1009
|
+
await new Promise<void>((resolve) => {
|
|
1010
|
+
if (ctx.abortSignal.aborted) {
|
|
1011
|
+
resolve();
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
ctx.abortSignal.addEventListener(
|
|
1015
|
+
"abort",
|
|
1016
|
+
() => {
|
|
1017
|
+
resolve();
|
|
1018
|
+
},
|
|
1019
|
+
{ once: true },
|
|
1020
|
+
);
|
|
1021
|
+
});
|
|
1022
|
+
},
|
|
1023
|
+
stopAccount: async (ctx) => {
|
|
1024
|
+
ctx.log?.info?.("[lanying] stopAccount called");
|
|
1025
|
+
await session.shutdown();
|
|
1026
|
+
},
|
|
1027
|
+
},
|
|
1028
|
+
};
|