@larksuiteoapi/node-sdk 1.61.1 → 1.62.1

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