@larksuiteoapi/node-sdk 1.61.1 → 1.62.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 +26 -4
- package/README.zh.md +170 -78
- package/es/index.js +3764 -7
- package/lib/index.js +3775 -7
- package/package.json +1 -1
- package/types/index.d.ts +592 -18
package/lib/index.js
CHANGED
|
@@ -15,25 +15,33 @@
|
|
|
15
15
|
Object.defineProperty(exports, '__esModule', { value: true });
|
|
16
16
|
|
|
17
17
|
var axios = require('axios');
|
|
18
|
+
var fs = require('fs');
|
|
19
|
+
var path = require('path');
|
|
18
20
|
var crypto = require('crypto');
|
|
19
21
|
var qs = require('qs');
|
|
20
22
|
var identity = require('lodash.identity');
|
|
21
23
|
var pickBy = require('lodash.pickby');
|
|
22
|
-
var fs = require('fs');
|
|
23
24
|
var merge = require('lodash.merge');
|
|
24
25
|
var qs$1 = require('querystring');
|
|
25
26
|
var WebSocket = require('ws');
|
|
27
|
+
var http = require('http');
|
|
28
|
+
var https = require('https');
|
|
29
|
+
var dns = require('dns');
|
|
30
|
+
var net = require('net');
|
|
26
31
|
|
|
27
32
|
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
|
|
28
33
|
|
|
29
34
|
var axios__default = /*#__PURE__*/_interopDefaultLegacy(axios);
|
|
35
|
+
var fs__default = /*#__PURE__*/_interopDefaultLegacy(fs);
|
|
36
|
+
var path__default = /*#__PURE__*/_interopDefaultLegacy(path);
|
|
30
37
|
var crypto__default = /*#__PURE__*/_interopDefaultLegacy(crypto);
|
|
31
38
|
var identity__default = /*#__PURE__*/_interopDefaultLegacy(identity);
|
|
32
39
|
var pickBy__default = /*#__PURE__*/_interopDefaultLegacy(pickBy);
|
|
33
|
-
var fs__default = /*#__PURE__*/_interopDefaultLegacy(fs);
|
|
34
40
|
var merge__default = /*#__PURE__*/_interopDefaultLegacy(merge);
|
|
35
41
|
var qs__default = /*#__PURE__*/_interopDefaultLegacy(qs$1);
|
|
36
42
|
var WebSocket__default = /*#__PURE__*/_interopDefaultLegacy(WebSocket);
|
|
43
|
+
var http__default = /*#__PURE__*/_interopDefaultLegacy(http);
|
|
44
|
+
var https__default = /*#__PURE__*/_interopDefaultLegacy(https);
|
|
37
45
|
|
|
38
46
|
/******************************************************************************
|
|
39
47
|
Copyright (c) Microsoft Corporation.
|
|
@@ -113,10 +121,72 @@ typeof SuppressedError === "function" ? SuppressedError : function (error, suppr
|
|
|
113
121
|
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
114
122
|
};
|
|
115
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Resolve the SDK's own version from `package.json`. The source lives at
|
|
126
|
+
* `utils/user-agent.ts`; the published bundle lives at `lib/index.js` or
|
|
127
|
+
* `es/index.js`. In both shapes the package.json sits exactly one level
|
|
128
|
+
* above the code, so `__dirname/..` is the primary candidate. A second
|
|
129
|
+
* candidate (two levels up) covers downstream bundlers that re-emit this
|
|
130
|
+
* module at a deeper path.
|
|
131
|
+
*
|
|
132
|
+
* We sanity-check `name === '@larksuiteoapi/node-sdk'` so we don't
|
|
133
|
+
* accidentally read an unrelated package.json that happens to sit in a
|
|
134
|
+
* parent directory during local development.
|
|
135
|
+
*/
|
|
136
|
+
let cachedVersion;
|
|
137
|
+
function getSdkVersion() {
|
|
138
|
+
if (cachedVersion !== undefined)
|
|
139
|
+
return cachedVersion;
|
|
140
|
+
const candidates = [
|
|
141
|
+
path__default["default"].resolve(__dirname, '..', 'package.json'),
|
|
142
|
+
path__default["default"].resolve(__dirname, '..', '..', 'package.json'),
|
|
143
|
+
];
|
|
144
|
+
for (const p of candidates) {
|
|
145
|
+
try {
|
|
146
|
+
const pkg = JSON.parse(fs__default["default"].readFileSync(p, 'utf-8'));
|
|
147
|
+
if (pkg.name === '@larksuiteoapi/node-sdk' && typeof pkg.version === 'string') {
|
|
148
|
+
cachedVersion = pkg.version;
|
|
149
|
+
return pkg.version;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch (_a) {
|
|
153
|
+
// try next candidate
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
cachedVersion = 'unknown';
|
|
157
|
+
return 'unknown';
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Sanitize a caller-supplied `source` tag so it can be safely concatenated
|
|
161
|
+
* into a User-Agent header. Non-token characters are replaced with '_'
|
|
162
|
+
* and the result is clamped to 64 characters. Never throws.
|
|
163
|
+
*/
|
|
164
|
+
function sanitizeSource(raw) {
|
|
165
|
+
return raw.replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 64);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Build the User-Agent value. When `source` is provided it appends a
|
|
169
|
+
* `source/<sanitized>` product token, e.g.
|
|
170
|
+
* oapi-node-sdk/1.62.0 source/cursor-bot
|
|
171
|
+
* Falsy or all-invalid-char `source` values produce the base UA without
|
|
172
|
+
* the extra token.
|
|
173
|
+
*/
|
|
174
|
+
function buildUserAgent(source) {
|
|
175
|
+
const base = `oapi-node-sdk/${getSdkVersion()}`;
|
|
176
|
+
if (!source)
|
|
177
|
+
return base;
|
|
178
|
+
const clean = sanitizeSource(source);
|
|
179
|
+
return clean ? `${base} source/${clean}` : base;
|
|
180
|
+
}
|
|
181
|
+
|
|
116
182
|
const defaultHttpInstance = axios__default["default"].create();
|
|
183
|
+
// Fallback UA for callers that bypass Client/WSClient and hit
|
|
184
|
+
// `defaultHttpInstance` directly. Client.formatPayload overrides this with
|
|
185
|
+
// a source-enriched UA when a `source` option is configured.
|
|
186
|
+
const FALLBACK_UA = buildUserAgent();
|
|
117
187
|
defaultHttpInstance.interceptors.request.use((req) => {
|
|
118
|
-
if (req.headers) {
|
|
119
|
-
req.headers['User-Agent'] =
|
|
188
|
+
if (req.headers && !req.headers['User-Agent']) {
|
|
189
|
+
req.headers['User-Agent'] = FALLBACK_UA;
|
|
120
190
|
}
|
|
121
191
|
return req;
|
|
122
192
|
}, undefined, { synchronous: true });
|
|
@@ -83941,6 +84011,7 @@ class Client extends Client$1 {
|
|
|
83941
84011
|
this.appId = params.appId;
|
|
83942
84012
|
this.appSecret = params.appSecret;
|
|
83943
84013
|
this.disableTokenCache = params.disableTokenCache;
|
|
84014
|
+
this.userAgent = buildUserAgent(params.source);
|
|
83944
84015
|
assert(!this.appId, () => this.logger.error('appId is needed'));
|
|
83945
84016
|
assert(!this.appSecret, () => this.logger.error('appSecret is needed'));
|
|
83946
84017
|
this.helpDeskId = params.helpDeskId;
|
|
@@ -84000,7 +84071,7 @@ class Client extends Client$1 {
|
|
|
84000
84071
|
}
|
|
84001
84072
|
return {
|
|
84002
84073
|
params: Object.assign(Object.assign({}, ((payload === null || payload === void 0 ? void 0 : payload.params) || {})), targetOptions.params),
|
|
84003
|
-
headers: Object.assign(Object.assign({ 'User-Agent':
|
|
84074
|
+
headers: Object.assign(Object.assign({ 'User-Agent': this.userAgent }, ((payload === null || payload === void 0 ? void 0 : payload.headers) || {})), targetOptions.headers),
|
|
84004
84075
|
data: Object.assign(Object.assign({}, ((payload === null || payload === void 0 ? void 0 : payload.data) || {})), targetOptions.data),
|
|
84005
84076
|
path: Object.assign(Object.assign({}, ((payload === null || payload === void 0 ? void 0 : payload.path) || {})), targetOptions.path),
|
|
84006
84077
|
};
|
|
@@ -85306,7 +85377,11 @@ class WSClient {
|
|
|
85306
85377
|
lastConnectTime: 0,
|
|
85307
85378
|
nextConnectTime: 0,
|
|
85308
85379
|
};
|
|
85309
|
-
|
|
85380
|
+
/** True if the WS has ever connected successfully in this client's
|
|
85381
|
+
* lifetime — used to distinguish first-connect from reconnect. */
|
|
85382
|
+
this.hasEverConnected = false;
|
|
85383
|
+
const { appId, appSecret, agent, domain = exports.Domain.Feishu, httpInstance = defaultHttpInstance, loggerLevel = exports.LoggerLevel.info, logger = defaultLogger, autoReconnect = true, source, onReady, onError, onReconnecting, onReconnected, } = params;
|
|
85384
|
+
this.userAgent = buildUserAgent(source);
|
|
85310
85385
|
this.logger = new LoggerProxy(loggerLevel, logger);
|
|
85311
85386
|
assert(!appId, () => this.logger.error('appId is needed'));
|
|
85312
85387
|
assert(!appSecret, () => this.logger.error('appSecret is needed'));
|
|
@@ -85321,6 +85396,24 @@ class WSClient {
|
|
|
85321
85396
|
this.wsConfig.updateWs({
|
|
85322
85397
|
autoReconnect
|
|
85323
85398
|
});
|
|
85399
|
+
this.onReady = onReady;
|
|
85400
|
+
this.onError = onError;
|
|
85401
|
+
this.onReconnecting = onReconnecting;
|
|
85402
|
+
this.onReconnected = onReconnected;
|
|
85403
|
+
}
|
|
85404
|
+
/**
|
|
85405
|
+
* Invoke a user-supplied callback safely: no-op if undefined, swallow any
|
|
85406
|
+
* exception to avoid breaking the WS state machine.
|
|
85407
|
+
*/
|
|
85408
|
+
safeInvoke(label, fn, ...args) {
|
|
85409
|
+
if (!fn)
|
|
85410
|
+
return;
|
|
85411
|
+
try {
|
|
85412
|
+
fn(...args);
|
|
85413
|
+
}
|
|
85414
|
+
catch (e) {
|
|
85415
|
+
this.logger.error(`[ws] ${label} callback threw`, e);
|
|
85416
|
+
}
|
|
85324
85417
|
}
|
|
85325
85418
|
pullConnectConfig() {
|
|
85326
85419
|
return __awaiter(this, void 0, void 0, function* () {
|
|
@@ -85336,6 +85429,7 @@ class WSClient {
|
|
|
85336
85429
|
// consumed by gateway
|
|
85337
85430
|
headers: {
|
|
85338
85431
|
"locale": "zh",
|
|
85432
|
+
"User-Agent": this.userAgent,
|
|
85339
85433
|
},
|
|
85340
85434
|
timeout: 15000,
|
|
85341
85435
|
});
|
|
@@ -85429,7 +85523,11 @@ class WSClient {
|
|
|
85429
85523
|
finally {
|
|
85430
85524
|
this.isConnecting = false;
|
|
85431
85525
|
}
|
|
85432
|
-
if (
|
|
85526
|
+
if (isSuccess) {
|
|
85527
|
+
this.hasEverConnected = true;
|
|
85528
|
+
this.safeInvoke('onReady', this.onReady);
|
|
85529
|
+
}
|
|
85530
|
+
else {
|
|
85433
85531
|
this.logger.error('[ws]', 'connect failed');
|
|
85434
85532
|
yield this.reConnect();
|
|
85435
85533
|
}
|
|
@@ -85438,9 +85536,15 @@ class WSClient {
|
|
|
85438
85536
|
}
|
|
85439
85537
|
const { autoReconnect, reconnectNonce, reconnectCount, reconnectInterval } = this.wsConfig.getWS();
|
|
85440
85538
|
if (!autoReconnect) {
|
|
85539
|
+
if (!this.hasEverConnected) {
|
|
85540
|
+
this.safeInvoke('onError', this.onError, new Error('WebSocket connect failed and autoReconnect is disabled'));
|
|
85541
|
+
}
|
|
85441
85542
|
return;
|
|
85442
85543
|
}
|
|
85443
85544
|
this.logger.info('[ws]', 'reconnect');
|
|
85545
|
+
if (this.hasEverConnected) {
|
|
85546
|
+
this.safeInvoke('onReconnecting', this.onReconnecting);
|
|
85547
|
+
}
|
|
85444
85548
|
if (wsInstance) {
|
|
85445
85549
|
wsInstance === null || wsInstance === void 0 ? void 0 : wsInstance.terminate();
|
|
85446
85550
|
}
|
|
@@ -85462,12 +85566,20 @@ class WSClient {
|
|
|
85462
85566
|
// if reconnectCount < 0, the reconnect time is infinite
|
|
85463
85567
|
if (isSuccess) {
|
|
85464
85568
|
this.logger.debug('[ws]', 'reconnect success');
|
|
85569
|
+
if (this.hasEverConnected) {
|
|
85570
|
+
this.safeInvoke('onReconnected', this.onReconnected);
|
|
85571
|
+
}
|
|
85572
|
+
else {
|
|
85573
|
+
this.hasEverConnected = true;
|
|
85574
|
+
this.safeInvoke('onReady', this.onReady);
|
|
85575
|
+
}
|
|
85465
85576
|
this.isConnecting = false;
|
|
85466
85577
|
return;
|
|
85467
85578
|
}
|
|
85468
85579
|
this.logger.info('ws', `unable to connect to the server after trying ${count} times")`);
|
|
85469
85580
|
if (reconnectCount >= 0 && count >= reconnectCount) {
|
|
85470
85581
|
this.isConnecting = false;
|
|
85582
|
+
this.safeInvoke('onError', this.onError, new Error(`WebSocket reconnect exhausted after ${count} attempts`));
|
|
85471
85583
|
return;
|
|
85472
85584
|
}
|
|
85473
85585
|
this.reconnectInterval = setTimeout(() => {
|
|
@@ -86058,6 +86170,3654 @@ function registerApp(options) {
|
|
|
86058
86170
|
});
|
|
86059
86171
|
}
|
|
86060
86172
|
|
|
86173
|
+
class LarkChannelError extends Error {
|
|
86174
|
+
constructor(code, message, opts) {
|
|
86175
|
+
super(message);
|
|
86176
|
+
this.name = 'LarkChannelError';
|
|
86177
|
+
this.code = code;
|
|
86178
|
+
this.cause = opts === null || opts === void 0 ? void 0 : opts.cause;
|
|
86179
|
+
this.context = opts === null || opts === void 0 ? void 0 : opts.context;
|
|
86180
|
+
}
|
|
86181
|
+
}
|
|
86182
|
+
|
|
86183
|
+
/**
|
|
86184
|
+
* Dual-threshold throttle: fires `flush` either when `ms` have elapsed
|
|
86185
|
+
* since the last fire OR when `chars` characters have accumulated.
|
|
86186
|
+
*
|
|
86187
|
+
* Usage inside a stream controller:
|
|
86188
|
+
* const t = new Throttle({ ms: 100, chars: 50 }, () => doPatch(buffer));
|
|
86189
|
+
* await t.note(deltaLen); // may or may not fire, schedule the rest
|
|
86190
|
+
* await t.flushNow(); // end-of-stream force flush
|
|
86191
|
+
*/
|
|
86192
|
+
class Throttle {
|
|
86193
|
+
constructor(opts, fire) {
|
|
86194
|
+
this.opts = opts;
|
|
86195
|
+
this.fire = fire;
|
|
86196
|
+
this.pendingChars = 0;
|
|
86197
|
+
this.lastFireAt = 0;
|
|
86198
|
+
}
|
|
86199
|
+
/**
|
|
86200
|
+
* Accumulate bytes and decide whether to fire now, schedule a timer,
|
|
86201
|
+
* or do nothing (a fire is already scheduled).
|
|
86202
|
+
*/
|
|
86203
|
+
note(deltaChars) {
|
|
86204
|
+
this.pendingChars += deltaChars;
|
|
86205
|
+
if (this.pendingChars >= this.opts.chars) {
|
|
86206
|
+
this.fireSoon(0);
|
|
86207
|
+
return;
|
|
86208
|
+
}
|
|
86209
|
+
if (!this.timer) {
|
|
86210
|
+
const elapsed = Date.now() - this.lastFireAt;
|
|
86211
|
+
const wait = Math.max(0, this.opts.ms - elapsed);
|
|
86212
|
+
this.fireSoon(wait);
|
|
86213
|
+
}
|
|
86214
|
+
}
|
|
86215
|
+
fireSoon(delay) {
|
|
86216
|
+
if (this.timer)
|
|
86217
|
+
clearTimeout(this.timer);
|
|
86218
|
+
this.timer = setTimeout(() => {
|
|
86219
|
+
this.timer = undefined;
|
|
86220
|
+
void this.doFire();
|
|
86221
|
+
}, delay);
|
|
86222
|
+
}
|
|
86223
|
+
/**
|
|
86224
|
+
* Force-flush everything accumulated so far, including any content that
|
|
86225
|
+
* arrived during an in-flight fire. Waits for the in-flight fire to
|
|
86226
|
+
* complete, then fires once more to capture the final state — without
|
|
86227
|
+
* this second fire, the last chunk of appended content would be missed
|
|
86228
|
+
* (the in-flight fire captured a snapshot BEFORE those chunks arrived).
|
|
86229
|
+
*/
|
|
86230
|
+
flushNow() {
|
|
86231
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
86232
|
+
if (this.timer) {
|
|
86233
|
+
clearTimeout(this.timer);
|
|
86234
|
+
this.timer = undefined;
|
|
86235
|
+
}
|
|
86236
|
+
if (this.inFlight) {
|
|
86237
|
+
yield this.inFlight;
|
|
86238
|
+
}
|
|
86239
|
+
yield this.doFire();
|
|
86240
|
+
});
|
|
86241
|
+
}
|
|
86242
|
+
doFire() {
|
|
86243
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
86244
|
+
if (this.inFlight) {
|
|
86245
|
+
// Someone else is firing; schedule a follow-up so the latest
|
|
86246
|
+
// accumulated chars get captured after the in-flight fire ends.
|
|
86247
|
+
this.fireSoon(this.opts.ms);
|
|
86248
|
+
return;
|
|
86249
|
+
}
|
|
86250
|
+
const p = (() => __awaiter(this, void 0, void 0, function* () {
|
|
86251
|
+
this.pendingChars = 0;
|
|
86252
|
+
this.lastFireAt = Date.now();
|
|
86253
|
+
yield this.fire();
|
|
86254
|
+
}))();
|
|
86255
|
+
this.inFlight = p;
|
|
86256
|
+
try {
|
|
86257
|
+
yield p;
|
|
86258
|
+
}
|
|
86259
|
+
finally {
|
|
86260
|
+
this.inFlight = undefined;
|
|
86261
|
+
}
|
|
86262
|
+
});
|
|
86263
|
+
}
|
|
86264
|
+
dispose() {
|
|
86265
|
+
if (this.timer) {
|
|
86266
|
+
clearTimeout(this.timer);
|
|
86267
|
+
this.timer = undefined;
|
|
86268
|
+
}
|
|
86269
|
+
}
|
|
86270
|
+
}
|
|
86271
|
+
|
|
86272
|
+
/**
|
|
86273
|
+
* A per-stream FIFO queue that serializes async updates, so that concurrent
|
|
86274
|
+
* calls to `append()` / `update()` always result in PATCH operations
|
|
86275
|
+
* happening in submission order.
|
|
86276
|
+
*/
|
|
86277
|
+
class UpdateQueue {
|
|
86278
|
+
constructor() {
|
|
86279
|
+
this.tail = Promise.resolve();
|
|
86280
|
+
}
|
|
86281
|
+
enqueue(task) {
|
|
86282
|
+
const next = this.tail.then(task, task);
|
|
86283
|
+
this.tail = next.then(() => undefined, () => undefined);
|
|
86284
|
+
return next;
|
|
86285
|
+
}
|
|
86286
|
+
drain() {
|
|
86287
|
+
return this.tail;
|
|
86288
|
+
}
|
|
86289
|
+
}
|
|
86290
|
+
|
|
86291
|
+
/**
|
|
86292
|
+
* Merge a streaming text chunk into an accumulator, handling both "delta"
|
|
86293
|
+
* and "accumulated" producers transparently.
|
|
86294
|
+
*
|
|
86295
|
+
* Rules:
|
|
86296
|
+
* - If `next` starts with `prev`, it's an accumulated stream → use `next`
|
|
86297
|
+
* - If `prev` starts with `next`, the stream rewound or overlapped → keep `prev`
|
|
86298
|
+
* - Otherwise, find the longest overlap between `prev`'s tail and `next`'s
|
|
86299
|
+
* head, and concatenate
|
|
86300
|
+
*/
|
|
86301
|
+
function mergeStreamingText(prev, next) {
|
|
86302
|
+
if (!prev)
|
|
86303
|
+
return next;
|
|
86304
|
+
if (!next)
|
|
86305
|
+
return prev;
|
|
86306
|
+
if (next.startsWith(prev))
|
|
86307
|
+
return next;
|
|
86308
|
+
if (prev.startsWith(next))
|
|
86309
|
+
return prev;
|
|
86310
|
+
const maxOverlap = Math.min(prev.length, next.length);
|
|
86311
|
+
for (let len = maxOverlap; len > 0; len--) {
|
|
86312
|
+
if (prev.endsWith(next.substring(0, len))) {
|
|
86313
|
+
return prev + next.substring(len);
|
|
86314
|
+
}
|
|
86315
|
+
}
|
|
86316
|
+
return prev + next;
|
|
86317
|
+
}
|
|
86318
|
+
|
|
86319
|
+
const DEFAULT_THROTTLE_MS$1 = 100;
|
|
86320
|
+
const DEFAULT_THROTTLE_CHARS$1 = 50;
|
|
86321
|
+
const DEFAULT_INITIAL = 'Thinking...';
|
|
86322
|
+
const DEFAULT_EMPTY = '(no content)';
|
|
86323
|
+
const INITIAL_SUMMARY = '[Generating...]';
|
|
86324
|
+
const SUMMARY_MAX_CHARS = 50;
|
|
86325
|
+
const ERROR_FOOTER = '\n\n— _(Generation interrupted)_';
|
|
86326
|
+
const ELEMENT_ID = 'stream_md';
|
|
86327
|
+
/**
|
|
86328
|
+
* Shorten a markdown content string into a single-line preview suitable for
|
|
86329
|
+
* the card's `summary.content` — shown in chat lists / message previews.
|
|
86330
|
+
*/
|
|
86331
|
+
function truncateSummary(text, max = SUMMARY_MAX_CHARS) {
|
|
86332
|
+
if (!text)
|
|
86333
|
+
return '';
|
|
86334
|
+
const cleaned = text.replace(/\s+/g, ' ').trim();
|
|
86335
|
+
return cleaned.length <= max ? cleaned : cleaned.slice(0, max - 1) + '…';
|
|
86336
|
+
}
|
|
86337
|
+
/**
|
|
86338
|
+
* Build the initial card JSON for a streaming markdown reply.
|
|
86339
|
+
*
|
|
86340
|
+
* `streaming_mode: true` tells Feishu client to render incremental updates
|
|
86341
|
+
* (via the cardElement.content API) as a native typewriter animation.
|
|
86342
|
+
*
|
|
86343
|
+
* `print_strategy: 'fast'` means: when a new full-content update arrives,
|
|
86344
|
+
* immediately show any already-buffered-but-not-yet-animated text, so the
|
|
86345
|
+
* display doesn't lag behind the upstream token rate.
|
|
86346
|
+
*/
|
|
86347
|
+
function buildStreamingCard(initialText) {
|
|
86348
|
+
return {
|
|
86349
|
+
schema: '2.0',
|
|
86350
|
+
config: {
|
|
86351
|
+
streaming_mode: true,
|
|
86352
|
+
summary: { content: INITIAL_SUMMARY },
|
|
86353
|
+
streaming_config: {
|
|
86354
|
+
print_frequency_ms: { default: 70 },
|
|
86355
|
+
print_step: { default: 1 },
|
|
86356
|
+
print_strategy: 'fast',
|
|
86357
|
+
},
|
|
86358
|
+
},
|
|
86359
|
+
body: {
|
|
86360
|
+
elements: [
|
|
86361
|
+
{
|
|
86362
|
+
tag: 'markdown',
|
|
86363
|
+
element_id: ELEMENT_ID,
|
|
86364
|
+
content: initialText,
|
|
86365
|
+
},
|
|
86366
|
+
],
|
|
86367
|
+
},
|
|
86368
|
+
};
|
|
86369
|
+
}
|
|
86370
|
+
/**
|
|
86371
|
+
* Streaming markdown reply that uses Feishu's native typewriter effect
|
|
86372
|
+
* (cardkit.v1.cardElement.content).
|
|
86373
|
+
*
|
|
86374
|
+
* Flow:
|
|
86375
|
+
* 1. ensureStarted(): create a card instance with streaming_mode=true,
|
|
86376
|
+
* send it as an interactive message referencing the card_id.
|
|
86377
|
+
* 2. append(chunk) / setContent(full): accumulate locally, throttle.
|
|
86378
|
+
* 3. throttle fires: updateCardElementContent(content, sequence++)
|
|
86379
|
+
* Feishu client renders typewriter animation based on the diff.
|
|
86380
|
+
* 4. completeTerminal(): drain queue + finishStreamingCard to disable
|
|
86381
|
+
* streaming_mode (removes the typing cursor).
|
|
86382
|
+
* 5. producer throws → append ERROR_FOOTER + finish stream → rethrow.
|
|
86383
|
+
*
|
|
86384
|
+
* The controller keeps its public shape (`append / setContent / run /
|
|
86385
|
+
* messageId`) — callers don't see the implementation change.
|
|
86386
|
+
*/
|
|
86387
|
+
class MarkdownStreamControllerImpl {
|
|
86388
|
+
constructor(sender, to, idType, opts) {
|
|
86389
|
+
var _a, _b;
|
|
86390
|
+
this.sender = sender;
|
|
86391
|
+
this.to = to;
|
|
86392
|
+
this.idType = idType;
|
|
86393
|
+
this.opts = opts;
|
|
86394
|
+
this.content = '';
|
|
86395
|
+
this._messageId = '';
|
|
86396
|
+
this.cardId = '';
|
|
86397
|
+
this.sequence = 0;
|
|
86398
|
+
this.queue = new UpdateQueue();
|
|
86399
|
+
this.started = false;
|
|
86400
|
+
const cfg = this.sender.config;
|
|
86401
|
+
this.throttle = new Throttle({
|
|
86402
|
+
ms: (_a = cfg.streamThrottleMs) !== null && _a !== void 0 ? _a : DEFAULT_THROTTLE_MS$1,
|
|
86403
|
+
chars: (_b = cfg.streamThrottleChars) !== null && _b !== void 0 ? _b : DEFAULT_THROTTLE_CHARS$1,
|
|
86404
|
+
}, () => this.pushContent());
|
|
86405
|
+
}
|
|
86406
|
+
get messageId() {
|
|
86407
|
+
return this._messageId;
|
|
86408
|
+
}
|
|
86409
|
+
append(chunk) {
|
|
86410
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
86411
|
+
if (!chunk)
|
|
86412
|
+
return;
|
|
86413
|
+
yield this.ensureStarted();
|
|
86414
|
+
this.content = mergeStreamingText(this.content, chunk);
|
|
86415
|
+
this.throttle.note(chunk.length);
|
|
86416
|
+
});
|
|
86417
|
+
}
|
|
86418
|
+
setContent(full) {
|
|
86419
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
86420
|
+
yield this.ensureStarted();
|
|
86421
|
+
this.content = full !== null && full !== void 0 ? full : '';
|
|
86422
|
+
this.throttle.note(Number.MAX_SAFE_INTEGER);
|
|
86423
|
+
});
|
|
86424
|
+
}
|
|
86425
|
+
run(producer) {
|
|
86426
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
86427
|
+
// Eagerly send the placeholder card so `run()` always returns a real
|
|
86428
|
+
// messageId, and so that failTerminal / completeTerminal have a card
|
|
86429
|
+
// to PATCH even if the producer never appends anything.
|
|
86430
|
+
yield this.ensureStarted();
|
|
86431
|
+
try {
|
|
86432
|
+
yield producer(this);
|
|
86433
|
+
}
|
|
86434
|
+
catch (e) {
|
|
86435
|
+
yield this.failTerminal(e);
|
|
86436
|
+
throw e;
|
|
86437
|
+
}
|
|
86438
|
+
yield this.completeTerminal();
|
|
86439
|
+
return { messageId: this._messageId };
|
|
86440
|
+
});
|
|
86441
|
+
}
|
|
86442
|
+
// ─── internals ─────────────────────────────────────────
|
|
86443
|
+
ensureStarted() {
|
|
86444
|
+
var _a;
|
|
86445
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
86446
|
+
if (this.started)
|
|
86447
|
+
return;
|
|
86448
|
+
this.started = true;
|
|
86449
|
+
const initialText = (_a = this.sender.config.streamInitialText) !== null && _a !== void 0 ? _a : DEFAULT_INITIAL;
|
|
86450
|
+
const cardSpec = buildStreamingCard(initialText || '...');
|
|
86451
|
+
this.cardId = yield this.sender.createCardInstance(cardSpec);
|
|
86452
|
+
this._messageId = yield this.sender.sendCardByReference(this.to, this.idType, this.cardId, this.opts);
|
|
86453
|
+
});
|
|
86454
|
+
}
|
|
86455
|
+
pushContent() {
|
|
86456
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
86457
|
+
if (!this.cardId)
|
|
86458
|
+
return;
|
|
86459
|
+
const snapshot = this.content || '...';
|
|
86460
|
+
const seq = ++this.sequence;
|
|
86461
|
+
yield this.queue.enqueue(() => __awaiter(this, void 0, void 0, function* () {
|
|
86462
|
+
yield this.sender.updateCardElementContent(this.cardId, ELEMENT_ID, snapshot, seq);
|
|
86463
|
+
}));
|
|
86464
|
+
});
|
|
86465
|
+
}
|
|
86466
|
+
completeTerminal() {
|
|
86467
|
+
var _a, _b;
|
|
86468
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
86469
|
+
yield this.throttle.flushNow();
|
|
86470
|
+
yield this.queue.drain();
|
|
86471
|
+
if (!this.cardId)
|
|
86472
|
+
return;
|
|
86473
|
+
// If the producer never appended anything, PATCH the card to a
|
|
86474
|
+
// neutral terminal state so the user sees the response is finalized
|
|
86475
|
+
// rather than a stuck "Thinking..." placeholder.
|
|
86476
|
+
if (!this.content) {
|
|
86477
|
+
const seq = ++this.sequence;
|
|
86478
|
+
yield this.queue.enqueue(() => __awaiter(this, void 0, void 0, function* () {
|
|
86479
|
+
try {
|
|
86480
|
+
yield this.sender.updateCardElementContent(this.cardId, ELEMENT_ID, DEFAULT_EMPTY, seq);
|
|
86481
|
+
}
|
|
86482
|
+
catch (_c) {
|
|
86483
|
+
// best effort
|
|
86484
|
+
}
|
|
86485
|
+
}));
|
|
86486
|
+
yield this.queue.drain();
|
|
86487
|
+
}
|
|
86488
|
+
try {
|
|
86489
|
+
yield this.sender.finishStreamingCard(this.cardId, ++this.sequence, truncateSummary(this.content || DEFAULT_EMPTY));
|
|
86490
|
+
}
|
|
86491
|
+
catch (e) {
|
|
86492
|
+
// best effort — Feishu auto-closes after 10min anyway
|
|
86493
|
+
(_b = (_a = this.sender.logger).warn) === null || _b === void 0 ? void 0 : _b.call(_a, '[stream] finishStreamingCard failed', e);
|
|
86494
|
+
}
|
|
86495
|
+
});
|
|
86496
|
+
}
|
|
86497
|
+
failTerminal(_err) {
|
|
86498
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
86499
|
+
this.throttle.dispose();
|
|
86500
|
+
if (!this.cardId)
|
|
86501
|
+
return;
|
|
86502
|
+
this.content = (this.content || '') + ERROR_FOOTER;
|
|
86503
|
+
const seq = ++this.sequence;
|
|
86504
|
+
yield this.queue.enqueue(() => __awaiter(this, void 0, void 0, function* () {
|
|
86505
|
+
try {
|
|
86506
|
+
yield this.sender.updateCardElementContent(this.cardId, ELEMENT_ID, this.content, seq);
|
|
86507
|
+
}
|
|
86508
|
+
catch (_b) {
|
|
86509
|
+
// best effort
|
|
86510
|
+
}
|
|
86511
|
+
}));
|
|
86512
|
+
yield this.queue.drain();
|
|
86513
|
+
try {
|
|
86514
|
+
yield this.sender.finishStreamingCard(this.cardId, ++this.sequence, truncateSummary(this.content));
|
|
86515
|
+
}
|
|
86516
|
+
catch (_a) {
|
|
86517
|
+
// best effort
|
|
86518
|
+
}
|
|
86519
|
+
});
|
|
86520
|
+
}
|
|
86521
|
+
}
|
|
86522
|
+
class MarkdownStreamController {
|
|
86523
|
+
constructor(sender, to, idType, opts) {
|
|
86524
|
+
this.impl = new MarkdownStreamControllerImpl(sender, to, idType, opts);
|
|
86525
|
+
}
|
|
86526
|
+
run(producer) {
|
|
86527
|
+
return this.impl.run(producer);
|
|
86528
|
+
}
|
|
86529
|
+
}
|
|
86530
|
+
|
|
86531
|
+
const DEFAULT_THROTTLE_MS = 100;
|
|
86532
|
+
const DEFAULT_THROTTLE_CHARS = 50;
|
|
86533
|
+
/**
|
|
86534
|
+
* Streaming card reply — the caller provides an initial card JSON and a
|
|
86535
|
+
* producer that drives incremental `update()` calls with full or partial
|
|
86536
|
+
* new card state.
|
|
86537
|
+
*
|
|
86538
|
+
* Updates are throttled + serialized. On producer exception, the last
|
|
86539
|
+
* known state is kept and an error footer element is appended.
|
|
86540
|
+
*/
|
|
86541
|
+
class CardStreamControllerImpl {
|
|
86542
|
+
constructor(sender, to, idType, opts, initial) {
|
|
86543
|
+
var _a, _b;
|
|
86544
|
+
this.sender = sender;
|
|
86545
|
+
this.to = to;
|
|
86546
|
+
this.idType = idType;
|
|
86547
|
+
this.opts = opts;
|
|
86548
|
+
this._messageId = '';
|
|
86549
|
+
this.queue = new UpdateQueue();
|
|
86550
|
+
this._current = initial;
|
|
86551
|
+
const cfg = sender.config;
|
|
86552
|
+
this.throttle = new Throttle({
|
|
86553
|
+
ms: (_a = cfg.streamThrottleMs) !== null && _a !== void 0 ? _a : DEFAULT_THROTTLE_MS,
|
|
86554
|
+
chars: (_b = cfg.streamThrottleChars) !== null && _b !== void 0 ? _b : DEFAULT_THROTTLE_CHARS,
|
|
86555
|
+
}, () => this.patch());
|
|
86556
|
+
}
|
|
86557
|
+
get messageId() {
|
|
86558
|
+
return this._messageId;
|
|
86559
|
+
}
|
|
86560
|
+
get current() {
|
|
86561
|
+
return this._current;
|
|
86562
|
+
}
|
|
86563
|
+
update(next) {
|
|
86564
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
86565
|
+
const nextCard = typeof next === 'function' ? next(this._current) : next;
|
|
86566
|
+
this._current = nextCard;
|
|
86567
|
+
this.throttle.note(JSON.stringify(nextCard).length);
|
|
86568
|
+
});
|
|
86569
|
+
}
|
|
86570
|
+
run(producer) {
|
|
86571
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
86572
|
+
yield this.sendInitial();
|
|
86573
|
+
try {
|
|
86574
|
+
yield producer(this);
|
|
86575
|
+
}
|
|
86576
|
+
catch (e) {
|
|
86577
|
+
yield this.failTerminal(e);
|
|
86578
|
+
throw e;
|
|
86579
|
+
}
|
|
86580
|
+
yield this.completeTerminal();
|
|
86581
|
+
return { messageId: this._messageId };
|
|
86582
|
+
});
|
|
86583
|
+
}
|
|
86584
|
+
// ─── internals ─────────────────────────────────────────
|
|
86585
|
+
sendInitial() {
|
|
86586
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
86587
|
+
const id = yield this.sender.sendOneWithFallback({
|
|
86588
|
+
to: this.to,
|
|
86589
|
+
idType: this.idType,
|
|
86590
|
+
msgType: 'interactive',
|
|
86591
|
+
content: this._current,
|
|
86592
|
+
replyTo: this.opts.replyTo,
|
|
86593
|
+
replyInThread: this.opts.replyInThread,
|
|
86594
|
+
});
|
|
86595
|
+
this._messageId = id;
|
|
86596
|
+
});
|
|
86597
|
+
}
|
|
86598
|
+
patch() {
|
|
86599
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
86600
|
+
if (!this._messageId)
|
|
86601
|
+
return;
|
|
86602
|
+
const snapshot = this._current;
|
|
86603
|
+
yield this.queue.enqueue(() => __awaiter(this, void 0, void 0, function* () {
|
|
86604
|
+
yield this.sender.patchCard(this._messageId, snapshot);
|
|
86605
|
+
}));
|
|
86606
|
+
});
|
|
86607
|
+
}
|
|
86608
|
+
completeTerminal() {
|
|
86609
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
86610
|
+
yield this.throttle.flushNow();
|
|
86611
|
+
yield this.queue.drain();
|
|
86612
|
+
});
|
|
86613
|
+
}
|
|
86614
|
+
failTerminal(_err) {
|
|
86615
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
86616
|
+
this.throttle.dispose();
|
|
86617
|
+
const withFooter = appendErrorFooter(this._current);
|
|
86618
|
+
yield this.queue.enqueue(() => __awaiter(this, void 0, void 0, function* () {
|
|
86619
|
+
try {
|
|
86620
|
+
yield this.sender.patchCard(this._messageId, withFooter);
|
|
86621
|
+
}
|
|
86622
|
+
catch (_a) {
|
|
86623
|
+
// best effort
|
|
86624
|
+
}
|
|
86625
|
+
}));
|
|
86626
|
+
yield this.queue.drain();
|
|
86627
|
+
});
|
|
86628
|
+
}
|
|
86629
|
+
}
|
|
86630
|
+
function appendErrorFooter(card) {
|
|
86631
|
+
const cardObj = card;
|
|
86632
|
+
const elements = Array.isArray(cardObj === null || cardObj === void 0 ? void 0 : cardObj.elements) ? [...cardObj.elements] : [];
|
|
86633
|
+
elements.push({
|
|
86634
|
+
tag: 'note',
|
|
86635
|
+
elements: [{ tag: 'plain_text', content: '⚠️ 生成中断' }],
|
|
86636
|
+
});
|
|
86637
|
+
return Object.assign(Object.assign({}, card), { elements });
|
|
86638
|
+
}
|
|
86639
|
+
class CardStreamController {
|
|
86640
|
+
constructor(sender, to, idType, opts, initial) {
|
|
86641
|
+
this.impl = new CardStreamControllerImpl(sender, to, idType, opts, initial);
|
|
86642
|
+
}
|
|
86643
|
+
run(producer) {
|
|
86644
|
+
return this.impl.run(producer);
|
|
86645
|
+
}
|
|
86646
|
+
}
|
|
86647
|
+
|
|
86648
|
+
/**
|
|
86649
|
+
* Parse Opus/OGG audio duration (ms) from a buffer.
|
|
86650
|
+
*
|
|
86651
|
+
* Scans backward for the last "OggS" page capture pattern (0x4f676753) and
|
|
86652
|
+
* reads its granule_position (64-bit LE at offset +6 from magic). For Opus,
|
|
86653
|
+
* granule_position is the number of decoded samples at 48 kHz — divide by
|
|
86654
|
+
* 48 (samples per ms) to get milliseconds.
|
|
86655
|
+
*
|
|
86656
|
+
* Returns undefined if the buffer doesn't contain a valid Ogg stream or if
|
|
86657
|
+
* the granule position is obviously invalid.
|
|
86658
|
+
*/
|
|
86659
|
+
function parseOpusDuration(buf) {
|
|
86660
|
+
if (!buf || buf.length < 27)
|
|
86661
|
+
return undefined;
|
|
86662
|
+
for (let i = buf.length - 27; i >= 0; i--) {
|
|
86663
|
+
// "OggS" = 0x4f 67 67 53
|
|
86664
|
+
if (buf[i] === 0x4f &&
|
|
86665
|
+
buf[i + 1] === 0x67 &&
|
|
86666
|
+
buf[i + 2] === 0x67 &&
|
|
86667
|
+
buf[i + 3] === 0x53) {
|
|
86668
|
+
const granule = buf.readBigInt64LE(i + 6);
|
|
86669
|
+
if (granule < BigInt(0))
|
|
86670
|
+
return undefined;
|
|
86671
|
+
const ms = Number(granule) / 48;
|
|
86672
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
86673
|
+
return undefined;
|
|
86674
|
+
return Math.round(ms);
|
|
86675
|
+
}
|
|
86676
|
+
}
|
|
86677
|
+
return undefined;
|
|
86678
|
+
}
|
|
86679
|
+
|
|
86680
|
+
/**
|
|
86681
|
+
* Parse MP4 video duration (ms) from a buffer by walking the ISO BMFF box
|
|
86682
|
+
* hierarchy and finding `moov → mvhd`.
|
|
86683
|
+
*
|
|
86684
|
+
* The `mvhd` box version determines field widths:
|
|
86685
|
+
* - version 0: creation/modification/timescale/duration = 4 bytes each
|
|
86686
|
+
* - version 1: creation/modification = 8 bytes, timescale = 4 bytes,
|
|
86687
|
+
* duration = 8 bytes
|
|
86688
|
+
*
|
|
86689
|
+
* Returns undefined if the stream is not parseable or duration is unknown.
|
|
86690
|
+
*/
|
|
86691
|
+
function parseMp4Duration(buf) {
|
|
86692
|
+
if (!buf || buf.length < 16)
|
|
86693
|
+
return undefined;
|
|
86694
|
+
const moov = findBoxPayload(buf, 0, buf.length, 'moov');
|
|
86695
|
+
if (!moov)
|
|
86696
|
+
return undefined;
|
|
86697
|
+
const mvhd = findBoxPayload(buf, moov.start, moov.end, 'mvhd');
|
|
86698
|
+
if (!mvhd)
|
|
86699
|
+
return undefined;
|
|
86700
|
+
const p = mvhd.start;
|
|
86701
|
+
if (p + 4 > buf.length)
|
|
86702
|
+
return undefined;
|
|
86703
|
+
const version = buf.readUInt8(p);
|
|
86704
|
+
// skip flags (3 bytes)
|
|
86705
|
+
const base = p + 4;
|
|
86706
|
+
let timescale;
|
|
86707
|
+
let duration;
|
|
86708
|
+
if (version === 1) {
|
|
86709
|
+
// creation(8) + modification(8) + timescale(4) + duration(8)
|
|
86710
|
+
if (base + 28 > buf.length)
|
|
86711
|
+
return undefined;
|
|
86712
|
+
timescale = buf.readUInt32BE(base + 16);
|
|
86713
|
+
duration = Number(buf.readBigUInt64BE(base + 20));
|
|
86714
|
+
}
|
|
86715
|
+
else {
|
|
86716
|
+
// creation(4) + modification(4) + timescale(4) + duration(4)
|
|
86717
|
+
if (base + 16 > buf.length)
|
|
86718
|
+
return undefined;
|
|
86719
|
+
timescale = buf.readUInt32BE(base + 8);
|
|
86720
|
+
duration = buf.readUInt32BE(base + 12);
|
|
86721
|
+
}
|
|
86722
|
+
if (!timescale || !Number.isFinite(timescale) || !Number.isFinite(duration)) {
|
|
86723
|
+
return undefined;
|
|
86724
|
+
}
|
|
86725
|
+
return Math.round((duration / timescale) * 1000);
|
|
86726
|
+
}
|
|
86727
|
+
/**
|
|
86728
|
+
* Walk the boxes from [begin, end) looking for one whose type matches
|
|
86729
|
+
* `name`. Returns the payload range (excluding the 8-byte header).
|
|
86730
|
+
*/
|
|
86731
|
+
function findBoxPayload(buf, begin, end, name) {
|
|
86732
|
+
let p = begin;
|
|
86733
|
+
while (p + 8 <= end && p + 8 <= buf.length) {
|
|
86734
|
+
const size = buf.readUInt32BE(p);
|
|
86735
|
+
const type = buf.slice(p + 4, p + 8).toString('ascii');
|
|
86736
|
+
const boxEnd = size === 1
|
|
86737
|
+
? p + Number(buf.readBigUInt64BE(p + 8))
|
|
86738
|
+
: size === 0
|
|
86739
|
+
? end // box extends to end-of-file
|
|
86740
|
+
: p + size;
|
|
86741
|
+
if (boxEnd <= p || boxEnd > end)
|
|
86742
|
+
return undefined;
|
|
86743
|
+
if (type === name) {
|
|
86744
|
+
const payloadStart = size === 1 ? p + 16 : p + 8;
|
|
86745
|
+
return { start: payloadStart, end: boxEnd };
|
|
86746
|
+
}
|
|
86747
|
+
p = boxEnd;
|
|
86748
|
+
}
|
|
86749
|
+
return undefined;
|
|
86750
|
+
}
|
|
86751
|
+
|
|
86752
|
+
/**
|
|
86753
|
+
* CIDR blocks that must not be reached from a URL fetch triggered by the
|
|
86754
|
+
* SDK. Covers loopback, link-local, private ranges, CGNAT, documentation.
|
|
86755
|
+
*/
|
|
86756
|
+
const BLOCKED_V4 = [
|
|
86757
|
+
[0x00000000, 8],
|
|
86758
|
+
[0x0a000000, 8],
|
|
86759
|
+
[0x7f000000, 8],
|
|
86760
|
+
[0xa9fe0000, 16],
|
|
86761
|
+
[0xac100000, 12],
|
|
86762
|
+
[0xc0a80000, 16],
|
|
86763
|
+
[0x64400000, 10],
|
|
86764
|
+
[0xc0000000, 24],
|
|
86765
|
+
[0xc0000200, 24],
|
|
86766
|
+
[0xc6120000, 15],
|
|
86767
|
+
[0xc6336400, 24],
|
|
86768
|
+
[0xcb007100, 24],
|
|
86769
|
+
[0xe0000000, 4],
|
|
86770
|
+
[0xf0000000, 4], // 240.0.0.0/4 (reserved)
|
|
86771
|
+
];
|
|
86772
|
+
/**
|
|
86773
|
+
* Validate that `url` does not resolve to an internal / reserved IP, and
|
|
86774
|
+
* return the single IP that downstream fetching should pin to. Pinning is
|
|
86775
|
+
* essential to close the DNS-rebinding TOCTOU gap: the attacker's DNS
|
|
86776
|
+
* server could otherwise return a public IP to this check and a private
|
|
86777
|
+
* IP (e.g. cloud metadata `169.254.169.254`) to the subsequent fetch.
|
|
86778
|
+
*
|
|
86779
|
+
* Allowlisted hostnames skip the public-IP check but still pin — pinning
|
|
86780
|
+
* is only about consistency between check-time and use-time, and is
|
|
86781
|
+
* harmless when the caller trusts the host.
|
|
86782
|
+
*/
|
|
86783
|
+
function assertPublicUrl(url, opts = {}) {
|
|
86784
|
+
var _a, _b;
|
|
86785
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
86786
|
+
const u = new URL(url);
|
|
86787
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
|
86788
|
+
throw new Error(`ssrf_blocked: protocol ${u.protocol}`);
|
|
86789
|
+
}
|
|
86790
|
+
// URL keeps IPv6 literals wrapped in brackets (`[::1]`) — strip them so
|
|
86791
|
+
// `isIP()` recognizes the literal and we don't fall through to DNS.
|
|
86792
|
+
const rawHost = u.hostname;
|
|
86793
|
+
const host = rawHost.startsWith('[') && rawHost.endsWith(']')
|
|
86794
|
+
? rawHost.slice(1, -1)
|
|
86795
|
+
: rawHost;
|
|
86796
|
+
const allowlisted = (_b = (_a = opts.allowlist) === null || _a === void 0 ? void 0 : _a.includes(host)) !== null && _b !== void 0 ? _b : false;
|
|
86797
|
+
let resolvedIp;
|
|
86798
|
+
if (net.isIP(host)) {
|
|
86799
|
+
resolvedIp = host;
|
|
86800
|
+
if (!allowlisted)
|
|
86801
|
+
assertIpPublic(resolvedIp);
|
|
86802
|
+
}
|
|
86803
|
+
else {
|
|
86804
|
+
const records = yield dns.promises.lookup(host, { all: true });
|
|
86805
|
+
if (records.length === 0) {
|
|
86806
|
+
throw new Error(`ssrf_blocked: no DNS records for ${host}`);
|
|
86807
|
+
}
|
|
86808
|
+
if (!allowlisted) {
|
|
86809
|
+
// Reject if ANY record is private — we can't trust DNS
|
|
86810
|
+
// round-robin to "win the lottery" and only return public IPs.
|
|
86811
|
+
for (const r of records)
|
|
86812
|
+
assertIpPublic(r.address);
|
|
86813
|
+
}
|
|
86814
|
+
resolvedIp = records[0].address;
|
|
86815
|
+
}
|
|
86816
|
+
return { resolvedIp, originalHost: host };
|
|
86817
|
+
});
|
|
86818
|
+
}
|
|
86819
|
+
function assertIpPublic(ip) {
|
|
86820
|
+
const v = net.isIP(ip);
|
|
86821
|
+
if (v === 4 && ipv4Blocked(ip)) {
|
|
86822
|
+
throw new Error(`ssrf_blocked: ${ip}`);
|
|
86823
|
+
}
|
|
86824
|
+
if (v === 6 && ipv6Blocked(ip)) {
|
|
86825
|
+
throw new Error(`ssrf_blocked: ${ip}`);
|
|
86826
|
+
}
|
|
86827
|
+
if (v === 0) {
|
|
86828
|
+
throw new Error(`ssrf_blocked: not a valid IP: ${ip}`);
|
|
86829
|
+
}
|
|
86830
|
+
}
|
|
86831
|
+
function ipv4Blocked(ip) {
|
|
86832
|
+
const parts = ip.split('.').map(Number);
|
|
86833
|
+
if (parts.length !== 4 || parts.some((p) => p < 0 || p > 255))
|
|
86834
|
+
return true;
|
|
86835
|
+
const n = ((parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]) >>> 0;
|
|
86836
|
+
return BLOCKED_V4.some(([net, bits]) => {
|
|
86837
|
+
const mask = bits === 0 ? 0 : ((-1) << (32 - bits)) >>> 0;
|
|
86838
|
+
return (n & mask) === (net & mask);
|
|
86839
|
+
});
|
|
86840
|
+
}
|
|
86841
|
+
/**
|
|
86842
|
+
* Hardcoded IPv6 CIDR blocks rejected outright (not delegated to v4).
|
|
86843
|
+
* Each tuple is [network address as 128-bit bigint, prefix length].
|
|
86844
|
+
*
|
|
86845
|
+
* Note: `BigInt('0x...')` is used (instead of `0x...n` literals) so the
|
|
86846
|
+
* code compiles under TypeScript `target: es6` — bigint literals need
|
|
86847
|
+
* es2020. Semantically identical.
|
|
86848
|
+
*/
|
|
86849
|
+
const BLOCKED_V6 = [
|
|
86850
|
+
[BigInt('0xfe800000000000000000000000000000'), 10],
|
|
86851
|
+
[BigInt('0xfc000000000000000000000000000000'), 7],
|
|
86852
|
+
[BigInt('0xff000000000000000000000000000000'), 8],
|
|
86853
|
+
[BigInt('0x01000000000000000000000000000000'), 64],
|
|
86854
|
+
[BigInt('0x20010db8000000000000000000000000'), 32],
|
|
86855
|
+
[BigInt('0x20010000000000000000000000000000'), 32], // 2001::/32 Teredo
|
|
86856
|
+
];
|
|
86857
|
+
// Top-96-bit prefixes that identify "an IPv4 address carried inside IPv6".
|
|
86858
|
+
// When these match, the low 32 bits are the actual IPv4 and we delegate
|
|
86859
|
+
// the decision to `ipv4Blocked` — so e.g. NAT64 around a public IPv4
|
|
86860
|
+
// passes, NAT64 around a private IPv4 is rejected.
|
|
86861
|
+
const HIGH96_MASK = BigInt('0xffffffffffffffffffffffff00000000');
|
|
86862
|
+
const PREFIX_V4_MAPPED = BigInt('0x00000000000000000000ffff00000000'); // ::ffff:0:0/96
|
|
86863
|
+
const PREFIX_V4_COMPAT = BigInt(0); // ::/96
|
|
86864
|
+
const PREFIX_NAT64 = BigInt('0x0064ff9b000000000000000000000000'); // 64:ff9b::/96
|
|
86865
|
+
const LOW32_MASK = BigInt('0xffffffff');
|
|
86866
|
+
function ipv6Blocked(ip) {
|
|
86867
|
+
let n;
|
|
86868
|
+
try {
|
|
86869
|
+
n = parseIPv6(ip);
|
|
86870
|
+
}
|
|
86871
|
+
catch (_a) {
|
|
86872
|
+
// Unparseable — fail-closed: treat as internal / suspicious.
|
|
86873
|
+
return true;
|
|
86874
|
+
}
|
|
86875
|
+
// If the address carries an IPv4 in its low 32 bits, check that IPv4.
|
|
86876
|
+
const high96 = n & HIGH96_MASK;
|
|
86877
|
+
if (high96 === PREFIX_V4_MAPPED ||
|
|
86878
|
+
high96 === PREFIX_NAT64 ||
|
|
86879
|
+
high96 === PREFIX_V4_COMPAT) {
|
|
86880
|
+
const v4 = Number(n & LOW32_MASK);
|
|
86881
|
+
const v4Str = [
|
|
86882
|
+
(v4 >>> 24) & 0xff,
|
|
86883
|
+
(v4 >>> 16) & 0xff,
|
|
86884
|
+
(v4 >>> 8) & 0xff,
|
|
86885
|
+
v4 & 0xff,
|
|
86886
|
+
].join('.');
|
|
86887
|
+
return ipv4Blocked(v4Str);
|
|
86888
|
+
}
|
|
86889
|
+
// Standalone blocked CIDRs via numeric comparison — handles all
|
|
86890
|
+
// equivalent string representations (compressed, expanded, mixed).
|
|
86891
|
+
return BLOCKED_V6.some(([net, bits]) => {
|
|
86892
|
+
const shift = BigInt(128 - bits);
|
|
86893
|
+
const allOnes = (BigInt(1) << BigInt(128)) - BigInt(1);
|
|
86894
|
+
const mask = (allOnes >> shift) << shift;
|
|
86895
|
+
return (n & mask) === (net & mask);
|
|
86896
|
+
});
|
|
86897
|
+
}
|
|
86898
|
+
/**
|
|
86899
|
+
* Parse any representation of an IPv6 address into a 128-bit bigint.
|
|
86900
|
+
* Handles:
|
|
86901
|
+
* - `::` compression (`::1`, `fe80::1`, `::`)
|
|
86902
|
+
* - fully expanded form (`0:0:0:0:0:0:0:1`)
|
|
86903
|
+
* - IPv4-in-IPv6 mixed notation (`::ffff:127.0.0.1`, `64:ff9b::10.0.0.1`)
|
|
86904
|
+
* - Zone ID suffix (`fe80::1%eth0` — the suffix is ignored)
|
|
86905
|
+
* Throws on any malformed input; the caller treats a throw as
|
|
86906
|
+
* fail-closed.
|
|
86907
|
+
*/
|
|
86908
|
+
function parseIPv6(ip) {
|
|
86909
|
+
let addr = ip.split('%')[0].toLowerCase(); // strip Zone ID
|
|
86910
|
+
// Convert IPv4 suffix (dotted decimal) to two 16-bit hex groups so the
|
|
86911
|
+
// rest of the parser only sees hex groups.
|
|
86912
|
+
const v4Suffix = addr.match(/:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
86913
|
+
if (v4Suffix) {
|
|
86914
|
+
const parts = v4Suffix[1].split('.').map(Number);
|
|
86915
|
+
if (parts.length !== 4 || parts.some((p) => !Number.isFinite(p) || p < 0 || p > 255)) {
|
|
86916
|
+
throw new Error('bad v4 suffix');
|
|
86917
|
+
}
|
|
86918
|
+
const hex1 = ((parts[0] << 8) | parts[1]).toString(16);
|
|
86919
|
+
const hex2 = ((parts[2] << 8) | parts[3]).toString(16);
|
|
86920
|
+
addr = addr.slice(0, addr.length - v4Suffix[1].length) + `${hex1}:${hex2}`;
|
|
86921
|
+
}
|
|
86922
|
+
// Expand `::`.
|
|
86923
|
+
const halves = addr.split('::');
|
|
86924
|
+
if (halves.length > 2)
|
|
86925
|
+
throw new Error('multiple "::"');
|
|
86926
|
+
let groups;
|
|
86927
|
+
if (halves.length === 2) {
|
|
86928
|
+
const left = halves[0] ? halves[0].split(':') : [];
|
|
86929
|
+
const right = halves[1] ? halves[1].split(':') : [];
|
|
86930
|
+
const fill = 8 - left.length - right.length;
|
|
86931
|
+
if (fill < 0)
|
|
86932
|
+
throw new Error('too many groups');
|
|
86933
|
+
groups = [...left, ...Array(fill).fill('0'), ...right];
|
|
86934
|
+
}
|
|
86935
|
+
else {
|
|
86936
|
+
groups = addr.split(':');
|
|
86937
|
+
}
|
|
86938
|
+
if (groups.length !== 8)
|
|
86939
|
+
throw new Error('wrong group count');
|
|
86940
|
+
let n = BigInt(0);
|
|
86941
|
+
for (const g of groups) {
|
|
86942
|
+
if (!/^[0-9a-f]{0,4}$/.test(g))
|
|
86943
|
+
throw new Error('bad group');
|
|
86944
|
+
n = (n << BigInt(16)) | BigInt(`0x${g || '0'}`);
|
|
86945
|
+
}
|
|
86946
|
+
return n;
|
|
86947
|
+
}
|
|
86948
|
+
|
|
86949
|
+
/**
|
|
86950
|
+
* POSIX directory prefixes that can never be a legitimate media source.
|
|
86951
|
+
* Skipped on Windows (no equivalent single-prefix set exists there; a
|
|
86952
|
+
* downstream allowlist is the right tool for Windows deployments).
|
|
86953
|
+
*/
|
|
86954
|
+
const POSIX_BLOCKED_PREFIXES = ['/etc/', '/proc/', '/sys/', '/dev/'];
|
|
86955
|
+
/** Max bytes accepted from a URL-based media source. Protects against
|
|
86956
|
+
* memory DoS when a malicious URL tries to return gigabytes. */
|
|
86957
|
+
const URL_MAX_BYTES = 50 * 1024 * 1024;
|
|
86958
|
+
class MediaUploader {
|
|
86959
|
+
constructor(client, config) {
|
|
86960
|
+
this.client = client;
|
|
86961
|
+
this.config = config;
|
|
86962
|
+
}
|
|
86963
|
+
upload(input) {
|
|
86964
|
+
var _a, _b, _c;
|
|
86965
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
86966
|
+
const buffer = yield this.toBuffer(input.source);
|
|
86967
|
+
if (input.kind === 'image') {
|
|
86968
|
+
return this.uploadImage(buffer);
|
|
86969
|
+
}
|
|
86970
|
+
if (input.kind === 'audio') {
|
|
86971
|
+
const duration = this.resolveDuration(input, buffer);
|
|
86972
|
+
return this.uploadFile(buffer, 'opus', (_a = input.fileName) !== null && _a !== void 0 ? _a : 'voice.opus', duration);
|
|
86973
|
+
}
|
|
86974
|
+
if (input.kind === 'video') {
|
|
86975
|
+
const duration = this.resolveDuration(input, buffer);
|
|
86976
|
+
return this.uploadFile(buffer, 'mp4', (_b = input.fileName) !== null && _b !== void 0 ? _b : 'video.mp4', duration);
|
|
86977
|
+
}
|
|
86978
|
+
// generic file — no duration required
|
|
86979
|
+
return this.uploadFile(buffer, 'stream', (_c = input.fileName) !== null && _c !== void 0 ? _c : 'upload.bin');
|
|
86980
|
+
});
|
|
86981
|
+
}
|
|
86982
|
+
/**
|
|
86983
|
+
* Materialize `source` into a Buffer. A string is treated as:
|
|
86984
|
+
* • `http://` / `https://` URL — fetch with SSRF guard
|
|
86985
|
+
* • anything else — local filesystem path, read directly
|
|
86986
|
+
*/
|
|
86987
|
+
toBuffer(source) {
|
|
86988
|
+
var _a, _b;
|
|
86989
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
86990
|
+
if (Buffer.isBuffer(source))
|
|
86991
|
+
return source;
|
|
86992
|
+
if (!/^https?:\/\//i.test(source)) {
|
|
86993
|
+
const allowedDirs = (_a = this.config) === null || _a === void 0 ? void 0 : _a.allowedFileDirs;
|
|
86994
|
+
const resolved = path__default["default"].resolve(source);
|
|
86995
|
+
// Pre-I/O POSIX blocklist check: catches `/etc/passwd` etc.
|
|
86996
|
+
// before touching the filesystem. Path-traversal variants like
|
|
86997
|
+
// `/tmp/../etc/passwd` are collapsed by `path.resolve`.
|
|
86998
|
+
this.assertNotOnPosixBlocklist(resolved);
|
|
86999
|
+
try {
|
|
87000
|
+
// Follow symlinks so that:
|
|
87001
|
+
// • a symlink inside an allowed dir pointing outside
|
|
87002
|
+
// is caught (allowlist containment is on real target);
|
|
87003
|
+
// • macOS `/etc` → `/private/etc` alias is also blocked.
|
|
87004
|
+
const realPath = yield fs__default["default"].promises.realpath(resolved);
|
|
87005
|
+
this.assertNotOnPosixBlocklist(realPath);
|
|
87006
|
+
if (allowedDirs && allowedDirs.length > 0) {
|
|
87007
|
+
// Realpath the allowed dirs too — otherwise macOS
|
|
87008
|
+
// `/var` → `/private/var` alias would reject all
|
|
87009
|
+
// tmpdir-based paths.
|
|
87010
|
+
const canonicalDirs = yield Promise.all(allowedDirs.map((d) => __awaiter(this, void 0, void 0, function* () {
|
|
87011
|
+
const r = path__default["default"].resolve(d);
|
|
87012
|
+
try {
|
|
87013
|
+
return yield fs__default["default"].promises.realpath(r);
|
|
87014
|
+
}
|
|
87015
|
+
catch (_c) {
|
|
87016
|
+
return r;
|
|
87017
|
+
}
|
|
87018
|
+
})));
|
|
87019
|
+
const inAllowed = canonicalDirs.some((d) => realPath === d || realPath.startsWith(d + path__default["default"].sep));
|
|
87020
|
+
if (!inAllowed) {
|
|
87021
|
+
throw new LarkChannelError('upload_failed', `file path is outside allowed directories: ${realPath}`);
|
|
87022
|
+
}
|
|
87023
|
+
}
|
|
87024
|
+
return yield fs__default["default"].promises.readFile(realPath);
|
|
87025
|
+
}
|
|
87026
|
+
catch (e) {
|
|
87027
|
+
if (e instanceof LarkChannelError)
|
|
87028
|
+
throw e;
|
|
87029
|
+
throw new LarkChannelError('upload_failed', `source is neither an http(s) URL nor a readable local file: ${source}`, { cause: e });
|
|
87030
|
+
}
|
|
87031
|
+
}
|
|
87032
|
+
const ssrf = (_b = this.config) === null || _b === void 0 ? void 0 : _b.ssrfGuard;
|
|
87033
|
+
const guardEnabled = ssrf !== false;
|
|
87034
|
+
let resolvedIp;
|
|
87035
|
+
if (guardEnabled) {
|
|
87036
|
+
const ssrfOpts = typeof ssrf === 'object' && ssrf ? { allowlist: ssrf.allowlist } : {};
|
|
87037
|
+
try {
|
|
87038
|
+
({ resolvedIp } = yield assertPublicUrl(source, ssrfOpts));
|
|
87039
|
+
}
|
|
87040
|
+
catch (e) {
|
|
87041
|
+
throw new LarkChannelError('ssrf_blocked', `URL blocked: ${String(e)}`, {
|
|
87042
|
+
cause: e,
|
|
87043
|
+
});
|
|
87044
|
+
}
|
|
87045
|
+
}
|
|
87046
|
+
try {
|
|
87047
|
+
// Pin the TCP connect target to the IP we just validated so the
|
|
87048
|
+
// attacker can't swap in a private IP between the DNS check and
|
|
87049
|
+
// the fetch (DNS rebinding / TOCTOU). We inject a custom `lookup`
|
|
87050
|
+
// into a per-request agent; the URL itself stays intact so TLS
|
|
87051
|
+
// SNI and certificate verification continue to use the original
|
|
87052
|
+
// hostname.
|
|
87053
|
+
const requestOpts = {
|
|
87054
|
+
url: source,
|
|
87055
|
+
method: 'GET',
|
|
87056
|
+
responseType: 'arraybuffer',
|
|
87057
|
+
timeout: 15000,
|
|
87058
|
+
maxContentLength: URL_MAX_BYTES,
|
|
87059
|
+
maxBodyLength: URL_MAX_BYTES,
|
|
87060
|
+
};
|
|
87061
|
+
if (resolvedIp) {
|
|
87062
|
+
const agent = makePinnedAgent(source, resolvedIp);
|
|
87063
|
+
requestOpts.httpAgent = agent;
|
|
87064
|
+
requestOpts.httpsAgent = agent;
|
|
87065
|
+
}
|
|
87066
|
+
const res = yield this.client.httpInstance.request(requestOpts);
|
|
87067
|
+
return Buffer.from(res);
|
|
87068
|
+
}
|
|
87069
|
+
catch (e) {
|
|
87070
|
+
throw new LarkChannelError('upload_failed', `fetch source URL failed`, {
|
|
87071
|
+
cause: e,
|
|
87072
|
+
});
|
|
87073
|
+
}
|
|
87074
|
+
});
|
|
87075
|
+
}
|
|
87076
|
+
/**
|
|
87077
|
+
* Reject paths pointing at POSIX system directories (`/etc`, `/proc`,
|
|
87078
|
+
* `/sys`, `/dev`). Called twice by the caller — once on the resolved
|
|
87079
|
+
* path pre-I/O, again on the realpath — so both direct hits
|
|
87080
|
+
* (`/etc/passwd`) and macOS alias hits (`/etc` → `/private/etc`) are
|
|
87081
|
+
* caught.
|
|
87082
|
+
*/
|
|
87083
|
+
assertNotOnPosixBlocklist(p) {
|
|
87084
|
+
if (process.platform === 'win32')
|
|
87085
|
+
return;
|
|
87086
|
+
if (POSIX_BLOCKED_PREFIXES.some((pre) => p === pre.slice(0, -1) || p.startsWith(pre))) {
|
|
87087
|
+
throw new LarkChannelError('upload_failed', `file path is not allowed: ${p}`);
|
|
87088
|
+
}
|
|
87089
|
+
}
|
|
87090
|
+
resolveDuration(input, buffer) {
|
|
87091
|
+
if (input.duration != null && input.duration > 0)
|
|
87092
|
+
return input.duration;
|
|
87093
|
+
const parsed = input.kind === 'audio'
|
|
87094
|
+
? parseOpusDuration(buffer)
|
|
87095
|
+
: input.kind === 'video'
|
|
87096
|
+
? parseMp4Duration(buffer)
|
|
87097
|
+
: undefined;
|
|
87098
|
+
if (parsed != null)
|
|
87099
|
+
return parsed;
|
|
87100
|
+
throw new LarkChannelError('upload_failed', `duration could not be determined for ${input.kind}; pass it explicitly`);
|
|
87101
|
+
}
|
|
87102
|
+
uploadImage(buffer) {
|
|
87103
|
+
var _a, _b;
|
|
87104
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87105
|
+
try {
|
|
87106
|
+
const r = yield this.client.im.v1.image.create({
|
|
87107
|
+
data: { image_type: 'message', image: buffer },
|
|
87108
|
+
});
|
|
87109
|
+
// The code-gen client already strips the outer envelope and returns
|
|
87110
|
+
// `res?.data` directly, so `image_key` sits at the top level. Keep
|
|
87111
|
+
// the nested `.data.image_key` path as a defensive fallback in case
|
|
87112
|
+
// a caller plugs in a different HttpInstance that preserves the
|
|
87113
|
+
// envelope.
|
|
87114
|
+
const key = (_a = r === null || r === void 0 ? void 0 : r.image_key) !== null && _a !== void 0 ? _a : (_b = r === null || r === void 0 ? void 0 : r.data) === null || _b === void 0 ? void 0 : _b.image_key;
|
|
87115
|
+
if (!key)
|
|
87116
|
+
throw new Error('image_key missing in upload response');
|
|
87117
|
+
return { kind: 'image', fileKey: key };
|
|
87118
|
+
}
|
|
87119
|
+
catch (e) {
|
|
87120
|
+
throw new LarkChannelError('upload_failed', `image upload failed`, {
|
|
87121
|
+
cause: e,
|
|
87122
|
+
});
|
|
87123
|
+
}
|
|
87124
|
+
});
|
|
87125
|
+
}
|
|
87126
|
+
uploadFile(buffer, fileType, fileName, durationMs) {
|
|
87127
|
+
var _a, _b;
|
|
87128
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87129
|
+
try {
|
|
87130
|
+
const data = {
|
|
87131
|
+
file_type: fileType,
|
|
87132
|
+
file_name: fileName,
|
|
87133
|
+
file: buffer,
|
|
87134
|
+
};
|
|
87135
|
+
if (durationMs != null)
|
|
87136
|
+
data.duration = durationMs;
|
|
87137
|
+
const r = yield this.client.im.v1.file.create({
|
|
87138
|
+
data: data,
|
|
87139
|
+
});
|
|
87140
|
+
const key = (_a = r === null || r === void 0 ? void 0 : r.file_key) !== null && _a !== void 0 ? _a : (_b = r === null || r === void 0 ? void 0 : r.data) === null || _b === void 0 ? void 0 : _b.file_key;
|
|
87141
|
+
if (!key)
|
|
87142
|
+
throw new Error('file_key missing in upload response');
|
|
87143
|
+
const kind = fileType === 'opus' ? 'audio' : fileType === 'mp4' ? 'video' : 'file';
|
|
87144
|
+
return { kind, fileKey: key, durationMs };
|
|
87145
|
+
}
|
|
87146
|
+
catch (e) {
|
|
87147
|
+
if (e instanceof LarkChannelError)
|
|
87148
|
+
throw e;
|
|
87149
|
+
throw new LarkChannelError('upload_failed', `file upload failed`, {
|
|
87150
|
+
cause: e,
|
|
87151
|
+
});
|
|
87152
|
+
}
|
|
87153
|
+
});
|
|
87154
|
+
}
|
|
87155
|
+
}
|
|
87156
|
+
/**
|
|
87157
|
+
* Build a per-request http(s) Agent whose DNS lookup always returns
|
|
87158
|
+
* `pinnedIp`, regardless of what hostname Node would otherwise resolve.
|
|
87159
|
+
* The URL's original hostname is preserved on the wire, which keeps TLS
|
|
87160
|
+
* SNI and certificate verification working. This closes the window where
|
|
87161
|
+
* a malicious DNS server could return a different (private) IP on the
|
|
87162
|
+
* second resolution triggered by the actual fetch.
|
|
87163
|
+
*/
|
|
87164
|
+
function makePinnedAgent(url, pinnedIp) {
|
|
87165
|
+
const AgentClass = url.startsWith('https:') ? https__default["default"].Agent : http__default["default"].Agent;
|
|
87166
|
+
const agent = new AgentClass();
|
|
87167
|
+
const family = pinnedIp.includes(':') ? 6 : 4;
|
|
87168
|
+
const lookup = (_hostname, _opts, cb) => {
|
|
87169
|
+
cb(null, pinnedIp, family);
|
|
87170
|
+
};
|
|
87171
|
+
// Node's Agent doesn't accept `lookup` in its constructor options, so we
|
|
87172
|
+
// wrap `createConnection` to fold `lookup` into every outgoing socket.
|
|
87173
|
+
const origCreateConnection = agent.createConnection.bind(agent);
|
|
87174
|
+
agent.createConnection = (opts, cb) => origCreateConnection(Object.assign(Object.assign({}, opts), { lookup }), cb);
|
|
87175
|
+
return agent;
|
|
87176
|
+
}
|
|
87177
|
+
|
|
87178
|
+
/**
|
|
87179
|
+
* Build a text prefix that renders as real Feishu mentions when prepended
|
|
87180
|
+
* to a text-type outbound message (the <at …> tag form).
|
|
87181
|
+
*
|
|
87182
|
+
* For post-type messages, the mentions should be injected as `at` elements
|
|
87183
|
+
* at the beginning of the post body — use `composePostMentionElements`
|
|
87184
|
+
* instead.
|
|
87185
|
+
*/
|
|
87186
|
+
function composeMentionsTextPrefix(mentions) {
|
|
87187
|
+
var _a;
|
|
87188
|
+
if (!(mentions === null || mentions === void 0 ? void 0 : mentions.length))
|
|
87189
|
+
return '';
|
|
87190
|
+
const parts = [];
|
|
87191
|
+
for (const m of mentions) {
|
|
87192
|
+
if (!m.openId)
|
|
87193
|
+
continue;
|
|
87194
|
+
const name = (_a = m.name) !== null && _a !== void 0 ? _a : '';
|
|
87195
|
+
parts.push(`<at user_id="${m.openId}">${name}</at>`);
|
|
87196
|
+
}
|
|
87197
|
+
return parts.length > 0 ? parts.join(' ') + ' ' : '';
|
|
87198
|
+
}
|
|
87199
|
+
/**
|
|
87200
|
+
* Produce `at` elements to prepend to the first paragraph of a post body.
|
|
87201
|
+
*/
|
|
87202
|
+
function composePostMentionElements(mentions) {
|
|
87203
|
+
if (!(mentions === null || mentions === void 0 ? void 0 : mentions.length))
|
|
87204
|
+
return [];
|
|
87205
|
+
const out = [];
|
|
87206
|
+
for (const m of mentions) {
|
|
87207
|
+
if (!m.openId)
|
|
87208
|
+
continue;
|
|
87209
|
+
out.push({ tag: 'at', user_id: m.openId, user_name: m.name });
|
|
87210
|
+
}
|
|
87211
|
+
return out;
|
|
87212
|
+
}
|
|
87213
|
+
|
|
87214
|
+
/**
|
|
87215
|
+
* Convert a Markdown string to Feishu post JSON.
|
|
87216
|
+
*
|
|
87217
|
+
* Coverage (the subset Agent frameworks realistically produce):
|
|
87218
|
+
* - Headings (rendered as bold text; Feishu post has no heading element)
|
|
87219
|
+
* - Paragraphs with inline **bold**, *italic*, `code`, [text](url)
|
|
87220
|
+
* - Fenced code blocks (```lang\n...\n```)
|
|
87221
|
+
* - Hard rules (---)
|
|
87222
|
+
* - Unordered / ordered list items (rendered as plain text with bullets)
|
|
87223
|
+
* - Blockquotes (rendered as "> " prefix preserved)
|
|
87224
|
+
*
|
|
87225
|
+
* Returns a post body keyed by `zh_cn` (Feishu's default locale).
|
|
87226
|
+
*/
|
|
87227
|
+
function markdownToPost(md, opts) {
|
|
87228
|
+
var _a, _b;
|
|
87229
|
+
const lines = md.replace(/\r\n/g, '\n').split('\n');
|
|
87230
|
+
const paragraphs = [];
|
|
87231
|
+
let fenceLang = null;
|
|
87232
|
+
let fenceBuf = [];
|
|
87233
|
+
const flushFence = () => {
|
|
87234
|
+
if (fenceLang !== null) {
|
|
87235
|
+
paragraphs.push([
|
|
87236
|
+
{
|
|
87237
|
+
tag: 'text',
|
|
87238
|
+
text: fenceBuf.join('\n'),
|
|
87239
|
+
un_escape: true,
|
|
87240
|
+
},
|
|
87241
|
+
]);
|
|
87242
|
+
fenceBuf = [];
|
|
87243
|
+
fenceLang = null;
|
|
87244
|
+
}
|
|
87245
|
+
};
|
|
87246
|
+
for (const line of lines) {
|
|
87247
|
+
const fenceMatch = line.match(/^```(\w*)\s*$/);
|
|
87248
|
+
if (fenceMatch) {
|
|
87249
|
+
if (fenceLang !== null) {
|
|
87250
|
+
flushFence();
|
|
87251
|
+
}
|
|
87252
|
+
else {
|
|
87253
|
+
fenceLang = fenceMatch[1] || '';
|
|
87254
|
+
}
|
|
87255
|
+
continue;
|
|
87256
|
+
}
|
|
87257
|
+
if (fenceLang !== null) {
|
|
87258
|
+
fenceBuf.push(line);
|
|
87259
|
+
continue;
|
|
87260
|
+
}
|
|
87261
|
+
if (/^\s*$/.test(line)) {
|
|
87262
|
+
paragraphs.push([{ tag: 'text', text: '' }]);
|
|
87263
|
+
continue;
|
|
87264
|
+
}
|
|
87265
|
+
if (/^(-{3,}|_{3,}|\*{3,})\s*$/.test(line)) {
|
|
87266
|
+
paragraphs.push([{ tag: 'text', text: '———' }]);
|
|
87267
|
+
continue;
|
|
87268
|
+
}
|
|
87269
|
+
paragraphs.push(parseInline(line));
|
|
87270
|
+
}
|
|
87271
|
+
flushFence();
|
|
87272
|
+
// Prepend explicit mentions as `at` elements at the very top.
|
|
87273
|
+
if ((_a = opts === null || opts === void 0 ? void 0 : opts.mentions) === null || _a === void 0 ? void 0 : _a.length) {
|
|
87274
|
+
const atElements = composePostMentionElements(opts.mentions).map((el) => el);
|
|
87275
|
+
if (atElements.length > 0) {
|
|
87276
|
+
// Insert as a new first paragraph with at elements + a space
|
|
87277
|
+
const first = [];
|
|
87278
|
+
for (const el of atElements) {
|
|
87279
|
+
first.push(el);
|
|
87280
|
+
first.push({ tag: 'text', text: ' ' });
|
|
87281
|
+
}
|
|
87282
|
+
paragraphs.unshift(first);
|
|
87283
|
+
}
|
|
87284
|
+
}
|
|
87285
|
+
return {
|
|
87286
|
+
zh_cn: {
|
|
87287
|
+
title: (_b = opts === null || opts === void 0 ? void 0 : opts.title) !== null && _b !== void 0 ? _b : '',
|
|
87288
|
+
content: paragraphs,
|
|
87289
|
+
},
|
|
87290
|
+
};
|
|
87291
|
+
}
|
|
87292
|
+
/**
|
|
87293
|
+
* Parse a single line of inline markdown into a list of post elements.
|
|
87294
|
+
*
|
|
87295
|
+
* Handles (in order):
|
|
87296
|
+
* ```inline code```, [text](url), **bold**, *italic*, __bold__, _italic_
|
|
87297
|
+
*
|
|
87298
|
+
* Headings (leading `#` sequence) render as bold text. List bullets and
|
|
87299
|
+
* blockquote markers pass through as plain text prefix.
|
|
87300
|
+
*/
|
|
87301
|
+
function parseInline(line) {
|
|
87302
|
+
// Normalize headings to bold
|
|
87303
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
|
|
87304
|
+
if (headingMatch) {
|
|
87305
|
+
const boldText = headingMatch[2];
|
|
87306
|
+
return [{ tag: 'text', text: boldText, style: ['bold'] }];
|
|
87307
|
+
}
|
|
87308
|
+
const out = [];
|
|
87309
|
+
// Token regex: inline code, link, bold, italic
|
|
87310
|
+
const pattern = /(`[^`\n]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*[^*\n]+\*\*)|(__[^_\n]+__)|(\*[^*\n]+\*)|(_[^_\n]+_)/g;
|
|
87311
|
+
let last = 0;
|
|
87312
|
+
let m;
|
|
87313
|
+
while ((m = pattern.exec(line))) {
|
|
87314
|
+
if (m.index > last) {
|
|
87315
|
+
out.push({ tag: 'text', text: line.slice(last, m.index) });
|
|
87316
|
+
}
|
|
87317
|
+
if (m[1]) {
|
|
87318
|
+
// inline code
|
|
87319
|
+
out.push({ tag: 'text', text: m[1].slice(1, -1), style: ['code'] });
|
|
87320
|
+
}
|
|
87321
|
+
else if (m[2]) {
|
|
87322
|
+
// link
|
|
87323
|
+
out.push({ tag: 'a', text: m[3], href: m[4] });
|
|
87324
|
+
}
|
|
87325
|
+
else if (m[5]) {
|
|
87326
|
+
out.push({ tag: 'text', text: m[5].slice(2, -2), style: ['bold'] });
|
|
87327
|
+
}
|
|
87328
|
+
else if (m[6]) {
|
|
87329
|
+
out.push({ tag: 'text', text: m[6].slice(2, -2), style: ['bold'] });
|
|
87330
|
+
}
|
|
87331
|
+
else if (m[7]) {
|
|
87332
|
+
out.push({ tag: 'text', text: m[7].slice(1, -1), style: ['italic'] });
|
|
87333
|
+
}
|
|
87334
|
+
else if (m[8]) {
|
|
87335
|
+
out.push({ tag: 'text', text: m[8].slice(1, -1), style: ['italic'] });
|
|
87336
|
+
}
|
|
87337
|
+
last = pattern.lastIndex;
|
|
87338
|
+
}
|
|
87339
|
+
if (last < line.length) {
|
|
87340
|
+
out.push({ tag: 'text', text: line.slice(last) });
|
|
87341
|
+
}
|
|
87342
|
+
return out.length > 0 ? out : [{ tag: 'text', text: line }];
|
|
87343
|
+
}
|
|
87344
|
+
/**
|
|
87345
|
+
* Extract the plain text body from a post JSON (recover flatted text for
|
|
87346
|
+
* fallback-to-text paths).
|
|
87347
|
+
*/
|
|
87348
|
+
function postToPlainText(post) {
|
|
87349
|
+
var _a;
|
|
87350
|
+
const body = post === null || post === void 0 ? void 0 : post.zh_cn;
|
|
87351
|
+
if (!(body === null || body === void 0 ? void 0 : body.content))
|
|
87352
|
+
return '';
|
|
87353
|
+
const lines = [];
|
|
87354
|
+
for (const paragraph of body.content) {
|
|
87355
|
+
if (!Array.isArray(paragraph))
|
|
87356
|
+
continue;
|
|
87357
|
+
const parts = [];
|
|
87358
|
+
for (const el of paragraph) {
|
|
87359
|
+
switch (el.tag) {
|
|
87360
|
+
case 'text':
|
|
87361
|
+
case 'a':
|
|
87362
|
+
parts.push((_a = el.text) !== null && _a !== void 0 ? _a : '');
|
|
87363
|
+
break;
|
|
87364
|
+
case 'at':
|
|
87365
|
+
parts.push(el.user_name ? `@${el.user_name}` : '');
|
|
87366
|
+
break;
|
|
87367
|
+
case 'img':
|
|
87368
|
+
parts.push(el.image_key ? `[image]` : '');
|
|
87369
|
+
break;
|
|
87370
|
+
}
|
|
87371
|
+
}
|
|
87372
|
+
lines.push(parts.join(''));
|
|
87373
|
+
}
|
|
87374
|
+
return lines.join('\n').trim();
|
|
87375
|
+
}
|
|
87376
|
+
|
|
87377
|
+
/**
|
|
87378
|
+
* Split a long markdown string into chunks under `limit` characters.
|
|
87379
|
+
*
|
|
87380
|
+
* Preserves code block integrity — if a chunk boundary would fall inside a
|
|
87381
|
+
* fenced code block, the current chunk closes the fence and the next chunk
|
|
87382
|
+
* reopens it with the same language tag.
|
|
87383
|
+
*
|
|
87384
|
+
* Prefers breaking before headings when possible.
|
|
87385
|
+
*/
|
|
87386
|
+
function splitWithCodeFences(text, limit) {
|
|
87387
|
+
if (text.length <= limit)
|
|
87388
|
+
return [text];
|
|
87389
|
+
const lines = text.split('\n');
|
|
87390
|
+
const out = [];
|
|
87391
|
+
let buf = [];
|
|
87392
|
+
let bufLen = 0;
|
|
87393
|
+
let fenceLang = null;
|
|
87394
|
+
const flush = () => {
|
|
87395
|
+
if (buf.length === 0)
|
|
87396
|
+
return;
|
|
87397
|
+
let chunk = buf.join('\n');
|
|
87398
|
+
if (fenceLang !== null)
|
|
87399
|
+
chunk += '\n```';
|
|
87400
|
+
out.push(chunk);
|
|
87401
|
+
buf = [];
|
|
87402
|
+
bufLen = 0;
|
|
87403
|
+
if (fenceLang !== null) {
|
|
87404
|
+
// reopen fence in the next chunk
|
|
87405
|
+
buf.push('```' + fenceLang);
|
|
87406
|
+
bufLen = buf[0].length;
|
|
87407
|
+
}
|
|
87408
|
+
};
|
|
87409
|
+
for (const line of lines) {
|
|
87410
|
+
const m = line.match(/^```(\w*)$/);
|
|
87411
|
+
const lineLen = line.length + (buf.length > 0 ? 1 : 0); // +1 for \n
|
|
87412
|
+
// If this line is a heading and we already have content that's near
|
|
87413
|
+
// the limit, prefer breaking here.
|
|
87414
|
+
const isHeading = /^#{1,6}\s/.test(line);
|
|
87415
|
+
const nearFull = bufLen > limit * 0.75;
|
|
87416
|
+
if (bufLen + lineLen > limit || (isHeading && nearFull && buf.length > 0)) {
|
|
87417
|
+
flush();
|
|
87418
|
+
}
|
|
87419
|
+
buf.push(line);
|
|
87420
|
+
bufLen += lineLen;
|
|
87421
|
+
if (m) {
|
|
87422
|
+
// entering or leaving a fence
|
|
87423
|
+
fenceLang = fenceLang === null ? m[1] || '' : null;
|
|
87424
|
+
}
|
|
87425
|
+
}
|
|
87426
|
+
flush();
|
|
87427
|
+
return out;
|
|
87428
|
+
}
|
|
87429
|
+
|
|
87430
|
+
/**
|
|
87431
|
+
* Infer Feishu's `receive_id_type` from the prefix of a target id.
|
|
87432
|
+
*
|
|
87433
|
+
* oc_* → chat_id
|
|
87434
|
+
* ou_* → open_id
|
|
87435
|
+
* on_* → union_id
|
|
87436
|
+
* contains '@' → email
|
|
87437
|
+
* fallback → user_id
|
|
87438
|
+
*/
|
|
87439
|
+
function detectReceiveIdType(to) {
|
|
87440
|
+
if (!to)
|
|
87441
|
+
throw new Error('empty receive_id');
|
|
87442
|
+
if (to.startsWith('oc_'))
|
|
87443
|
+
return 'chat_id';
|
|
87444
|
+
if (to.startsWith('ou_'))
|
|
87445
|
+
return 'open_id';
|
|
87446
|
+
if (to.startsWith('on_'))
|
|
87447
|
+
return 'union_id';
|
|
87448
|
+
if (to.includes('@'))
|
|
87449
|
+
return 'email';
|
|
87450
|
+
return 'user_id';
|
|
87451
|
+
}
|
|
87452
|
+
|
|
87453
|
+
/**
|
|
87454
|
+
* Classify a raw error (typically from axios/fetch or a Feishu API response)
|
|
87455
|
+
* into a LarkChannelError with a stable code.
|
|
87456
|
+
*/
|
|
87457
|
+
function classifyError(err, context) {
|
|
87458
|
+
if (err instanceof LarkChannelError)
|
|
87459
|
+
return err;
|
|
87460
|
+
const code = inferCode(err);
|
|
87461
|
+
const message = extractMessage(err);
|
|
87462
|
+
return new LarkChannelError(code, message, { cause: err, context });
|
|
87463
|
+
}
|
|
87464
|
+
function inferCode(err) {
|
|
87465
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
87466
|
+
const raw = err;
|
|
87467
|
+
const status = (_b = (_a = raw === null || raw === void 0 ? void 0 : raw.response) === null || _a === void 0 ? void 0 : _a.status) !== null && _b !== void 0 ? _b : raw === null || raw === void 0 ? void 0 : raw.status;
|
|
87468
|
+
const feishuCode = (_g = (_e = (_d = (_c = raw === null || raw === void 0 ? void 0 : raw.response) === null || _c === void 0 ? void 0 : _c.data) === null || _d === void 0 ? void 0 : _d.code) !== null && _e !== void 0 ? _e : (_f = raw === null || raw === void 0 ? void 0 : raw.data) === null || _f === void 0 ? void 0 : _f.code) !== null && _g !== void 0 ? _g : raw === null || raw === void 0 ? void 0 : raw.code;
|
|
87469
|
+
const msg = String((_h = raw === null || raw === void 0 ? void 0 : raw.message) !== null && _h !== void 0 ? _h : '').toLowerCase();
|
|
87470
|
+
if (typeof feishuCode === 'number') {
|
|
87471
|
+
if (feishuCode === 230020 || feishuCode === 230017)
|
|
87472
|
+
return 'target_revoked';
|
|
87473
|
+
if (feishuCode === 99991400 || feishuCode === 99991401)
|
|
87474
|
+
return 'permission_denied';
|
|
87475
|
+
if (feishuCode === 230002 || feishuCode === 230001)
|
|
87476
|
+
return 'format_error';
|
|
87477
|
+
}
|
|
87478
|
+
if (status === 429)
|
|
87479
|
+
return 'rate_limited';
|
|
87480
|
+
if (status === 401 || status === 403)
|
|
87481
|
+
return 'permission_denied';
|
|
87482
|
+
if (status === 400)
|
|
87483
|
+
return 'format_error';
|
|
87484
|
+
if (status === 404)
|
|
87485
|
+
return 'target_revoked';
|
|
87486
|
+
if (msg.startsWith('ssrf_blocked'))
|
|
87487
|
+
return 'ssrf_blocked';
|
|
87488
|
+
if (msg.includes('timeout') || (raw === null || raw === void 0 ? void 0 : raw.code) === 'ETIMEDOUT' || (raw === null || raw === void 0 ? void 0 : raw.code) === 'ECONNABORTED') {
|
|
87489
|
+
return 'send_timeout';
|
|
87490
|
+
}
|
|
87491
|
+
return 'unknown';
|
|
87492
|
+
}
|
|
87493
|
+
function extractMessage(err) {
|
|
87494
|
+
var _a, _b, _c, _d;
|
|
87495
|
+
const raw = err;
|
|
87496
|
+
return (((_b = (_a = raw === null || raw === void 0 ? void 0 : raw.response) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b.msg) ||
|
|
87497
|
+
((_d = (_c = raw === null || raw === void 0 ? void 0 : raw.response) === null || _c === void 0 ? void 0 : _c.data) === null || _d === void 0 ? void 0 : _d.message) ||
|
|
87498
|
+
(raw === null || raw === void 0 ? void 0 : raw.message) ||
|
|
87499
|
+
String(err));
|
|
87500
|
+
}
|
|
87501
|
+
function isRetryable(err) {
|
|
87502
|
+
return err.code === 'rate_limited' || err.code === 'unknown';
|
|
87503
|
+
}
|
|
87504
|
+
function isFormatError(err) {
|
|
87505
|
+
return err.code === 'format_error';
|
|
87506
|
+
}
|
|
87507
|
+
function isReplyTargetGone(err) {
|
|
87508
|
+
return err.code === 'target_revoked';
|
|
87509
|
+
}
|
|
87510
|
+
|
|
87511
|
+
/**
|
|
87512
|
+
* Execute `op` with exponential backoff. Only retries errors classified as
|
|
87513
|
+
* retryable (rate_limited / unknown). Business errors (format / revoked /
|
|
87514
|
+
* permission / timeout) fail fast and bubble up.
|
|
87515
|
+
*/
|
|
87516
|
+
function retry(op, opts = {}) {
|
|
87517
|
+
var _a, _b;
|
|
87518
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87519
|
+
const max = (_a = opts.maxAttempts) !== null && _a !== void 0 ? _a : 3;
|
|
87520
|
+
const base = (_b = opts.baseDelayMs) !== null && _b !== void 0 ? _b : 500;
|
|
87521
|
+
let lastErr;
|
|
87522
|
+
for (let attempt = 1; attempt <= max; attempt++) {
|
|
87523
|
+
try {
|
|
87524
|
+
return yield op(attempt);
|
|
87525
|
+
}
|
|
87526
|
+
catch (raw) {
|
|
87527
|
+
const err = classifyError(raw, { attempt });
|
|
87528
|
+
lastErr = err;
|
|
87529
|
+
if (attempt >= max || !isRetryable(err)) {
|
|
87530
|
+
throw err;
|
|
87531
|
+
}
|
|
87532
|
+
const delay = base * Math.pow(3, attempt - 1);
|
|
87533
|
+
yield sleep(delay);
|
|
87534
|
+
}
|
|
87535
|
+
}
|
|
87536
|
+
throw lastErr;
|
|
87537
|
+
});
|
|
87538
|
+
}
|
|
87539
|
+
function sleep(ms) {
|
|
87540
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
87541
|
+
}
|
|
87542
|
+
|
|
87543
|
+
const DEFAULT_CHUNK_LIMIT = 3500;
|
|
87544
|
+
class OutboundSender {
|
|
87545
|
+
constructor(client, config, logger) {
|
|
87546
|
+
var _a;
|
|
87547
|
+
this.client = client;
|
|
87548
|
+
this.config = config;
|
|
87549
|
+
this.logger = logger;
|
|
87550
|
+
this.uploader = new MediaUploader(client, config);
|
|
87551
|
+
this.chunkLimit = (_a = config.textChunkLimit) !== null && _a !== void 0 ? _a : DEFAULT_CHUNK_LIMIT;
|
|
87552
|
+
}
|
|
87553
|
+
send(to, input, opts = {}) {
|
|
87554
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87555
|
+
const idType = detectReceiveIdType(to);
|
|
87556
|
+
if ('markdown' in input)
|
|
87557
|
+
return this.sendMarkdown(to, idType, input.markdown, opts);
|
|
87558
|
+
if ('text' in input)
|
|
87559
|
+
return this.sendText(to, idType, input.text, opts);
|
|
87560
|
+
if ('post' in input)
|
|
87561
|
+
return this.sendPost(to, idType, input.post, opts);
|
|
87562
|
+
if ('image' in input)
|
|
87563
|
+
return this.sendImage(to, idType, input.image, opts);
|
|
87564
|
+
if ('file' in input)
|
|
87565
|
+
return this.sendFile(to, idType, input.file, opts);
|
|
87566
|
+
if ('audio' in input)
|
|
87567
|
+
return this.sendAudio(to, idType, input.audio, opts);
|
|
87568
|
+
if ('video' in input)
|
|
87569
|
+
return this.sendVideo(to, idType, input.video, opts);
|
|
87570
|
+
if ('card' in input)
|
|
87571
|
+
return this.sendCard(to, idType, input.card, opts);
|
|
87572
|
+
if ('shareChat' in input)
|
|
87573
|
+
return this.sendShareChat(to, idType, input.shareChat.chatId, opts);
|
|
87574
|
+
if ('shareUser' in input)
|
|
87575
|
+
return this.sendShareUser(to, idType, input.shareUser.userId, opts);
|
|
87576
|
+
if ('sticker' in input)
|
|
87577
|
+
return this.sendSticker(to, idType, input.sticker.fileKey, opts);
|
|
87578
|
+
throw new LarkChannelError('format_error', 'unrecognized SendInput shape');
|
|
87579
|
+
});
|
|
87580
|
+
}
|
|
87581
|
+
stream(to, input, opts = {}) {
|
|
87582
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87583
|
+
const idType = detectReceiveIdType(to);
|
|
87584
|
+
if ('markdown' in input) {
|
|
87585
|
+
const controller = new MarkdownStreamController(this, to, idType, opts);
|
|
87586
|
+
return controller.run(input.markdown);
|
|
87587
|
+
}
|
|
87588
|
+
if ('card' in input) {
|
|
87589
|
+
const controller = new CardStreamController(this, to, idType, opts, input.card.initial);
|
|
87590
|
+
return controller.run(input.card.producer);
|
|
87591
|
+
}
|
|
87592
|
+
throw new LarkChannelError('format_error', 'unrecognized StreamInput shape');
|
|
87593
|
+
});
|
|
87594
|
+
}
|
|
87595
|
+
// ─── text / markdown / post ───────────────────────────
|
|
87596
|
+
sendMarkdown(to, idType, md, opts) {
|
|
87597
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87598
|
+
const chunks = splitWithCodeFences(md, this.chunkLimit);
|
|
87599
|
+
const ids = [];
|
|
87600
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
87601
|
+
const post = this.convertMarkdown(chunks[i], i === 0 ? opts.mentions : undefined);
|
|
87602
|
+
const id = yield this.sendOneWithFallback({
|
|
87603
|
+
to, idType,
|
|
87604
|
+
msgType: 'post', content: post,
|
|
87605
|
+
replyTo: i === 0 ? opts.replyTo : undefined,
|
|
87606
|
+
replyInThread: opts.replyInThread,
|
|
87607
|
+
});
|
|
87608
|
+
ids.push(id);
|
|
87609
|
+
}
|
|
87610
|
+
return this.makeResult(ids);
|
|
87611
|
+
});
|
|
87612
|
+
}
|
|
87613
|
+
sendText(to, idType, text, opts) {
|
|
87614
|
+
var _a;
|
|
87615
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87616
|
+
const prefix = composeMentionsTextPrefix((_a = opts.mentions) !== null && _a !== void 0 ? _a : []);
|
|
87617
|
+
const body = prefix + text;
|
|
87618
|
+
const chunks = splitPlain(body, this.chunkLimit);
|
|
87619
|
+
const ids = [];
|
|
87620
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
87621
|
+
const id = yield this.sendOneWithFallback({
|
|
87622
|
+
to, idType,
|
|
87623
|
+
msgType: 'text',
|
|
87624
|
+
content: { text: chunks[i] },
|
|
87625
|
+
replyTo: i === 0 ? opts.replyTo : undefined,
|
|
87626
|
+
replyInThread: opts.replyInThread,
|
|
87627
|
+
});
|
|
87628
|
+
ids.push(id);
|
|
87629
|
+
}
|
|
87630
|
+
return this.makeResult(ids);
|
|
87631
|
+
});
|
|
87632
|
+
}
|
|
87633
|
+
sendPost(to, idType, post, opts) {
|
|
87634
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87635
|
+
const id = yield this.sendOneWithFallback({
|
|
87636
|
+
to, idType,
|
|
87637
|
+
msgType: 'post',
|
|
87638
|
+
content: post,
|
|
87639
|
+
replyTo: opts.replyTo,
|
|
87640
|
+
replyInThread: opts.replyInThread,
|
|
87641
|
+
});
|
|
87642
|
+
return { messageId: id };
|
|
87643
|
+
});
|
|
87644
|
+
}
|
|
87645
|
+
convertMarkdown(md, mentions) {
|
|
87646
|
+
const conv = this.config.markdownConverter;
|
|
87647
|
+
if (typeof conv === 'function')
|
|
87648
|
+
return conv(md);
|
|
87649
|
+
return markdownToPost(md, { mentions });
|
|
87650
|
+
}
|
|
87651
|
+
// ─── media ────────────────────────────────────────────
|
|
87652
|
+
sendImage(to, idType, input, opts) {
|
|
87653
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87654
|
+
const up = yield this.uploader.upload({ kind: 'image', source: input.source });
|
|
87655
|
+
const id = yield this.sendOneWithFallback({
|
|
87656
|
+
to, idType,
|
|
87657
|
+
msgType: 'image',
|
|
87658
|
+
content: { image_key: up.fileKey },
|
|
87659
|
+
replyTo: opts.replyTo,
|
|
87660
|
+
replyInThread: opts.replyInThread,
|
|
87661
|
+
});
|
|
87662
|
+
return { messageId: id };
|
|
87663
|
+
});
|
|
87664
|
+
}
|
|
87665
|
+
sendFile(to, idType, input, opts) {
|
|
87666
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87667
|
+
const up = yield this.uploader.upload({
|
|
87668
|
+
kind: 'file', source: input.source, fileName: input.fileName,
|
|
87669
|
+
});
|
|
87670
|
+
const id = yield this.sendOneWithFallback({
|
|
87671
|
+
to, idType,
|
|
87672
|
+
msgType: 'file',
|
|
87673
|
+
content: { file_key: up.fileKey },
|
|
87674
|
+
replyTo: opts.replyTo,
|
|
87675
|
+
replyInThread: opts.replyInThread,
|
|
87676
|
+
});
|
|
87677
|
+
return { messageId: id };
|
|
87678
|
+
});
|
|
87679
|
+
}
|
|
87680
|
+
sendAudio(to, idType, input, opts) {
|
|
87681
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87682
|
+
const up = yield this.uploader.upload({
|
|
87683
|
+
kind: 'audio', source: input.source, duration: input.duration,
|
|
87684
|
+
});
|
|
87685
|
+
const id = yield this.sendOneWithFallback({
|
|
87686
|
+
to, idType,
|
|
87687
|
+
msgType: 'audio',
|
|
87688
|
+
content: { file_key: up.fileKey },
|
|
87689
|
+
replyTo: opts.replyTo,
|
|
87690
|
+
replyInThread: opts.replyInThread,
|
|
87691
|
+
});
|
|
87692
|
+
return { messageId: id };
|
|
87693
|
+
});
|
|
87694
|
+
}
|
|
87695
|
+
sendVideo(to, idType, input, opts) {
|
|
87696
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87697
|
+
const up = yield this.uploader.upload({
|
|
87698
|
+
kind: 'video', source: input.source, duration: input.duration,
|
|
87699
|
+
});
|
|
87700
|
+
const content = { file_key: up.fileKey };
|
|
87701
|
+
if (input.coverImageKey)
|
|
87702
|
+
content.image_key = input.coverImageKey;
|
|
87703
|
+
const id = yield this.sendOneWithFallback({
|
|
87704
|
+
to, idType,
|
|
87705
|
+
msgType: 'media',
|
|
87706
|
+
content,
|
|
87707
|
+
replyTo: opts.replyTo,
|
|
87708
|
+
replyInThread: opts.replyInThread,
|
|
87709
|
+
});
|
|
87710
|
+
return { messageId: id };
|
|
87711
|
+
});
|
|
87712
|
+
}
|
|
87713
|
+
// ─── card ─────────────────────────────────────────────
|
|
87714
|
+
sendCard(to, idType, card, opts) {
|
|
87715
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87716
|
+
const id = yield this.sendOneWithFallback({
|
|
87717
|
+
to, idType,
|
|
87718
|
+
msgType: 'interactive',
|
|
87719
|
+
content: card,
|
|
87720
|
+
replyTo: opts.replyTo,
|
|
87721
|
+
replyInThread: opts.replyInThread,
|
|
87722
|
+
});
|
|
87723
|
+
return { messageId: id };
|
|
87724
|
+
});
|
|
87725
|
+
}
|
|
87726
|
+
// ─── share / sticker ──────────────────────────────────
|
|
87727
|
+
sendShareChat(to, idType, chatId, opts) {
|
|
87728
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87729
|
+
const id = yield this.sendOneWithFallback({
|
|
87730
|
+
to, idType,
|
|
87731
|
+
msgType: 'share_chat',
|
|
87732
|
+
content: { chat_id: chatId },
|
|
87733
|
+
replyTo: opts.replyTo,
|
|
87734
|
+
replyInThread: opts.replyInThread,
|
|
87735
|
+
});
|
|
87736
|
+
return { messageId: id };
|
|
87737
|
+
});
|
|
87738
|
+
}
|
|
87739
|
+
sendShareUser(to, idType, userId, opts) {
|
|
87740
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87741
|
+
const id = yield this.sendOneWithFallback({
|
|
87742
|
+
to, idType,
|
|
87743
|
+
msgType: 'share_user',
|
|
87744
|
+
content: { user_id: userId },
|
|
87745
|
+
replyTo: opts.replyTo,
|
|
87746
|
+
replyInThread: opts.replyInThread,
|
|
87747
|
+
});
|
|
87748
|
+
return { messageId: id };
|
|
87749
|
+
});
|
|
87750
|
+
}
|
|
87751
|
+
sendSticker(to, idType, fileKey, opts) {
|
|
87752
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87753
|
+
const id = yield this.sendOneWithFallback({
|
|
87754
|
+
to, idType,
|
|
87755
|
+
msgType: 'sticker',
|
|
87756
|
+
content: { file_key: fileKey },
|
|
87757
|
+
replyTo: opts.replyTo,
|
|
87758
|
+
replyInThread: opts.replyInThread,
|
|
87759
|
+
});
|
|
87760
|
+
return { messageId: id };
|
|
87761
|
+
});
|
|
87762
|
+
}
|
|
87763
|
+
// ─── low-level raw send with fallback & retry ─────────
|
|
87764
|
+
/**
|
|
87765
|
+
* Send once with fallback for format errors (post → text) and for
|
|
87766
|
+
* vanished reply targets (reply → create).
|
|
87767
|
+
*/
|
|
87768
|
+
sendOneWithFallback(args) {
|
|
87769
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87770
|
+
try {
|
|
87771
|
+
return yield this.rawSendWithRetry(args);
|
|
87772
|
+
}
|
|
87773
|
+
catch (e) {
|
|
87774
|
+
const err = classifyError(e, { to: args.to });
|
|
87775
|
+
if (isReplyTargetGone(err) && args.replyTo) {
|
|
87776
|
+
return this.rawSendWithRetry(Object.assign(Object.assign({}, args), { replyTo: undefined }));
|
|
87777
|
+
}
|
|
87778
|
+
if (isFormatError(err) && args.msgType === 'post') {
|
|
87779
|
+
const plain = postToPlainText(args.content);
|
|
87780
|
+
return this.rawSendWithRetry(Object.assign(Object.assign({}, args), { msgType: 'text', content: { text: plain || '[message]' } }));
|
|
87781
|
+
}
|
|
87782
|
+
throw err;
|
|
87783
|
+
}
|
|
87784
|
+
});
|
|
87785
|
+
}
|
|
87786
|
+
rawSendWithRetry(args) {
|
|
87787
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87788
|
+
return retry(() => this.rawSend(args), this.config.retry);
|
|
87789
|
+
});
|
|
87790
|
+
}
|
|
87791
|
+
rawSend(args) {
|
|
87792
|
+
var _a, _b;
|
|
87793
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87794
|
+
const payload = {
|
|
87795
|
+
receive_id: args.to,
|
|
87796
|
+
msg_type: args.msgType,
|
|
87797
|
+
content: JSON.stringify(args.content),
|
|
87798
|
+
};
|
|
87799
|
+
if (args.replyTo) {
|
|
87800
|
+
const r = yield this.client.im.v1.message.reply({
|
|
87801
|
+
path: { message_id: args.replyTo },
|
|
87802
|
+
data: {
|
|
87803
|
+
content: payload.content,
|
|
87804
|
+
msg_type: args.msgType,
|
|
87805
|
+
reply_in_thread: args.replyInThread,
|
|
87806
|
+
},
|
|
87807
|
+
});
|
|
87808
|
+
const id = (_a = r.data) === null || _a === void 0 ? void 0 : _a.message_id;
|
|
87809
|
+
if (!id)
|
|
87810
|
+
throw new LarkChannelError('unknown', 'message_id missing from reply response');
|
|
87811
|
+
return id;
|
|
87812
|
+
}
|
|
87813
|
+
const r = yield this.client.im.v1.message.create({
|
|
87814
|
+
params: { receive_id_type: args.idType },
|
|
87815
|
+
data: payload,
|
|
87816
|
+
});
|
|
87817
|
+
const id = (_b = r.data) === null || _b === void 0 ? void 0 : _b.message_id;
|
|
87818
|
+
if (!id)
|
|
87819
|
+
throw new LarkChannelError('unknown', 'message_id missing from create response');
|
|
87820
|
+
return id;
|
|
87821
|
+
});
|
|
87822
|
+
}
|
|
87823
|
+
// ─── helpers used by streaming ────────────────────────
|
|
87824
|
+
/**
|
|
87825
|
+
* Full card replace via im.v1.message.patch — used by CardStreamController
|
|
87826
|
+
* and the public `channel.updateCard()` low-level API. Not suitable for
|
|
87827
|
+
* text streaming (use native cardkit streaming below for that).
|
|
87828
|
+
*/
|
|
87829
|
+
patchCard(messageId, card) {
|
|
87830
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87831
|
+
yield this.client.im.v1.message.patch({
|
|
87832
|
+
path: { message_id: messageId },
|
|
87833
|
+
data: { content: JSON.stringify(card) },
|
|
87834
|
+
});
|
|
87835
|
+
});
|
|
87836
|
+
}
|
|
87837
|
+
/**
|
|
87838
|
+
* Create a card instance via cardkit.v1.card.create. Used to get a
|
|
87839
|
+
* `card_id` that can be referenced from messages AND updated with
|
|
87840
|
+
* native streaming APIs (cardElement.content for typewriter effect).
|
|
87841
|
+
*/
|
|
87842
|
+
createCardInstance(spec) {
|
|
87843
|
+
var _a;
|
|
87844
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87845
|
+
const r = yield this.client.cardkit.v1.card.create({
|
|
87846
|
+
data: { type: 'card_json', data: JSON.stringify(spec) },
|
|
87847
|
+
});
|
|
87848
|
+
const cardId = (_a = r.data) === null || _a === void 0 ? void 0 : _a.card_id;
|
|
87849
|
+
if (!cardId) {
|
|
87850
|
+
throw new LarkChannelError('unknown', 'cardkit.card.create returned no card_id');
|
|
87851
|
+
}
|
|
87852
|
+
return cardId;
|
|
87853
|
+
});
|
|
87854
|
+
}
|
|
87855
|
+
/**
|
|
87856
|
+
* Send an interactive message that references a pre-created card instance
|
|
87857
|
+
* by card_id. Returns the resulting message_id.
|
|
87858
|
+
*/
|
|
87859
|
+
sendCardByReference(to, idType, cardId, opts) {
|
|
87860
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87861
|
+
return this.rawSendWithRetry({
|
|
87862
|
+
to, idType,
|
|
87863
|
+
msgType: 'interactive',
|
|
87864
|
+
content: { type: 'card', data: { card_id: cardId } },
|
|
87865
|
+
replyTo: opts.replyTo,
|
|
87866
|
+
replyInThread: opts.replyInThread,
|
|
87867
|
+
});
|
|
87868
|
+
});
|
|
87869
|
+
}
|
|
87870
|
+
/**
|
|
87871
|
+
* Stream update: replace a card element's content and let Feishu render
|
|
87872
|
+
* the incremental diff as a typewriter animation. `sequence` must be
|
|
87873
|
+
* monotonically increasing per card (duplicates/out-of-order → rejected
|
|
87874
|
+
* server-side). `uuid` is required for idempotency — we compose it from
|
|
87875
|
+
* cardId + sequence so two concurrent calls with the same seq would
|
|
87876
|
+
* dedupe rather than both take effect.
|
|
87877
|
+
*/
|
|
87878
|
+
updateCardElementContent(cardId, elementId, content, sequence) {
|
|
87879
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87880
|
+
yield this.client.cardkit.v1.cardElement.content({
|
|
87881
|
+
path: { card_id: cardId, element_id: elementId },
|
|
87882
|
+
data: {
|
|
87883
|
+
content,
|
|
87884
|
+
sequence,
|
|
87885
|
+
uuid: `c_${cardId}_${sequence}`,
|
|
87886
|
+
},
|
|
87887
|
+
});
|
|
87888
|
+
});
|
|
87889
|
+
}
|
|
87890
|
+
/**
|
|
87891
|
+
* Switch a streaming card to finalized state (streaming_mode: false).
|
|
87892
|
+
* Feishu auto-closes after 10min regardless, but callers should close
|
|
87893
|
+
* explicitly when producer completes.
|
|
87894
|
+
*
|
|
87895
|
+
* `summary` is optional; when provided, also updates the card's preview
|
|
87896
|
+
* text (the one shown in message lists / chat previews). Without it, the
|
|
87897
|
+
* preview stays at whatever was set during streaming (typically the
|
|
87898
|
+
* default "[Generating...]"), which looks stuck.
|
|
87899
|
+
*/
|
|
87900
|
+
finishStreamingCard(cardId, sequence, summary) {
|
|
87901
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87902
|
+
const config = { streaming_mode: false };
|
|
87903
|
+
if (summary !== undefined) {
|
|
87904
|
+
config.summary = { content: summary };
|
|
87905
|
+
}
|
|
87906
|
+
yield this.client.cardkit.v1.card.settings({
|
|
87907
|
+
path: { card_id: cardId },
|
|
87908
|
+
data: {
|
|
87909
|
+
settings: JSON.stringify({ config }),
|
|
87910
|
+
sequence,
|
|
87911
|
+
uuid: `s_${cardId}_${sequence}`,
|
|
87912
|
+
},
|
|
87913
|
+
});
|
|
87914
|
+
});
|
|
87915
|
+
}
|
|
87916
|
+
makeResult(ids) {
|
|
87917
|
+
return {
|
|
87918
|
+
messageId: ids[0],
|
|
87919
|
+
chunkIds: ids.length > 1 ? ids : undefined,
|
|
87920
|
+
};
|
|
87921
|
+
}
|
|
87922
|
+
}
|
|
87923
|
+
function splitPlain(text, limit) {
|
|
87924
|
+
if (text.length <= limit)
|
|
87925
|
+
return [text];
|
|
87926
|
+
const out = [];
|
|
87927
|
+
for (let i = 0; i < text.length; i += limit) {
|
|
87928
|
+
out.push(text.slice(i, i + limit));
|
|
87929
|
+
}
|
|
87930
|
+
return out;
|
|
87931
|
+
}
|
|
87932
|
+
|
|
87933
|
+
/**
|
|
87934
|
+
* Per-scope pipeline that does two things at once:
|
|
87935
|
+
* - Debounce-based batch aggregation (for IM messages)
|
|
87936
|
+
* - Strict serialization of all work (both batched flushes and direct
|
|
87937
|
+
* `run()` tasks) via a promise chain
|
|
87938
|
+
*
|
|
87939
|
+
* Supports two entry points:
|
|
87940
|
+
* - push(msg, handler): enqueue for batched dispatch
|
|
87941
|
+
* - run(task): run a one-shot async task serialized after any
|
|
87942
|
+
* pending batch and previous tasks
|
|
87943
|
+
*/
|
|
87944
|
+
class ChatPipeline {
|
|
87945
|
+
constructor(config, serialOnly) {
|
|
87946
|
+
this.config = config;
|
|
87947
|
+
this.serialOnly = serialOnly;
|
|
87948
|
+
this.buffer = [];
|
|
87949
|
+
this.bufferChars = 0;
|
|
87950
|
+
this.tail = Promise.resolve();
|
|
87951
|
+
}
|
|
87952
|
+
push(msg, handler) {
|
|
87953
|
+
var _a;
|
|
87954
|
+
this.buffer.push(msg);
|
|
87955
|
+
this.bufferChars += msg.content.length;
|
|
87956
|
+
(_a = this.pendingHandler) !== null && _a !== void 0 ? _a : (this.pendingHandler = handler);
|
|
87957
|
+
// Force flush when caps reached.
|
|
87958
|
+
if (this.buffer.length >= this.config.maxMessages
|
|
87959
|
+
|| this.bufferChars >= this.config.maxChars) {
|
|
87960
|
+
this.clearTimer();
|
|
87961
|
+
this.enqueueFlush();
|
|
87962
|
+
return;
|
|
87963
|
+
}
|
|
87964
|
+
// Pure-serial mode (debounce disabled).
|
|
87965
|
+
if (this.config.delayMs <= 0 || this.serialOnly) {
|
|
87966
|
+
this.clearTimer();
|
|
87967
|
+
this.enqueueFlush();
|
|
87968
|
+
return;
|
|
87969
|
+
}
|
|
87970
|
+
// Debounced flush.
|
|
87971
|
+
this.clearTimer();
|
|
87972
|
+
const delay = this.bufferChars >= this.config.longThresholdChars
|
|
87973
|
+
? this.config.longDelayMs
|
|
87974
|
+
: this.config.delayMs;
|
|
87975
|
+
this.timer = setTimeout(() => {
|
|
87976
|
+
this.timer = undefined;
|
|
87977
|
+
this.enqueueFlush();
|
|
87978
|
+
}, delay);
|
|
87979
|
+
}
|
|
87980
|
+
run(task) {
|
|
87981
|
+
// Any pending batch flushes first so a follow-on action-like task
|
|
87982
|
+
// runs after its chat's in-flight message work.
|
|
87983
|
+
if (this.buffer.length > 0) {
|
|
87984
|
+
this.clearTimer();
|
|
87985
|
+
this.enqueueFlush();
|
|
87986
|
+
}
|
|
87987
|
+
const next = this.tail.then(task, task);
|
|
87988
|
+
this.tail = next.then(() => undefined, () => undefined);
|
|
87989
|
+
return next;
|
|
87990
|
+
}
|
|
87991
|
+
flushNow() {
|
|
87992
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
87993
|
+
if (this.buffer.length > 0) {
|
|
87994
|
+
this.clearTimer();
|
|
87995
|
+
this.enqueueFlush();
|
|
87996
|
+
}
|
|
87997
|
+
yield this.tail;
|
|
87998
|
+
});
|
|
87999
|
+
}
|
|
88000
|
+
isIdle() {
|
|
88001
|
+
return this.buffer.length === 0 && !this.timer;
|
|
88002
|
+
}
|
|
88003
|
+
dispose() {
|
|
88004
|
+
this.clearTimer();
|
|
88005
|
+
this.buffer = [];
|
|
88006
|
+
this.pendingHandler = undefined;
|
|
88007
|
+
}
|
|
88008
|
+
clearTimer() {
|
|
88009
|
+
if (this.timer) {
|
|
88010
|
+
clearTimeout(this.timer);
|
|
88011
|
+
this.timer = undefined;
|
|
88012
|
+
}
|
|
88013
|
+
}
|
|
88014
|
+
enqueueFlush() {
|
|
88015
|
+
if (this.buffer.length === 0)
|
|
88016
|
+
return;
|
|
88017
|
+
const batch = this.buffer;
|
|
88018
|
+
const handler = this.pendingHandler;
|
|
88019
|
+
this.buffer = [];
|
|
88020
|
+
this.bufferChars = 0;
|
|
88021
|
+
this.pendingHandler = undefined;
|
|
88022
|
+
if (!handler)
|
|
88023
|
+
return;
|
|
88024
|
+
const dispatch = {
|
|
88025
|
+
message: mergeBatch(batch),
|
|
88026
|
+
sourceIds: batch.map((m) => m.messageId),
|
|
88027
|
+
};
|
|
88028
|
+
const task = () => handler(dispatch);
|
|
88029
|
+
const next = this.tail.then(task, task);
|
|
88030
|
+
this.tail = next.then(() => undefined, () => undefined);
|
|
88031
|
+
}
|
|
88032
|
+
}
|
|
88033
|
+
class ChatPipelineManager {
|
|
88034
|
+
constructor(config) {
|
|
88035
|
+
this.config = config;
|
|
88036
|
+
this.pipelines = new Map();
|
|
88037
|
+
}
|
|
88038
|
+
push(scope, msg, handler) {
|
|
88039
|
+
this.getOrCreate(scope, false).push(msg, handler);
|
|
88040
|
+
}
|
|
88041
|
+
run(scope, task) {
|
|
88042
|
+
return this.getOrCreate(scope, true).run(task);
|
|
88043
|
+
}
|
|
88044
|
+
getOrCreate(scope, serialOnly) {
|
|
88045
|
+
let p = this.pipelines.get(scope);
|
|
88046
|
+
if (!p) {
|
|
88047
|
+
p = new ChatPipeline(this.config, serialOnly);
|
|
88048
|
+
this.pipelines.set(scope, p);
|
|
88049
|
+
}
|
|
88050
|
+
return p;
|
|
88051
|
+
}
|
|
88052
|
+
flushAll() {
|
|
88053
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
88054
|
+
yield Promise.all([...this.pipelines.values()].map((p) => p.flushNow()));
|
|
88055
|
+
});
|
|
88056
|
+
}
|
|
88057
|
+
dispose() {
|
|
88058
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
88059
|
+
yield this.flushAll();
|
|
88060
|
+
for (const p of this.pipelines.values())
|
|
88061
|
+
p.dispose();
|
|
88062
|
+
this.pipelines.clear();
|
|
88063
|
+
});
|
|
88064
|
+
}
|
|
88065
|
+
}
|
|
88066
|
+
/**
|
|
88067
|
+
* Merge a batch of NormalizedMessages (all from the same chat) into a
|
|
88068
|
+
* single representative message. Keeps the latest-arrival metadata and
|
|
88069
|
+
* unions content / resources / mentions.
|
|
88070
|
+
*/
|
|
88071
|
+
function mergeBatch(batch) {
|
|
88072
|
+
if (batch.length === 1)
|
|
88073
|
+
return batch[0];
|
|
88074
|
+
const last = batch[batch.length - 1];
|
|
88075
|
+
const content = batch
|
|
88076
|
+
.map((m) => m.content)
|
|
88077
|
+
.filter((c) => c && c.length > 0)
|
|
88078
|
+
.join('\n\n');
|
|
88079
|
+
const resources = dedupBy(batch.flatMap((m) => m.resources), (r) => r.fileKey);
|
|
88080
|
+
const mentions = dedupBy(batch.flatMap((m) => m.mentions), (m) => { var _a; return (_a = m.openId) !== null && _a !== void 0 ? _a : m.key; });
|
|
88081
|
+
return Object.assign(Object.assign({}, last), { content,
|
|
88082
|
+
resources,
|
|
88083
|
+
mentions, mentionAll: batch.some((m) => m.mentionAll), mentionedBot: batch.some((m) => m.mentionedBot) });
|
|
88084
|
+
}
|
|
88085
|
+
function dedupBy(items, key) {
|
|
88086
|
+
const seen = new Set();
|
|
88087
|
+
const out = [];
|
|
88088
|
+
for (const item of items) {
|
|
88089
|
+
const k = key(item);
|
|
88090
|
+
if (k == null) {
|
|
88091
|
+
out.push(item);
|
|
88092
|
+
continue;
|
|
88093
|
+
}
|
|
88094
|
+
if (seen.has(k))
|
|
88095
|
+
continue;
|
|
88096
|
+
seen.add(k);
|
|
88097
|
+
out.push(item);
|
|
88098
|
+
}
|
|
88099
|
+
return out;
|
|
88100
|
+
}
|
|
88101
|
+
|
|
88102
|
+
const DEFAULT_BATCH = {
|
|
88103
|
+
delayMs: 600,
|
|
88104
|
+
longThresholdChars: 1000,
|
|
88105
|
+
longDelayMs: 2000,
|
|
88106
|
+
maxMessages: 8,
|
|
88107
|
+
maxChars: 4000,
|
|
88108
|
+
};
|
|
88109
|
+
const DEFAULT_DEDUP = {
|
|
88110
|
+
ttl: 12 * 3600000,
|
|
88111
|
+
maxEntries: 5000,
|
|
88112
|
+
sweepIntervalMs: 5 * 60000,
|
|
88113
|
+
namespace: 'channel:seen',
|
|
88114
|
+
};
|
|
88115
|
+
const DEFAULT_STALE_MS = 30 * 60000;
|
|
88116
|
+
const DEFAULT_LOCK_TTL_MS = 5 * 60000;
|
|
88117
|
+
function resolveBatchConfig(cfg) {
|
|
88118
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
88119
|
+
const t = (_b = (_a = cfg === null || cfg === void 0 ? void 0 : cfg.batch) === null || _a === void 0 ? void 0 : _a.text) !== null && _b !== void 0 ? _b : {};
|
|
88120
|
+
return {
|
|
88121
|
+
delayMs: (_c = t.delayMs) !== null && _c !== void 0 ? _c : DEFAULT_BATCH.delayMs,
|
|
88122
|
+
longThresholdChars: (_d = t.longThresholdChars) !== null && _d !== void 0 ? _d : DEFAULT_BATCH.longThresholdChars,
|
|
88123
|
+
longDelayMs: (_e = t.longDelayMs) !== null && _e !== void 0 ? _e : DEFAULT_BATCH.longDelayMs,
|
|
88124
|
+
maxMessages: (_f = t.maxMessages) !== null && _f !== void 0 ? _f : DEFAULT_BATCH.maxMessages,
|
|
88125
|
+
maxChars: (_g = t.maxChars) !== null && _g !== void 0 ? _g : DEFAULT_BATCH.maxChars,
|
|
88126
|
+
};
|
|
88127
|
+
}
|
|
88128
|
+
|
|
88129
|
+
/**
|
|
88130
|
+
* Two-tier dedup cache: hot in-memory LRU + injectable long-term Cache.
|
|
88131
|
+
* Memory tier serves 99% of queries sub-millisecond; long-term tier
|
|
88132
|
+
* survives process restarts when a persistent Cache implementation is
|
|
88133
|
+
* provided. Default node-sdk `internalCache` is memory-only — callers
|
|
88134
|
+
* wanting cross-restart persistence inject their own Cache (e.g., Redis).
|
|
88135
|
+
*/
|
|
88136
|
+
class SeenCache {
|
|
88137
|
+
constructor(cache, opts = {}) {
|
|
88138
|
+
var _a, _b, _c, _d, _e, _f;
|
|
88139
|
+
this.cache = cache;
|
|
88140
|
+
this.memory = new Map(); // id → expireAt
|
|
88141
|
+
this.ttlMs = (_a = opts.ttlMs) !== null && _a !== void 0 ? _a : DEFAULT_DEDUP.ttl;
|
|
88142
|
+
this.maxMem = (_b = opts.maxMemEntries) !== null && _b !== void 0 ? _b : DEFAULT_DEDUP.maxEntries;
|
|
88143
|
+
this.ns = (_c = opts.namespace) !== null && _c !== void 0 ? _c : DEFAULT_DEDUP.namespace;
|
|
88144
|
+
const sweepMs = (_d = opts.sweepMs) !== null && _d !== void 0 ? _d : DEFAULT_DEDUP.sweepIntervalMs;
|
|
88145
|
+
this.sweeper = setInterval(() => this.sweep(), sweepMs);
|
|
88146
|
+
(_f = (_e = this.sweeper).unref) === null || _f === void 0 ? void 0 : _f.call(_e);
|
|
88147
|
+
}
|
|
88148
|
+
has(id) {
|
|
88149
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
88150
|
+
const now = Date.now();
|
|
88151
|
+
const exp = this.memory.get(id);
|
|
88152
|
+
if (exp && exp > now) {
|
|
88153
|
+
// refresh LRU position
|
|
88154
|
+
this.memory.delete(id);
|
|
88155
|
+
this.memory.set(id, exp);
|
|
88156
|
+
return true;
|
|
88157
|
+
}
|
|
88158
|
+
// fall through to long-term tier
|
|
88159
|
+
const hit = yield this.cache.get(id, { namespace: this.ns });
|
|
88160
|
+
if (hit) {
|
|
88161
|
+
this.memory.set(id, now + this.ttlMs);
|
|
88162
|
+
this.evictIfNeeded();
|
|
88163
|
+
return true;
|
|
88164
|
+
}
|
|
88165
|
+
return false;
|
|
88166
|
+
});
|
|
88167
|
+
}
|
|
88168
|
+
add(id) {
|
|
88169
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
88170
|
+
const expireAt = Date.now() + this.ttlMs;
|
|
88171
|
+
this.memory.set(id, expireAt);
|
|
88172
|
+
this.evictIfNeeded();
|
|
88173
|
+
// best-effort write to long-term; ignore failures
|
|
88174
|
+
try {
|
|
88175
|
+
yield this.cache.set(id, '1', expireAt, { namespace: this.ns });
|
|
88176
|
+
}
|
|
88177
|
+
catch (_a) {
|
|
88178
|
+
// tolerated — memory tier remains authoritative during the session
|
|
88179
|
+
}
|
|
88180
|
+
});
|
|
88181
|
+
}
|
|
88182
|
+
evictIfNeeded() {
|
|
88183
|
+
while (this.memory.size > this.maxMem) {
|
|
88184
|
+
const first = this.memory.keys().next().value;
|
|
88185
|
+
if (first === undefined)
|
|
88186
|
+
break;
|
|
88187
|
+
this.memory.delete(first);
|
|
88188
|
+
}
|
|
88189
|
+
}
|
|
88190
|
+
sweep() {
|
|
88191
|
+
const now = Date.now();
|
|
88192
|
+
for (const [k, v] of this.memory) {
|
|
88193
|
+
if (v <= now)
|
|
88194
|
+
this.memory.delete(k);
|
|
88195
|
+
}
|
|
88196
|
+
}
|
|
88197
|
+
dispose() {
|
|
88198
|
+
clearInterval(this.sweeper);
|
|
88199
|
+
this.memory.clear();
|
|
88200
|
+
}
|
|
88201
|
+
}
|
|
88202
|
+
|
|
88203
|
+
class PolicyGate {
|
|
88204
|
+
constructor(cfg, bot) {
|
|
88205
|
+
this.cfg = Object.assign({}, (cfg !== null && cfg !== void 0 ? cfg : {}));
|
|
88206
|
+
this.bot = bot;
|
|
88207
|
+
}
|
|
88208
|
+
evaluate(msg) {
|
|
88209
|
+
if (msg.chatType === 'group')
|
|
88210
|
+
return this.evaluateGroup(msg);
|
|
88211
|
+
return this.evaluateDm(msg);
|
|
88212
|
+
}
|
|
88213
|
+
evaluateGroup(msg) {
|
|
88214
|
+
var _a, _b;
|
|
88215
|
+
const allow = this.cfg.groupAllowlist;
|
|
88216
|
+
if (allow && allow.length > 0 && !allow.includes(msg.chatId)) {
|
|
88217
|
+
return { allowed: false, reason: 'group_not_allowed' };
|
|
88218
|
+
}
|
|
88219
|
+
const requireMention = (_a = this.cfg.requireMention) !== null && _a !== void 0 ? _a : true;
|
|
88220
|
+
if (requireMention && !msg.mentionedBot) {
|
|
88221
|
+
return { allowed: false, reason: 'no_mention' };
|
|
88222
|
+
}
|
|
88223
|
+
if (msg.mentionAll && !((_b = this.cfg.respondToMentionAll) !== null && _b !== void 0 ? _b : false)) {
|
|
88224
|
+
return { allowed: false, reason: 'mention_all_blocked' };
|
|
88225
|
+
}
|
|
88226
|
+
return { allowed: true };
|
|
88227
|
+
}
|
|
88228
|
+
evaluateDm(msg) {
|
|
88229
|
+
var _a, _b;
|
|
88230
|
+
const mode = (_a = this.cfg.dmMode) !== null && _a !== void 0 ? _a : 'open';
|
|
88231
|
+
if (mode === 'disabled') {
|
|
88232
|
+
return { allowed: false, reason: 'dm_disabled' };
|
|
88233
|
+
}
|
|
88234
|
+
if (mode === 'allowlist') {
|
|
88235
|
+
const allow = (_b = this.cfg.dmAllowlist) !== null && _b !== void 0 ? _b : [];
|
|
88236
|
+
if (!allow.includes(msg.senderId)) {
|
|
88237
|
+
return { allowed: false, reason: 'sender_not_allowed' };
|
|
88238
|
+
}
|
|
88239
|
+
}
|
|
88240
|
+
// 'pair' mode is reserved for a future iteration; treat as open for now.
|
|
88241
|
+
return { allowed: true };
|
|
88242
|
+
}
|
|
88243
|
+
updateConfig(partial) {
|
|
88244
|
+
this.cfg = Object.assign(Object.assign({}, this.cfg), partial);
|
|
88245
|
+
}
|
|
88246
|
+
getConfig() {
|
|
88247
|
+
return this.cfg;
|
|
88248
|
+
}
|
|
88249
|
+
setBotIdentity(bot) {
|
|
88250
|
+
this.bot = bot;
|
|
88251
|
+
}
|
|
88252
|
+
getBotIdentity() {
|
|
88253
|
+
return this.bot;
|
|
88254
|
+
}
|
|
88255
|
+
}
|
|
88256
|
+
|
|
88257
|
+
/**
|
|
88258
|
+
* Short-TTL in-memory lock to prevent concurrent processing of the same
|
|
88259
|
+
* event — complements SeenCache by covering the "currently in flight"
|
|
88260
|
+
* window, during which the event is not yet committed to SeenCache.
|
|
88261
|
+
*/
|
|
88262
|
+
class ProcessingLock {
|
|
88263
|
+
constructor(ttlMs = DEFAULT_LOCK_TTL_MS, sweepMs = 60000) {
|
|
88264
|
+
var _a, _b;
|
|
88265
|
+
this.ttlMs = ttlMs;
|
|
88266
|
+
this.locks = new Map(); // id → expireAt (ms)
|
|
88267
|
+
this.sweeper = setInterval(() => this.sweep(), sweepMs);
|
|
88268
|
+
(_b = (_a = this.sweeper).unref) === null || _b === void 0 ? void 0 : _b.call(_a);
|
|
88269
|
+
}
|
|
88270
|
+
/** Returns true if the lock is acquired; false if already held. */
|
|
88271
|
+
acquire(id) {
|
|
88272
|
+
const now = Date.now();
|
|
88273
|
+
const exp = this.locks.get(id);
|
|
88274
|
+
if (exp && exp > now)
|
|
88275
|
+
return false;
|
|
88276
|
+
this.locks.set(id, now + this.ttlMs);
|
|
88277
|
+
return true;
|
|
88278
|
+
}
|
|
88279
|
+
release(id) {
|
|
88280
|
+
this.locks.delete(id);
|
|
88281
|
+
}
|
|
88282
|
+
sweep() {
|
|
88283
|
+
const now = Date.now();
|
|
88284
|
+
for (const [k, v] of this.locks) {
|
|
88285
|
+
if (v <= now)
|
|
88286
|
+
this.locks.delete(k);
|
|
88287
|
+
}
|
|
88288
|
+
}
|
|
88289
|
+
dispose() {
|
|
88290
|
+
clearInterval(this.sweeper);
|
|
88291
|
+
this.locks.clear();
|
|
88292
|
+
}
|
|
88293
|
+
}
|
|
88294
|
+
|
|
88295
|
+
function isStale(createTimeMs, windowMs = DEFAULT_STALE_MS) {
|
|
88296
|
+
if (!createTimeMs || !Number.isFinite(createTimeMs))
|
|
88297
|
+
return false;
|
|
88298
|
+
return Date.now() - createTimeMs > windowMs;
|
|
88299
|
+
}
|
|
88300
|
+
|
|
88301
|
+
/**
|
|
88302
|
+
* Pipeline entry facade for the channel's safety layer.
|
|
88303
|
+
*
|
|
88304
|
+
* Three tiers of protection, each targeting different event shapes:
|
|
88305
|
+
* - pushMessage: full pipeline (stale + dedup + policy + lock + batch + queue)
|
|
88306
|
+
* - pushAction: dedup + lock + queue — for card button clicks and doc comments
|
|
88307
|
+
* - pushLight: dedup only — for reactions
|
|
88308
|
+
*/
|
|
88309
|
+
class SafetyPipeline {
|
|
88310
|
+
constructor(opts) {
|
|
88311
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
|
|
88312
|
+
this.logger = opts.logger;
|
|
88313
|
+
this.onReject = opts.onReject;
|
|
88314
|
+
this.onMessage = opts.onMessage;
|
|
88315
|
+
this.staleWindow = (_b = (_a = opts.config) === null || _a === void 0 ? void 0 : _a.staleMessageWindowMs) !== null && _b !== void 0 ? _b : DEFAULT_STALE_MS;
|
|
88316
|
+
this.queueEnabled = (_e = (_d = (_c = opts.config) === null || _c === void 0 ? void 0 : _c.chatQueue) === null || _d === void 0 ? void 0 : _d.enabled) !== null && _e !== void 0 ? _e : true;
|
|
88317
|
+
this.seenCache = new SeenCache(opts.cache, {
|
|
88318
|
+
ttlMs: (_g = (_f = opts.config) === null || _f === void 0 ? void 0 : _f.dedup) === null || _g === void 0 ? void 0 : _g.ttl,
|
|
88319
|
+
maxMemEntries: (_j = (_h = opts.config) === null || _h === void 0 ? void 0 : _h.dedup) === null || _j === void 0 ? void 0 : _j.maxEntries,
|
|
88320
|
+
sweepMs: (_l = (_k = opts.config) === null || _k === void 0 ? void 0 : _k.dedup) === null || _l === void 0 ? void 0 : _l.sweepIntervalMs,
|
|
88321
|
+
});
|
|
88322
|
+
this.lock = new ProcessingLock();
|
|
88323
|
+
this.policy = new PolicyGate(opts.policy, opts.botIdentity);
|
|
88324
|
+
this.manager = new ChatPipelineManager(resolveBatchConfig(opts.config));
|
|
88325
|
+
}
|
|
88326
|
+
// ─── tier 1: full pipeline for IM messages ─────────────
|
|
88327
|
+
pushMessage(msg) {
|
|
88328
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
88329
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
88330
|
+
if (isStale(msg.createTime, this.staleWindow)) {
|
|
88331
|
+
(_b = (_a = this.logger).debug) === null || _b === void 0 ? void 0 : _b.call(_a, `safety: drop stale message ${msg.messageId}`);
|
|
88332
|
+
return;
|
|
88333
|
+
}
|
|
88334
|
+
if (yield this.seenCache.has(msg.messageId)) {
|
|
88335
|
+
(_d = (_c = this.logger).debug) === null || _d === void 0 ? void 0 : _d.call(_c, `safety: drop duplicate message ${msg.messageId}`);
|
|
88336
|
+
return;
|
|
88337
|
+
}
|
|
88338
|
+
const decision = this.policy.evaluate(msg);
|
|
88339
|
+
if (!decision.allowed) {
|
|
88340
|
+
this.onReject({
|
|
88341
|
+
messageId: msg.messageId,
|
|
88342
|
+
chatId: msg.chatId,
|
|
88343
|
+
senderId: msg.senderId,
|
|
88344
|
+
reason: (_e = decision.reason) !== null && _e !== void 0 ? _e : 'group_not_allowed',
|
|
88345
|
+
});
|
|
88346
|
+
return;
|
|
88347
|
+
}
|
|
88348
|
+
if (!this.lock.acquire(msg.messageId)) {
|
|
88349
|
+
(_g = (_f = this.logger).debug) === null || _g === void 0 ? void 0 : _g.call(_f, `safety: drop in-flight message ${msg.messageId}`);
|
|
88350
|
+
return;
|
|
88351
|
+
}
|
|
88352
|
+
const dispatchHandler = (batch) => __awaiter(this, void 0, void 0, function* () {
|
|
88353
|
+
var _h, _j;
|
|
88354
|
+
try {
|
|
88355
|
+
yield this.onMessage(batch.message);
|
|
88356
|
+
}
|
|
88357
|
+
catch (e) {
|
|
88358
|
+
(_j = (_h = this.logger).error) === null || _j === void 0 ? void 0 : _j.call(_h, `safety: message handler threw`, e);
|
|
88359
|
+
}
|
|
88360
|
+
finally {
|
|
88361
|
+
for (const id of batch.sourceIds) {
|
|
88362
|
+
try {
|
|
88363
|
+
yield this.seenCache.add(id);
|
|
88364
|
+
}
|
|
88365
|
+
catch ( /* best effort */_k) { /* best effort */ }
|
|
88366
|
+
this.lock.release(id);
|
|
88367
|
+
}
|
|
88368
|
+
}
|
|
88369
|
+
});
|
|
88370
|
+
if (this.queueEnabled) {
|
|
88371
|
+
this.manager.push(msg.chatId, msg, dispatchHandler);
|
|
88372
|
+
}
|
|
88373
|
+
else {
|
|
88374
|
+
// queueing disabled: fire-and-forget, no batch either
|
|
88375
|
+
void dispatchHandler({ message: msg, sourceIds: [msg.messageId] });
|
|
88376
|
+
}
|
|
88377
|
+
});
|
|
88378
|
+
}
|
|
88379
|
+
// ─── tier 2: dedup + lock + queue for cardAction & comment ─────
|
|
88380
|
+
pushAction(eventId, queueScope, handler) {
|
|
88381
|
+
var _a, _b, _c, _d;
|
|
88382
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
88383
|
+
if (yield this.seenCache.has(eventId)) {
|
|
88384
|
+
(_b = (_a = this.logger).debug) === null || _b === void 0 ? void 0 : _b.call(_a, `safety: drop duplicate action ${eventId}`);
|
|
88385
|
+
return;
|
|
88386
|
+
}
|
|
88387
|
+
if (!this.lock.acquire(eventId)) {
|
|
88388
|
+
(_d = (_c = this.logger).debug) === null || _d === void 0 ? void 0 : _d.call(_c, `safety: drop in-flight action ${eventId}`);
|
|
88389
|
+
return;
|
|
88390
|
+
}
|
|
88391
|
+
const task = () => __awaiter(this, void 0, void 0, function* () {
|
|
88392
|
+
var _e, _f;
|
|
88393
|
+
try {
|
|
88394
|
+
yield handler();
|
|
88395
|
+
}
|
|
88396
|
+
catch (e) {
|
|
88397
|
+
(_f = (_e = this.logger).error) === null || _f === void 0 ? void 0 : _f.call(_e, `safety: action handler threw`, e);
|
|
88398
|
+
}
|
|
88399
|
+
finally {
|
|
88400
|
+
try {
|
|
88401
|
+
yield this.seenCache.add(eventId);
|
|
88402
|
+
}
|
|
88403
|
+
catch ( /* best effort */_g) { /* best effort */ }
|
|
88404
|
+
this.lock.release(eventId);
|
|
88405
|
+
}
|
|
88406
|
+
});
|
|
88407
|
+
if (this.queueEnabled) {
|
|
88408
|
+
yield this.manager.run(queueScope, task);
|
|
88409
|
+
}
|
|
88410
|
+
else {
|
|
88411
|
+
yield task();
|
|
88412
|
+
}
|
|
88413
|
+
});
|
|
88414
|
+
}
|
|
88415
|
+
// ─── tier 3: dedup only (reactions) ────────────────────
|
|
88416
|
+
pushLight(eventId, handler) {
|
|
88417
|
+
var _a, _b;
|
|
88418
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
88419
|
+
if (yield this.seenCache.has(eventId))
|
|
88420
|
+
return;
|
|
88421
|
+
yield this.seenCache.add(eventId);
|
|
88422
|
+
try {
|
|
88423
|
+
yield handler();
|
|
88424
|
+
}
|
|
88425
|
+
catch (e) {
|
|
88426
|
+
(_b = (_a = this.logger).warn) === null || _b === void 0 ? void 0 : _b.call(_a, `safety: light handler threw`, e);
|
|
88427
|
+
}
|
|
88428
|
+
});
|
|
88429
|
+
}
|
|
88430
|
+
// ─── runtime config ────────────────────────────────────
|
|
88431
|
+
updatePolicy(partial) {
|
|
88432
|
+
this.policy.updateConfig(partial);
|
|
88433
|
+
}
|
|
88434
|
+
getPolicy() {
|
|
88435
|
+
return this.policy.getConfig();
|
|
88436
|
+
}
|
|
88437
|
+
setBotIdentity(bot) {
|
|
88438
|
+
this.policy.setBotIdentity(bot);
|
|
88439
|
+
}
|
|
88440
|
+
dispose() {
|
|
88441
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
88442
|
+
yield this.manager.dispose();
|
|
88443
|
+
this.seenCache.dispose();
|
|
88444
|
+
this.lock.dispose();
|
|
88445
|
+
});
|
|
88446
|
+
}
|
|
88447
|
+
}
|
|
88448
|
+
|
|
88449
|
+
function isMentionAll(m) {
|
|
88450
|
+
return m.key === '@_all';
|
|
88451
|
+
}
|
|
88452
|
+
function extractMentions(raw, botOpenId) {
|
|
88453
|
+
var _a, _b, _c;
|
|
88454
|
+
const mentions = new Map();
|
|
88455
|
+
const mentionsByOpenId = new Map();
|
|
88456
|
+
const mentionList = [];
|
|
88457
|
+
let mentionAll = false;
|
|
88458
|
+
let mentionedBot = false;
|
|
88459
|
+
for (const m of raw !== null && raw !== void 0 ? raw : []) {
|
|
88460
|
+
if (isMentionAll(m)) {
|
|
88461
|
+
mentionAll = true;
|
|
88462
|
+
mentions.set(m.key, { key: m.key, name: m.name, isBot: false });
|
|
88463
|
+
continue;
|
|
88464
|
+
}
|
|
88465
|
+
const openId = (_b = (_a = m.id) === null || _a === void 0 ? void 0 : _a.open_id) !== null && _b !== void 0 ? _b : '';
|
|
88466
|
+
const userId = (_c = m.id) === null || _c === void 0 ? void 0 : _c.user_id;
|
|
88467
|
+
const isBot = Boolean(botOpenId && openId === botOpenId);
|
|
88468
|
+
if (isBot)
|
|
88469
|
+
mentionedBot = true;
|
|
88470
|
+
const info = {
|
|
88471
|
+
key: m.key,
|
|
88472
|
+
openId: openId || undefined,
|
|
88473
|
+
userId,
|
|
88474
|
+
name: m.name,
|
|
88475
|
+
isBot,
|
|
88476
|
+
};
|
|
88477
|
+
mentions.set(m.key, info);
|
|
88478
|
+
if (openId)
|
|
88479
|
+
mentionsByOpenId.set(openId, info);
|
|
88480
|
+
mentionList.push(info);
|
|
88481
|
+
}
|
|
88482
|
+
return { mentions, mentionsByOpenId, mentionList, mentionAll, mentionedBot };
|
|
88483
|
+
}
|
|
88484
|
+
/**
|
|
88485
|
+
* Second-pass: replace placeholder keys in `content` with human-readable names
|
|
88486
|
+
* (or strip bot mentions if configured).
|
|
88487
|
+
*
|
|
88488
|
+
* Must run AFTER all converters have done their work, because converters use
|
|
88489
|
+
* placeholder keys to defer resolution to this single point.
|
|
88490
|
+
*/
|
|
88491
|
+
function resolveMentions(content, ctx) {
|
|
88492
|
+
if (!content || ctx.mentions.size === 0)
|
|
88493
|
+
return content;
|
|
88494
|
+
let out = content;
|
|
88495
|
+
for (const [key, info] of ctx.mentions) {
|
|
88496
|
+
if (info.isBot && ctx.stripBotMentions) {
|
|
88497
|
+
// Remove key plus one surrounding whitespace on either side.
|
|
88498
|
+
const re = new RegExp(`\\s?${escapeRegex(key)}\\s?`, 'g');
|
|
88499
|
+
out = out.replace(re, ' ');
|
|
88500
|
+
continue;
|
|
88501
|
+
}
|
|
88502
|
+
const replacement = info.name ? `@${info.name}` : key;
|
|
88503
|
+
out = out.split(key).join(replacement);
|
|
88504
|
+
}
|
|
88505
|
+
// Collapse any double-spaces introduced by bot-mention stripping.
|
|
88506
|
+
return out.replace(/[ \t]{2,}/g, ' ').trim();
|
|
88507
|
+
}
|
|
88508
|
+
function escapeRegex(s) {
|
|
88509
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
88510
|
+
}
|
|
88511
|
+
|
|
88512
|
+
function safeParse(raw) {
|
|
88513
|
+
if (!raw)
|
|
88514
|
+
return undefined;
|
|
88515
|
+
try {
|
|
88516
|
+
return JSON.parse(raw);
|
|
88517
|
+
}
|
|
88518
|
+
catch (_a) {
|
|
88519
|
+
return undefined;
|
|
88520
|
+
}
|
|
88521
|
+
}
|
|
88522
|
+
function applyStyle(text, style) {
|
|
88523
|
+
if (!style || style.length === 0)
|
|
88524
|
+
return text;
|
|
88525
|
+
let out = text;
|
|
88526
|
+
if (style.includes('bold'))
|
|
88527
|
+
out = `**${out}**`;
|
|
88528
|
+
if (style.includes('italic'))
|
|
88529
|
+
out = `*${out}*`;
|
|
88530
|
+
if (style.includes('underline'))
|
|
88531
|
+
out = `<u>${out}</u>`;
|
|
88532
|
+
if (style.includes('lineThrough') || style.includes('strikethrough'))
|
|
88533
|
+
out = `~~${out}~~`;
|
|
88534
|
+
if (style.includes('codeInline') || style.includes('code'))
|
|
88535
|
+
out = `\`${out}\``;
|
|
88536
|
+
return out;
|
|
88537
|
+
}
|
|
88538
|
+
const LOCALE_PRIORITY = ['zh_cn', 'en_us', 'ja_jp'];
|
|
88539
|
+
function unwrapLocale(parsed) {
|
|
88540
|
+
if ('title' in parsed || 'content' in parsed) {
|
|
88541
|
+
return parsed;
|
|
88542
|
+
}
|
|
88543
|
+
for (const loc of LOCALE_PRIORITY) {
|
|
88544
|
+
const hit = parsed[loc];
|
|
88545
|
+
if (hit != null && typeof hit === 'object')
|
|
88546
|
+
return hit;
|
|
88547
|
+
}
|
|
88548
|
+
const firstKey = Object.keys(parsed)[0];
|
|
88549
|
+
if (firstKey) {
|
|
88550
|
+
const first = parsed[firstKey];
|
|
88551
|
+
if (first != null && typeof first === 'object')
|
|
88552
|
+
return first;
|
|
88553
|
+
}
|
|
88554
|
+
return undefined;
|
|
88555
|
+
}
|
|
88556
|
+
function formatDuration(ms) {
|
|
88557
|
+
if (ms == null || !Number.isFinite(ms) || ms < 0)
|
|
88558
|
+
return undefined;
|
|
88559
|
+
if (ms < 1000)
|
|
88560
|
+
return `${Math.round(ms)}ms`;
|
|
88561
|
+
if (ms % 1000 === 0)
|
|
88562
|
+
return `${ms / 1000}s`;
|
|
88563
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
88564
|
+
}
|
|
88565
|
+
function millisToDatetime(ms) {
|
|
88566
|
+
if (ms == null)
|
|
88567
|
+
return undefined;
|
|
88568
|
+
const n = typeof ms === 'string' ? parseInt(ms, 10) : ms;
|
|
88569
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
88570
|
+
return undefined;
|
|
88571
|
+
const d = new Date(n + 8 * 3600000);
|
|
88572
|
+
const y = d.getUTCFullYear();
|
|
88573
|
+
const mo = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
88574
|
+
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
88575
|
+
const h = String(d.getUTCHours()).padStart(2, '0');
|
|
88576
|
+
const mi = String(d.getUTCMinutes()).padStart(2, '0');
|
|
88577
|
+
return `${y}-${mo}-${day} ${h}:${mi}`;
|
|
88578
|
+
}
|
|
88579
|
+
function formatRFC3339Beijing(ms) {
|
|
88580
|
+
const d = new Date(ms + 8 * 3600000);
|
|
88581
|
+
const y = d.getUTCFullYear();
|
|
88582
|
+
const mo = String(d.getUTCMonth() + 1).padStart(2, '0');
|
|
88583
|
+
const day = String(d.getUTCDate()).padStart(2, '0');
|
|
88584
|
+
const h = String(d.getUTCHours()).padStart(2, '0');
|
|
88585
|
+
const mi = String(d.getUTCMinutes()).padStart(2, '0');
|
|
88586
|
+
const s = String(d.getUTCSeconds()).padStart(2, '0');
|
|
88587
|
+
return `${y}-${mo}-${day}T${h}:${mi}:${s}+08:00`;
|
|
88588
|
+
}
|
|
88589
|
+
function indentLines(text, indent) {
|
|
88590
|
+
return text
|
|
88591
|
+
.split('\n')
|
|
88592
|
+
.map((line) => `${indent}${line}`)
|
|
88593
|
+
.join('\n');
|
|
88594
|
+
}
|
|
88595
|
+
function escapeAttr(s) {
|
|
88596
|
+
return s.replace(/"/g, '"');
|
|
88597
|
+
}
|
|
88598
|
+
|
|
88599
|
+
const convertAudio = (raw, _ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
88600
|
+
const parsed = safeParse(raw);
|
|
88601
|
+
const fileKey = parsed === null || parsed === void 0 ? void 0 : parsed.file_key;
|
|
88602
|
+
if (!fileKey)
|
|
88603
|
+
return { content: '[audio]', resources: [] };
|
|
88604
|
+
const duration = parsed === null || parsed === void 0 ? void 0 : parsed.duration;
|
|
88605
|
+
const durAttr = formatDuration(duration);
|
|
88606
|
+
const attr = durAttr ? ` duration="${durAttr}"` : '';
|
|
88607
|
+
const content = `<audio key="${fileKey}"${attr}/>`;
|
|
88608
|
+
const resources = [
|
|
88609
|
+
{ type: 'audio', fileKey, durationMs: duration },
|
|
88610
|
+
];
|
|
88611
|
+
return { content, resources };
|
|
88612
|
+
});
|
|
88613
|
+
|
|
88614
|
+
function formatCalendarInner(raw) {
|
|
88615
|
+
const parsed = safeParse(raw);
|
|
88616
|
+
if (!parsed)
|
|
88617
|
+
return '[calendar event]';
|
|
88618
|
+
const lines = [];
|
|
88619
|
+
if (parsed.summary)
|
|
88620
|
+
lines.push(`📅 ${parsed.summary}`);
|
|
88621
|
+
const start = millisToDatetime(parsed.start_time);
|
|
88622
|
+
const end = millisToDatetime(parsed.end_time);
|
|
88623
|
+
if (start && end)
|
|
88624
|
+
lines.push(`🕙 ${start} ~ ${end}`);
|
|
88625
|
+
else if (start)
|
|
88626
|
+
lines.push(`🕙 ${start}`);
|
|
88627
|
+
return lines.length > 0 ? lines.join('\n') : '[calendar event]';
|
|
88628
|
+
}
|
|
88629
|
+
const convertCalendar = (raw, _ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
88630
|
+
return ({
|
|
88631
|
+
content: `<calendar_invite>\n${formatCalendarInner(raw)}\n</calendar_invite>`,
|
|
88632
|
+
resources: [],
|
|
88633
|
+
});
|
|
88634
|
+
});
|
|
88635
|
+
const convertGeneralCalendar = (raw, _ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
88636
|
+
return ({
|
|
88637
|
+
content: `<calendar>\n${formatCalendarInner(raw)}\n</calendar>`,
|
|
88638
|
+
resources: [],
|
|
88639
|
+
});
|
|
88640
|
+
});
|
|
88641
|
+
const convertShareCalendarEvent = (raw, _ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
88642
|
+
return ({
|
|
88643
|
+
content: `<calendar_share>\n${formatCalendarInner(raw)}\n</calendar_share>`,
|
|
88644
|
+
resources: [],
|
|
88645
|
+
});
|
|
88646
|
+
});
|
|
88647
|
+
|
|
88648
|
+
const convertFile = (raw, _ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
88649
|
+
const parsed = safeParse(raw);
|
|
88650
|
+
const fileKey = parsed === null || parsed === void 0 ? void 0 : parsed.file_key;
|
|
88651
|
+
if (!fileKey)
|
|
88652
|
+
return { content: '[file]', resources: [] };
|
|
88653
|
+
const fileName = parsed === null || parsed === void 0 ? void 0 : parsed.file_name;
|
|
88654
|
+
const nameAttr = fileName ? ` name="${escapeAttr(fileName)}"` : '';
|
|
88655
|
+
const content = `<file key="${fileKey}"${nameAttr}/>`;
|
|
88656
|
+
const resources = [{ type: 'file', fileKey, fileName }];
|
|
88657
|
+
return { content, resources };
|
|
88658
|
+
});
|
|
88659
|
+
|
|
88660
|
+
const convertFolder = (raw, _ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
88661
|
+
const parsed = safeParse(raw);
|
|
88662
|
+
const fileKey = parsed === null || parsed === void 0 ? void 0 : parsed.file_key;
|
|
88663
|
+
if (!fileKey)
|
|
88664
|
+
return { content: '[folder]', resources: [] };
|
|
88665
|
+
const nameAttr = (parsed === null || parsed === void 0 ? void 0 : parsed.file_name) ? ` name="${escapeAttr(parsed.file_name)}"` : '';
|
|
88666
|
+
return { content: `<folder key="${fileKey}"${nameAttr}/>`, resources: [] };
|
|
88667
|
+
});
|
|
88668
|
+
|
|
88669
|
+
const convertHongbao = (raw, _ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
88670
|
+
const parsed = safeParse(raw);
|
|
88671
|
+
const textAttr = (parsed === null || parsed === void 0 ? void 0 : parsed.text) ? ` text="${escapeAttr(parsed.text)}"` : '';
|
|
88672
|
+
return { content: `<hongbao${textAttr}/>`, resources: [] };
|
|
88673
|
+
});
|
|
88674
|
+
|
|
88675
|
+
const convertImage = (raw, _ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
88676
|
+
const parsed = safeParse(raw);
|
|
88677
|
+
const imageKey = parsed === null || parsed === void 0 ? void 0 : parsed.image_key;
|
|
88678
|
+
if (!imageKey)
|
|
88679
|
+
return { content: '[image]', resources: [] };
|
|
88680
|
+
const resources = [{ type: 'image', fileKey: imageKey }];
|
|
88681
|
+
return { content: ``, resources };
|
|
88682
|
+
});
|
|
88683
|
+
|
|
88684
|
+
/**
|
|
88685
|
+
* Recursive walker that extracts human-readable text from a Feishu
|
|
88686
|
+
* interactive card JSON tree. Covers header titles, plain_text / lark_md
|
|
88687
|
+
* elements, button labels, form fields, notes, and commonly used nested
|
|
88688
|
+
* element types.
|
|
88689
|
+
*/
|
|
88690
|
+
function walkCard(node) {
|
|
88691
|
+
const out = [];
|
|
88692
|
+
visit(node, out);
|
|
88693
|
+
// de-duplicate adjacent empties and collapse
|
|
88694
|
+
return out.filter((s) => s && s.trim().length > 0);
|
|
88695
|
+
}
|
|
88696
|
+
function visit(node, out) {
|
|
88697
|
+
if (node == null)
|
|
88698
|
+
return;
|
|
88699
|
+
if (typeof node === 'string' || typeof node === 'number' || typeof node === 'boolean')
|
|
88700
|
+
return;
|
|
88701
|
+
if (Array.isArray(node)) {
|
|
88702
|
+
for (const child of node)
|
|
88703
|
+
visit(child, out);
|
|
88704
|
+
return;
|
|
88705
|
+
}
|
|
88706
|
+
if (typeof node !== 'object')
|
|
88707
|
+
return;
|
|
88708
|
+
const obj = node;
|
|
88709
|
+
// tag: plain_text / lark_md / markdown → push content
|
|
88710
|
+
const tag = obj.tag;
|
|
88711
|
+
if (typeof tag === 'string' && (tag === 'plain_text' || tag === 'lark_md' || tag === 'markdown')) {
|
|
88712
|
+
if (typeof obj.content === 'string')
|
|
88713
|
+
out.push(obj.content);
|
|
88714
|
+
return;
|
|
88715
|
+
}
|
|
88716
|
+
// header.title
|
|
88717
|
+
if (obj.header && typeof obj.header === 'object') {
|
|
88718
|
+
const header = obj.header;
|
|
88719
|
+
if (header.title)
|
|
88720
|
+
visit(header.title, out);
|
|
88721
|
+
}
|
|
88722
|
+
// text / content on common elements (div, button, note, etc.)
|
|
88723
|
+
if (obj.text)
|
|
88724
|
+
visit(obj.text, out);
|
|
88725
|
+
// button / select option label (typed as button)
|
|
88726
|
+
if (typeof tag === 'string' && tag === 'button') {
|
|
88727
|
+
const text = obj.text;
|
|
88728
|
+
if (text)
|
|
88729
|
+
visit(text, out);
|
|
88730
|
+
}
|
|
88731
|
+
// form fields: label, placeholder, options
|
|
88732
|
+
if (obj.label)
|
|
88733
|
+
visit(obj.label, out);
|
|
88734
|
+
if (obj.placeholder)
|
|
88735
|
+
visit(obj.placeholder, out);
|
|
88736
|
+
if (Array.isArray(obj.options)) {
|
|
88737
|
+
for (const opt of obj.options) {
|
|
88738
|
+
const o = opt;
|
|
88739
|
+
if (o === null || o === void 0 ? void 0 : o.text)
|
|
88740
|
+
visit(o.text, out);
|
|
88741
|
+
}
|
|
88742
|
+
}
|
|
88743
|
+
// column / row containers
|
|
88744
|
+
if (Array.isArray(obj.elements))
|
|
88745
|
+
for (const el of obj.elements)
|
|
88746
|
+
visit(el, out);
|
|
88747
|
+
if (Array.isArray(obj.fields))
|
|
88748
|
+
for (const f of obj.fields)
|
|
88749
|
+
visit(f, out);
|
|
88750
|
+
if (Array.isArray(obj.actions))
|
|
88751
|
+
for (const a of obj.actions)
|
|
88752
|
+
visit(a, out);
|
|
88753
|
+
if (Array.isArray(obj.columns))
|
|
88754
|
+
for (const c of obj.columns)
|
|
88755
|
+
visit(c, out);
|
|
88756
|
+
// common nested shapes for v2 card body
|
|
88757
|
+
if (obj.body)
|
|
88758
|
+
visit(obj.body, out);
|
|
88759
|
+
}
|
|
88760
|
+
|
|
88761
|
+
const convertInteractive = (raw, _ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
88762
|
+
const parsed = safeParse(raw);
|
|
88763
|
+
if (parsed == null || typeof parsed !== 'object') {
|
|
88764
|
+
return { content: '[interactive card]', resources: [] };
|
|
88765
|
+
}
|
|
88766
|
+
const pieces = walkCard(parsed);
|
|
88767
|
+
if (pieces.length === 0) {
|
|
88768
|
+
return { content: '[interactive card]', resources: [] };
|
|
88769
|
+
}
|
|
88770
|
+
// Dedup adjacent duplicates while preserving order
|
|
88771
|
+
const seen = new Set();
|
|
88772
|
+
const out = [];
|
|
88773
|
+
for (const p of pieces) {
|
|
88774
|
+
const key = p.trim();
|
|
88775
|
+
if (!key || seen.has(key))
|
|
88776
|
+
continue;
|
|
88777
|
+
seen.add(key);
|
|
88778
|
+
out.push(key);
|
|
88779
|
+
}
|
|
88780
|
+
return { content: out.join('\n'), resources: [] };
|
|
88781
|
+
});
|
|
88782
|
+
|
|
88783
|
+
const convertLocation = (raw, _ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
88784
|
+
const parsed = safeParse(raw);
|
|
88785
|
+
const name = parsed === null || parsed === void 0 ? void 0 : parsed.name;
|
|
88786
|
+
const lat = parsed === null || parsed === void 0 ? void 0 : parsed.latitude;
|
|
88787
|
+
const lng = parsed === null || parsed === void 0 ? void 0 : parsed.longitude;
|
|
88788
|
+
const nameAttr = name ? ` name="${escapeAttr(name)}"` : '';
|
|
88789
|
+
const coordsAttr = lat && lng ? ` coords="lat:${lat},lng:${lng}"` : '';
|
|
88790
|
+
return { content: `<location${nameAttr}${coordsAttr}/>`, resources: [] };
|
|
88791
|
+
});
|
|
88792
|
+
|
|
88793
|
+
const MAX_ITEMS = 50;
|
|
88794
|
+
const convertMergeForward = (_raw, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
88795
|
+
var _a;
|
|
88796
|
+
const { messageId, fetchSubMessages, dispatch } = ctx;
|
|
88797
|
+
if (!fetchSubMessages || !dispatch) {
|
|
88798
|
+
return { content: '<forwarded_messages/>', resources: [] };
|
|
88799
|
+
}
|
|
88800
|
+
let items;
|
|
88801
|
+
try {
|
|
88802
|
+
items = yield fetchSubMessages(messageId);
|
|
88803
|
+
}
|
|
88804
|
+
catch (_b) {
|
|
88805
|
+
return { content: '<forwarded_messages/>', resources: [] };
|
|
88806
|
+
}
|
|
88807
|
+
if (!items || items.length === 0) {
|
|
88808
|
+
return { content: '<forwarded_messages/>', resources: [] };
|
|
88809
|
+
}
|
|
88810
|
+
const capped = items.slice(0, MAX_ITEMS);
|
|
88811
|
+
const truncated = items.length > MAX_ITEMS;
|
|
88812
|
+
// Pre-warm sender name cache in one batch call.
|
|
88813
|
+
if (ctx.batchResolveNames) {
|
|
88814
|
+
const senderIds = new Set();
|
|
88815
|
+
for (const it of capped) {
|
|
88816
|
+
const sid = (_a = it.sender) === null || _a === void 0 ? void 0 : _a.id;
|
|
88817
|
+
if (sid && it.message_id !== messageId)
|
|
88818
|
+
senderIds.add(sid);
|
|
88819
|
+
}
|
|
88820
|
+
if (senderIds.size > 0) {
|
|
88821
|
+
try {
|
|
88822
|
+
yield ctx.batchResolveNames([...senderIds]);
|
|
88823
|
+
}
|
|
88824
|
+
catch (_c) {
|
|
88825
|
+
// best effort
|
|
88826
|
+
}
|
|
88827
|
+
}
|
|
88828
|
+
}
|
|
88829
|
+
const childrenMap = buildChildrenMap(capped, messageId);
|
|
88830
|
+
const content = yield formatSubTree(messageId, childrenMap, ctx, truncated);
|
|
88831
|
+
return { content, resources: [] };
|
|
88832
|
+
});
|
|
88833
|
+
function buildChildrenMap(items, rootId) {
|
|
88834
|
+
var _a;
|
|
88835
|
+
const map = new Map();
|
|
88836
|
+
for (const it of items) {
|
|
88837
|
+
if (it.message_id === rootId && !it.upper_message_id)
|
|
88838
|
+
continue;
|
|
88839
|
+
const pid = (_a = it.upper_message_id) !== null && _a !== void 0 ? _a : rootId;
|
|
88840
|
+
let arr = map.get(pid);
|
|
88841
|
+
if (!arr) {
|
|
88842
|
+
arr = [];
|
|
88843
|
+
map.set(pid, arr);
|
|
88844
|
+
}
|
|
88845
|
+
arr.push(it);
|
|
88846
|
+
}
|
|
88847
|
+
for (const arr of map.values()) {
|
|
88848
|
+
arr.sort((a, b) => {
|
|
88849
|
+
var _a, _b;
|
|
88850
|
+
const ta = parseInt(String((_a = a.create_time) !== null && _a !== void 0 ? _a : '0'), 10);
|
|
88851
|
+
const tb = parseInt(String((_b = b.create_time) !== null && _b !== void 0 ? _b : '0'), 10);
|
|
88852
|
+
return ta - tb;
|
|
88853
|
+
});
|
|
88854
|
+
}
|
|
88855
|
+
return map;
|
|
88856
|
+
}
|
|
88857
|
+
function formatSubTree(parentId, map, ctx, truncated = false) {
|
|
88858
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
88859
|
+
const children = map.get(parentId);
|
|
88860
|
+
if (!children || children.length === 0)
|
|
88861
|
+
return '<forwarded_messages/>';
|
|
88862
|
+
const parts = [];
|
|
88863
|
+
for (const item of children) {
|
|
88864
|
+
try {
|
|
88865
|
+
const sub = yield renderItem(item, map, ctx);
|
|
88866
|
+
if (sub)
|
|
88867
|
+
parts.push(sub);
|
|
88868
|
+
}
|
|
88869
|
+
catch (_a) {
|
|
88870
|
+
// skip bad item
|
|
88871
|
+
}
|
|
88872
|
+
}
|
|
88873
|
+
if (parts.length === 0)
|
|
88874
|
+
return '<forwarded_messages/>';
|
|
88875
|
+
const body = parts.join('\n');
|
|
88876
|
+
const footer = truncated ? '\n... (truncated)' : '';
|
|
88877
|
+
return `<forwarded_messages>\n${body}${footer}\n</forwarded_messages>`;
|
|
88878
|
+
});
|
|
88879
|
+
}
|
|
88880
|
+
function renderItem(item, map, ctx) {
|
|
88881
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
88882
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
88883
|
+
const msgType = (_a = item.msg_type) !== null && _a !== void 0 ? _a : 'text';
|
|
88884
|
+
const senderId = (_c = (_b = item.sender) === null || _b === void 0 ? void 0 : _b.id) !== null && _c !== void 0 ? _c : 'unknown';
|
|
88885
|
+
const createMs = parseInt(String((_d = item.create_time) !== null && _d !== void 0 ? _d : '0'), 10);
|
|
88886
|
+
const timestamp = createMs > 0 ? formatRFC3339Beijing(createMs) : 'unknown';
|
|
88887
|
+
const displayName = (_f = (_e = ctx.resolveUserName) === null || _e === void 0 ? void 0 : _e.call(ctx, senderId)) !== null && _f !== void 0 ? _f : senderId;
|
|
88888
|
+
let content;
|
|
88889
|
+
if (msgType === 'merge_forward') {
|
|
88890
|
+
// Nested forward — recurse locally without another API call.
|
|
88891
|
+
const nestedId = item.message_id;
|
|
88892
|
+
content = nestedId ? yield formatSubTree(nestedId, map, ctx) : '<forwarded_messages/>';
|
|
88893
|
+
}
|
|
88894
|
+
else {
|
|
88895
|
+
const rawContent = (_h = (_g = item.body) === null || _g === void 0 ? void 0 : _g.content) !== null && _h !== void 0 ? _h : '{}';
|
|
88896
|
+
if (!ctx.dispatch) {
|
|
88897
|
+
content = rawContent;
|
|
88898
|
+
}
|
|
88899
|
+
else {
|
|
88900
|
+
const r = yield ctx.dispatch(rawContent, msgType, ctx);
|
|
88901
|
+
content = r.content;
|
|
88902
|
+
}
|
|
88903
|
+
}
|
|
88904
|
+
const indented = indentLines(content, ' ');
|
|
88905
|
+
return `[${timestamp}] ${displayName}:\n${indented}`;
|
|
88906
|
+
});
|
|
88907
|
+
}
|
|
88908
|
+
|
|
88909
|
+
const convertPost = (raw, ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
88910
|
+
var _a;
|
|
88911
|
+
const rawParsed = safeParse(raw);
|
|
88912
|
+
if (rawParsed == null || typeof rawParsed !== 'object') {
|
|
88913
|
+
return { content: '[rich text message]', resources: [] };
|
|
88914
|
+
}
|
|
88915
|
+
const body = unwrapLocale(rawParsed);
|
|
88916
|
+
if (!body)
|
|
88917
|
+
return { content: '[rich text message]', resources: [] };
|
|
88918
|
+
const resources = [];
|
|
88919
|
+
const lines = [];
|
|
88920
|
+
if (body.title) {
|
|
88921
|
+
lines.push(`**${body.title}**`);
|
|
88922
|
+
lines.push('');
|
|
88923
|
+
}
|
|
88924
|
+
for (const paragraph of (_a = body.content) !== null && _a !== void 0 ? _a : []) {
|
|
88925
|
+
if (!Array.isArray(paragraph))
|
|
88926
|
+
continue;
|
|
88927
|
+
let line = '';
|
|
88928
|
+
for (const el of paragraph) {
|
|
88929
|
+
line += renderElement(el, ctx, resources);
|
|
88930
|
+
}
|
|
88931
|
+
lines.push(line);
|
|
88932
|
+
}
|
|
88933
|
+
const content = lines.join('\n').trim() || '[rich text message]';
|
|
88934
|
+
return { content, resources };
|
|
88935
|
+
});
|
|
88936
|
+
function renderElement(el, ctx, resources) {
|
|
88937
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
88938
|
+
switch (el.tag) {
|
|
88939
|
+
case 'text':
|
|
88940
|
+
return applyStyle((_a = el.text) !== null && _a !== void 0 ? _a : '', el.style);
|
|
88941
|
+
case 'a': {
|
|
88942
|
+
const label = (_c = (_b = el.text) !== null && _b !== void 0 ? _b : el.href) !== null && _c !== void 0 ? _c : '';
|
|
88943
|
+
return el.href ? `[${label}](${el.href})` : label;
|
|
88944
|
+
}
|
|
88945
|
+
case 'at': {
|
|
88946
|
+
const userId = (_d = el.user_id) !== null && _d !== void 0 ? _d : '';
|
|
88947
|
+
if (userId === 'all' || userId === 'all_members')
|
|
88948
|
+
return '@all';
|
|
88949
|
+
// Prefer placeholder key so resolveMentions handles it uniformly
|
|
88950
|
+
const info = ctx.mentionsByOpenId.get(userId);
|
|
88951
|
+
if (info)
|
|
88952
|
+
return info.key;
|
|
88953
|
+
return el.user_name ? `@${el.user_name}` : `@${userId}`;
|
|
88954
|
+
}
|
|
88955
|
+
case 'img': {
|
|
88956
|
+
if (el.image_key) {
|
|
88957
|
+
resources.push({ type: 'image', fileKey: el.image_key });
|
|
88958
|
+
return ``;
|
|
88959
|
+
}
|
|
88960
|
+
return '';
|
|
88961
|
+
}
|
|
88962
|
+
case 'media': {
|
|
88963
|
+
if (el.file_key) {
|
|
88964
|
+
resources.push({ type: 'file', fileKey: el.file_key });
|
|
88965
|
+
return `<file key="${el.file_key}"/>`;
|
|
88966
|
+
}
|
|
88967
|
+
return '';
|
|
88968
|
+
}
|
|
88969
|
+
case 'code_block': {
|
|
88970
|
+
const lang = (_e = el.language) !== null && _e !== void 0 ? _e : '';
|
|
88971
|
+
const code = (_f = el.text) !== null && _f !== void 0 ? _f : '';
|
|
88972
|
+
return `\n\`\`\`${lang}\n${code}\n\`\`\`\n`;
|
|
88973
|
+
}
|
|
88974
|
+
case 'hr':
|
|
88975
|
+
return '\n---\n';
|
|
88976
|
+
default:
|
|
88977
|
+
return (_g = el.text) !== null && _g !== void 0 ? _g : '';
|
|
88978
|
+
}
|
|
88979
|
+
}
|
|
88980
|
+
|
|
88981
|
+
const convertShareChat = (raw, _ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
88982
|
+
var _a;
|
|
88983
|
+
const parsed = safeParse(raw);
|
|
88984
|
+
return {
|
|
88985
|
+
content: `<group_card id="${(_a = parsed === null || parsed === void 0 ? void 0 : parsed.chat_id) !== null && _a !== void 0 ? _a : ''}"/>`,
|
|
88986
|
+
resources: [],
|
|
88987
|
+
};
|
|
88988
|
+
});
|
|
88989
|
+
const convertShareUser = (raw, _ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
88990
|
+
var _b;
|
|
88991
|
+
const parsed = safeParse(raw);
|
|
88992
|
+
return {
|
|
88993
|
+
content: `<contact_card id="${(_b = parsed === null || parsed === void 0 ? void 0 : parsed.user_id) !== null && _b !== void 0 ? _b : ''}"/>`,
|
|
88994
|
+
resources: [],
|
|
88995
|
+
};
|
|
88996
|
+
});
|
|
88997
|
+
|
|
88998
|
+
const convertSticker = (raw, _ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
88999
|
+
const parsed = safeParse(raw);
|
|
89000
|
+
const fileKey = parsed === null || parsed === void 0 ? void 0 : parsed.file_key;
|
|
89001
|
+
if (!fileKey)
|
|
89002
|
+
return { content: '[sticker]', resources: [] };
|
|
89003
|
+
const resources = [{ type: 'sticker', fileKey }];
|
|
89004
|
+
return { content: `<sticker key="${fileKey}"/>`, resources };
|
|
89005
|
+
});
|
|
89006
|
+
|
|
89007
|
+
const convertSystem = (raw, _ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
89008
|
+
const parsed = safeParse(raw);
|
|
89009
|
+
if (!parsed || !parsed.template) {
|
|
89010
|
+
return { content: '[system message]', resources: [] };
|
|
89011
|
+
}
|
|
89012
|
+
const out = parsed.template.replace(/\{([a-z_]+)\}/g, (match, name) => {
|
|
89013
|
+
const val = parsed[name];
|
|
89014
|
+
if (Array.isArray(val))
|
|
89015
|
+
return val.join(', ');
|
|
89016
|
+
if (typeof val === 'string')
|
|
89017
|
+
return val;
|
|
89018
|
+
if (val == null)
|
|
89019
|
+
return '';
|
|
89020
|
+
return match;
|
|
89021
|
+
});
|
|
89022
|
+
return { content: out.trim() || '[system message]', resources: [] };
|
|
89023
|
+
});
|
|
89024
|
+
|
|
89025
|
+
const convertText = (raw, _ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
89026
|
+
var _a;
|
|
89027
|
+
const parsed = safeParse(raw);
|
|
89028
|
+
return { content: (_a = parsed === null || parsed === void 0 ? void 0 : parsed.text) !== null && _a !== void 0 ? _a : '', resources: [] };
|
|
89029
|
+
});
|
|
89030
|
+
|
|
89031
|
+
const convertTodo = (raw, _ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
89032
|
+
const parsed = safeParse(raw);
|
|
89033
|
+
if (!(parsed === null || parsed === void 0 ? void 0 : parsed.summary))
|
|
89034
|
+
return { content: '<todo>\n[todo]\n</todo>', resources: [] };
|
|
89035
|
+
const lines = [];
|
|
89036
|
+
if (parsed.summary.title)
|
|
89037
|
+
lines.push(parsed.summary.title);
|
|
89038
|
+
const bodyText = extractPostPlainText(parsed.summary.content);
|
|
89039
|
+
if (bodyText)
|
|
89040
|
+
lines.push(bodyText);
|
|
89041
|
+
const due = millisToDatetime(parsed.due_time);
|
|
89042
|
+
if (due)
|
|
89043
|
+
lines.push(`Due: ${due}`);
|
|
89044
|
+
if (lines.length === 0)
|
|
89045
|
+
return { content: '<todo>\n[todo]\n</todo>', resources: [] };
|
|
89046
|
+
return { content: `<todo>\n${lines.join('\n')}\n</todo>`, resources: [] };
|
|
89047
|
+
});
|
|
89048
|
+
function extractPostPlainText(blocks) {
|
|
89049
|
+
if (!blocks)
|
|
89050
|
+
return '';
|
|
89051
|
+
const lines = [];
|
|
89052
|
+
for (const paragraph of blocks) {
|
|
89053
|
+
if (!Array.isArray(paragraph))
|
|
89054
|
+
continue;
|
|
89055
|
+
const parts = [];
|
|
89056
|
+
for (const el of paragraph) {
|
|
89057
|
+
if (el.tag === 'text' && el.text)
|
|
89058
|
+
parts.push(el.text);
|
|
89059
|
+
else if (el.tag === 'a' && el.text)
|
|
89060
|
+
parts.push(el.text);
|
|
89061
|
+
}
|
|
89062
|
+
if (parts.length > 0)
|
|
89063
|
+
lines.push(parts.join(''));
|
|
89064
|
+
}
|
|
89065
|
+
return lines.join('\n');
|
|
89066
|
+
}
|
|
89067
|
+
|
|
89068
|
+
const convertUnknown = (raw, _ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
89069
|
+
const parsed = safeParse(raw);
|
|
89070
|
+
if (parsed && typeof parsed.text === 'string') {
|
|
89071
|
+
return { content: parsed.text, resources: [] };
|
|
89072
|
+
}
|
|
89073
|
+
return { content: '[unsupported message]', resources: [] };
|
|
89074
|
+
});
|
|
89075
|
+
|
|
89076
|
+
const convertVideo = (raw, _ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
89077
|
+
const parsed = safeParse(raw);
|
|
89078
|
+
const fileKey = parsed === null || parsed === void 0 ? void 0 : parsed.file_key;
|
|
89079
|
+
if (!fileKey)
|
|
89080
|
+
return { content: '[video]', resources: [] };
|
|
89081
|
+
const nameAttr = (parsed === null || parsed === void 0 ? void 0 : parsed.file_name) ? ` name="${escapeAttr(parsed.file_name)}"` : '';
|
|
89082
|
+
const durStr = formatDuration(parsed === null || parsed === void 0 ? void 0 : parsed.duration);
|
|
89083
|
+
const durAttr = durStr ? ` duration="${durStr}"` : '';
|
|
89084
|
+
const content = `<video key="${fileKey}"${nameAttr}${durAttr}/>`;
|
|
89085
|
+
const resources = [
|
|
89086
|
+
{
|
|
89087
|
+
type: 'video',
|
|
89088
|
+
fileKey,
|
|
89089
|
+
fileName: parsed === null || parsed === void 0 ? void 0 : parsed.file_name,
|
|
89090
|
+
durationMs: parsed === null || parsed === void 0 ? void 0 : parsed.duration,
|
|
89091
|
+
coverImageKey: parsed === null || parsed === void 0 ? void 0 : parsed.image_key,
|
|
89092
|
+
},
|
|
89093
|
+
];
|
|
89094
|
+
return { content, resources };
|
|
89095
|
+
});
|
|
89096
|
+
|
|
89097
|
+
const convertVideoChat = (raw, _ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
89098
|
+
const parsed = safeParse(raw);
|
|
89099
|
+
if (!parsed) {
|
|
89100
|
+
return { content: '<meeting>\n[video chat]\n</meeting>', resources: [] };
|
|
89101
|
+
}
|
|
89102
|
+
const lines = [];
|
|
89103
|
+
if (parsed.topic)
|
|
89104
|
+
lines.push(`📹 ${parsed.topic}`);
|
|
89105
|
+
const start = millisToDatetime(parsed.start_time);
|
|
89106
|
+
if (start)
|
|
89107
|
+
lines.push(`🕙 ${start}`);
|
|
89108
|
+
const inner = lines.length > 0 ? lines.join('\n') : '[video chat]';
|
|
89109
|
+
return { content: `<meeting>\n${inner}\n</meeting>`, resources: [] };
|
|
89110
|
+
});
|
|
89111
|
+
|
|
89112
|
+
const convertVote = (raw, _ctx) => __awaiter(void 0, void 0, void 0, function* () {
|
|
89113
|
+
var _a, _b;
|
|
89114
|
+
const parsed = safeParse(raw);
|
|
89115
|
+
if (!parsed || (!parsed.topic && !((_a = parsed.options) === null || _a === void 0 ? void 0 : _a.length))) {
|
|
89116
|
+
return { content: '<vote>\n[vote]\n</vote>', resources: [] };
|
|
89117
|
+
}
|
|
89118
|
+
const lines = [];
|
|
89119
|
+
if (parsed.topic)
|
|
89120
|
+
lines.push(parsed.topic);
|
|
89121
|
+
for (const opt of (_b = parsed.options) !== null && _b !== void 0 ? _b : [])
|
|
89122
|
+
lines.push(`• ${opt}`);
|
|
89123
|
+
return { content: `<vote>\n${lines.join('\n')}\n</vote>`, resources: [] };
|
|
89124
|
+
});
|
|
89125
|
+
|
|
89126
|
+
const converters = new Map([
|
|
89127
|
+
['text', convertText],
|
|
89128
|
+
['post', convertPost],
|
|
89129
|
+
['image', convertImage],
|
|
89130
|
+
['file', convertFile],
|
|
89131
|
+
['audio', convertAudio],
|
|
89132
|
+
['video', convertVideo],
|
|
89133
|
+
['media', convertVideo],
|
|
89134
|
+
['sticker', convertSticker],
|
|
89135
|
+
['interactive', convertInteractive],
|
|
89136
|
+
['merge_forward', convertMergeForward],
|
|
89137
|
+
['share_chat', convertShareChat],
|
|
89138
|
+
['share_user', convertShareUser],
|
|
89139
|
+
['location', convertLocation],
|
|
89140
|
+
['system', convertSystem],
|
|
89141
|
+
['vote', convertVote],
|
|
89142
|
+
['todo', convertTodo],
|
|
89143
|
+
['calendar', convertCalendar],
|
|
89144
|
+
['general_calendar', convertGeneralCalendar],
|
|
89145
|
+
['share_calendar_event', convertShareCalendarEvent],
|
|
89146
|
+
['folder', convertFolder],
|
|
89147
|
+
['hongbao', convertHongbao],
|
|
89148
|
+
['video_chat', convertVideoChat],
|
|
89149
|
+
]);
|
|
89150
|
+
/**
|
|
89151
|
+
* Dispatch a message content to the matching converter, with uniform error
|
|
89152
|
+
* containment — any thrown error is trapped and the fallback converter is
|
|
89153
|
+
* invoked instead so that normalization never fails catastrophically.
|
|
89154
|
+
*/
|
|
89155
|
+
function dispatchConvert(raw, msgType, ctx) {
|
|
89156
|
+
var _a;
|
|
89157
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
89158
|
+
const fn = (_a = converters.get(msgType)) !== null && _a !== void 0 ? _a : convertUnknown;
|
|
89159
|
+
try {
|
|
89160
|
+
return yield fn(raw, ctx);
|
|
89161
|
+
}
|
|
89162
|
+
catch (_b) {
|
|
89163
|
+
return convertUnknown(raw);
|
|
89164
|
+
}
|
|
89165
|
+
});
|
|
89166
|
+
}
|
|
89167
|
+
|
|
89168
|
+
function normalizeCardAction(event) {
|
|
89169
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
|
|
89170
|
+
const messageId = (_b = (_a = event.context) === null || _a === void 0 ? void 0 : _a.open_message_id) !== null && _b !== void 0 ? _b : event.open_message_id;
|
|
89171
|
+
const chatId = (_d = (_c = event.context) === null || _c === void 0 ? void 0 : _c.open_chat_id) !== null && _d !== void 0 ? _d : event.open_chat_id;
|
|
89172
|
+
const operatorOpenId = (_e = event.operator) === null || _e === void 0 ? void 0 : _e.open_id;
|
|
89173
|
+
if (!messageId || !chatId || !operatorOpenId)
|
|
89174
|
+
return null;
|
|
89175
|
+
return {
|
|
89176
|
+
messageId,
|
|
89177
|
+
chatId,
|
|
89178
|
+
operator: {
|
|
89179
|
+
openId: operatorOpenId,
|
|
89180
|
+
userId: (_f = event.operator) === null || _f === void 0 ? void 0 : _f.user_id,
|
|
89181
|
+
name: (_g = event.operator) === null || _g === void 0 ? void 0 : _g.name,
|
|
89182
|
+
},
|
|
89183
|
+
action: {
|
|
89184
|
+
value: (_h = event.action) === null || _h === void 0 ? void 0 : _h.value,
|
|
89185
|
+
tag: (_k = (_j = event.action) === null || _j === void 0 ? void 0 : _j.tag) !== null && _k !== void 0 ? _k : 'unknown',
|
|
89186
|
+
name: (_l = event.action) === null || _l === void 0 ? void 0 : _l.name,
|
|
89187
|
+
option: (_m = event.action) === null || _m === void 0 ? void 0 : _m.option,
|
|
89188
|
+
},
|
|
89189
|
+
};
|
|
89190
|
+
}
|
|
89191
|
+
|
|
89192
|
+
function normalizeReaction(event, action) {
|
|
89193
|
+
var _a, _b, _c, _d;
|
|
89194
|
+
const messageId = event.message_id;
|
|
89195
|
+
const emojiType = (_a = event.reaction_type) === null || _a === void 0 ? void 0 : _a.emoji_type;
|
|
89196
|
+
const operatorOpenId = (_b = event.user_id) === null || _b === void 0 ? void 0 : _b.open_id;
|
|
89197
|
+
if (!messageId || !emojiType || !operatorOpenId)
|
|
89198
|
+
return null;
|
|
89199
|
+
const actionTimeStr = event.action_time;
|
|
89200
|
+
const actionTime = actionTimeStr ? parseInt(actionTimeStr, 10) : undefined;
|
|
89201
|
+
return {
|
|
89202
|
+
messageId,
|
|
89203
|
+
operator: {
|
|
89204
|
+
openId: operatorOpenId,
|
|
89205
|
+
userId: (_d = (_c = event.user_id) === null || _c === void 0 ? void 0 : _c.user_id) !== null && _d !== void 0 ? _d : undefined,
|
|
89206
|
+
},
|
|
89207
|
+
emojiType,
|
|
89208
|
+
action,
|
|
89209
|
+
actionTime: actionTime != null && Number.isFinite(actionTime) ? actionTime : undefined,
|
|
89210
|
+
};
|
|
89211
|
+
}
|
|
89212
|
+
|
|
89213
|
+
function normalizeBotAdded(event, opts) {
|
|
89214
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
|
|
89215
|
+
const chatId = event.chat_id;
|
|
89216
|
+
const operatorOpenId = (_a = event.operator_id) === null || _a === void 0 ? void 0 : _a.open_id;
|
|
89217
|
+
if (!chatId || !operatorOpenId)
|
|
89218
|
+
return null;
|
|
89219
|
+
const botName = (_f = (_d = (_b = event.name) !== null && _b !== void 0 ? _b : (_c = event.i18n_names) === null || _c === void 0 ? void 0 : _c.zh_cn) !== null && _d !== void 0 ? _d : (_e = event.i18n_names) === null || _e === void 0 ? void 0 : _e.en_us) !== null && _f !== void 0 ? _f : (_g = event.i18n_names) === null || _g === void 0 ? void 0 : _g.ja_jp;
|
|
89220
|
+
return {
|
|
89221
|
+
chatId,
|
|
89222
|
+
operator: {
|
|
89223
|
+
openId: operatorOpenId,
|
|
89224
|
+
userId: (_j = (_h = event.operator_id) === null || _h === void 0 ? void 0 : _h.user_id) !== null && _j !== void 0 ? _j : undefined,
|
|
89225
|
+
},
|
|
89226
|
+
botName,
|
|
89227
|
+
external: event.external,
|
|
89228
|
+
raw: (opts === null || opts === void 0 ? void 0 : opts.includeRaw) ? event : undefined,
|
|
89229
|
+
};
|
|
89230
|
+
}
|
|
89231
|
+
|
|
89232
|
+
function normalizeComment(event, opts) {
|
|
89233
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
|
|
89234
|
+
const fileToken = (_a = event.file_token) !== null && _a !== void 0 ? _a : (_b = event.notice_meta) === null || _b === void 0 ? void 0 : _b.file_token;
|
|
89235
|
+
const fileType = (_c = event.file_type) !== null && _c !== void 0 ? _c : (_d = event.notice_meta) === null || _d === void 0 ? void 0 : _d.file_type;
|
|
89236
|
+
const commentId = event.comment_id;
|
|
89237
|
+
const userId = (_f = (_e = event.notice_meta) === null || _e === void 0 ? void 0 : _e.from_user_id) !== null && _f !== void 0 ? _f : event.user_id;
|
|
89238
|
+
const operatorOpenId = userId === null || userId === void 0 ? void 0 : userId.open_id;
|
|
89239
|
+
if (!fileToken || !fileType || !commentId || !operatorOpenId)
|
|
89240
|
+
return null;
|
|
89241
|
+
const tsStr = (_j = (_g = event.create_time) !== null && _g !== void 0 ? _g : (_h = event.notice_meta) === null || _h === void 0 ? void 0 : _h.timestamp) !== null && _j !== void 0 ? _j : event.action_time;
|
|
89242
|
+
const timestamp = tsStr ? parseInt(tsStr, 10) : Date.now();
|
|
89243
|
+
return {
|
|
89244
|
+
fileToken,
|
|
89245
|
+
fileType,
|
|
89246
|
+
commentId,
|
|
89247
|
+
replyId: event.reply_id,
|
|
89248
|
+
operator: {
|
|
89249
|
+
openId: operatorOpenId,
|
|
89250
|
+
userId: (_k = userId === null || userId === void 0 ? void 0 : userId.user_id) !== null && _k !== void 0 ? _k : undefined,
|
|
89251
|
+
unionId: userId === null || userId === void 0 ? void 0 : userId.union_id,
|
|
89252
|
+
},
|
|
89253
|
+
mentionedBot: Boolean((_o = (_l = event.is_mentioned) !== null && _l !== void 0 ? _l : (_m = event.notice_meta) === null || _m === void 0 ? void 0 : _m.is_mentioned) !== null && _o !== void 0 ? _o : event.is_mention),
|
|
89254
|
+
timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(),
|
|
89255
|
+
raw: (opts === null || opts === void 0 ? void 0 : opts.includeRaw) ? event : undefined,
|
|
89256
|
+
};
|
|
89257
|
+
}
|
|
89258
|
+
|
|
89259
|
+
/**
|
|
89260
|
+
* Normalize a raw Feishu message event into a NormalizedMessage.
|
|
89261
|
+
*
|
|
89262
|
+
* Pipeline:
|
|
89263
|
+
* 1. Extract mentions → build key/openId maps + bot detection
|
|
89264
|
+
* 2. For `interactive` type, fetch full v2 card content if capability available
|
|
89265
|
+
* 3. Build ConvertContext with injected capabilities
|
|
89266
|
+
* 4. Dispatch to the matching converter (uniform error containment inside)
|
|
89267
|
+
* 5. Run resolveMentions second pass — replace placeholders with @name
|
|
89268
|
+
* 6. Assemble and return NormalizedMessage
|
|
89269
|
+
*/
|
|
89270
|
+
function normalize(event, opts) {
|
|
89271
|
+
var _a, _b, _c, _d, _e;
|
|
89272
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
89273
|
+
const msg = event.message;
|
|
89274
|
+
const botOpenId = (_a = opts.botIdentity) === null || _a === void 0 ? void 0 : _a.openId;
|
|
89275
|
+
const { mentions, mentionsByOpenId, mentionList, mentionAll: mentionAllFromRaw, mentionedBot } = extractMentions(msg.mentions, botOpenId);
|
|
89276
|
+
// Feishu frequently omits the `mentions` array even when the message
|
|
89277
|
+
// contains `@所有人` — the placeholder `@_all` stays inline in content.
|
|
89278
|
+
// Fall back to a content-level scan so policy gating (respondToMentionAll)
|
|
89279
|
+
// and downstream consumers see a truthy mentionAll in that case.
|
|
89280
|
+
const mentionAll = mentionAllFromRaw || detectMentionAllInContent(msg.content);
|
|
89281
|
+
const ctx = {
|
|
89282
|
+
messageId: msg.message_id,
|
|
89283
|
+
botOpenId,
|
|
89284
|
+
mentions,
|
|
89285
|
+
mentionsByOpenId,
|
|
89286
|
+
stripBotMentions: (_b = opts.stripBotMentions) !== null && _b !== void 0 ? _b : true,
|
|
89287
|
+
fetchSubMessages: opts.fetchSubMessages,
|
|
89288
|
+
resolveUserName: opts.resolveUserName,
|
|
89289
|
+
batchResolveNames: opts.batchResolveNames,
|
|
89290
|
+
dispatch: dispatchConvert,
|
|
89291
|
+
};
|
|
89292
|
+
const { content: rawContent, resources } = yield dispatchConvert(msg.content, msg.message_type, ctx);
|
|
89293
|
+
const content = resolveMentions(rawContent, ctx);
|
|
89294
|
+
const senderOpenId = event.sender.sender_id.open_id;
|
|
89295
|
+
const senderFallbackId = (_d = (_c = event.sender.sender_id.user_id) !== null && _c !== void 0 ? _c : event.sender.sender_id.union_id) !== null && _d !== void 0 ? _d : '';
|
|
89296
|
+
const senderId = senderOpenId !== null && senderOpenId !== void 0 ? senderOpenId : senderFallbackId;
|
|
89297
|
+
const senderName = senderOpenId ? (_e = opts.resolveSenderName) === null || _e === void 0 ? void 0 : _e.call(opts, senderOpenId) : undefined;
|
|
89298
|
+
const createMs = msg.create_time ? parseInt(msg.create_time, 10) : 0;
|
|
89299
|
+
return {
|
|
89300
|
+
messageId: msg.message_id,
|
|
89301
|
+
chatId: msg.chat_id,
|
|
89302
|
+
chatType: msg.chat_type,
|
|
89303
|
+
senderId,
|
|
89304
|
+
senderName,
|
|
89305
|
+
content,
|
|
89306
|
+
rawContentType: msg.message_type,
|
|
89307
|
+
resources,
|
|
89308
|
+
mentions: mentionList,
|
|
89309
|
+
mentionAll,
|
|
89310
|
+
mentionedBot,
|
|
89311
|
+
rootId: msg.root_id,
|
|
89312
|
+
threadId: msg.thread_id,
|
|
89313
|
+
replyToMessageId: msg.parent_id,
|
|
89314
|
+
createTime: Number.isFinite(createMs) ? createMs : 0,
|
|
89315
|
+
raw: opts.includeRaw ? event : undefined,
|
|
89316
|
+
};
|
|
89317
|
+
});
|
|
89318
|
+
}
|
|
89319
|
+
/**
|
|
89320
|
+
* Detect `@_all` placeholder inside a raw Feishu content JSON string without
|
|
89321
|
+
* parsing. We deliberately search the serialized form (not the parsed text),
|
|
89322
|
+
* because the placeholder can appear in a `text` field (text/post) or inside
|
|
89323
|
+
* nested content arrays (post). The placeholder is bounded by non-word chars
|
|
89324
|
+
* on the right (whitespace, quote, punctuation) — on the left `@` is already
|
|
89325
|
+
* a non-word char so no explicit boundary is needed.
|
|
89326
|
+
*/
|
|
89327
|
+
function detectMentionAllInContent(content) {
|
|
89328
|
+
if (!content)
|
|
89329
|
+
return false;
|
|
89330
|
+
return /@_all\b/.test(content);
|
|
89331
|
+
}
|
|
89332
|
+
|
|
89333
|
+
class LarkChannel {
|
|
89334
|
+
constructor(opts) {
|
|
89335
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
89336
|
+
this.handlers = {};
|
|
89337
|
+
this.connected = false;
|
|
89338
|
+
this.opts = opts;
|
|
89339
|
+
this.logger = new LoggerProxy((_a = opts.loggerLevel) !== null && _a !== void 0 ? _a : exports.LoggerLevel.info, (_b = opts.logger) !== null && _b !== void 0 ? _b : defaultLogger);
|
|
89340
|
+
this.rawClient = new Client({
|
|
89341
|
+
appId: opts.appId,
|
|
89342
|
+
appSecret: opts.appSecret,
|
|
89343
|
+
domain: (_c = opts.domain) !== null && _c !== void 0 ? _c : exports.Domain.Feishu,
|
|
89344
|
+
cache: opts.cache,
|
|
89345
|
+
httpInstance: opts.httpInstance,
|
|
89346
|
+
logger: opts.logger,
|
|
89347
|
+
loggerLevel: opts.loggerLevel,
|
|
89348
|
+
source: opts.source,
|
|
89349
|
+
});
|
|
89350
|
+
this.dispatcher = new EventDispatcher({
|
|
89351
|
+
verificationToken: (_d = opts.webhook) === null || _d === void 0 ? void 0 : _d.verificationToken,
|
|
89352
|
+
encryptKey: (_e = opts.webhook) === null || _e === void 0 ? void 0 : _e.encryptKey,
|
|
89353
|
+
cache: opts.cache,
|
|
89354
|
+
logger: opts.logger,
|
|
89355
|
+
loggerLevel: opts.loggerLevel,
|
|
89356
|
+
});
|
|
89357
|
+
this.sender = new OutboundSender(this.rawClient, (_f = opts.outbound) !== null && _f !== void 0 ? _f : {}, this.logger);
|
|
89358
|
+
this.safety = new SafetyPipeline({
|
|
89359
|
+
config: opts.safety,
|
|
89360
|
+
policy: opts.policy,
|
|
89361
|
+
cache: (_g = opts.cache) !== null && _g !== void 0 ? _g : internalCache,
|
|
89362
|
+
logger: this.logger,
|
|
89363
|
+
onReject: (evt) => { var _a, _b; (_b = (_a = this.handlers).reject) === null || _b === void 0 ? void 0 : _b.call(_a, evt); },
|
|
89364
|
+
onMessage: (merged) => __awaiter(this, void 0, void 0, function* () {
|
|
89365
|
+
const handler = this.handlers.message;
|
|
89366
|
+
if (handler)
|
|
89367
|
+
yield handler(merged);
|
|
89368
|
+
}),
|
|
89369
|
+
});
|
|
89370
|
+
}
|
|
89371
|
+
// ─── lifecycle ──────────────────────────────────────────
|
|
89372
|
+
connect() {
|
|
89373
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
89374
|
+
if (this.connectPromise)
|
|
89375
|
+
return this.connectPromise;
|
|
89376
|
+
this.connectPromise = this.doConnect().catch((err) => {
|
|
89377
|
+
this.connectPromise = undefined;
|
|
89378
|
+
throw err;
|
|
89379
|
+
});
|
|
89380
|
+
return this.connectPromise;
|
|
89381
|
+
});
|
|
89382
|
+
}
|
|
89383
|
+
doConnect() {
|
|
89384
|
+
var _a;
|
|
89385
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
89386
|
+
this.botIdentity = yield this.fetchBotIdentity();
|
|
89387
|
+
this.safety.setBotIdentity(this.botIdentity);
|
|
89388
|
+
this.registerDispatcherHandlers();
|
|
89389
|
+
const transport = (_a = this.opts.transport) !== null && _a !== void 0 ? _a : 'websocket';
|
|
89390
|
+
if (transport === 'websocket') {
|
|
89391
|
+
yield this.connectWebSocket(15000);
|
|
89392
|
+
}
|
|
89393
|
+
// webhook transport wiring is external: user plugs this.dispatcher into
|
|
89394
|
+
// their HTTP handler via the existing adaptor modules.
|
|
89395
|
+
this.connected = true;
|
|
89396
|
+
});
|
|
89397
|
+
}
|
|
89398
|
+
/**
|
|
89399
|
+
* Construct the underlying WSClient and wait for its `onReady` callback —
|
|
89400
|
+
* so `connect()` only resolves after the first WebSocket handshake
|
|
89401
|
+
* actually succeeds. Rejects on `onError` or if the handshake doesn't
|
|
89402
|
+
* complete within `timeoutMs`.
|
|
89403
|
+
*
|
|
89404
|
+
* Also wires `onReconnecting` / `onReconnected` callbacks to emit the
|
|
89405
|
+
* corresponding public events.
|
|
89406
|
+
*/
|
|
89407
|
+
connectWebSocket(timeoutMs) {
|
|
89408
|
+
return new Promise((resolve, reject) => {
|
|
89409
|
+
var _a;
|
|
89410
|
+
let settled = false;
|
|
89411
|
+
const timer = setTimeout(() => {
|
|
89412
|
+
if (settled)
|
|
89413
|
+
return;
|
|
89414
|
+
settled = true;
|
|
89415
|
+
reject(new LarkChannelError('not_connected', `WebSocket handshake did not complete within ${timeoutMs}ms`));
|
|
89416
|
+
}, timeoutMs);
|
|
89417
|
+
this.rawWsClient = new WSClient({
|
|
89418
|
+
appId: this.opts.appId,
|
|
89419
|
+
appSecret: this.opts.appSecret,
|
|
89420
|
+
domain: (_a = this.opts.domain) !== null && _a !== void 0 ? _a : exports.Domain.Feishu,
|
|
89421
|
+
logger: this.opts.logger,
|
|
89422
|
+
loggerLevel: this.opts.loggerLevel,
|
|
89423
|
+
httpInstance: this.opts.httpInstance,
|
|
89424
|
+
autoReconnect: true,
|
|
89425
|
+
source: this.opts.source,
|
|
89426
|
+
onReady: () => {
|
|
89427
|
+
if (settled)
|
|
89428
|
+
return;
|
|
89429
|
+
settled = true;
|
|
89430
|
+
clearTimeout(timer);
|
|
89431
|
+
resolve();
|
|
89432
|
+
},
|
|
89433
|
+
onError: (err) => {
|
|
89434
|
+
if (settled)
|
|
89435
|
+
return;
|
|
89436
|
+
settled = true;
|
|
89437
|
+
clearTimeout(timer);
|
|
89438
|
+
reject(new LarkChannelError('not_connected', `WebSocket connect failed: ${err.message}`, { cause: err }));
|
|
89439
|
+
},
|
|
89440
|
+
onReconnecting: () => { var _a, _b; return (_b = (_a = this.handlers).reconnecting) === null || _b === void 0 ? void 0 : _b.call(_a); },
|
|
89441
|
+
onReconnected: () => { var _a, _b; return (_b = (_a = this.handlers).reconnected) === null || _b === void 0 ? void 0 : _b.call(_a); },
|
|
89442
|
+
});
|
|
89443
|
+
this.rawWsClient.start({ eventDispatcher: this.dispatcher });
|
|
89444
|
+
});
|
|
89445
|
+
}
|
|
89446
|
+
disconnect() {
|
|
89447
|
+
var _a;
|
|
89448
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
89449
|
+
if (!this.connected)
|
|
89450
|
+
return;
|
|
89451
|
+
try {
|
|
89452
|
+
(_a = this.rawWsClient) === null || _a === void 0 ? void 0 : _a.close({});
|
|
89453
|
+
}
|
|
89454
|
+
catch ( /* best effort */_b) { /* best effort */ }
|
|
89455
|
+
try {
|
|
89456
|
+
yield this.safety.dispose();
|
|
89457
|
+
}
|
|
89458
|
+
catch ( /* best effort */_c) { /* best effort */ }
|
|
89459
|
+
this.connected = false;
|
|
89460
|
+
this.connectPromise = undefined;
|
|
89461
|
+
});
|
|
89462
|
+
}
|
|
89463
|
+
on(nameOrMap, handler) {
|
|
89464
|
+
if (typeof nameOrMap === 'string') {
|
|
89465
|
+
return this.attachSingle(nameOrMap, handler);
|
|
89466
|
+
}
|
|
89467
|
+
const unsubs = [];
|
|
89468
|
+
Object.keys(nameOrMap).forEach((k) => {
|
|
89469
|
+
const fn = nameOrMap[k];
|
|
89470
|
+
if (fn)
|
|
89471
|
+
unsubs.push(this.attachSingle(k, fn));
|
|
89472
|
+
});
|
|
89473
|
+
return () => unsubs.forEach((u) => u());
|
|
89474
|
+
}
|
|
89475
|
+
attachSingle(name, handler) {
|
|
89476
|
+
if (this.handlers[name]) {
|
|
89477
|
+
this.logger.warn(`channel: handler for "${name}" is being overwritten`);
|
|
89478
|
+
}
|
|
89479
|
+
this.handlers[name] = handler;
|
|
89480
|
+
return () => {
|
|
89481
|
+
if (this.handlers[name] === handler)
|
|
89482
|
+
delete this.handlers[name];
|
|
89483
|
+
};
|
|
89484
|
+
}
|
|
89485
|
+
// ─── outbound ──────────────────────────────────────────
|
|
89486
|
+
send(to, input, opts) {
|
|
89487
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
89488
|
+
return this.sender.send(to, input, opts);
|
|
89489
|
+
});
|
|
89490
|
+
}
|
|
89491
|
+
stream(to, input, opts) {
|
|
89492
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
89493
|
+
return this.sender.stream(to, input, opts);
|
|
89494
|
+
});
|
|
89495
|
+
}
|
|
89496
|
+
// ─── low-level ─────────────────────────────────────────
|
|
89497
|
+
updateCard(messageId, card) {
|
|
89498
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
89499
|
+
yield this.sender.patchCard(messageId, card);
|
|
89500
|
+
});
|
|
89501
|
+
}
|
|
89502
|
+
/**
|
|
89503
|
+
* Edit an already-sent message's text/post content. Uses `im.v1.message.update`
|
|
89504
|
+
* which (per Feishu docs) only supports editing text and rich-text (post)
|
|
89505
|
+
* messages. For cards, use {@link updateCard} instead — a wrong attempt to
|
|
89506
|
+
* use this on a card would hit the same API and fail with a clearer
|
|
89507
|
+
* Feishu-side error.
|
|
89508
|
+
*/
|
|
89509
|
+
editMessage(messageId, text) {
|
|
89510
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
89511
|
+
yield this.rawClient.im.v1.message.update({
|
|
89512
|
+
path: { message_id: messageId },
|
|
89513
|
+
data: {
|
|
89514
|
+
msg_type: 'text',
|
|
89515
|
+
content: JSON.stringify({ text }),
|
|
89516
|
+
},
|
|
89517
|
+
});
|
|
89518
|
+
});
|
|
89519
|
+
}
|
|
89520
|
+
recallMessage(messageId) {
|
|
89521
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
89522
|
+
yield this.rawClient.im.v1.message.delete({
|
|
89523
|
+
path: { message_id: messageId },
|
|
89524
|
+
});
|
|
89525
|
+
});
|
|
89526
|
+
}
|
|
89527
|
+
/**
|
|
89528
|
+
* Add an emoji reaction to a message. Returns the `reaction_id` Feishu
|
|
89529
|
+
* assigned — stash it if you want to {@link removeReaction} later,
|
|
89530
|
+
* since the raw `im.message.reaction.*_v1` events don't carry the id.
|
|
89531
|
+
* Only the bot's own reactions can be removed.
|
|
89532
|
+
*/
|
|
89533
|
+
addReaction(messageId, emojiType) {
|
|
89534
|
+
var _a, _b;
|
|
89535
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
89536
|
+
const r = yield this.rawClient.im.v1.messageReaction.create({
|
|
89537
|
+
path: { message_id: messageId },
|
|
89538
|
+
data: { reaction_type: { emoji_type: emojiType } },
|
|
89539
|
+
});
|
|
89540
|
+
const rid = (_b = (_a = r === null || r === void 0 ? void 0 : r.data) === null || _a === void 0 ? void 0 : _a.reaction_id) !== null && _b !== void 0 ? _b : r === null || r === void 0 ? void 0 : r.reaction_id;
|
|
89541
|
+
if (!rid) {
|
|
89542
|
+
throw new LarkChannelError('unknown', 'messageReaction.create returned no reaction_id');
|
|
89543
|
+
}
|
|
89544
|
+
return rid;
|
|
89545
|
+
});
|
|
89546
|
+
}
|
|
89547
|
+
/**
|
|
89548
|
+
* Remove a reaction by its `reaction_id` (the value returned from
|
|
89549
|
+
* {@link addReaction}). Only the bot's own reactions can be removed —
|
|
89550
|
+
* removing a user-added reaction will fail with a Feishu permission
|
|
89551
|
+
* error.
|
|
89552
|
+
*/
|
|
89553
|
+
removeReaction(messageId, reactionId) {
|
|
89554
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
89555
|
+
yield this.rawClient.im.v1.messageReaction.delete({
|
|
89556
|
+
path: { message_id: messageId, reaction_id: reactionId },
|
|
89557
|
+
});
|
|
89558
|
+
});
|
|
89559
|
+
}
|
|
89560
|
+
/**
|
|
89561
|
+
* Convenience: remove the bot's reaction on `messageId` matching
|
|
89562
|
+
* `emojiType`, without needing the `reaction_id`. Lists the message's
|
|
89563
|
+
* reactions filtered by emoji, picks the one added by this bot
|
|
89564
|
+
* (operator_type === 'app'), and deletes it. Returns `true` if a
|
|
89565
|
+
* matching reaction was found and deleted, `false` otherwise (including
|
|
89566
|
+
* the case where the bot never added that emoji).
|
|
89567
|
+
*/
|
|
89568
|
+
removeReactionByEmoji(messageId, emojiType) {
|
|
89569
|
+
var _a, _b;
|
|
89570
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
89571
|
+
const r = yield this.rawClient.im.v1.messageReaction.list({
|
|
89572
|
+
path: { message_id: messageId },
|
|
89573
|
+
params: { reaction_type: emojiType, page_size: 50 },
|
|
89574
|
+
});
|
|
89575
|
+
const items = (_b = (_a = r === null || r === void 0 ? void 0 : r.data) === null || _a === void 0 ? void 0 : _a.items) !== null && _b !== void 0 ? _b : [];
|
|
89576
|
+
const mine = items.find((it) => { var _a; return ((_a = it.operator) === null || _a === void 0 ? void 0 : _a.operator_type) === 'app'; });
|
|
89577
|
+
if (!(mine === null || mine === void 0 ? void 0 : mine.reaction_id))
|
|
89578
|
+
return false;
|
|
89579
|
+
yield this.removeReaction(messageId, mine.reaction_id);
|
|
89580
|
+
return true;
|
|
89581
|
+
});
|
|
89582
|
+
}
|
|
89583
|
+
downloadResource(fileKey, type) {
|
|
89584
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
89585
|
+
if (type === 'image') {
|
|
89586
|
+
const r = yield this.rawClient.im.v1.image.get({
|
|
89587
|
+
path: { image_key: fileKey },
|
|
89588
|
+
});
|
|
89589
|
+
return yield bufferFromStream(r);
|
|
89590
|
+
}
|
|
89591
|
+
const r = yield this.rawClient.im.v1.file.get({
|
|
89592
|
+
path: { file_key: fileKey },
|
|
89593
|
+
});
|
|
89594
|
+
return yield bufferFromStream(r);
|
|
89595
|
+
});
|
|
89596
|
+
}
|
|
89597
|
+
getChatInfo(chatId) {
|
|
89598
|
+
var _a, _b;
|
|
89599
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
89600
|
+
const r = yield this.rawClient.im.v1.chat.get({
|
|
89601
|
+
path: { chat_id: chatId },
|
|
89602
|
+
});
|
|
89603
|
+
const d = (_a = r.data) !== null && _a !== void 0 ? _a : {};
|
|
89604
|
+
return {
|
|
89605
|
+
chatId,
|
|
89606
|
+
name: d.name,
|
|
89607
|
+
description: d.description,
|
|
89608
|
+
chatType: (_b = d.chat_mode) !== null && _b !== void 0 ? _b : 'group',
|
|
89609
|
+
ownerId: d.owner_id,
|
|
89610
|
+
memberCount: d.user_count,
|
|
89611
|
+
};
|
|
89612
|
+
});
|
|
89613
|
+
}
|
|
89614
|
+
// ─── runtime config ────────────────────────────────────
|
|
89615
|
+
updatePolicy(partial) {
|
|
89616
|
+
this.safety.updatePolicy(partial);
|
|
89617
|
+
}
|
|
89618
|
+
getPolicy() {
|
|
89619
|
+
return this.safety.getPolicy();
|
|
89620
|
+
}
|
|
89621
|
+
// ─── internals: bot identity & dispatch wiring ────────
|
|
89622
|
+
fetchBotIdentity() {
|
|
89623
|
+
var _a;
|
|
89624
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
89625
|
+
// Standard Feishu API: GET /open-apis/bot/v3/info
|
|
89626
|
+
// Returns: { code, msg, bot: { open_id, app_name, avatar_url, ... } }
|
|
89627
|
+
let lastError;
|
|
89628
|
+
try {
|
|
89629
|
+
const r = yield this.rawClient.request({
|
|
89630
|
+
url: '/open-apis/bot/v3/info',
|
|
89631
|
+
method: 'GET',
|
|
89632
|
+
});
|
|
89633
|
+
const bot = r.bot;
|
|
89634
|
+
if (bot === null || bot === void 0 ? void 0 : bot.open_id) {
|
|
89635
|
+
return { openId: bot.open_id, name: (_a = bot.app_name) !== null && _a !== void 0 ? _a : 'bot' };
|
|
89636
|
+
}
|
|
89637
|
+
lastError = new Error(`bot/v3/info response missing open_id: ${JSON.stringify(r).slice(0, 200)}`);
|
|
89638
|
+
}
|
|
89639
|
+
catch (e) {
|
|
89640
|
+
lastError = e;
|
|
89641
|
+
}
|
|
89642
|
+
// Let the shared error classifier decide: 401/403 / feishu auth codes
|
|
89643
|
+
// → permission_denied; rate_limited / send_timeout pass through;
|
|
89644
|
+
// everything else falls back to `not_connected` (the genuine
|
|
89645
|
+
// "couldn't reach the API" bucket). Without this, all connect
|
|
89646
|
+
// failures collapse to `not_connected`, making auth errors
|
|
89647
|
+
// indistinguishable from network errors.
|
|
89648
|
+
const classified = classifyError(lastError);
|
|
89649
|
+
const code = classified.code === 'unknown' ? 'not_connected' : classified.code;
|
|
89650
|
+
throw new LarkChannelError(code, 'could not resolve bot identity via /open-apis/bot/v3/info — required for channel to function', { cause: lastError });
|
|
89651
|
+
});
|
|
89652
|
+
}
|
|
89653
|
+
registerDispatcherHandlers() {
|
|
89654
|
+
// `im.v1.message.get(mid)` on a merge_forward message returns
|
|
89655
|
+
// `data.items[]` as a flat list: the parent message first (no
|
|
89656
|
+
// `upper_message_id`) followed by every descendant, each with
|
|
89657
|
+
// `upper_message_id` pointing at its direct parent. That is
|
|
89658
|
+
// exactly what `convertMergeForward` / `buildChildrenMap` consume,
|
|
89659
|
+
// so the converter tree-builds correctly without further work.
|
|
89660
|
+
// (Earlier attempts used `message.list` with
|
|
89661
|
+
// `container_id_type: 'message'`, which Feishu rejects — 'message'
|
|
89662
|
+
// isn't a valid container type.)
|
|
89663
|
+
const fetchSubMessages = (mid) => __awaiter(this, void 0, void 0, function* () {
|
|
89664
|
+
var _a, _b, _c, _d;
|
|
89665
|
+
try {
|
|
89666
|
+
const r = yield this.rawClient.im.v1.message.get({
|
|
89667
|
+
path: { message_id: mid },
|
|
89668
|
+
});
|
|
89669
|
+
const items = (_b = (_a = r.data) === null || _a === void 0 ? void 0 : _a.items) !== null && _b !== void 0 ? _b : [];
|
|
89670
|
+
return items;
|
|
89671
|
+
}
|
|
89672
|
+
catch (e) {
|
|
89673
|
+
(_d = (_c = this.logger).warn) === null || _d === void 0 ? void 0 : _d.call(_c, 'channel: fetchSubMessages failed', e);
|
|
89674
|
+
return [];
|
|
89675
|
+
}
|
|
89676
|
+
});
|
|
89677
|
+
const normalizeOpts = {
|
|
89678
|
+
botIdentity: this.botIdentity,
|
|
89679
|
+
stripBotMentions: true,
|
|
89680
|
+
includeRaw: this.opts.includeRawInMessage,
|
|
89681
|
+
fetchSubMessages,
|
|
89682
|
+
};
|
|
89683
|
+
this.dispatcher.register({
|
|
89684
|
+
// IM message — full safety pipeline
|
|
89685
|
+
'im.message.receive_v1': (raw) => __awaiter(this, void 0, void 0, function* () {
|
|
89686
|
+
try {
|
|
89687
|
+
const msg = yield normalize(raw, normalizeOpts);
|
|
89688
|
+
yield this.safety.pushMessage(msg);
|
|
89689
|
+
}
|
|
89690
|
+
catch (e) {
|
|
89691
|
+
this.emitError(e);
|
|
89692
|
+
}
|
|
89693
|
+
}),
|
|
89694
|
+
// Card button click — dedup + lock + queue (by chatId).
|
|
89695
|
+
// The key includes the action's identity (tag + value) so that
|
|
89696
|
+
// different buttons on the same card by the same user are NOT
|
|
89697
|
+
// collapsed by the dedup cache. A genuine Feishu re-delivery
|
|
89698
|
+
// of the same click still hashes to the same key.
|
|
89699
|
+
'card.action.trigger': (raw) => __awaiter(this, void 0, void 0, function* () {
|
|
89700
|
+
const evt = normalizeCardAction(raw);
|
|
89701
|
+
if (!evt)
|
|
89702
|
+
return;
|
|
89703
|
+
const actionId = cardActionId(evt.action);
|
|
89704
|
+
yield this.safety.pushAction(`card:${evt.messageId}:${evt.operator.openId}:${actionId}`, evt.chatId, () => __awaiter(this, void 0, void 0, function* () {
|
|
89705
|
+
const h = this.handlers.cardAction;
|
|
89706
|
+
if (h)
|
|
89707
|
+
yield h(evt);
|
|
89708
|
+
}));
|
|
89709
|
+
}),
|
|
89710
|
+
// Reactions — dedup only
|
|
89711
|
+
'im.message.reaction.created_v1': (raw) => __awaiter(this, void 0, void 0, function* () {
|
|
89712
|
+
const evt = normalizeReaction(raw, 'added');
|
|
89713
|
+
if (!evt)
|
|
89714
|
+
return;
|
|
89715
|
+
const key = reactionKey(evt);
|
|
89716
|
+
yield this.safety.pushLight(key, () => { var _a, _b; return (_b = (_a = this.handlers).reaction) === null || _b === void 0 ? void 0 : _b.call(_a, evt); });
|
|
89717
|
+
}),
|
|
89718
|
+
'im.message.reaction.deleted_v1': (raw) => __awaiter(this, void 0, void 0, function* () {
|
|
89719
|
+
const evt = normalizeReaction(raw, 'removed');
|
|
89720
|
+
if (!evt)
|
|
89721
|
+
return;
|
|
89722
|
+
const key = reactionKey(evt);
|
|
89723
|
+
yield this.safety.pushLight(key, () => { var _a, _b; return (_b = (_a = this.handlers).reaction) === null || _b === void 0 ? void 0 : _b.call(_a, evt); });
|
|
89724
|
+
}),
|
|
89725
|
+
// Bot added — direct fire, no safety
|
|
89726
|
+
'im.chat.member.bot.added_v1': (raw) => {
|
|
89727
|
+
var _a, _b;
|
|
89728
|
+
const evt = normalizeBotAdded(raw, {
|
|
89729
|
+
includeRaw: this.opts.includeRawInMessage,
|
|
89730
|
+
});
|
|
89731
|
+
if (!evt)
|
|
89732
|
+
return;
|
|
89733
|
+
try {
|
|
89734
|
+
(_b = (_a = this.handlers).botAdded) === null || _b === void 0 ? void 0 : _b.call(_a, evt);
|
|
89735
|
+
}
|
|
89736
|
+
catch (e) {
|
|
89737
|
+
this.emitError(e);
|
|
89738
|
+
}
|
|
89739
|
+
},
|
|
89740
|
+
// Drive comments — dedup + lock + queue (by fileToken)
|
|
89741
|
+
'drive.notice.comment_add_v1': (raw) => __awaiter(this, void 0, void 0, function* () {
|
|
89742
|
+
const evt = normalizeComment(raw, {
|
|
89743
|
+
includeRaw: this.opts.includeRawInMessage,
|
|
89744
|
+
});
|
|
89745
|
+
if (!evt)
|
|
89746
|
+
return;
|
|
89747
|
+
yield this.safety.pushAction(`comment:${evt.fileToken}:${evt.commentId}`, evt.fileToken, () => __awaiter(this, void 0, void 0, function* () {
|
|
89748
|
+
const h = this.handlers.comment;
|
|
89749
|
+
if (h)
|
|
89750
|
+
yield h(evt);
|
|
89751
|
+
}));
|
|
89752
|
+
}),
|
|
89753
|
+
});
|
|
89754
|
+
}
|
|
89755
|
+
emitError(e) {
|
|
89756
|
+
var _a, _b, _c;
|
|
89757
|
+
const err = e instanceof LarkChannelError
|
|
89758
|
+
? e
|
|
89759
|
+
: new LarkChannelError('unknown', String((_a = e === null || e === void 0 ? void 0 : e.message) !== null && _a !== void 0 ? _a : e), { cause: e });
|
|
89760
|
+
const handler = this.handlers.error;
|
|
89761
|
+
if (handler)
|
|
89762
|
+
handler(err);
|
|
89763
|
+
else
|
|
89764
|
+
(_c = (_b = this.logger).error) === null || _c === void 0 ? void 0 : _c.call(_b, 'channel: unhandled error', err);
|
|
89765
|
+
}
|
|
89766
|
+
}
|
|
89767
|
+
function createLarkChannel(opts) {
|
|
89768
|
+
return new LarkChannel(opts);
|
|
89769
|
+
}
|
|
89770
|
+
function bufferFromStream(raw) {
|
|
89771
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
89772
|
+
if (Buffer.isBuffer(raw))
|
|
89773
|
+
return raw;
|
|
89774
|
+
if (raw instanceof Uint8Array)
|
|
89775
|
+
return Buffer.from(raw);
|
|
89776
|
+
if (typeof raw === 'object' && raw !== null) {
|
|
89777
|
+
const r = raw;
|
|
89778
|
+
// The code-gen download endpoints (im.v1.image.get / im.v1.file.get)
|
|
89779
|
+
// return a wrapper object `{ writeFile, getReadableStream, headers }`
|
|
89780
|
+
// where the body is exposed as a stream. Consume it into a Buffer.
|
|
89781
|
+
if (typeof r.getReadableStream === 'function') {
|
|
89782
|
+
return yield readableToBuffer(r.getReadableStream());
|
|
89783
|
+
}
|
|
89784
|
+
if (Buffer.isBuffer(r.data))
|
|
89785
|
+
return r.data;
|
|
89786
|
+
if (r.data instanceof Uint8Array)
|
|
89787
|
+
return Buffer.from(r.data);
|
|
89788
|
+
}
|
|
89789
|
+
throw new LarkChannelError('unknown', 'unexpected download response type');
|
|
89790
|
+
});
|
|
89791
|
+
}
|
|
89792
|
+
function readableToBuffer(stream) {
|
|
89793
|
+
return new Promise((resolve, reject) => {
|
|
89794
|
+
const chunks = [];
|
|
89795
|
+
stream.on('data', (chunk) => {
|
|
89796
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
89797
|
+
});
|
|
89798
|
+
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
|
89799
|
+
stream.on('error', reject);
|
|
89800
|
+
});
|
|
89801
|
+
}
|
|
89802
|
+
function reactionKey(evt) {
|
|
89803
|
+
var _a;
|
|
89804
|
+
return `rx:${evt.messageId}:${evt.operator.openId}:${evt.emojiType}:${evt.action}:${(_a = evt.actionTime) !== null && _a !== void 0 ? _a : 0}`;
|
|
89805
|
+
}
|
|
89806
|
+
/**
|
|
89807
|
+
* Build a stable identity for a card action event's button/element, so that
|
|
89808
|
+
* different clicks on the same card by the same user dedup independently.
|
|
89809
|
+
* `tag` plus serialized `value` is enough to tell buttons apart; `name` and
|
|
89810
|
+
* `option` are rolled in for form-style interactions where the same value
|
|
89811
|
+
* may repeat but the triggering element differs. The serialized payload is
|
|
89812
|
+
* length-clamped to keep cache keys small.
|
|
89813
|
+
*/
|
|
89814
|
+
function cardActionId(action) {
|
|
89815
|
+
var _a, _b, _c;
|
|
89816
|
+
const serialized = typeof action.value === 'string' ? action.value : JSON.stringify((_a = action.value) !== null && _a !== void 0 ? _a : '');
|
|
89817
|
+
const valuePart = serialized.length > 128 ? serialized.slice(0, 128) : serialized;
|
|
89818
|
+
return `${action.tag}|${(_b = action.name) !== null && _b !== void 0 ? _b : ''}|${(_c = action.option) !== null && _c !== void 0 ? _c : ''}|${valuePart}`;
|
|
89819
|
+
}
|
|
89820
|
+
|
|
86061
89821
|
exports.AESCipher = AESCipher;
|
|
86062
89822
|
exports.Aily = Aily;
|
|
86063
89823
|
exports.CAppTicket = CAppTicket;
|
|
@@ -86065,14 +89825,22 @@ exports.CTenantAccessToken = CTenantAccessToken;
|
|
|
86065
89825
|
exports.CardActionHandler = CardActionHandler;
|
|
86066
89826
|
exports.Client = Client;
|
|
86067
89827
|
exports.EventDispatcher = EventDispatcher;
|
|
89828
|
+
exports.LarkChannel = LarkChannel;
|
|
89829
|
+
exports.LarkChannelError = LarkChannelError;
|
|
86068
89830
|
exports.WSClient = WSClient;
|
|
86069
89831
|
exports.adaptDefault = adaptDefault;
|
|
86070
89832
|
exports.adaptExpress = adaptExpress;
|
|
86071
89833
|
exports.adaptKoa = adaptKoa;
|
|
86072
89834
|
exports.adaptKoaRouter = adaptKoaRouter;
|
|
89835
|
+
exports.createLarkChannel = createLarkChannel;
|
|
86073
89836
|
exports.defaultHttpInstance = defaultHttpInstance;
|
|
86074
89837
|
exports.generateChallenge = generateChallenge;
|
|
86075
89838
|
exports.messageCard = messageCard;
|
|
89839
|
+
exports.normalize = normalize;
|
|
89840
|
+
exports.normalizeBotAdded = normalizeBotAdded;
|
|
89841
|
+
exports.normalizeCardAction = normalizeCardAction;
|
|
89842
|
+
exports.normalizeComment = normalizeComment;
|
|
89843
|
+
exports.normalizeReaction = normalizeReaction;
|
|
86076
89844
|
exports.registerApp = registerApp;
|
|
86077
89845
|
exports.withAll = withAll;
|
|
86078
89846
|
exports.withHelpDeskCredential = withHelpDeskCredential;
|