@larksuiteoapi/node-sdk 1.61.0 → 1.62.0

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