@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.
- package/README.md +28 -0
- package/index.ts +4 -0
- package/package.json +2 -1
- package/skills/feishu-message/SKILL.md +111 -0
- package/skills/feishu-reaction/SKILL.md +120 -0
- package/src/accounts.ts +27 -5
- package/src/bot.ts +18 -5
- package/src/channel.ts +12 -7
- package/src/config-schema.ts +22 -0
- package/src/drive-tools/register.ts +1 -1
- package/src/media-duration.ts +43 -8
- package/src/media.ts +29 -12
- package/src/message-tools/actions.ts +154 -0
- package/src/message-tools/index.ts +2 -0
- package/src/message-tools/register.ts +58 -0
- package/src/message-tools/schemas.ts +56 -0
- package/src/onboarding.ts +4 -2
- package/src/reaction-tools/actions.ts +127 -0
- package/src/reaction-tools/index.ts +2 -0
- package/src/reaction-tools/register.ts +51 -0
- package/src/reaction-tools/schemas.ts +34 -0
- package/src/reply-dispatcher.ts +23 -33
- package/src/streaming-card.ts +34 -11
- package/src/text/markdown-links.ts +16 -1
- package/src/tools-common/tool-context.ts +1 -0
- package/src/tools-config.ts +2 -0
- package/src/types.ts +4 -0
package/src/reply-dispatcher.ts
CHANGED
|
@@ -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
|
|
126
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
},
|
package/src/streaming-card.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
154
|
-
if (
|
|
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 =
|
|
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
|
-
|
|
170
|
-
if (!mergedText || mergedText === this.state.currentText) {
|
|
192
|
+
if (toSend === this.state.currentText) {
|
|
171
193
|
return;
|
|
172
194
|
}
|
|
173
|
-
this.state.currentText =
|
|
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:
|
|
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
|
-
|
|
200
|
-
|
|
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
|
|
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
|
|
package/src/tools-config.ts
CHANGED
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 = {
|