chat 4.20.2 → 4.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -131,7 +131,7 @@ async function toAiMessages(messages, options) {
131
131
  filtered.map(async (msg) => {
132
132
  const role = msg.author.isMe ? "assistant" : "user";
133
133
  let textContent = includeNames && role === "user" ? `[${msg.author.userName}]: ${msg.text}` : msg.text;
134
- if (msg.links.length > 0) {
134
+ if (msg.links && msg.links.length > 0) {
135
135
  const linkParts = msg.links.map((link2) => {
136
136
  const parts = link2.fetchMessage ? [`[Embedded message: ${link2.url}]`] : [link2.url];
137
137
  if (link2.title) {
@@ -153,7 +153,7 @@ ${linkParts}`;
153
153
  let aiMessage;
154
154
  if (role === "user") {
155
155
  const attachmentParts = [];
156
- for (const att of msg.attachments) {
156
+ for (const att of msg.attachments ?? []) {
157
157
  const part = await attachmentToPart(att);
158
158
  if (part) {
159
159
  attachmentParts.push(part);
@@ -236,7 +236,7 @@ async function* fromFullStream(stream) {
236
236
  needsSeparator = false;
237
237
  hasEmittedText = true;
238
238
  yield textContent;
239
- } else if (typed.type === "step-finish") {
239
+ } else if (typed.type === "finish-step") {
240
240
  needsSeparator = true;
241
241
  }
242
242
  }
@@ -1766,6 +1766,9 @@ function extractMessageContent2(message) {
1766
1766
 
1767
1767
  // src/chat.ts
1768
1768
  var DEFAULT_LOCK_TTL_MS = 3e4;
1769
+ function sleep(ms) {
1770
+ return new Promise((resolve) => setTimeout(resolve, ms));
1771
+ }
1769
1772
  var SLACK_USER_ID_REGEX = /^U[A-Z0-9]+$/i;
1770
1773
  var DISCORD_SNOWFLAKE_REGEX = /^\d{17,19}$/;
1771
1774
  var DEDUPE_TTL_MS = 5 * 60 * 1e3;
@@ -1810,6 +1813,9 @@ var Chat = class {
1810
1813
  _dedupeTtlMs;
1811
1814
  _onLockConflict;
1812
1815
  _messageHistory;
1816
+ _concurrencyStrategy;
1817
+ _concurrencyConfig;
1818
+ _lockScope;
1813
1819
  mentionHandlers = [];
1814
1820
  directMessageHandlers = [];
1815
1821
  messagePatterns = [];
@@ -1840,6 +1846,38 @@ var Chat = class {
1840
1846
  this._fallbackStreamingPlaceholderText = config.fallbackStreamingPlaceholderText !== void 0 ? config.fallbackStreamingPlaceholderText : "...";
1841
1847
  this._dedupeTtlMs = config.dedupeTtlMs ?? DEDUPE_TTL_MS;
1842
1848
  this._onLockConflict = config.onLockConflict;
1849
+ this._lockScope = config.lockScope;
1850
+ const concurrency = config.concurrency;
1851
+ if (concurrency) {
1852
+ if (typeof concurrency === "string") {
1853
+ this._concurrencyStrategy = concurrency;
1854
+ this._concurrencyConfig = {
1855
+ debounceMs: 1500,
1856
+ maxConcurrent: Number.POSITIVE_INFINITY,
1857
+ maxQueueSize: 10,
1858
+ onQueueFull: "drop-oldest",
1859
+ queueEntryTtlMs: 9e4
1860
+ };
1861
+ } else {
1862
+ this._concurrencyStrategy = concurrency.strategy;
1863
+ this._concurrencyConfig = {
1864
+ debounceMs: concurrency.debounceMs ?? 1500,
1865
+ maxConcurrent: concurrency.maxConcurrent ?? Number.POSITIVE_INFINITY,
1866
+ maxQueueSize: concurrency.maxQueueSize ?? 10,
1867
+ onQueueFull: concurrency.onQueueFull ?? "drop-oldest",
1868
+ queueEntryTtlMs: concurrency.queueEntryTtlMs ?? 9e4
1869
+ };
1870
+ }
1871
+ } else {
1872
+ this._concurrencyStrategy = "drop";
1873
+ this._concurrencyConfig = {
1874
+ debounceMs: 1500,
1875
+ maxConcurrent: Number.POSITIVE_INFINITY,
1876
+ maxQueueSize: 10,
1877
+ onQueueFull: "drop-oldest",
1878
+ queueEntryTtlMs: 9e4
1879
+ };
1880
+ }
1843
1881
  this._messageHistory = new MessageHistoryCache(
1844
1882
  this._stateAdapter,
1845
1883
  config.messageHistory
@@ -1907,6 +1945,22 @@ var Chat = class {
1907
1945
  */
1908
1946
  async shutdown() {
1909
1947
  this.logger.info("Shutting down chat instance...");
1948
+ const shutdownPromises = Array.from(this.adapters.values()).map(
1949
+ async (adapter) => {
1950
+ if (!adapter.disconnect) {
1951
+ return;
1952
+ }
1953
+ this.logger.debug("Disconnecting adapter", adapter.name);
1954
+ await adapter.disconnect();
1955
+ this.logger.debug("Adapter disconnected", adapter.name);
1956
+ }
1957
+ );
1958
+ const results = await Promise.allSettled(shutdownPromises);
1959
+ for (const result of results) {
1960
+ if (result.status === "rejected") {
1961
+ this.logger.error("Adapter disconnect failed", result.reason);
1962
+ }
1963
+ }
1910
1964
  await this._stateAdapter.disconnect();
1911
1965
  this.initialized = false;
1912
1966
  this.initPromise = null;
@@ -2755,6 +2809,27 @@ var Chat = class {
2755
2809
  "UNKNOWN_USER_ID_FORMAT"
2756
2810
  );
2757
2811
  }
2812
+ /**
2813
+ * Resolve the lock key for a message based on lock scope.
2814
+ * With 'thread' scope, returns threadId. With 'channel' scope,
2815
+ * returns channelId (derived via adapter.channelIdFromThreadId).
2816
+ */
2817
+ async getLockKey(adapter, threadId) {
2818
+ const channelId = adapter.channelIdFromThreadId(threadId);
2819
+ let scope;
2820
+ if (typeof this._lockScope === "function") {
2821
+ const isDM = adapter.isDM?.(threadId) ?? false;
2822
+ scope = await this._lockScope({
2823
+ adapter,
2824
+ channelId,
2825
+ isDM,
2826
+ threadId
2827
+ });
2828
+ } else {
2829
+ scope = this._lockScope ?? adapter.lockScope ?? "thread";
2830
+ }
2831
+ return scope === "channel" ? channelId : threadId;
2832
+ }
2758
2833
  /**
2759
2834
  * Handle an incoming message from an adapter.
2760
2835
  * This is called by adapters when they receive a webhook.
@@ -2763,7 +2838,7 @@ var Chat = class {
2763
2838
  * - Deduplication: Same message may arrive multiple times (e.g., Slack sends
2764
2839
  * both `message` and `app_mention` events, GChat sends direct webhook + Pub/Sub)
2765
2840
  * - Bot filtering: Messages from the bot itself are skipped
2766
- * - Locking: Only one instance processes a thread at a time
2841
+ * - Concurrency: Controlled by `concurrency` config (drop, queue, debounce, concurrent)
2767
2842
  */
2768
2843
  async handleIncomingMessage(adapter, threadId, message) {
2769
2844
  this.logger.debug("Incoming message", {
@@ -2805,103 +2880,312 @@ var Chat = class {
2805
2880
  }
2806
2881
  await Promise.all(appends);
2807
2882
  }
2883
+ const lockKey = await this.getLockKey(adapter, threadId);
2884
+ const strategy = this._concurrencyStrategy;
2885
+ if (strategy === "concurrent") {
2886
+ await this.handleConcurrent(adapter, threadId, message);
2887
+ return;
2888
+ }
2889
+ if (strategy === "queue" || strategy === "debounce") {
2890
+ await this.handleQueueOrDebounce(
2891
+ adapter,
2892
+ threadId,
2893
+ lockKey,
2894
+ message,
2895
+ strategy
2896
+ );
2897
+ return;
2898
+ }
2899
+ await this.handleDrop(adapter, threadId, lockKey, message);
2900
+ }
2901
+ /**
2902
+ * Drop strategy: acquire lock or fail. Original behavior.
2903
+ */
2904
+ async handleDrop(adapter, threadId, lockKey, message) {
2808
2905
  let lock = await this._stateAdapter.acquireLock(
2809
- threadId,
2906
+ lockKey,
2810
2907
  DEFAULT_LOCK_TTL_MS
2811
2908
  );
2812
2909
  if (!lock) {
2813
2910
  const resolution = typeof this._onLockConflict === "function" ? await this._onLockConflict(threadId, message) : this._onLockConflict ?? "drop";
2814
2911
  if (resolution === "force") {
2815
- this.logger.info("Force-releasing lock on thread", { threadId });
2816
- await this._stateAdapter.forceReleaseLock(threadId);
2817
- lock = await this._stateAdapter.acquireLock(
2912
+ this.logger.info("Force-releasing lock on thread", {
2818
2913
  threadId,
2914
+ lockKey
2915
+ });
2916
+ await this._stateAdapter.forceReleaseLock(lockKey);
2917
+ lock = await this._stateAdapter.acquireLock(
2918
+ lockKey,
2819
2919
  DEFAULT_LOCK_TTL_MS
2820
2920
  );
2821
2921
  }
2822
2922
  if (!lock) {
2823
- this.logger.warn("Could not acquire lock on thread", { threadId });
2923
+ this.logger.warn("Could not acquire lock on thread", {
2924
+ threadId,
2925
+ lockKey
2926
+ });
2824
2927
  throw new LockError(
2825
2928
  `Could not acquire lock on thread ${threadId}. Another instance may be processing.`
2826
2929
  );
2827
2930
  }
2828
2931
  }
2829
- this.logger.debug("Lock acquired", { threadId, token: lock.token });
2932
+ this.logger.debug("Lock acquired", {
2933
+ threadId,
2934
+ lockKey,
2935
+ token: lock.token
2936
+ });
2830
2937
  try {
2831
- message.isMention = message.isMention || this.detectMention(adapter, message);
2832
- const isSubscribed = await this._stateAdapter.isSubscribed(threadId);
2833
- this.logger.debug("Subscription check", {
2834
- threadId,
2835
- isSubscribed,
2836
- subscribedHandlerCount: this.subscribedMessageHandlers.length
2837
- });
2838
- const thread = await this.createThread(
2839
- adapter,
2840
- threadId,
2841
- message,
2842
- isSubscribed
2843
- );
2844
- const isDM = adapter.isDM?.(threadId) ?? false;
2845
- if (isDM && this.directMessageHandlers.length > 0) {
2846
- this.logger.debug("Direct message received - calling handlers", {
2938
+ await this.dispatchToHandlers(adapter, threadId, message);
2939
+ } finally {
2940
+ await this._stateAdapter.releaseLock(lock);
2941
+ this.logger.debug("Lock released", { threadId, lockKey });
2942
+ }
2943
+ }
2944
+ /**
2945
+ * Queue/Debounce strategy: enqueue if lock is busy, drain after processing.
2946
+ */
2947
+ async handleQueueOrDebounce(adapter, threadId, lockKey, message, strategy) {
2948
+ const { maxQueueSize, queueEntryTtlMs, onQueueFull, debounceMs } = this._concurrencyConfig;
2949
+ const lock = await this._stateAdapter.acquireLock(
2950
+ lockKey,
2951
+ DEFAULT_LOCK_TTL_MS
2952
+ );
2953
+ if (!lock) {
2954
+ const effectiveMaxSize = strategy === "debounce" ? 1 : maxQueueSize;
2955
+ const depth = await this._stateAdapter.queueDepth(lockKey);
2956
+ if (depth >= effectiveMaxSize && strategy !== "debounce" && onQueueFull === "drop-newest") {
2957
+ this.logger.info("message-dropped", {
2847
2958
  threadId,
2848
- handlerCount: this.directMessageHandlers.length
2959
+ lockKey,
2960
+ messageId: message.id,
2961
+ reason: "queue-full"
2849
2962
  });
2850
- const channel = thread.channel;
2851
- for (const handler of this.directMessageHandlers) {
2852
- await handler(thread, message, channel);
2853
- }
2854
2963
  return;
2855
2964
  }
2856
- if (isDM) {
2857
- message.isMention = true;
2965
+ await this._stateAdapter.enqueue(
2966
+ lockKey,
2967
+ {
2968
+ message,
2969
+ enqueuedAt: Date.now(),
2970
+ expiresAt: Date.now() + queueEntryTtlMs
2971
+ },
2972
+ effectiveMaxSize
2973
+ );
2974
+ this.logger.info(
2975
+ strategy === "debounce" ? "message-debounce-reset" : "message-queued",
2976
+ {
2977
+ threadId,
2978
+ lockKey,
2979
+ messageId: message.id,
2980
+ queueDepth: Math.min(depth + 1, effectiveMaxSize)
2981
+ }
2982
+ );
2983
+ return;
2984
+ }
2985
+ this.logger.debug("Lock acquired", {
2986
+ threadId,
2987
+ lockKey,
2988
+ token: lock.token
2989
+ });
2990
+ try {
2991
+ if (strategy === "debounce") {
2992
+ await this._stateAdapter.enqueue(
2993
+ lockKey,
2994
+ {
2995
+ message,
2996
+ enqueuedAt: Date.now(),
2997
+ expiresAt: Date.now() + queueEntryTtlMs
2998
+ },
2999
+ 1
3000
+ );
3001
+ this.logger.info("message-debouncing", {
3002
+ threadId,
3003
+ lockKey,
3004
+ messageId: message.id,
3005
+ debounceMs
3006
+ });
3007
+ await this.debounceLoop(lock, adapter, threadId, lockKey);
3008
+ } else {
3009
+ await this.dispatchToHandlers(adapter, threadId, message);
3010
+ await this.drainQueue(lock, adapter, threadId, lockKey);
2858
3011
  }
2859
- if (isSubscribed) {
2860
- this.logger.debug("Message in subscribed thread - calling handlers", {
3012
+ } finally {
3013
+ await this._stateAdapter.releaseLock(lock);
3014
+ this.logger.debug("Lock released", { threadId, lockKey });
3015
+ }
3016
+ }
3017
+ /**
3018
+ * Debounce loop: wait for debounceMs, check if newer message arrived,
3019
+ * repeat until no new messages, then process the final message.
3020
+ */
3021
+ async debounceLoop(lock, adapter, threadId, lockKey) {
3022
+ const { debounceMs } = this._concurrencyConfig;
3023
+ while (true) {
3024
+ await sleep(debounceMs);
3025
+ await this._stateAdapter.extendLock(lock, DEFAULT_LOCK_TTL_MS);
3026
+ const entry = await this._stateAdapter.dequeue(lockKey);
3027
+ if (!entry) {
3028
+ break;
3029
+ }
3030
+ const msg = this.rehydrateMessage(entry.message);
3031
+ if (Date.now() > entry.expiresAt) {
3032
+ this.logger.info("message-expired", {
2861
3033
  threadId,
2862
- handlerCount: this.subscribedMessageHandlers.length
3034
+ lockKey,
3035
+ messageId: msg.id
2863
3036
  });
2864
- await this.runHandlers(this.subscribedMessageHandlers, thread, message);
2865
- return;
3037
+ continue;
2866
3038
  }
2867
- if (message.isMention) {
2868
- this.logger.debug("Bot mentioned", {
3039
+ const depth = await this._stateAdapter.queueDepth(lockKey);
3040
+ if (depth > 0) {
3041
+ this.logger.info("message-superseded", {
2869
3042
  threadId,
2870
- text: message.text.slice(0, 100)
3043
+ lockKey,
3044
+ droppedId: msg.id
2871
3045
  });
2872
- await this.runHandlers(this.mentionHandlers, thread, message);
2873
- return;
3046
+ continue;
2874
3047
  }
2875
- this.logger.debug("Checking message patterns", {
2876
- patternCount: this.messagePatterns.length,
2877
- patterns: this.messagePatterns.map((p) => p.pattern.toString()),
2878
- messageText: message.text
3048
+ this.logger.info("message-dequeued", {
3049
+ threadId,
3050
+ lockKey,
3051
+ messageId: msg.id
2879
3052
  });
2880
- let matchedPattern = false;
2881
- for (const { pattern, handler } of this.messagePatterns) {
2882
- const matches = pattern.test(message.text);
2883
- this.logger.debug("Pattern test", {
2884
- pattern: pattern.toString(),
2885
- text: message.text,
2886
- matches
2887
- });
2888
- if (matches) {
2889
- this.logger.debug("Message matched pattern - calling handler", {
2890
- pattern: pattern.toString()
3053
+ await this.dispatchToHandlers(adapter, threadId, msg);
3054
+ break;
3055
+ }
3056
+ }
3057
+ /**
3058
+ * Drain queue: collect all pending messages, dispatch the latest with
3059
+ * skipped context, then check for more.
3060
+ */
3061
+ async drainQueue(lock, adapter, threadId, lockKey) {
3062
+ while (true) {
3063
+ const pending = [];
3064
+ while (true) {
3065
+ const entry = await this._stateAdapter.dequeue(lockKey);
3066
+ if (!entry) {
3067
+ break;
3068
+ }
3069
+ const msg = this.rehydrateMessage(entry.message);
3070
+ if (Date.now() <= entry.expiresAt) {
3071
+ pending.push({ message: msg, expiresAt: entry.expiresAt });
3072
+ } else {
3073
+ this.logger.info("message-expired", {
3074
+ threadId,
3075
+ lockKey,
3076
+ messageId: msg.id
2891
3077
  });
2892
- matchedPattern = true;
2893
- await handler(thread, message);
2894
3078
  }
2895
3079
  }
2896
- if (!matchedPattern) {
2897
- this.logger.debug("No handlers matched message", {
2898
- threadId,
2899
- text: message.text.slice(0, 100)
3080
+ if (pending.length === 0) {
3081
+ return;
3082
+ }
3083
+ await this._stateAdapter.extendLock(lock, DEFAULT_LOCK_TTL_MS);
3084
+ const latest = pending.at(-1);
3085
+ if (!latest) {
3086
+ return;
3087
+ }
3088
+ const skipped = pending.slice(0, -1).map((e) => e.message);
3089
+ this.logger.info("message-dequeued", {
3090
+ threadId,
3091
+ lockKey,
3092
+ messageId: latest.message.id,
3093
+ skippedCount: skipped.length,
3094
+ totalSinceLastHandler: pending.length
3095
+ });
3096
+ const context = {
3097
+ skipped,
3098
+ totalSinceLastHandler: pending.length
3099
+ };
3100
+ await this.dispatchToHandlers(adapter, threadId, latest.message, context);
3101
+ }
3102
+ }
3103
+ /**
3104
+ * Concurrent strategy: no locking, process immediately.
3105
+ */
3106
+ async handleConcurrent(adapter, threadId, message) {
3107
+ await this.dispatchToHandlers(adapter, threadId, message);
3108
+ }
3109
+ /**
3110
+ * Dispatch a message to the appropriate handler chain based on
3111
+ * subscription status, mention detection, and pattern matching.
3112
+ */
3113
+ async dispatchToHandlers(adapter, threadId, message, context) {
3114
+ message.isMention = message.isMention || this.detectMention(adapter, message);
3115
+ const isSubscribed = await this._stateAdapter.isSubscribed(threadId);
3116
+ this.logger.debug("Subscription check", {
3117
+ threadId,
3118
+ isSubscribed,
3119
+ subscribedHandlerCount: this.subscribedMessageHandlers.length
3120
+ });
3121
+ const thread = await this.createThread(
3122
+ adapter,
3123
+ threadId,
3124
+ message,
3125
+ isSubscribed
3126
+ );
3127
+ const isDM = adapter.isDM?.(threadId) ?? false;
3128
+ if (isDM && this.directMessageHandlers.length > 0) {
3129
+ this.logger.debug("Direct message received - calling handlers", {
3130
+ threadId,
3131
+ handlerCount: this.directMessageHandlers.length
3132
+ });
3133
+ const channel = thread.channel;
3134
+ for (const handler of this.directMessageHandlers) {
3135
+ await handler(thread, message, channel, context);
3136
+ }
3137
+ return;
3138
+ }
3139
+ if (isDM) {
3140
+ message.isMention = true;
3141
+ }
3142
+ if (isSubscribed) {
3143
+ this.logger.debug("Message in subscribed thread - calling handlers", {
3144
+ threadId,
3145
+ handlerCount: this.subscribedMessageHandlers.length
3146
+ });
3147
+ await this.runHandlers(
3148
+ this.subscribedMessageHandlers,
3149
+ thread,
3150
+ message,
3151
+ context
3152
+ );
3153
+ return;
3154
+ }
3155
+ if (message.isMention) {
3156
+ this.logger.debug("Bot mentioned", {
3157
+ threadId,
3158
+ text: message.text.slice(0, 100)
3159
+ });
3160
+ await this.runHandlers(this.mentionHandlers, thread, message, context);
3161
+ return;
3162
+ }
3163
+ this.logger.debug("Checking message patterns", {
3164
+ patternCount: this.messagePatterns.length,
3165
+ patterns: this.messagePatterns.map((p) => p.pattern.toString()),
3166
+ messageText: message.text
3167
+ });
3168
+ let matchedPattern = false;
3169
+ for (const { pattern, handler } of this.messagePatterns) {
3170
+ const matches = pattern.test(message.text);
3171
+ this.logger.debug("Pattern test", {
3172
+ pattern: pattern.toString(),
3173
+ text: message.text,
3174
+ matches
3175
+ });
3176
+ if (matches) {
3177
+ this.logger.debug("Message matched pattern - calling handler", {
3178
+ pattern: pattern.toString()
2900
3179
  });
3180
+ matchedPattern = true;
3181
+ await handler(thread, message, context);
2901
3182
  }
2902
- } finally {
2903
- await this._stateAdapter.releaseLock(lock);
2904
- this.logger.debug("Lock released", { threadId });
3183
+ }
3184
+ if (!matchedPattern) {
3185
+ this.logger.debug("No handlers matched message", {
3186
+ threadId,
3187
+ text: message.text.slice(0, 100)
3188
+ });
2905
3189
  }
2906
3190
  }
2907
3191
  createThread(adapter, threadId, initialMessage, isSubscribedContext = false) {
@@ -2958,9 +3242,45 @@ var Chat = class {
2958
3242
  escapeRegex(str) {
2959
3243
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2960
3244
  }
2961
- async runHandlers(handlers, thread, message) {
3245
+ /**
3246
+ * Reconstruct a proper Message instance from a dequeued entry.
3247
+ * After JSON roundtrip through the state adapter, the message is a plain
3248
+ * object (not a Message instance). This restores class invariants like
3249
+ * `links` defaulting to `[]` and `metadata.dateSent` being a Date.
3250
+ */
3251
+ rehydrateMessage(raw) {
3252
+ if (raw instanceof Message) {
3253
+ return raw;
3254
+ }
3255
+ const obj = raw;
3256
+ if (obj._type === "chat:Message") {
3257
+ return Message.fromJSON(obj);
3258
+ }
3259
+ const metadata = obj.metadata;
3260
+ const dateSent = metadata.dateSent;
3261
+ const editedAt = metadata.editedAt;
3262
+ return new Message({
3263
+ id: obj.id,
3264
+ threadId: obj.threadId,
3265
+ text: obj.text,
3266
+ formatted: obj.formatted,
3267
+ raw: obj.raw,
3268
+ author: obj.author,
3269
+ metadata: {
3270
+ dateSent: dateSent instanceof Date ? dateSent : new Date(dateSent),
3271
+ edited: metadata.edited,
3272
+ editedAt: editedAt ? new Date(
3273
+ editedAt instanceof Date ? editedAt.toISOString() : editedAt
3274
+ ) : void 0
3275
+ },
3276
+ attachments: obj.attachments ?? [],
3277
+ isMention: obj.isMention,
3278
+ links: obj.links ?? []
3279
+ });
3280
+ }
3281
+ async runHandlers(handlers, thread, message, context) {
2962
3282
  for (const handler of handlers) {
2963
- await handler(thread, message);
3283
+ await handler(thread, message, context);
2964
3284
  }
2965
3285
  }
2966
3286
  };