@larksuiteoapi/node-sdk 1.61.1 → 1.62.1

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