@openclaw/feishu 2026.5.1-beta.1 → 2026.5.2-beta.2
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/openclaw.plugin.json +174 -0
- package/package.json +4 -4
- package/src/channel.ts +1 -0
- package/src/chat.test.ts +51 -0
- package/src/chat.ts +2 -2
- package/src/comment-shared.ts +41 -0
- package/src/dedup.ts +1 -1
- package/src/directory.test.ts +6 -9
- package/src/directory.ts +0 -2
- package/src/media.test.ts +28 -0
- package/src/media.ts +77 -46
- package/src/send.reply-fallback.test.ts +28 -0
- package/src/send.ts +15 -9
- package/src/test-support/lifecycle-test-support.ts +0 -26
- package/src/monitor.reply-once.lifecycle.test-support.ts +0 -190
package/openclaw.plugin.json
CHANGED
|
@@ -6,6 +6,180 @@
|
|
|
6
6
|
"channels": [
|
|
7
7
|
"feishu"
|
|
8
8
|
],
|
|
9
|
+
"contracts": {
|
|
10
|
+
"tools": [
|
|
11
|
+
"feishu_app_scopes",
|
|
12
|
+
"feishu_bitable_create_app",
|
|
13
|
+
"feishu_bitable_create_field",
|
|
14
|
+
"feishu_bitable_create_record",
|
|
15
|
+
"feishu_bitable_get_meta",
|
|
16
|
+
"feishu_bitable_get_record",
|
|
17
|
+
"feishu_bitable_list_fields",
|
|
18
|
+
"feishu_bitable_list_records",
|
|
19
|
+
"feishu_bitable_update_record",
|
|
20
|
+
"feishu_chat",
|
|
21
|
+
"feishu_doc",
|
|
22
|
+
"feishu_drive",
|
|
23
|
+
"feishu_perm",
|
|
24
|
+
"feishu_wiki"
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
"toolMetadata": {
|
|
28
|
+
"feishu_app_scopes": {
|
|
29
|
+
"configSignals": [
|
|
30
|
+
{
|
|
31
|
+
"rootPath": "channels.feishu",
|
|
32
|
+
"required": [
|
|
33
|
+
"appId",
|
|
34
|
+
"appSecret"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
"feishu_bitable_create_app": {
|
|
40
|
+
"configSignals": [
|
|
41
|
+
{
|
|
42
|
+
"rootPath": "channels.feishu",
|
|
43
|
+
"required": [
|
|
44
|
+
"appId",
|
|
45
|
+
"appSecret"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
"feishu_bitable_create_field": {
|
|
51
|
+
"configSignals": [
|
|
52
|
+
{
|
|
53
|
+
"rootPath": "channels.feishu",
|
|
54
|
+
"required": [
|
|
55
|
+
"appId",
|
|
56
|
+
"appSecret"
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
"feishu_bitable_create_record": {
|
|
62
|
+
"configSignals": [
|
|
63
|
+
{
|
|
64
|
+
"rootPath": "channels.feishu",
|
|
65
|
+
"required": [
|
|
66
|
+
"appId",
|
|
67
|
+
"appSecret"
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
"feishu_bitable_get_meta": {
|
|
73
|
+
"configSignals": [
|
|
74
|
+
{
|
|
75
|
+
"rootPath": "channels.feishu",
|
|
76
|
+
"required": [
|
|
77
|
+
"appId",
|
|
78
|
+
"appSecret"
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
},
|
|
83
|
+
"feishu_bitable_get_record": {
|
|
84
|
+
"configSignals": [
|
|
85
|
+
{
|
|
86
|
+
"rootPath": "channels.feishu",
|
|
87
|
+
"required": [
|
|
88
|
+
"appId",
|
|
89
|
+
"appSecret"
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
]
|
|
93
|
+
},
|
|
94
|
+
"feishu_bitable_list_fields": {
|
|
95
|
+
"configSignals": [
|
|
96
|
+
{
|
|
97
|
+
"rootPath": "channels.feishu",
|
|
98
|
+
"required": [
|
|
99
|
+
"appId",
|
|
100
|
+
"appSecret"
|
|
101
|
+
]
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
},
|
|
105
|
+
"feishu_bitable_list_records": {
|
|
106
|
+
"configSignals": [
|
|
107
|
+
{
|
|
108
|
+
"rootPath": "channels.feishu",
|
|
109
|
+
"required": [
|
|
110
|
+
"appId",
|
|
111
|
+
"appSecret"
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
]
|
|
115
|
+
},
|
|
116
|
+
"feishu_bitable_update_record": {
|
|
117
|
+
"configSignals": [
|
|
118
|
+
{
|
|
119
|
+
"rootPath": "channels.feishu",
|
|
120
|
+
"required": [
|
|
121
|
+
"appId",
|
|
122
|
+
"appSecret"
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
},
|
|
127
|
+
"feishu_chat": {
|
|
128
|
+
"configSignals": [
|
|
129
|
+
{
|
|
130
|
+
"rootPath": "channels.feishu",
|
|
131
|
+
"required": [
|
|
132
|
+
"appId",
|
|
133
|
+
"appSecret"
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
]
|
|
137
|
+
},
|
|
138
|
+
"feishu_doc": {
|
|
139
|
+
"configSignals": [
|
|
140
|
+
{
|
|
141
|
+
"rootPath": "channels.feishu",
|
|
142
|
+
"required": [
|
|
143
|
+
"appId",
|
|
144
|
+
"appSecret"
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
},
|
|
149
|
+
"feishu_drive": {
|
|
150
|
+
"configSignals": [
|
|
151
|
+
{
|
|
152
|
+
"rootPath": "channels.feishu",
|
|
153
|
+
"required": [
|
|
154
|
+
"appId",
|
|
155
|
+
"appSecret"
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
]
|
|
159
|
+
},
|
|
160
|
+
"feishu_perm": {
|
|
161
|
+
"configSignals": [
|
|
162
|
+
{
|
|
163
|
+
"rootPath": "channels.feishu",
|
|
164
|
+
"required": [
|
|
165
|
+
"appId",
|
|
166
|
+
"appSecret"
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
]
|
|
170
|
+
},
|
|
171
|
+
"feishu_wiki": {
|
|
172
|
+
"configSignals": [
|
|
173
|
+
{
|
|
174
|
+
"rootPath": "channels.feishu",
|
|
175
|
+
"required": [
|
|
176
|
+
"appId",
|
|
177
|
+
"appSecret"
|
|
178
|
+
]
|
|
179
|
+
}
|
|
180
|
+
]
|
|
181
|
+
}
|
|
182
|
+
},
|
|
9
183
|
"channelEnvVars": {
|
|
10
184
|
"feishu": [
|
|
11
185
|
"FEISHU_APP_ID",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/feishu",
|
|
3
|
-
"version": "2026.5.
|
|
3
|
+
"version": "2026.5.2-beta.2",
|
|
4
4
|
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"openclaw": "workspace:*"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
|
-
"openclaw": ">=2026.
|
|
19
|
+
"openclaw": ">=2026.5.2-beta.2"
|
|
20
20
|
},
|
|
21
21
|
"peerDependenciesMeta": {
|
|
22
22
|
"openclaw": {
|
|
@@ -47,10 +47,10 @@
|
|
|
47
47
|
"minHostVersion": ">=2026.4.25"
|
|
48
48
|
},
|
|
49
49
|
"compat": {
|
|
50
|
-
"pluginApi": ">=2026.
|
|
50
|
+
"pluginApi": ">=2026.5.2-beta.2"
|
|
51
51
|
},
|
|
52
52
|
"build": {
|
|
53
|
-
"openclawVersion": "2026.5.
|
|
53
|
+
"openclawVersion": "2026.5.2-beta.2"
|
|
54
54
|
},
|
|
55
55
|
"release": {
|
|
56
56
|
"publishToClawHub": true,
|
package/src/channel.ts
CHANGED
|
@@ -1154,6 +1154,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
|
|
|
1154
1154
|
setup: feishuSetupAdapter,
|
|
1155
1155
|
setupWizard: feishuSetupWizard,
|
|
1156
1156
|
messaging: {
|
|
1157
|
+
targetPrefixes: ["feishu", "lark"],
|
|
1157
1158
|
normalizeTarget: (raw) => normalizeFeishuTarget(raw) ?? undefined,
|
|
1158
1159
|
resolveDeliveryTarget: ({ conversationId, parentConversationId }) => {
|
|
1159
1160
|
const directId = parseFeishuDirectConversationId(conversationId);
|
package/src/chat.test.ts
CHANGED
|
@@ -142,4 +142,55 @@ describe("registerFeishuChatTools", () => {
|
|
|
142
142
|
);
|
|
143
143
|
expect(registerTool).not.toHaveBeenCalled();
|
|
144
144
|
});
|
|
145
|
+
|
|
146
|
+
it("preserves Feishu diagnostics from rejected member lookups", async () => {
|
|
147
|
+
const registerTool = vi.fn();
|
|
148
|
+
registerFeishuChatTools(
|
|
149
|
+
createChatToolApi({
|
|
150
|
+
config: {
|
|
151
|
+
channels: {
|
|
152
|
+
feishu: {
|
|
153
|
+
enabled: true,
|
|
154
|
+
appId: "app_id",
|
|
155
|
+
appSecret: "app_secret", // pragma: allowlist secret
|
|
156
|
+
tools: { chat: true },
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
registerTool,
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const tool = registerTool.mock.calls[0]?.[0];
|
|
165
|
+
contactUserGetMock.mockRejectedValueOnce(
|
|
166
|
+
Object.assign(new Error("Request failed with status code 400"), {
|
|
167
|
+
response: {
|
|
168
|
+
status: 400,
|
|
169
|
+
data: {
|
|
170
|
+
code: 99992360,
|
|
171
|
+
msg: "The request you send is not a valid {user_id} or not exists",
|
|
172
|
+
error: {
|
|
173
|
+
log_id: "20260429124800CHAT",
|
|
174
|
+
troubleshooter: "https://open.feishu.cn/search?log_id=20260429124800CHAT",
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const result = await tool.execute("tc_4", {
|
|
182
|
+
action: "member_info",
|
|
183
|
+
member_id: "ou_1",
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(result.details.error).toContain('"http_status":400');
|
|
187
|
+
expect(result.details.error).toContain('"feishu_code":99992360');
|
|
188
|
+
expect(result.details.error).toContain(
|
|
189
|
+
'"feishu_msg":"The request you send is not a valid {user_id} or not exists"',
|
|
190
|
+
);
|
|
191
|
+
expect(result.details.error).toContain('"feishu_log_id":"20260429124800CHAT"');
|
|
192
|
+
expect(result.details.error).toContain(
|
|
193
|
+
'"feishu_troubleshooter":"https://open.feishu.cn/search?log_id=20260429124800CHAT"',
|
|
194
|
+
);
|
|
195
|
+
});
|
|
145
196
|
});
|
package/src/chat.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type * as Lark from "@larksuiteoapi/node-sdk";
|
|
2
|
-
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
3
2
|
import type { OpenClawPluginApi } from "../runtime-api.js";
|
|
4
3
|
import { listEnabledFeishuAccounts } from "./accounts.js";
|
|
5
4
|
import { FeishuChatSchema, type FeishuChatParams } from "./chat-schema.js";
|
|
6
5
|
import { createFeishuClient } from "./client.js";
|
|
6
|
+
import { formatFeishuApiError } from "./comment-shared.js";
|
|
7
7
|
import { resolveToolsConfig } from "./tools-config.js";
|
|
8
8
|
|
|
9
9
|
function json(data: unknown) {
|
|
@@ -179,7 +179,7 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) {
|
|
|
179
179
|
return json({ error: `Unknown action: ${String(p.action)}` });
|
|
180
180
|
}
|
|
181
181
|
} catch (err) {
|
|
182
|
-
return json({ error:
|
|
182
|
+
return json({ error: formatFeishuApiError(err, { includeNestedErrorLogId: true }) });
|
|
183
183
|
}
|
|
184
184
|
},
|
|
185
185
|
},
|
package/src/comment-shared.ts
CHANGED
|
@@ -41,6 +41,7 @@ export function formatFeishuApiError(
|
|
|
41
41
|
(options.includeNestedErrorLogId
|
|
42
42
|
? readString(isRecord(responseData?.error) ? responseData.error.log_id : undefined)
|
|
43
43
|
: undefined);
|
|
44
|
+
const nestedError = isRecord(responseData?.error) ? responseData.error : undefined;
|
|
44
45
|
|
|
45
46
|
return JSON.stringify({
|
|
46
47
|
message:
|
|
@@ -58,9 +59,49 @@ export function formatFeishuApiError(
|
|
|
58
59
|
typeof responseData?.code === "number" ? responseData.code : readString(responseData?.code),
|
|
59
60
|
feishu_msg: readString(responseData?.msg),
|
|
60
61
|
feishu_log_id: feishuLogId,
|
|
62
|
+
feishu_troubleshooter:
|
|
63
|
+
readString(responseData?.troubleshooter) || readString(nestedError?.troubleshooter),
|
|
61
64
|
});
|
|
62
65
|
}
|
|
63
66
|
|
|
67
|
+
function formatFeishuApiFailure(
|
|
68
|
+
error: unknown,
|
|
69
|
+
errorPrefix: string,
|
|
70
|
+
options: {
|
|
71
|
+
includeConfigParams?: boolean;
|
|
72
|
+
includeNestedErrorLogId?: boolean;
|
|
73
|
+
} = {},
|
|
74
|
+
): string {
|
|
75
|
+
const details = formatFeishuApiError(error, options);
|
|
76
|
+
return `${errorPrefix}: ${details || "unknown error"}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createFeishuApiError(
|
|
80
|
+
error: unknown,
|
|
81
|
+
errorPrefix: string,
|
|
82
|
+
options: {
|
|
83
|
+
includeConfigParams?: boolean;
|
|
84
|
+
includeNestedErrorLogId?: boolean;
|
|
85
|
+
} = {},
|
|
86
|
+
): Error {
|
|
87
|
+
return new Error(formatFeishuApiFailure(error, errorPrefix, options), { cause: error });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function requestFeishuApi<T>(
|
|
91
|
+
request: () => Promise<T>,
|
|
92
|
+
errorPrefix: string,
|
|
93
|
+
options: {
|
|
94
|
+
includeConfigParams?: boolean;
|
|
95
|
+
includeNestedErrorLogId?: boolean;
|
|
96
|
+
} = {},
|
|
97
|
+
): Promise<T> {
|
|
98
|
+
try {
|
|
99
|
+
return await request();
|
|
100
|
+
} catch (error) {
|
|
101
|
+
throw createFeishuApiError(error, errorPrefix, options);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
64
105
|
type ParsedCommentDocumentRef = {
|
|
65
106
|
fileType?: CommentFileType;
|
|
66
107
|
fileToken?: string;
|
package/src/dedup.ts
CHANGED
|
@@ -118,7 +118,7 @@ export async function tryRecordMessagePersistent(
|
|
|
118
118
|
});
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
-
|
|
121
|
+
async function hasRecordedMessagePersistent(
|
|
122
122
|
messageId: string,
|
|
123
123
|
namespace = "global",
|
|
124
124
|
log?: (...args: unknown[]) => void,
|
package/src/directory.test.ts
CHANGED
|
@@ -8,15 +8,12 @@ vi.mock("./client.js", () => ({
|
|
|
8
8
|
createFeishuClient: createFeishuClientMock,
|
|
9
9
|
}));
|
|
10
10
|
|
|
11
|
-
const {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
import.meta.url,
|
|
18
|
-
"./directory.js?directory-test",
|
|
19
|
-
);
|
|
11
|
+
const { listFeishuDirectoryGroupsLive, listFeishuDirectoryPeersLive } = await importFreshModule<
|
|
12
|
+
typeof import("./directory.js")
|
|
13
|
+
>(import.meta.url, "./directory.js?directory-test");
|
|
14
|
+
const { listFeishuDirectoryGroups, listFeishuDirectoryPeers } = await importFreshModule<
|
|
15
|
+
typeof import("./directory.static.js")
|
|
16
|
+
>(import.meta.url, "./directory.static.js?directory-test");
|
|
20
17
|
|
|
21
18
|
function makeStaticCfg(): ClawdbotConfig {
|
|
22
19
|
return {
|
package/src/directory.ts
CHANGED
|
@@ -9,8 +9,6 @@ import {
|
|
|
9
9
|
type FeishuDirectoryPeer,
|
|
10
10
|
} from "./directory.static.js";
|
|
11
11
|
|
|
12
|
-
export { listFeishuDirectoryGroups, listFeishuDirectoryPeers } from "./directory.static.js";
|
|
13
|
-
|
|
14
12
|
export async function listFeishuDirectoryPeersLive(params: {
|
|
15
13
|
cfg: ClawdbotConfig;
|
|
16
14
|
query?: string;
|
package/src/media.test.ts
CHANGED
|
@@ -384,6 +384,34 @@ describe("sendMediaFeishu msg_type routing", () => {
|
|
|
384
384
|
);
|
|
385
385
|
});
|
|
386
386
|
|
|
387
|
+
it("preserves Feishu diagnostics when media sends reject before response checks", async () => {
|
|
388
|
+
messageCreateMock.mockRejectedValueOnce(
|
|
389
|
+
Object.assign(new Error("Request failed with status code 400"), {
|
|
390
|
+
response: {
|
|
391
|
+
status: 400,
|
|
392
|
+
data: {
|
|
393
|
+
code: 9499,
|
|
394
|
+
msg: "Bad Request",
|
|
395
|
+
error: {
|
|
396
|
+
log_id: "20260429124731MEDIA",
|
|
397
|
+
troubleshooter: "https://open.feishu.cn/search?log_id=20260429124731MEDIA",
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
}),
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
const send = sendMediaFeishu({
|
|
405
|
+
cfg: emptyConfig,
|
|
406
|
+
to: "user:ou_target",
|
|
407
|
+
mediaBuffer: Buffer.from("image"),
|
|
408
|
+
fileName: "photo.png",
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
await expect(send).rejects.toThrow(/Feishu image send failed: .*"feishu_code":9499/);
|
|
412
|
+
await expect(send).rejects.toThrow(/"feishu_log_id":"20260429124731MEDIA"/);
|
|
413
|
+
});
|
|
414
|
+
|
|
387
415
|
it("uses msg_type=media when replying with mp4", async () => {
|
|
388
416
|
await sendMediaFeishu({
|
|
389
417
|
cfg: emptyConfig,
|
package/src/media.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtim
|
|
|
12
12
|
import type { ClawdbotConfig } from "../runtime-api.js";
|
|
13
13
|
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
|
14
14
|
import { createFeishuClient } from "./client.js";
|
|
15
|
+
import { requestFeishuApi } from "./comment-shared.js";
|
|
15
16
|
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
|
16
17
|
import { getFeishuRuntime } from "./runtime.js";
|
|
17
18
|
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
|
|
@@ -418,12 +419,17 @@ export async function uploadImageFeishu(params: {
|
|
|
418
419
|
// See: https://github.com/larksuite/node-sdk/issues/121
|
|
419
420
|
const imageData = typeof image === "string" ? fs.createReadStream(image) : image;
|
|
420
421
|
|
|
421
|
-
const response = await
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
422
|
+
const response = await requestFeishuApi(
|
|
423
|
+
() =>
|
|
424
|
+
client.im.image.create({
|
|
425
|
+
data: {
|
|
426
|
+
image_type: imageType,
|
|
427
|
+
image: imageData,
|
|
428
|
+
},
|
|
429
|
+
}),
|
|
430
|
+
"Feishu image upload failed",
|
|
431
|
+
{ includeNestedErrorLogId: true },
|
|
432
|
+
);
|
|
427
433
|
|
|
428
434
|
return {
|
|
429
435
|
imageKey: extractFeishuUploadKey(response, {
|
|
@@ -469,14 +475,19 @@ export async function uploadFileFeishu(params: {
|
|
|
469
475
|
|
|
470
476
|
const safeFileName = sanitizeFileNameForUpload(fileName);
|
|
471
477
|
|
|
472
|
-
const response = await
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
478
|
+
const response = await requestFeishuApi(
|
|
479
|
+
() =>
|
|
480
|
+
client.im.file.create({
|
|
481
|
+
data: {
|
|
482
|
+
file_type: fileType,
|
|
483
|
+
file_name: safeFileName,
|
|
484
|
+
file: fileData,
|
|
485
|
+
...(duration !== undefined && { duration }),
|
|
486
|
+
},
|
|
487
|
+
}),
|
|
488
|
+
"Feishu file upload failed",
|
|
489
|
+
{ includeNestedErrorLogId: true },
|
|
490
|
+
);
|
|
480
491
|
|
|
481
492
|
return {
|
|
482
493
|
fileKey: extractFeishuUploadKey(response, {
|
|
@@ -506,26 +517,36 @@ export async function sendImageFeishu(params: {
|
|
|
506
517
|
const content = JSON.stringify({ image_key: imageKey });
|
|
507
518
|
|
|
508
519
|
if (replyToMessageId) {
|
|
509
|
-
const response = await
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
520
|
+
const response = await requestFeishuApi(
|
|
521
|
+
() =>
|
|
522
|
+
client.im.message.reply({
|
|
523
|
+
path: { message_id: replyToMessageId },
|
|
524
|
+
data: {
|
|
525
|
+
content,
|
|
526
|
+
msg_type: "image",
|
|
527
|
+
...(replyInThread ? { reply_in_thread: true } : {}),
|
|
528
|
+
},
|
|
529
|
+
}),
|
|
530
|
+
"Feishu image reply failed",
|
|
531
|
+
{ includeNestedErrorLogId: true },
|
|
532
|
+
);
|
|
517
533
|
assertFeishuMessageApiSuccess(response, "Feishu image reply failed");
|
|
518
534
|
return toFeishuSendResult(response, receiveId);
|
|
519
535
|
}
|
|
520
536
|
|
|
521
|
-
const response = await
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
537
|
+
const response = await requestFeishuApi(
|
|
538
|
+
() =>
|
|
539
|
+
client.im.message.create({
|
|
540
|
+
params: { receive_id_type: receiveIdType },
|
|
541
|
+
data: {
|
|
542
|
+
receive_id: receiveId,
|
|
543
|
+
content,
|
|
544
|
+
msg_type: "image",
|
|
545
|
+
},
|
|
546
|
+
}),
|
|
547
|
+
"Feishu image send failed",
|
|
548
|
+
{ includeNestedErrorLogId: true },
|
|
549
|
+
);
|
|
529
550
|
assertFeishuMessageApiSuccess(response, "Feishu image send failed");
|
|
530
551
|
return toFeishuSendResult(response, receiveId);
|
|
531
552
|
}
|
|
@@ -553,26 +574,36 @@ export async function sendFileFeishu(params: {
|
|
|
553
574
|
const content = JSON.stringify({ file_key: fileKey });
|
|
554
575
|
|
|
555
576
|
if (replyToMessageId) {
|
|
556
|
-
const response = await
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
577
|
+
const response = await requestFeishuApi(
|
|
578
|
+
() =>
|
|
579
|
+
client.im.message.reply({
|
|
580
|
+
path: { message_id: replyToMessageId },
|
|
581
|
+
data: {
|
|
582
|
+
content,
|
|
583
|
+
msg_type: msgType,
|
|
584
|
+
...(replyInThread ? { reply_in_thread: true } : {}),
|
|
585
|
+
},
|
|
586
|
+
}),
|
|
587
|
+
"Feishu file reply failed",
|
|
588
|
+
{ includeNestedErrorLogId: true },
|
|
589
|
+
);
|
|
564
590
|
assertFeishuMessageApiSuccess(response, "Feishu file reply failed");
|
|
565
591
|
return toFeishuSendResult(response, receiveId);
|
|
566
592
|
}
|
|
567
593
|
|
|
568
|
-
const response = await
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
594
|
+
const response = await requestFeishuApi(
|
|
595
|
+
() =>
|
|
596
|
+
client.im.message.create({
|
|
597
|
+
params: { receive_id_type: receiveIdType },
|
|
598
|
+
data: {
|
|
599
|
+
receive_id: receiveId,
|
|
600
|
+
content,
|
|
601
|
+
msg_type: msgType,
|
|
602
|
+
},
|
|
603
|
+
}),
|
|
604
|
+
"Feishu file send failed",
|
|
605
|
+
{ includeNestedErrorLogId: true },
|
|
606
|
+
);
|
|
576
607
|
assertFeishuMessageApiSuccess(response, "Feishu file send failed");
|
|
577
608
|
return toFeishuSendResult(response, receiveId);
|
|
578
609
|
}
|
|
@@ -57,6 +57,34 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
|
|
|
57
57
|
});
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
+
it("preserves Feishu diagnostics when direct sends reject before response checks", async () => {
|
|
61
|
+
const apiError = Object.assign(new Error("Request failed with status code 400"), {
|
|
62
|
+
response: {
|
|
63
|
+
status: 400,
|
|
64
|
+
data: {
|
|
65
|
+
code: 9499,
|
|
66
|
+
msg: "Bad Request",
|
|
67
|
+
error: {
|
|
68
|
+
log_id: "202604291247104BEF4C42D2420A9AD569",
|
|
69
|
+
troubleshooter:
|
|
70
|
+
"https://open.feishu.cn/search?log_id=202604291247104BEF4C42D2420A9AD569",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
createMock.mockRejectedValue(apiError);
|
|
76
|
+
|
|
77
|
+
await expect(
|
|
78
|
+
sendMessageFeishu({
|
|
79
|
+
cfg: {} as never,
|
|
80
|
+
to: "user:ou_target",
|
|
81
|
+
text: "hello",
|
|
82
|
+
}),
|
|
83
|
+
).rejects.toThrow(
|
|
84
|
+
/Feishu send failed: .*"http_status":400.*"feishu_code":9499.*"feishu_msg":"Bad Request".*"feishu_log_id":"202604291247104BEF4C42D2420A9AD569".*"feishu_troubleshooter":"https:\/\/open\.feishu\.cn\/search\?log_id=202604291247104BEF4C42D2420A9AD569"/,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
60
88
|
it("falls back to create for withdrawn post replies", async () => {
|
|
61
89
|
replyMock.mockResolvedValue({
|
|
62
90
|
code: 230011,
|
package/src/send.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
import type { ClawdbotConfig } from "../runtime-api.js";
|
|
8
8
|
import { resolveFeishuRuntimeAccount } from "./accounts.js";
|
|
9
9
|
import { createFeishuClient } from "./client.js";
|
|
10
|
+
import { createFeishuApiError, requestFeishuApi } from "./comment-shared.js";
|
|
10
11
|
import type { MentionTarget } from "./mention-target.types.js";
|
|
11
12
|
import { buildMentionedCardContent, buildMentionedMessage } from "./mention.js";
|
|
12
13
|
import { parsePostContent } from "./post.js";
|
|
@@ -117,14 +118,19 @@ async function sendFallbackDirect(
|
|
|
117
118
|
},
|
|
118
119
|
errorPrefix: string,
|
|
119
120
|
): Promise<FeishuSendResult> {
|
|
120
|
-
const response = await
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
121
|
+
const response = await requestFeishuApi(
|
|
122
|
+
() =>
|
|
123
|
+
client.im.message.create({
|
|
124
|
+
params: { receive_id_type: params.receiveIdType },
|
|
125
|
+
data: {
|
|
126
|
+
receive_id: params.receiveId,
|
|
127
|
+
content: params.content,
|
|
128
|
+
msg_type: params.msgType,
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
errorPrefix,
|
|
132
|
+
{ includeNestedErrorLogId: true },
|
|
133
|
+
);
|
|
128
134
|
assertFeishuMessageApiSuccess(response, errorPrefix);
|
|
129
135
|
return toFeishuSendResult(response, params.receiveId);
|
|
130
136
|
}
|
|
@@ -168,7 +174,7 @@ async function sendReplyOrFallbackDirect(
|
|
|
168
174
|
});
|
|
169
175
|
} catch (err) {
|
|
170
176
|
if (!isWithdrawnReplyError(err)) {
|
|
171
|
-
throw err;
|
|
177
|
+
throw createFeishuApiError(err, params.replyErrorPrefix, { includeNestedErrorLogId: true });
|
|
172
178
|
}
|
|
173
179
|
if (threadReplyFallbackError) {
|
|
174
180
|
throw threadReplyFallbackError;
|
|
@@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers";
|
|
3
3
|
import { expect, vi, type Mock } from "vitest";
|
|
4
4
|
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../../runtime-api.js";
|
|
5
|
-
import { createFeishuMessageReceiveHandler } from "../monitor.message-handler.js";
|
|
6
5
|
import { setFeishuRuntime } from "../runtime.js";
|
|
7
6
|
import type { ResolvedFeishuAccount } from "../types.js";
|
|
8
7
|
|
|
@@ -411,31 +410,6 @@ async function loadMonitorSingleAccount() {
|
|
|
411
410
|
return module.monitorSingleAccount;
|
|
412
411
|
}
|
|
413
412
|
|
|
414
|
-
export async function setupFeishuMessageReceiveLifecycleHandler(params: {
|
|
415
|
-
runtime: RuntimeEnv;
|
|
416
|
-
core: PluginRuntime;
|
|
417
|
-
cfg: ClawdbotConfig;
|
|
418
|
-
accountId: string;
|
|
419
|
-
fireAndForget?: boolean;
|
|
420
|
-
handleMessage: Parameters<typeof createFeishuMessageReceiveHandler>[0]["handleMessage"];
|
|
421
|
-
resolveDebounceText: Parameters<
|
|
422
|
-
typeof createFeishuMessageReceiveHandler
|
|
423
|
-
>[0]["resolveDebounceText"];
|
|
424
|
-
}): Promise<(data: unknown) => Promise<void>> {
|
|
425
|
-
return createFeishuMessageReceiveHandler({
|
|
426
|
-
cfg: params.cfg,
|
|
427
|
-
core: params.core,
|
|
428
|
-
accountId: params.accountId,
|
|
429
|
-
runtime: params.runtime,
|
|
430
|
-
chatHistories: new Map(),
|
|
431
|
-
fireAndForget: params.fireAndForget,
|
|
432
|
-
handleMessage: params.handleMessage,
|
|
433
|
-
resolveDebounceText: params.resolveDebounceText,
|
|
434
|
-
hasProcessedMessage: vi.fn(async () => false),
|
|
435
|
-
recordProcessedMessage: vi.fn(async () => true),
|
|
436
|
-
});
|
|
437
|
-
}
|
|
438
|
-
|
|
439
413
|
export async function setupFeishuLifecycleHandler(params: {
|
|
440
414
|
createEventDispatcherMock: {
|
|
441
415
|
mockReturnValue: (value: unknown) => unknown;
|
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
|
|
2
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
-
import "./lifecycle.test-support.js";
|
|
4
|
-
import {
|
|
5
|
-
getFeishuLifecycleTestMocks,
|
|
6
|
-
resetFeishuLifecycleTestMocks,
|
|
7
|
-
} from "./lifecycle.test-support.js";
|
|
8
|
-
import {
|
|
9
|
-
createFeishuLifecycleConfig,
|
|
10
|
-
createFeishuLifecycleReplyDispatcher,
|
|
11
|
-
createFeishuTextMessageEvent,
|
|
12
|
-
expectFeishuReplyDispatcherSentFinalReplyOnce,
|
|
13
|
-
expectFeishuReplyPipelineDedupedAcrossReplay,
|
|
14
|
-
expectFeishuReplyPipelineDedupedAfterPostSendFailure,
|
|
15
|
-
installFeishuLifecycleReplyRuntime,
|
|
16
|
-
mockFeishuReplyOnceDispatch,
|
|
17
|
-
restoreFeishuLifecycleStateDir,
|
|
18
|
-
setFeishuLifecycleStateDir,
|
|
19
|
-
setupFeishuMessageReceiveLifecycleHandler,
|
|
20
|
-
} from "./test-support/lifecycle-test-support.js";
|
|
21
|
-
|
|
22
|
-
const {
|
|
23
|
-
createFeishuReplyDispatcherMock,
|
|
24
|
-
dispatchReplyFromConfigMock,
|
|
25
|
-
finalizeInboundContextMock,
|
|
26
|
-
resolveAgentRouteMock,
|
|
27
|
-
withReplyDispatcherMock,
|
|
28
|
-
} = getFeishuLifecycleTestMocks();
|
|
29
|
-
|
|
30
|
-
let lastRuntime = createRuntimeEnv();
|
|
31
|
-
let lifecycleCore: ReturnType<typeof installFeishuLifecycleReplyRuntime>;
|
|
32
|
-
const handleMessageMock = vi.fn();
|
|
33
|
-
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
|
|
34
|
-
const lifecycleConfig = createFeishuLifecycleConfig({
|
|
35
|
-
accountId: "acct-lifecycle",
|
|
36
|
-
appId: "cli_test",
|
|
37
|
-
appSecret: "secret_test",
|
|
38
|
-
accountConfig: {
|
|
39
|
-
groupPolicy: "open",
|
|
40
|
-
groups: {
|
|
41
|
-
oc_group_1: {
|
|
42
|
-
requireMention: false,
|
|
43
|
-
groupSessionScope: "group_topic_sender",
|
|
44
|
-
replyInThread: "enabled",
|
|
45
|
-
},
|
|
46
|
-
},
|
|
47
|
-
},
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
async function setupLifecycleMonitor() {
|
|
51
|
-
lastRuntime = createRuntimeEnv();
|
|
52
|
-
return setupFeishuMessageReceiveLifecycleHandler({
|
|
53
|
-
runtime: lastRuntime,
|
|
54
|
-
core: lifecycleCore,
|
|
55
|
-
cfg: lifecycleConfig,
|
|
56
|
-
accountId: "acct-lifecycle",
|
|
57
|
-
handleMessage: handleMessageMock,
|
|
58
|
-
resolveDebounceText: ({ event }) => {
|
|
59
|
-
const parsed = JSON.parse(event.message.content) as { text?: string };
|
|
60
|
-
return parsed.text ?? "";
|
|
61
|
-
},
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
describe("Feishu reply-once lifecycle", () => {
|
|
66
|
-
beforeEach(() => {
|
|
67
|
-
vi.useRealTimers();
|
|
68
|
-
resetFeishuLifecycleTestMocks();
|
|
69
|
-
handleMessageMock.mockReset();
|
|
70
|
-
lastRuntime = createRuntimeEnv();
|
|
71
|
-
setFeishuLifecycleStateDir("openclaw-feishu-lifecycle");
|
|
72
|
-
|
|
73
|
-
createFeishuReplyDispatcherMock.mockReturnValue(createFeishuLifecycleReplyDispatcher());
|
|
74
|
-
|
|
75
|
-
resolveAgentRouteMock.mockReturnValue({
|
|
76
|
-
agentId: "main",
|
|
77
|
-
channel: "feishu",
|
|
78
|
-
accountId: "acct-lifecycle",
|
|
79
|
-
sessionKey: "agent:main:feishu:group:oc_group_1",
|
|
80
|
-
mainSessionKey: "agent:main:main",
|
|
81
|
-
matchedBy: "default",
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
mockFeishuReplyOnceDispatch({
|
|
85
|
-
dispatchReplyFromConfigMock,
|
|
86
|
-
replyText: "reply once",
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
withReplyDispatcherMock.mockImplementation(async ({ run }) => await run());
|
|
90
|
-
handleMessageMock.mockImplementation(async ({ event }) => {
|
|
91
|
-
const reply = createFeishuReplyDispatcherMock({
|
|
92
|
-
accountId: "acct-lifecycle",
|
|
93
|
-
chatId: event.message.chat_id,
|
|
94
|
-
replyToMessageId: event.message.root_id ?? event.message.message_id,
|
|
95
|
-
replyInThread: true,
|
|
96
|
-
rootId: event.message.root_id,
|
|
97
|
-
});
|
|
98
|
-
try {
|
|
99
|
-
await withReplyDispatcherMock({
|
|
100
|
-
dispatcher: reply.dispatcher,
|
|
101
|
-
onSettled: () => reply.markDispatchIdle(),
|
|
102
|
-
run: () =>
|
|
103
|
-
dispatchReplyFromConfigMock({
|
|
104
|
-
ctx: {
|
|
105
|
-
AccountId: "acct-lifecycle",
|
|
106
|
-
MessageSid: event.message.message_id,
|
|
107
|
-
},
|
|
108
|
-
dispatcher: reply.dispatcher,
|
|
109
|
-
}),
|
|
110
|
-
});
|
|
111
|
-
} catch (err) {
|
|
112
|
-
lastRuntime?.error(`feishu[acct-lifecycle]: failed to dispatch message: ${String(err)}`);
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
lifecycleCore = installFeishuLifecycleReplyRuntime({
|
|
117
|
-
resolveAgentRouteMock,
|
|
118
|
-
finalizeInboundContextMock,
|
|
119
|
-
dispatchReplyFromConfigMock,
|
|
120
|
-
withReplyDispatcherMock,
|
|
121
|
-
storePath: "/tmp/feishu-lifecycle-sessions.json",
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
afterEach(() => {
|
|
126
|
-
vi.useRealTimers();
|
|
127
|
-
restoreFeishuLifecycleStateDir(originalStateDir);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it("routes a topic-bound inbound event and emits one reply across duplicate replay", async () => {
|
|
131
|
-
const onMessage = await setupLifecycleMonitor();
|
|
132
|
-
const event = createFeishuTextMessageEvent({
|
|
133
|
-
messageId: "om_lifecycle_once",
|
|
134
|
-
chatId: "oc_group_1",
|
|
135
|
-
rootId: "om_root_topic_1",
|
|
136
|
-
threadId: "omt_topic_1",
|
|
137
|
-
text: "hello from topic",
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
await expectFeishuReplyPipelineDedupedAcrossReplay({
|
|
141
|
-
handler: onMessage,
|
|
142
|
-
event,
|
|
143
|
-
dispatchReplyFromConfigMock,
|
|
144
|
-
createFeishuReplyDispatcherMock,
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
expect(lastRuntime?.error).not.toHaveBeenCalled();
|
|
148
|
-
expect(handleMessageMock).toHaveBeenCalledTimes(1);
|
|
149
|
-
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
|
|
150
|
-
expect(createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1);
|
|
151
|
-
expect(createFeishuReplyDispatcherMock).toHaveBeenCalledWith(
|
|
152
|
-
expect.objectContaining({
|
|
153
|
-
accountId: "acct-lifecycle",
|
|
154
|
-
chatId: "oc_group_1",
|
|
155
|
-
replyToMessageId: "om_root_topic_1",
|
|
156
|
-
replyInThread: true,
|
|
157
|
-
rootId: "om_root_topic_1",
|
|
158
|
-
}),
|
|
159
|
-
);
|
|
160
|
-
expectFeishuReplyDispatcherSentFinalReplyOnce({ createFeishuReplyDispatcherMock });
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it("does not duplicate delivery when the first attempt fails after sending the reply", async () => {
|
|
164
|
-
const onMessage = await setupLifecycleMonitor();
|
|
165
|
-
const event = createFeishuTextMessageEvent({
|
|
166
|
-
messageId: "om_lifecycle_retry",
|
|
167
|
-
chatId: "oc_group_1",
|
|
168
|
-
rootId: "om_root_topic_1",
|
|
169
|
-
threadId: "omt_topic_1",
|
|
170
|
-
text: "hello from topic",
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
dispatchReplyFromConfigMock.mockImplementationOnce(async ({ dispatcher }) => {
|
|
174
|
-
await dispatcher.sendFinalReply({ text: "reply once" });
|
|
175
|
-
throw new Error("post-send failure");
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
await expectFeishuReplyPipelineDedupedAfterPostSendFailure({
|
|
179
|
-
handler: onMessage,
|
|
180
|
-
event,
|
|
181
|
-
dispatchReplyFromConfigMock,
|
|
182
|
-
runtimeErrorMock: lastRuntime?.error as ReturnType<typeof vi.fn>,
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
expect(lastRuntime?.error).toHaveBeenCalledTimes(1);
|
|
186
|
-
expect(handleMessageMock).toHaveBeenCalledTimes(1);
|
|
187
|
-
expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
|
|
188
|
-
expectFeishuReplyDispatcherSentFinalReplyOnce({ createFeishuReplyDispatcherMock });
|
|
189
|
-
});
|
|
190
|
-
});
|