@m1heng-clawd/feishu 0.1.16 → 0.1.18

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.
@@ -12,7 +12,7 @@ import { buildMentionedCardContent, type MentionTarget } from "./mention.js";
12
12
  import { normalizeFeishuMarkdownLinks } from "./text/markdown-links.js";
13
13
  import { getFeishuRuntime } from "./runtime.js";
14
14
  import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
15
- import { FeishuStreamingSession } from "./streaming-card.js";
15
+ import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js";
16
16
  import { resolveReceiveIdType } from "./targets.js";
17
17
  import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
18
18
 
@@ -119,39 +119,15 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
119
119
  let streaming: FeishuStreamingSession | null = null;
120
120
  let streamText = "";
121
121
  let lastPartial = "";
122
+ let streamingCompleted = false;
122
123
  let partialUpdateQueue: Promise<void> = Promise.resolve();
123
124
  let streamingStartPromise: Promise<void> | null = null;
124
125
 
125
- const mergeStreamingText = (nextText: string) => {
126
- if (!streamText) {
127
- streamText = nextText;
128
- return;
129
- }
130
- if (nextText.startsWith(streamText)) {
131
- // Handle cumulative partial payloads where nextText already includes prior text.
132
- streamText = nextText;
133
- return;
134
- }
135
- if (streamText.endsWith(nextText)) {
136
- return;
137
- }
138
- streamText += nextText;
126
+ const mergeIntoStreamText = (nextText: string) => {
127
+ streamText = mergeStreamingText(streamText, nextText);
139
128
  };
140
129
 
141
- const queueStreamingUpdate = (
142
- nextText: string,
143
- options?: { dedupeWithLastPartial?: boolean },
144
- ) => {
145
- if (!nextText) {
146
- return;
147
- }
148
- if (options?.dedupeWithLastPartial && nextText === lastPartial) {
149
- return;
150
- }
151
- if (options?.dedupeWithLastPartial) {
152
- lastPartial = nextText;
153
- }
154
- mergeStreamingText(nextText);
130
+ const enqueueStreamingFlush = () => {
155
131
  partialUpdateQueue = partialUpdateQueue.then(async () => {
156
132
  if (streamingStartPromise) {
157
133
  await streamingStartPromise;
@@ -163,7 +139,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
163
139
  };
164
140
 
165
141
  const startStreaming = () => {
166
- if (!streamingEnabled || streamingStartPromise || streaming) {
142
+ if (!streamingEnabled || streamingStartPromise || streaming || streamingCompleted) {
167
143
  return;
168
144
  }
169
145
  streamingStartPromise = (async () => {
@@ -198,6 +174,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
198
174
  text = buildMentionedCardContent(mentionTargets, text);
199
175
  }
200
176
  await streaming.close(normalizeFeishuMarkdownLinks(text));
177
+ streamingCompleted = true;
201
178
  }
202
179
  streaming = null;
203
180
  streamingStartPromise = null;
@@ -247,7 +224,8 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
247
224
  if (info?.kind === "block") {
248
225
  // Some runtimes emit block payloads without onPartial/final callbacks.
249
226
  // Mirror block text into streamText so onIdle close still sends content.
250
- queueStreamingUpdate(text);
227
+ mergeIntoStreamText(text);
228
+ enqueueStreamingFlush();
251
229
  }
252
230
  if (info?.kind === "final") {
253
231
  streamText = text;
@@ -256,6 +234,13 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
256
234
  return;
257
235
  }
258
236
 
237
+ // Streaming card already delivered the content — skip regular send to
238
+ // avoid a duplicate card when the runtime delivers a second payload
239
+ // after the streaming session has closed (#399).
240
+ if (streamingCompleted) {
241
+ return;
242
+ }
243
+
259
244
  let first = true;
260
245
  if (useCard) {
261
246
  for (const chunk of core.channel.text.chunkTextWithMode(text, textChunkLimit, chunkMode)) {
@@ -312,10 +297,15 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
312
297
  onPartialReply: streamingEnabled
313
298
  ? (payload: ReplyPayload) => {
314
299
  const partialText = normalizeFeishuMarkdownLinks(payload.text ?? "");
315
- if (!partialText) {
300
+ if (!partialText || partialText === lastPartial) {
316
301
  return;
317
302
  }
318
- queueStreamingUpdate(partialText, { dedupeWithLastPartial: true });
303
+ lastPartial = partialText;
304
+ // Partials are cumulative — replace streamText directly instead of
305
+ // merging, which avoids duplication when segment boundaries shift
306
+ // (e.g. after a tool call mid-stream).
307
+ streamText = partialText;
308
+ enqueueStreamingFlush();
319
309
  }
320
310
  : undefined,
321
311
  },
@@ -44,6 +44,21 @@ async function getToken(creds: Credentials): Promise<string> {
44
44
  return data.tenant_access_token;
45
45
  }
46
46
 
47
+ /**
48
+ * Find the length of the longest suffix of `a` that equals a prefix of `b`.
49
+ * Used to detect partial overlaps between consecutive streaming segments.
50
+ * Capped at 500 chars to keep cost bounded.
51
+ */
52
+ function findOverlapLength(a: string, b: string): number {
53
+ const max = Math.min(a.length, b.length, 500);
54
+ for (let len = max; len > 0; len--) {
55
+ if (a.endsWith(b.slice(0, len))) {
56
+ return len;
57
+ }
58
+ }
59
+ return 0;
60
+ }
61
+
47
62
  export function mergeStreamingText(
48
63
  previousText: string | undefined,
49
64
  nextText: string | undefined,
@@ -59,7 +74,14 @@ export function mergeStreamingText(
59
74
  if (previous.includes(next)) {
60
75
  return previous;
61
76
  }
62
- // Fallback for fragmented partial chunks: append to avoid losing tokens.
77
+ // Detect partial overlap: the end of previous matches the start of next.
78
+ // This happens when partials are cumulative within a segment but don't
79
+ // include prior segments (e.g. after a tool call mid-stream).
80
+ const overlap = findOverlapLength(previous, next);
81
+ if (overlap > 0) {
82
+ return previous + next.slice(overlap);
83
+ }
84
+ // No overlap — truly incremental chunk, just append.
63
85
  return `${previous}${next}`;
64
86
  }
65
87
 
@@ -147,30 +169,30 @@ export class FeishuStreamingSession {
147
169
  }
148
170
 
149
171
  async update(text: string): Promise<void> {
150
- if (!this.state || this.closed) {
172
+ if (!this.state || this.closed || !text) {
151
173
  return;
152
174
  }
153
- const mergedInput = mergeStreamingText(this.pendingText ?? this.state.currentText, text);
154
- if (!mergedInput || mergedInput === this.state.currentText) {
175
+ // Caller sends cumulative full text — no merge needed, just throttle.
176
+ if (text === this.state.currentText) {
155
177
  return;
156
178
  }
157
179
  const now = Date.now();
158
180
  if (now - this.lastUpdateTime < this.updateThrottleMs) {
159
- this.pendingText = mergedInput;
181
+ this.pendingText = text;
160
182
  return;
161
183
  }
162
184
  this.pendingText = null;
163
185
  this.lastUpdateTime = now;
164
186
 
187
+ const toSend = text;
165
188
  this.queue = this.queue.then(async () => {
166
189
  if (!this.state || this.closed) {
167
190
  return;
168
191
  }
169
- const mergedText = mergeStreamingText(this.state.currentText, mergedInput);
170
- if (!mergedText || mergedText === this.state.currentText) {
192
+ if (toSend === this.state.currentText) {
171
193
  return;
172
194
  }
173
- this.state.currentText = mergedText;
195
+ this.state.currentText = toSend;
174
196
  this.state.sequence += 1;
175
197
  const apiBase = resolveApiBase(this.creds.domain);
176
198
  await fetch(`${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/content/content`, {
@@ -180,7 +202,7 @@ export class FeishuStreamingSession {
180
202
  "Content-Type": "application/json",
181
203
  },
182
204
  body: JSON.stringify({
183
- content: mergedText,
205
+ content: toSend,
184
206
  sequence: this.state.sequence,
185
207
  uuid: `s_${this.state.cardId}_${this.state.sequence}`,
186
208
  }),
@@ -196,8 +218,9 @@ export class FeishuStreamingSession {
196
218
  this.closed = true;
197
219
  await this.queue;
198
220
 
199
- const pendingMerged = mergeStreamingText(this.state.currentText, this.pendingText ?? undefined);
200
- const text = finalText ? mergeStreamingText(pendingMerged, finalText) : pendingMerged;
221
+ // finalText is authoritative when provided (from deliver kind=final).
222
+ // Fall back to pending or current text when closing via onIdle.
223
+ const text = finalText || this.pendingText || this.state.currentText;
201
224
  const apiBase = resolveApiBase(this.creds.domain);
202
225
 
203
226
  if (text && text !== this.state.currentText) {
@@ -91,8 +91,23 @@ function normalizeNonCodeSegments(text: string): string {
91
91
  .join("");
92
92
  }
93
93
 
94
+ function normalizeCodeBlockFences(block: string): string {
95
+ const lines = block.split("\n");
96
+ return lines
97
+ .map((line) => {
98
+ // Fix opening and closing fences: remove leading whitespace
99
+ if (line.match(/^\s*```/)) {
100
+ return line.replace(/^\s+/, "");
101
+ }
102
+ return line;
103
+ })
104
+ .join("\n");
105
+ }
106
+
94
107
  export function normalizeFeishuMarkdownLinks(text: string): string {
95
- if (!text || (!text.includes("http://") && !text.includes("https://"))) {
108
+ if (!text) return text;
109
+ text = normalizeCodeBlockFences(text);
110
+ if (!text.includes("http://") && !text.includes("https://")) {
96
111
  return text;
97
112
  }
98
113
 
@@ -5,6 +5,7 @@ export type FeishuToolContext = {
5
5
  accountId: string;
6
6
  sessionKey?: string;
7
7
  senderOpenId?: string;
8
+ chatId?: string;
8
9
  };
9
10
 
10
11
  const toolContextStorage = new AsyncLocalStorage<FeishuToolContext>();
@@ -14,6 +14,8 @@ export const DEFAULT_TOOLS_CONFIG: Required<FeishuToolsConfig> = {
14
14
  task: true,
15
15
  chat: true,
16
16
  urgent: true,
17
+ message: true,
18
+ reaction: true,
17
19
  };
18
20
 
19
21
  /**
package/src/types.ts CHANGED
@@ -76,6 +76,10 @@ export type FeishuToolsConfig = {
76
76
  chat?: boolean;
77
77
  /** Enable the feishu_urgent tool (buzz/urgent notifications). Enabled by default. */
78
78
  urgent?: boolean;
79
+ /** Enable the feishu_message tool (get/list messages). Enabled by default. */
80
+ message?: boolean;
81
+ /** Enable the feishu_reaction tool (add/remove/list emoji reactions). Enabled by default. */
82
+ reaction?: boolean;
79
83
  };
80
84
 
81
85
  export type DynamicAgentCreationConfig = {