@nextclaw/channel-plugin-feishu 0.2.13 → 0.2.14
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 +3 -1
- package/index.ts +65 -0
- package/openclaw.plugin.json +3 -7
- package/package.json +33 -9
- package/skills/feishu-doc/SKILL.md +211 -0
- package/skills/feishu-doc/references/block-types.md +103 -0
- package/skills/feishu-drive/SKILL.md +97 -0
- package/skills/feishu-perm/SKILL.md +119 -0
- package/skills/feishu-wiki/SKILL.md +111 -0
- package/src/accounts.test.ts +371 -0
- package/src/accounts.ts +244 -0
- package/src/async.ts +62 -0
- package/src/bitable.ts +725 -0
- package/src/bot.card-action.test.ts +63 -0
- package/src/bot.checkBotMentioned.test.ts +193 -0
- package/src/bot.stripBotMention.test.ts +134 -0
- package/src/bot.test.ts +2107 -0
- package/src/bot.ts +1556 -0
- package/src/card-action.ts +79 -0
- package/src/channel.test.ts +48 -0
- package/src/channel.ts +369 -0
- package/src/chat-schema.ts +24 -0
- package/src/chat.test.ts +89 -0
- package/src/chat.ts +130 -0
- package/src/client.test.ts +324 -0
- package/src/client.ts +196 -0
- package/src/config-schema.test.ts +247 -0
- package/src/config-schema.ts +306 -0
- package/src/dedup.ts +203 -0
- package/src/directory.test.ts +40 -0
- package/src/directory.ts +156 -0
- package/src/doc-schema.ts +182 -0
- package/src/docx-batch-insert.test.ts +90 -0
- package/src/docx-batch-insert.ts +187 -0
- package/src/docx-color-text.ts +149 -0
- package/src/docx-table-ops.ts +298 -0
- package/src/docx.account-selection.test.ts +70 -0
- package/src/docx.test.ts +445 -0
- package/src/docx.ts +1460 -0
- package/src/drive-schema.ts +46 -0
- package/src/drive.ts +228 -0
- package/src/dynamic-agent.ts +131 -0
- package/src/external-keys.test.ts +20 -0
- package/src/external-keys.ts +19 -0
- package/src/feishu-command-handler.ts +59 -0
- package/src/media.test.ts +523 -0
- package/src/media.ts +484 -0
- package/src/mention.ts +133 -0
- package/src/monitor.account.ts +562 -0
- package/src/monitor.reaction.test.ts +653 -0
- package/src/monitor.startup.test.ts +190 -0
- package/src/monitor.startup.ts +64 -0
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +155 -0
- package/src/monitor.test-mocks.ts +45 -0
- package/src/monitor.transport.ts +264 -0
- package/src/monitor.ts +95 -0
- package/src/monitor.webhook-e2e.test.ts +214 -0
- package/src/monitor.webhook-security.test.ts +142 -0
- package/src/monitor.webhook.test-helpers.ts +98 -0
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.test.ts +143 -0
- package/src/onboarding.ts +489 -0
- package/src/outbound.test.ts +356 -0
- package/src/outbound.ts +176 -0
- package/src/perm-schema.ts +52 -0
- package/src/perm.ts +176 -0
- package/src/policy.test.ts +154 -0
- package/src/policy.ts +123 -0
- package/src/post.test.ts +105 -0
- package/src/post.ts +274 -0
- package/src/probe.test.ts +270 -0
- package/src/probe.ts +156 -0
- package/src/reactions.ts +153 -0
- package/src/reply-dispatcher.test.ts +513 -0
- package/src/reply-dispatcher.ts +397 -0
- package/src/runtime.ts +6 -0
- package/src/secret-input.ts +13 -0
- package/src/send-message.ts +71 -0
- package/src/send-result.ts +29 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +29 -0
- package/src/send.reply-fallback.test.ts +189 -0
- package/src/send.test.ts +168 -0
- package/src/send.ts +481 -0
- package/src/streaming-card.test.ts +54 -0
- package/src/streaming-card.ts +374 -0
- package/src/targets.test.ts +70 -0
- package/src/targets.ts +107 -0
- package/src/tool-account-routing.test.ts +129 -0
- package/src/tool-account.ts +70 -0
- package/src/tool-factory-test-harness.ts +76 -0
- package/src/tool-result.test.ts +32 -0
- package/src/tool-result.ts +14 -0
- package/src/tools-config.test.ts +21 -0
- package/src/tools-config.ts +22 -0
- package/src/types.ts +103 -0
- package/src/typing.test.ts +144 -0
- package/src/typing.ts +210 -0
- package/src/wiki-schema.ts +55 -0
- package/src/wiki.ts +233 -0
- package/index.js +0 -27
package/src/reactions.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
|
|
2
|
+
import { resolveFeishuAccount } from "./accounts.js";
|
|
3
|
+
import { createFeishuClient } from "./client.js";
|
|
4
|
+
|
|
5
|
+
export type FeishuReaction = {
|
|
6
|
+
reactionId: string;
|
|
7
|
+
emojiType: string;
|
|
8
|
+
operatorType: "app" | "user";
|
|
9
|
+
operatorId: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function resolveConfiguredFeishuClient(params: { cfg: ClawdbotConfig; accountId?: string }) {
|
|
13
|
+
const account = resolveFeishuAccount(params);
|
|
14
|
+
if (!account.configured) {
|
|
15
|
+
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
16
|
+
}
|
|
17
|
+
return createFeishuClient(account);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function assertFeishuReactionApiSuccess(response: { code?: number; msg?: string }, action: string) {
|
|
21
|
+
if (response.code !== 0) {
|
|
22
|
+
throw new Error(`Feishu ${action} failed: ${response.msg || `code ${response.code}`}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Add a reaction (emoji) to a message.
|
|
28
|
+
* @param emojiType - Feishu emoji type, e.g., "SMILE", "THUMBSUP", "HEART"
|
|
29
|
+
* @see https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce
|
|
30
|
+
*/
|
|
31
|
+
export async function addReactionFeishu(params: {
|
|
32
|
+
cfg: ClawdbotConfig;
|
|
33
|
+
messageId: string;
|
|
34
|
+
emojiType: string;
|
|
35
|
+
accountId?: string;
|
|
36
|
+
}): Promise<{ reactionId: string }> {
|
|
37
|
+
const { cfg, messageId, emojiType, accountId } = params;
|
|
38
|
+
const client = resolveConfiguredFeishuClient({ cfg, accountId });
|
|
39
|
+
|
|
40
|
+
const response = (await client.im.messageReaction.create({
|
|
41
|
+
path: { message_id: messageId },
|
|
42
|
+
data: {
|
|
43
|
+
reaction_type: {
|
|
44
|
+
emoji_type: emojiType,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
})) as {
|
|
48
|
+
code?: number;
|
|
49
|
+
msg?: string;
|
|
50
|
+
data?: { reaction_id?: string };
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
assertFeishuReactionApiSuccess(response, "add reaction");
|
|
54
|
+
|
|
55
|
+
const reactionId = response.data?.reaction_id;
|
|
56
|
+
if (!reactionId) {
|
|
57
|
+
throw new Error("Feishu add reaction failed: no reaction_id returned");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { reactionId };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Remove a reaction from a message.
|
|
65
|
+
*/
|
|
66
|
+
export async function removeReactionFeishu(params: {
|
|
67
|
+
cfg: ClawdbotConfig;
|
|
68
|
+
messageId: string;
|
|
69
|
+
reactionId: string;
|
|
70
|
+
accountId?: string;
|
|
71
|
+
}): Promise<void> {
|
|
72
|
+
const { cfg, messageId, reactionId, accountId } = params;
|
|
73
|
+
const client = resolveConfiguredFeishuClient({ cfg, accountId });
|
|
74
|
+
|
|
75
|
+
const response = (await client.im.messageReaction.delete({
|
|
76
|
+
path: {
|
|
77
|
+
message_id: messageId,
|
|
78
|
+
reaction_id: reactionId,
|
|
79
|
+
},
|
|
80
|
+
})) as { code?: number; msg?: string };
|
|
81
|
+
|
|
82
|
+
assertFeishuReactionApiSuccess(response, "remove reaction");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* List all reactions for a message.
|
|
87
|
+
*/
|
|
88
|
+
export async function listReactionsFeishu(params: {
|
|
89
|
+
cfg: ClawdbotConfig;
|
|
90
|
+
messageId: string;
|
|
91
|
+
emojiType?: string;
|
|
92
|
+
accountId?: string;
|
|
93
|
+
}): Promise<FeishuReaction[]> {
|
|
94
|
+
const { cfg, messageId, emojiType, accountId } = params;
|
|
95
|
+
const client = resolveConfiguredFeishuClient({ cfg, accountId });
|
|
96
|
+
|
|
97
|
+
const response = (await client.im.messageReaction.list({
|
|
98
|
+
path: { message_id: messageId },
|
|
99
|
+
params: emojiType ? { reaction_type: emojiType } : undefined,
|
|
100
|
+
})) as {
|
|
101
|
+
code?: number;
|
|
102
|
+
msg?: string;
|
|
103
|
+
data?: {
|
|
104
|
+
items?: Array<{
|
|
105
|
+
reaction_id?: string;
|
|
106
|
+
reaction_type?: { emoji_type?: string };
|
|
107
|
+
operator_type?: string;
|
|
108
|
+
operator_id?: { open_id?: string; user_id?: string; union_id?: string };
|
|
109
|
+
}>;
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
assertFeishuReactionApiSuccess(response, "list reactions");
|
|
114
|
+
|
|
115
|
+
const items = response.data?.items ?? [];
|
|
116
|
+
return items.map((item) => ({
|
|
117
|
+
reactionId: item.reaction_id ?? "",
|
|
118
|
+
emojiType: item.reaction_type?.emoji_type ?? "",
|
|
119
|
+
operatorType: item.operator_type === "app" ? "app" : "user",
|
|
120
|
+
operatorId:
|
|
121
|
+
item.operator_id?.open_id ?? item.operator_id?.user_id ?? item.operator_id?.union_id ?? "",
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Common Feishu emoji types for convenience.
|
|
127
|
+
* @see https://open.feishu.cn/document/server-docs/im-v1/message-reaction/emojis-introduce
|
|
128
|
+
*/
|
|
129
|
+
export const FeishuEmoji = {
|
|
130
|
+
// Common reactions
|
|
131
|
+
THUMBSUP: "THUMBSUP",
|
|
132
|
+
THUMBSDOWN: "THUMBSDOWN",
|
|
133
|
+
HEART: "HEART",
|
|
134
|
+
SMILE: "SMILE",
|
|
135
|
+
GRINNING: "GRINNING",
|
|
136
|
+
LAUGHING: "LAUGHING",
|
|
137
|
+
CRY: "CRY",
|
|
138
|
+
ANGRY: "ANGRY",
|
|
139
|
+
SURPRISED: "SURPRISED",
|
|
140
|
+
THINKING: "THINKING",
|
|
141
|
+
CLAP: "CLAP",
|
|
142
|
+
OK: "OK",
|
|
143
|
+
FIST: "FIST",
|
|
144
|
+
PRAY: "PRAY",
|
|
145
|
+
FIRE: "FIRE",
|
|
146
|
+
PARTY: "PARTY",
|
|
147
|
+
CHECK: "CHECK",
|
|
148
|
+
CROSS: "CROSS",
|
|
149
|
+
QUESTION: "QUESTION",
|
|
150
|
+
EXCLAMATION: "EXCLAMATION",
|
|
151
|
+
} as const;
|
|
152
|
+
|
|
153
|
+
export type FeishuEmojiType = (typeof FeishuEmoji)[keyof typeof FeishuEmoji];
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
|
|
4
|
+
const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
|
|
5
|
+
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
|
|
6
|
+
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
|
|
7
|
+
const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
|
|
8
|
+
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
|
9
|
+
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
|
|
10
|
+
const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
|
|
11
|
+
const addTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "om_msg" })));
|
|
12
|
+
const removeTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => {}));
|
|
13
|
+
const streamingInstances = vi.hoisted(() => [] as any[]);
|
|
14
|
+
|
|
15
|
+
vi.mock("./accounts.js", () => ({ resolveFeishuAccount: resolveFeishuAccountMock }));
|
|
16
|
+
vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock }));
|
|
17
|
+
vi.mock("./send.js", () => ({
|
|
18
|
+
sendMessageFeishu: sendMessageFeishuMock,
|
|
19
|
+
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
|
|
20
|
+
}));
|
|
21
|
+
vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock }));
|
|
22
|
+
vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
|
|
23
|
+
vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock }));
|
|
24
|
+
vi.mock("./typing.js", () => ({
|
|
25
|
+
addTypingIndicator: addTypingIndicatorMock,
|
|
26
|
+
removeTypingIndicator: removeTypingIndicatorMock,
|
|
27
|
+
}));
|
|
28
|
+
vi.mock("./streaming-card.js", async () => {
|
|
29
|
+
const actual = await vi.importActual<typeof import("./streaming-card.js")>("./streaming-card.js");
|
|
30
|
+
return {
|
|
31
|
+
mergeStreamingText: actual.mergeStreamingText,
|
|
32
|
+
FeishuStreamingSession: class {
|
|
33
|
+
active = false;
|
|
34
|
+
start = vi.fn(async () => {
|
|
35
|
+
this.active = true;
|
|
36
|
+
});
|
|
37
|
+
update = vi.fn(async () => {});
|
|
38
|
+
close = vi.fn(async () => {
|
|
39
|
+
this.active = false;
|
|
40
|
+
});
|
|
41
|
+
isActive = vi.fn(() => this.active);
|
|
42
|
+
|
|
43
|
+
constructor() {
|
|
44
|
+
streamingInstances.push(this);
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
|
51
|
+
|
|
52
|
+
describe("createFeishuReplyDispatcher streaming behavior", () => {
|
|
53
|
+
type ReplyDispatcherArgs = Parameters<typeof createFeishuReplyDispatcher>[0];
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
vi.clearAllMocks();
|
|
57
|
+
streamingInstances.length = 0;
|
|
58
|
+
sendMediaFeishuMock.mockResolvedValue(undefined);
|
|
59
|
+
|
|
60
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
61
|
+
accountId: "main",
|
|
62
|
+
appId: "app_id",
|
|
63
|
+
appSecret: "app_secret",
|
|
64
|
+
domain: "feishu",
|
|
65
|
+
config: {
|
|
66
|
+
renderMode: "auto",
|
|
67
|
+
streaming: true,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
resolveReceiveIdTypeMock.mockReturnValue("chat_id");
|
|
72
|
+
createFeishuClientMock.mockReturnValue({});
|
|
73
|
+
|
|
74
|
+
createReplyDispatcherWithTypingMock.mockImplementation((opts) => ({
|
|
75
|
+
dispatcher: {},
|
|
76
|
+
replyOptions: {},
|
|
77
|
+
markDispatchIdle: vi.fn(),
|
|
78
|
+
_opts: opts,
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
getFeishuRuntimeMock.mockReturnValue({
|
|
82
|
+
channel: {
|
|
83
|
+
text: {
|
|
84
|
+
resolveTextChunkLimit: vi.fn(() => 4000),
|
|
85
|
+
resolveChunkMode: vi.fn(() => "line"),
|
|
86
|
+
resolveMarkdownTableMode: vi.fn(() => "preserve"),
|
|
87
|
+
convertMarkdownTables: vi.fn((text) => text),
|
|
88
|
+
chunkTextWithMode: vi.fn((text) => [text]),
|
|
89
|
+
},
|
|
90
|
+
reply: {
|
|
91
|
+
createReplyDispatcherWithTyping: createReplyDispatcherWithTypingMock,
|
|
92
|
+
resolveHumanDelayConfig: vi.fn(() => undefined),
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
function setupNonStreamingAutoDispatcher() {
|
|
99
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
100
|
+
accountId: "main",
|
|
101
|
+
appId: "app_id",
|
|
102
|
+
appSecret: "app_secret",
|
|
103
|
+
domain: "feishu",
|
|
104
|
+
config: {
|
|
105
|
+
renderMode: "auto",
|
|
106
|
+
streaming: false,
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
createFeishuReplyDispatcher({
|
|
111
|
+
cfg: {} as never,
|
|
112
|
+
agentId: "agent",
|
|
113
|
+
runtime: { log: vi.fn(), error: vi.fn() } as never,
|
|
114
|
+
chatId: "oc_chat",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function createRuntimeLogger() {
|
|
121
|
+
return { log: vi.fn(), error: vi.fn() } as never;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function createDispatcherHarness(overrides: Partial<ReplyDispatcherArgs> = {}) {
|
|
125
|
+
const result = createFeishuReplyDispatcher({
|
|
126
|
+
cfg: {} as never,
|
|
127
|
+
agentId: "agent",
|
|
128
|
+
runtime: {} as never,
|
|
129
|
+
chatId: "oc_chat",
|
|
130
|
+
...overrides,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
result,
|
|
135
|
+
options: createReplyDispatcherWithTypingMock.mock.calls.at(-1)?.[0],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
it("skips typing indicator when account typingIndicator is disabled", async () => {
|
|
140
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
141
|
+
accountId: "main",
|
|
142
|
+
appId: "app_id",
|
|
143
|
+
appSecret: "app_secret",
|
|
144
|
+
domain: "feishu",
|
|
145
|
+
config: {
|
|
146
|
+
renderMode: "auto",
|
|
147
|
+
streaming: true,
|
|
148
|
+
typingIndicator: false,
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
createFeishuReplyDispatcher({
|
|
153
|
+
cfg: {} as never,
|
|
154
|
+
agentId: "agent",
|
|
155
|
+
runtime: {} as never,
|
|
156
|
+
chatId: "oc_chat",
|
|
157
|
+
replyToMessageId: "om_parent",
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
161
|
+
await options.onReplyStart?.();
|
|
162
|
+
|
|
163
|
+
expect(addTypingIndicatorMock).not.toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("skips typing indicator for stale replayed messages", async () => {
|
|
167
|
+
createFeishuReplyDispatcher({
|
|
168
|
+
cfg: {} as never,
|
|
169
|
+
agentId: "agent",
|
|
170
|
+
runtime: {} as never,
|
|
171
|
+
chatId: "oc_chat",
|
|
172
|
+
replyToMessageId: "om_parent",
|
|
173
|
+
messageCreateTimeMs: Date.now() - 3 * 60_000,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
177
|
+
await options.onReplyStart?.();
|
|
178
|
+
|
|
179
|
+
expect(addTypingIndicatorMock).not.toHaveBeenCalled();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("treats second-based timestamps as stale for typing suppression", async () => {
|
|
183
|
+
createFeishuReplyDispatcher({
|
|
184
|
+
cfg: {} as never,
|
|
185
|
+
agentId: "agent",
|
|
186
|
+
runtime: {} as never,
|
|
187
|
+
chatId: "oc_chat",
|
|
188
|
+
replyToMessageId: "om_parent",
|
|
189
|
+
messageCreateTimeMs: Math.floor((Date.now() - 3 * 60_000) / 1000),
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
193
|
+
await options.onReplyStart?.();
|
|
194
|
+
|
|
195
|
+
expect(addTypingIndicatorMock).not.toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("keeps typing indicator for fresh messages", async () => {
|
|
199
|
+
createFeishuReplyDispatcher({
|
|
200
|
+
cfg: {} as never,
|
|
201
|
+
agentId: "agent",
|
|
202
|
+
runtime: {} as never,
|
|
203
|
+
chatId: "oc_chat",
|
|
204
|
+
replyToMessageId: "om_parent",
|
|
205
|
+
messageCreateTimeMs: Date.now() - 30_000,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
|
|
209
|
+
await options.onReplyStart?.();
|
|
210
|
+
|
|
211
|
+
expect(addTypingIndicatorMock).toHaveBeenCalledTimes(1);
|
|
212
|
+
expect(addTypingIndicatorMock).toHaveBeenCalledWith(
|
|
213
|
+
expect.objectContaining({
|
|
214
|
+
messageId: "om_parent",
|
|
215
|
+
}),
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("keeps auto mode plain text on non-streaming send path", async () => {
|
|
220
|
+
const { options } = createDispatcherHarness();
|
|
221
|
+
await options.deliver({ text: "plain text" }, { kind: "final" });
|
|
222
|
+
|
|
223
|
+
expect(streamingInstances).toHaveLength(0);
|
|
224
|
+
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
|
|
225
|
+
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("suppresses internal block payload delivery", async () => {
|
|
229
|
+
const { options } = createDispatcherHarness();
|
|
230
|
+
await options.deliver({ text: "internal reasoning chunk" }, { kind: "block" });
|
|
231
|
+
|
|
232
|
+
expect(streamingInstances).toHaveLength(0);
|
|
233
|
+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
234
|
+
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
235
|
+
expect(sendMediaFeishuMock).not.toHaveBeenCalled();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("sets disableBlockStreaming in replyOptions to prevent silent reply drops", async () => {
|
|
239
|
+
const result = createFeishuReplyDispatcher({
|
|
240
|
+
cfg: {} as never,
|
|
241
|
+
agentId: "agent",
|
|
242
|
+
runtime: {} as never,
|
|
243
|
+
chatId: "oc_chat",
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(result.replyOptions).toHaveProperty("disableBlockStreaming", true);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("uses streaming session for auto mode markdown payloads", async () => {
|
|
250
|
+
const { options } = createDispatcherHarness({
|
|
251
|
+
runtime: createRuntimeLogger(),
|
|
252
|
+
rootId: "om_root_topic",
|
|
253
|
+
});
|
|
254
|
+
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
|
255
|
+
|
|
256
|
+
expect(streamingInstances).toHaveLength(1);
|
|
257
|
+
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
|
|
258
|
+
expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", {
|
|
259
|
+
replyToMessageId: undefined,
|
|
260
|
+
replyInThread: undefined,
|
|
261
|
+
rootId: "om_root_topic",
|
|
262
|
+
});
|
|
263
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
264
|
+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
265
|
+
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("closes streaming with block text when final reply is missing", async () => {
|
|
269
|
+
const { options } = createDispatcherHarness({
|
|
270
|
+
runtime: createRuntimeLogger(),
|
|
271
|
+
});
|
|
272
|
+
await options.deliver({ text: "```md\npartial answer\n```" }, { kind: "block" });
|
|
273
|
+
await options.onIdle?.();
|
|
274
|
+
|
|
275
|
+
expect(streamingInstances).toHaveLength(1);
|
|
276
|
+
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
|
|
277
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
278
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("delivers distinct final payloads after streaming close", async () => {
|
|
282
|
+
const { options } = createDispatcherHarness({
|
|
283
|
+
runtime: createRuntimeLogger(),
|
|
284
|
+
});
|
|
285
|
+
await options.deliver({ text: "```md\n完整回复第一段\n```" }, { kind: "final" });
|
|
286
|
+
await options.deliver({ text: "```md\n完整回复第一段 + 第二段\n```" }, { kind: "final" });
|
|
287
|
+
|
|
288
|
+
expect(streamingInstances).toHaveLength(2);
|
|
289
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
290
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n完整回复第一段\n```");
|
|
291
|
+
expect(streamingInstances[1].close).toHaveBeenCalledTimes(1);
|
|
292
|
+
expect(streamingInstances[1].close).toHaveBeenCalledWith("```md\n完整回复第一段 + 第二段\n```");
|
|
293
|
+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
294
|
+
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("skips exact duplicate final text after streaming close", async () => {
|
|
298
|
+
const { options } = createDispatcherHarness({
|
|
299
|
+
runtime: createRuntimeLogger(),
|
|
300
|
+
});
|
|
301
|
+
await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
|
|
302
|
+
await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
|
|
303
|
+
|
|
304
|
+
expect(streamingInstances).toHaveLength(1);
|
|
305
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
306
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```");
|
|
307
|
+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
308
|
+
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
309
|
+
});
|
|
310
|
+
it("suppresses duplicate final text while still sending media", async () => {
|
|
311
|
+
const options = setupNonStreamingAutoDispatcher();
|
|
312
|
+
await options.deliver({ text: "plain final" }, { kind: "final" });
|
|
313
|
+
await options.deliver(
|
|
314
|
+
{ text: "plain final", mediaUrl: "https://example.com/a.png" },
|
|
315
|
+
{ kind: "final" },
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
|
|
319
|
+
expect(sendMessageFeishuMock).toHaveBeenLastCalledWith(
|
|
320
|
+
expect.objectContaining({
|
|
321
|
+
text: "plain final",
|
|
322
|
+
}),
|
|
323
|
+
);
|
|
324
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
|
|
325
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
|
326
|
+
expect.objectContaining({
|
|
327
|
+
mediaUrl: "https://example.com/a.png",
|
|
328
|
+
}),
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("keeps distinct non-streaming final payloads", async () => {
|
|
333
|
+
const options = setupNonStreamingAutoDispatcher();
|
|
334
|
+
await options.deliver({ text: "notice header" }, { kind: "final" });
|
|
335
|
+
await options.deliver({ text: "actual answer body" }, { kind: "final" });
|
|
336
|
+
|
|
337
|
+
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(2);
|
|
338
|
+
expect(sendMessageFeishuMock).toHaveBeenNthCalledWith(
|
|
339
|
+
1,
|
|
340
|
+
expect.objectContaining({ text: "notice header" }),
|
|
341
|
+
);
|
|
342
|
+
expect(sendMessageFeishuMock).toHaveBeenNthCalledWith(
|
|
343
|
+
2,
|
|
344
|
+
expect.objectContaining({ text: "actual answer body" }),
|
|
345
|
+
);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("treats block updates as delta chunks", async () => {
|
|
349
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
350
|
+
accountId: "main",
|
|
351
|
+
appId: "app_id",
|
|
352
|
+
appSecret: "app_secret",
|
|
353
|
+
domain: "feishu",
|
|
354
|
+
config: {
|
|
355
|
+
renderMode: "card",
|
|
356
|
+
streaming: true,
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const { result, options } = createDispatcherHarness({
|
|
361
|
+
runtime: createRuntimeLogger(),
|
|
362
|
+
});
|
|
363
|
+
await options.onReplyStart?.();
|
|
364
|
+
await result.replyOptions.onPartialReply?.({ text: "hello" });
|
|
365
|
+
await options.deliver({ text: "lo world" }, { kind: "block" });
|
|
366
|
+
await options.onIdle?.();
|
|
367
|
+
|
|
368
|
+
expect(streamingInstances).toHaveLength(1);
|
|
369
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
370
|
+
expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("sends media-only payloads as attachments", async () => {
|
|
374
|
+
const { options } = createDispatcherHarness();
|
|
375
|
+
await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
|
|
376
|
+
|
|
377
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
|
|
378
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
|
379
|
+
expect.objectContaining({
|
|
380
|
+
to: "oc_chat",
|
|
381
|
+
mediaUrl: "https://example.com/a.png",
|
|
382
|
+
}),
|
|
383
|
+
);
|
|
384
|
+
expect(sendMessageFeishuMock).not.toHaveBeenCalled();
|
|
385
|
+
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("falls back to legacy mediaUrl when mediaUrls is an empty array", async () => {
|
|
389
|
+
const { options } = createDispatcherHarness();
|
|
390
|
+
await options.deliver(
|
|
391
|
+
{ text: "caption", mediaUrl: "https://example.com/a.png", mediaUrls: [] },
|
|
392
|
+
{ kind: "final" },
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
|
|
396
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
|
|
397
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
|
398
|
+
expect.objectContaining({
|
|
399
|
+
mediaUrl: "https://example.com/a.png",
|
|
400
|
+
}),
|
|
401
|
+
);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("sends attachments after streaming final markdown replies", async () => {
|
|
405
|
+
const { options } = createDispatcherHarness({
|
|
406
|
+
runtime: createRuntimeLogger(),
|
|
407
|
+
});
|
|
408
|
+
await options.deliver(
|
|
409
|
+
{ text: "```ts\nconst x = 1\n```", mediaUrls: ["https://example.com/a.png"] },
|
|
410
|
+
{ kind: "final" },
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
expect(streamingInstances).toHaveLength(1);
|
|
414
|
+
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
|
|
415
|
+
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
|
|
416
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
|
|
417
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
|
418
|
+
expect.objectContaining({
|
|
419
|
+
mediaUrl: "https://example.com/a.png",
|
|
420
|
+
}),
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("passes replyInThread to sendMessageFeishu for plain text", async () => {
|
|
425
|
+
const { options } = createDispatcherHarness({
|
|
426
|
+
replyToMessageId: "om_msg",
|
|
427
|
+
replyInThread: true,
|
|
428
|
+
});
|
|
429
|
+
await options.deliver({ text: "plain text" }, { kind: "final" });
|
|
430
|
+
|
|
431
|
+
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
|
|
432
|
+
expect.objectContaining({
|
|
433
|
+
replyToMessageId: "om_msg",
|
|
434
|
+
replyInThread: true,
|
|
435
|
+
}),
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("passes replyInThread to sendMarkdownCardFeishu for card text", async () => {
|
|
440
|
+
resolveFeishuAccountMock.mockReturnValue({
|
|
441
|
+
accountId: "main",
|
|
442
|
+
appId: "app_id",
|
|
443
|
+
appSecret: "app_secret",
|
|
444
|
+
domain: "feishu",
|
|
445
|
+
config: {
|
|
446
|
+
renderMode: "card",
|
|
447
|
+
streaming: false,
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const { options } = createDispatcherHarness({
|
|
452
|
+
replyToMessageId: "om_msg",
|
|
453
|
+
replyInThread: true,
|
|
454
|
+
});
|
|
455
|
+
await options.deliver({ text: "card text" }, { kind: "final" });
|
|
456
|
+
|
|
457
|
+
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
|
|
458
|
+
expect.objectContaining({
|
|
459
|
+
replyToMessageId: "om_msg",
|
|
460
|
+
replyInThread: true,
|
|
461
|
+
}),
|
|
462
|
+
);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("passes replyToMessageId and replyInThread to streaming.start()", async () => {
|
|
466
|
+
const { options } = createDispatcherHarness({
|
|
467
|
+
runtime: createRuntimeLogger(),
|
|
468
|
+
replyToMessageId: "om_msg",
|
|
469
|
+
replyInThread: true,
|
|
470
|
+
});
|
|
471
|
+
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
|
472
|
+
|
|
473
|
+
expect(streamingInstances).toHaveLength(1);
|
|
474
|
+
expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", {
|
|
475
|
+
replyToMessageId: "om_msg",
|
|
476
|
+
replyInThread: true,
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("disables streaming for thread replies and keeps reply metadata", async () => {
|
|
481
|
+
const { options } = createDispatcherHarness({
|
|
482
|
+
runtime: createRuntimeLogger(),
|
|
483
|
+
replyToMessageId: "om_msg",
|
|
484
|
+
replyInThread: false,
|
|
485
|
+
threadReply: true,
|
|
486
|
+
rootId: "om_root_topic",
|
|
487
|
+
});
|
|
488
|
+
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
|
|
489
|
+
|
|
490
|
+
expect(streamingInstances).toHaveLength(0);
|
|
491
|
+
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
|
|
492
|
+
expect.objectContaining({
|
|
493
|
+
replyToMessageId: "om_msg",
|
|
494
|
+
replyInThread: true,
|
|
495
|
+
}),
|
|
496
|
+
);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("passes replyInThread to media attachments", async () => {
|
|
500
|
+
const { options } = createDispatcherHarness({
|
|
501
|
+
replyToMessageId: "om_msg",
|
|
502
|
+
replyInThread: true,
|
|
503
|
+
});
|
|
504
|
+
await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
|
|
505
|
+
|
|
506
|
+
expect(sendMediaFeishuMock).toHaveBeenCalledWith(
|
|
507
|
+
expect.objectContaining({
|
|
508
|
+
replyToMessageId: "om_msg",
|
|
509
|
+
replyInThread: true,
|
|
510
|
+
}),
|
|
511
|
+
);
|
|
512
|
+
});
|
|
513
|
+
});
|