@m1heng-clawd/feishu 0.1.14 → 0.1.16

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.
Files changed (43) hide show
  1. package/README.md +82 -2
  2. package/index.ts +7 -2
  3. package/package.json +5 -4
  4. package/skills/feishu-chat/SKILL.md +139 -0
  5. package/skills/feishu-urgent/SKILL.md +107 -0
  6. package/src/bot.ts +391 -45
  7. package/src/channel.ts +2 -0
  8. package/src/chat-tools/actions.ts +502 -0
  9. package/src/chat-tools/common.ts +13 -0
  10. package/src/chat-tools/index.ts +2 -0
  11. package/src/chat-tools/register.ts +72 -0
  12. package/src/chat-tools/schemas.ts +61 -0
  13. package/src/client.ts +13 -0
  14. package/src/config-schema.ts +6 -0
  15. package/src/doc-tools/actions.ts +63 -14
  16. package/src/doc-tools/schemas.ts +41 -77
  17. package/src/doc-write-service.ts +19 -0
  18. package/src/drive-tools/actions.ts +75 -27
  19. package/src/drive-tools/schemas.ts +45 -58
  20. package/src/media-duration.ts +190 -0
  21. package/src/media.ts +50 -27
  22. package/src/mention.ts +1 -1
  23. package/src/monitor.ts +32 -8
  24. package/src/outbound.ts +2 -2
  25. package/src/perm-tools/actions.ts +27 -3
  26. package/src/perm-tools/schemas.ts +39 -45
  27. package/src/policy.ts +7 -3
  28. package/src/reply-dispatcher.ts +97 -13
  29. package/src/send.ts +360 -42
  30. package/src/streaming-card.ts +32 -4
  31. package/src/task-tools/actions.ts +145 -10
  32. package/src/task-tools/register.ts +56 -1
  33. package/src/task-tools/schemas.ts +23 -18
  34. package/src/tools-common/tool-context.ts +1 -0
  35. package/src/tools-config.ts +9 -2
  36. package/src/types.ts +8 -1
  37. package/src/typing.ts +115 -5
  38. package/src/urgent-tools/actions.ts +84 -0
  39. package/src/urgent-tools/index.ts +3 -0
  40. package/src/urgent-tools/register.ts +64 -0
  41. package/src/urgent-tools/schemas.ts +25 -0
  42. package/src/wiki-tools/actions.ts +24 -6
  43. package/src/wiki-tools/schemas.ts +32 -51
@@ -3,26 +3,33 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import type { TaskClient } from "./common.js";
5
5
  import {
6
+ TASK_COMMENT_UPDATE_FIELD_VALUES,
6
7
  TASKLIST_UPDATE_FIELD_VALUES,
7
8
  TASK_UPDATE_FIELD_VALUES,
8
9
  type AddTaskToTasklistParams,
9
10
  type AddTasklistMembersParams,
11
+ type CreateTaskCommentParams,
10
12
  type CreateTasklistParams,
11
- CreateSubtaskParams,
12
- CreateTaskParams,
13
- DeleteTaskAttachmentParams,
14
- GetTaskAttachmentParams,
15
- GetTaskParams,
13
+ type CreateSubtaskParams,
14
+ type CreateTaskParams,
15
+ type DeleteTaskAttachmentParams,
16
+ type DeleteTaskCommentParams,
17
+ type GetTaskAttachmentParams,
18
+ type GetTaskCommentParams,
19
+ type GetTaskParams,
16
20
  type GetTasklistParams,
17
- ListTaskAttachmentsParams,
21
+ type ListTaskAttachmentsParams,
22
+ type ListTaskCommentsParams,
18
23
  type ListTasklistsParams,
19
24
  type RemoveTaskFromTasklistParams,
20
25
  type RemoveTasklistMembersParams,
21
- TaskUpdateTask,
26
+ type TaskCommentPatchComment,
27
+ type TaskUpdateTask,
22
28
  type TasklistPatchTasklist,
23
- UploadTaskAttachmentParams,
29
+ type UploadTaskAttachmentParams,
30
+ type UpdateTaskCommentParams,
24
31
  type UpdateTasklistParams,
25
- UpdateTaskParams,
32
+ type UpdateTaskParams,
26
33
  } from "./schemas.js";
27
34
  import {
28
35
  DEFAULT_TASK_ATTACHMENT_FILENAME,
@@ -36,6 +43,7 @@ import { getFeishuRuntime } from "../runtime.js";
36
43
  import { runTaskApiCall } from "./common.js";
37
44
 
38
45
  const SUPPORTED_PATCH_FIELDS = new Set<string>(TASK_UPDATE_FIELD_VALUES);
46
+ const SUPPORTED_COMMENT_PATCH_FIELDS = new Set<string>(TASK_COMMENT_UPDATE_FIELD_VALUES);
39
47
  const SUPPORTED_TASKLIST_PATCH_FIELDS = new Set<string>(TASKLIST_UPDATE_FIELD_VALUES);
40
48
 
41
49
  function omitUndefined<T extends Record<string, unknown>>(obj: T): T {
@@ -50,10 +58,16 @@ function inferUpdateFields(task: TaskUpdateTask): string[] {
50
58
  );
51
59
  }
52
60
 
61
+ function inferCommentUpdateFields(comment: TaskCommentPatchComment): string[] {
62
+ return Object.keys(comment).filter((field) =>
63
+ SUPPORTED_COMMENT_PATCH_FIELDS.has(field),
64
+ );
65
+ }
66
+
53
67
  function ensureSupportedUpdateFields(
54
68
  updateFields: string[],
55
69
  supported: Set<string>,
56
- resource: "task" | "tasklist",
70
+ resource: "task" | "comment" | "tasklist",
57
71
  ) {
58
72
  const invalid = updateFields.filter((field) => !supported.has(field));
59
73
  if (invalid.length > 0) {
@@ -102,6 +116,20 @@ function formatTasklist(tasklist: Record<string, unknown> | undefined) {
102
116
  };
103
117
  }
104
118
 
119
+ function formatComment(comment: Record<string, unknown> | undefined) {
120
+ if (!comment) return undefined;
121
+ return {
122
+ id: comment.id,
123
+ content: comment.content,
124
+ creator: comment.creator,
125
+ reply_to_comment_id: comment.reply_to_comment_id,
126
+ created_at: comment.created_at,
127
+ updated_at: comment.updated_at,
128
+ resource_type: comment.resource_type,
129
+ resource_id: comment.resource_id,
130
+ };
131
+ }
132
+
105
133
  function formatAttachment(attachment: Record<string, unknown> | undefined) {
106
134
  if (!attachment) return undefined;
107
135
  return {
@@ -307,6 +335,113 @@ export async function getTask(client: TaskClient, params: GetTaskParams) {
307
335
  };
308
336
  }
309
337
 
338
+ export async function createTaskComment(client: TaskClient, params: CreateTaskCommentParams) {
339
+ const res = await runTaskApiCall("task.v2.comment.create", () =>
340
+ client.task.v2.comment.create({
341
+ data: omitUndefined({
342
+ content: params.content,
343
+ reply_to_comment_id: params.reply_to_comment_id,
344
+ resource_type: "task",
345
+ resource_id: params.task_guid,
346
+ }),
347
+ params: omitUndefined({
348
+ user_id_type: params.user_id_type,
349
+ }),
350
+ }),
351
+ );
352
+
353
+ return {
354
+ comment: formatComment((res.data?.comment ?? undefined) as Record<string, unknown> | undefined),
355
+ };
356
+ }
357
+
358
+ export async function listTaskComments(client: TaskClient, params: ListTaskCommentsParams) {
359
+ const res = await runTaskApiCall("task.v2.comment.list", () =>
360
+ client.task.v2.comment.list({
361
+ params: omitUndefined({
362
+ resource_type: "task",
363
+ resource_id: params.task_guid,
364
+ page_size: params.page_size,
365
+ page_token: params.page_token,
366
+ direction: params.direction,
367
+ user_id_type: params.user_id_type,
368
+ }),
369
+ }),
370
+ );
371
+
372
+ const items = (res.data?.items ?? []) as Record<string, unknown>[];
373
+
374
+ return {
375
+ items: items.map((item) => formatComment(item)),
376
+ page_token: res.data?.page_token,
377
+ has_more: res.data?.has_more,
378
+ };
379
+ }
380
+
381
+ export async function getTaskComment(client: TaskClient, params: GetTaskCommentParams) {
382
+ const res = await runTaskApiCall("task.v2.comment.get", () =>
383
+ client.task.v2.comment.get({
384
+ path: { comment_id: params.comment_id },
385
+ params: omitUndefined({
386
+ user_id_type: params.user_id_type,
387
+ }),
388
+ }),
389
+ );
390
+
391
+ return {
392
+ comment: formatComment((res.data?.comment ?? undefined) as Record<string, unknown> | undefined),
393
+ };
394
+ }
395
+
396
+ export async function updateTaskComment(client: TaskClient, params: UpdateTaskCommentParams) {
397
+ const comment = omitUndefined(params.comment as Record<string, unknown>) as TaskCommentPatchComment;
398
+ const updateFields = params.update_fields?.length
399
+ ? params.update_fields
400
+ : inferCommentUpdateFields(comment);
401
+
402
+ if (params.update_fields?.length) {
403
+ ensureSupportedUpdateFields(updateFields, SUPPORTED_COMMENT_PATCH_FIELDS, "comment");
404
+ }
405
+
406
+ if (Object.keys(comment).length === 0) {
407
+ throw new Error("comment update payload is empty");
408
+ }
409
+ if (updateFields.length === 0) {
410
+ throw new Error("no valid update_fields provided or inferred from comment payload");
411
+ }
412
+
413
+ const res = await runTaskApiCall("task.v2.comment.patch", () =>
414
+ client.task.v2.comment.patch({
415
+ path: { comment_id: params.comment_id },
416
+ data: {
417
+ comment,
418
+ update_fields: updateFields,
419
+ },
420
+ params: omitUndefined({
421
+ user_id_type: params.user_id_type,
422
+ }),
423
+ }),
424
+ );
425
+
426
+ return {
427
+ comment: formatComment((res.data?.comment ?? undefined) as Record<string, unknown> | undefined),
428
+ update_fields: updateFields,
429
+ };
430
+ }
431
+
432
+ export async function deleteTaskComment(client: TaskClient, params: DeleteTaskCommentParams) {
433
+ await runTaskApiCall("task.v2.comment.delete", () =>
434
+ client.task.v2.comment.delete({
435
+ path: { comment_id: params.comment_id },
436
+ }),
437
+ );
438
+
439
+ return {
440
+ success: true,
441
+ comment_id: params.comment_id,
442
+ };
443
+ }
444
+
310
445
  export async function getTasklist(client: TaskClient, params: GetTasklistParams) {
311
446
  const res = await runTaskApiCall("task.v2.tasklist.get", () =>
312
447
  client.task.v2.tasklist.get({
@@ -6,19 +6,24 @@ import {
6
6
  addTaskToTasklist,
7
7
  addTasklistMembers,
8
8
  createSubtask,
9
+ createTaskComment,
9
10
  createTask,
10
11
  createTasklist,
11
12
  deleteTaskAttachment,
13
+ deleteTaskComment,
12
14
  deleteTask,
13
15
  deleteTasklist,
14
16
  getTaskAttachment,
17
+ getTaskComment,
15
18
  getTask,
16
19
  getTasklist,
20
+ listTaskComments,
17
21
  listTasklists,
18
22
  removeTaskFromTasklist,
19
23
  removeTasklistMembers,
20
24
  listTaskAttachments,
21
25
  uploadTaskAttachment,
26
+ updateTaskComment,
22
27
  updateTask,
23
28
  updateTasklist,
24
29
  } from "./actions.js";
@@ -36,26 +41,36 @@ import {
36
41
  type CreateTasklistParams,
37
42
  DeleteTaskAttachmentSchema,
38
43
  type DeleteTaskAttachmentParams,
44
+ DeleteTaskCommentSchema,
45
+ type DeleteTaskCommentParams,
39
46
  DeleteTaskSchema,
40
47
  type DeleteTaskParams,
41
48
  DeleteTasklistSchema,
42
49
  type DeleteTasklistParams,
43
50
  GetTaskAttachmentSchema,
44
51
  type GetTaskAttachmentParams,
52
+ GetTaskCommentSchema,
53
+ type GetTaskCommentParams,
45
54
  GetTaskSchema,
46
55
  type GetTaskParams,
47
56
  GetTasklistSchema,
48
57
  type GetTasklistParams,
49
58
  ListTaskAttachmentsSchema,
50
59
  type ListTaskAttachmentsParams,
60
+ ListTaskCommentsSchema,
61
+ type ListTaskCommentsParams,
51
62
  ListTasklistsSchema,
52
63
  type ListTasklistsParams,
53
64
  RemoveTaskFromTasklistSchema,
54
65
  type RemoveTaskFromTasklistParams,
55
66
  RemoveTasklistMembersSchema,
56
67
  type RemoveTasklistMembersParams,
68
+ CreateTaskCommentSchema,
69
+ type CreateTaskCommentParams,
57
70
  UploadTaskAttachmentSchema,
58
71
  type UploadTaskAttachmentParams,
72
+ UpdateTaskCommentSchema,
73
+ type UpdateTaskCommentParams,
59
74
  UpdateTaskSchema,
60
75
  UpdateTasklistSchema,
61
76
  type UpdateTaskParams,
@@ -259,5 +274,45 @@ export function registerFeishuTaskTools(api: OpenClawPluginApi) {
259
274
  run: async ({ client }, params) => updateTask(client, params),
260
275
  });
261
276
 
262
- api.logger.debug?.("feishu_task: Registered task, tasklist, subtask, and attachment tools");
277
+ registerTaskTool<CreateTaskCommentParams>(api, {
278
+ name: "feishu_task_comment_create",
279
+ label: "Feishu Task Comment Create",
280
+ description: "Create a comment on a Feishu task (task v2)",
281
+ parameters: CreateTaskCommentSchema,
282
+ run: async ({ client }, params) => createTaskComment(client, params),
283
+ });
284
+
285
+ registerTaskTool<ListTaskCommentsParams>(api, {
286
+ name: "feishu_task_comment_list",
287
+ label: "Feishu Task Comment List",
288
+ description: "List comments for a Feishu task (task v2)",
289
+ parameters: ListTaskCommentsSchema,
290
+ run: async ({ client }, params) => listTaskComments(client, params),
291
+ });
292
+
293
+ registerTaskTool<GetTaskCommentParams>(api, {
294
+ name: "feishu_task_comment_get",
295
+ label: "Feishu Task Comment Get",
296
+ description: "Get a Feishu task comment by comment_id (task v2)",
297
+ parameters: GetTaskCommentSchema,
298
+ run: async ({ client }, params) => getTaskComment(client, params),
299
+ });
300
+
301
+ registerTaskTool<UpdateTaskCommentParams>(api, {
302
+ name: "feishu_task_comment_update",
303
+ label: "Feishu Task Comment Update",
304
+ description: "Update a Feishu task comment by comment_id (task v2 patch)",
305
+ parameters: UpdateTaskCommentSchema,
306
+ run: async ({ client }, params) => updateTaskComment(client, params),
307
+ });
308
+
309
+ registerTaskTool<DeleteTaskCommentParams>(api, {
310
+ name: "feishu_task_comment_delete",
311
+ label: "Feishu Task Comment Delete",
312
+ description: "Delete a Feishu task comment by comment_id (task v2)",
313
+ parameters: DeleteTaskCommentSchema,
314
+ run: async ({ client }, params) => deleteTaskComment(client, params),
315
+ });
316
+
317
+ api.logger.debug?.("feishu_task: Registered task, comment, tasklist, subtask, and attachment tools");
263
318
  }
@@ -1,6 +1,15 @@
1
1
  import { Type } from "@sinclair/typebox";
2
2
  import type { TaskClient } from "./common.js";
3
3
 
4
+ // NOTE: Avoid Type.Union([Type.Literal(...)]) which compiles to anyOf.
5
+ // Some providers reject anyOf in tool schemas; a flat string enum is safer.
6
+ function stringEnum<T extends readonly string[]>(
7
+ values: T,
8
+ options: { description?: string; default?: T[number] } = {},
9
+ ) {
10
+ return Type.Unsafe<T[number]>({ type: "string", enum: [...values], ...options });
11
+ }
12
+
4
13
  type TaskCreatePayload = NonNullable<Parameters<TaskClient["task"]["v2"]["task"]["create"]>[0]>;
5
14
  type TaskUpdatePayload = NonNullable<Parameters<TaskClient["task"]["v2"]["task"]["patch"]>[0]>;
6
15
  type TaskDeletePayload = NonNullable<Parameters<TaskClient["task"]["v2"]["task"]["delete"]>[0]>;
@@ -75,6 +84,7 @@ export const TASK_UPDATE_FIELD_VALUES = [
75
84
  "mode",
76
85
  "is_milestone",
77
86
  ] as const;
87
+ export const TASK_COMMENT_UPDATE_FIELD_VALUES = ["content"] as const;
78
88
  export const TASKLIST_UPDATE_FIELD_VALUES = ["name", "owner", "archive_tasklist"] as const;
79
89
 
80
90
  export type CreateTaskParams = {
@@ -245,23 +255,19 @@ const TasklistRefSchema = Type.Object({
245
255
  section_guid: Type.Optional(Type.String({ description: "Section GUID in tasklist" })),
246
256
  });
247
257
 
248
- const TaskUpdateFieldSchema = Type.Union(
249
- TASK_UPDATE_FIELD_VALUES.map((field) => Type.Literal(field)),
250
- );
258
+ const TaskUpdateFieldSchema = stringEnum(TASK_UPDATE_FIELD_VALUES);
251
259
 
252
- const TasklistMemberRoleSchema = Type.Union(
253
- [Type.Literal("owner"), Type.Literal("editor"), Type.Literal("viewer")],
254
- { description: "Member role (owner/editor/viewer)" },
255
- );
260
+ const TaskCommentUpdateFieldSchema = stringEnum(TASK_COMMENT_UPDATE_FIELD_VALUES);
256
261
 
257
- const TasklistUpdateFieldSchema = Type.Union(
258
- TASKLIST_UPDATE_FIELD_VALUES.map((field) => Type.Literal(field)),
259
- );
262
+ const TasklistMemberRoleSchema = stringEnum(["owner", "editor", "viewer"] as const, {
263
+ description: "Member role (owner/editor/viewer)",
264
+ });
260
265
 
261
- const TasklistOriginOwnerRoleSchema = Type.Union(
262
- [Type.Literal("editor"), Type.Literal("viewer"), Type.Literal("none")],
263
- { description: "Role for original owner after owner transfer" },
264
- );
266
+ const TasklistUpdateFieldSchema = stringEnum(TASKLIST_UPDATE_FIELD_VALUES);
267
+
268
+ const TasklistOriginOwnerRoleSchema = stringEnum(["editor", "viewer", "none"] as const, {
269
+ description: "Role for original owner after owner transfer",
270
+ });
265
271
 
266
272
  const TasklistMemberSchema = Type.Object({
267
273
  id: Type.String({ description: "Member ID (with type controlled by user_id_type)" }),
@@ -372,9 +378,7 @@ export const ListTaskCommentsSchema = Type.Object({
372
378
  ),
373
379
  page_token: Type.Optional(Type.String({ description: "Pagination token" })),
374
380
  direction: Type.Optional(
375
- Type.Union([Type.Literal("asc"), Type.Literal("desc")], {
376
- description: "Sort direction",
377
- }),
381
+ stringEnum(["asc", "desc"] as const, { description: "Sort direction" }),
378
382
  ),
379
383
  user_id_type: Type.Optional(
380
384
  Type.String({ description: "User ID type for returned creators" }),
@@ -399,9 +403,10 @@ export const UpdateTaskCommentSchema = Type.Object({
399
403
  comment_id: Type.String({ description: "Comment ID to update" }),
400
404
  comment: TaskCommentUpdateContentSchema,
401
405
  update_fields: Type.Optional(
402
- Type.Array(Type.String(), {
406
+ Type.Array(TaskCommentUpdateFieldSchema, {
403
407
  description: "Fields to update. If omitted, this tool infers from keys in comment (content)",
404
408
  minItems: 1,
409
+ uniqueItems: true,
405
410
  }),
406
411
  ),
407
412
  user_id_type: Type.Optional(
@@ -4,6 +4,7 @@ export type FeishuToolContext = {
4
4
  channel: "feishu";
5
5
  accountId: string;
6
6
  sessionKey?: string;
7
+ senderOpenId?: string;
7
8
  };
8
9
 
9
10
  const toolContextStorage = new AsyncLocalStorage<FeishuToolContext>();
@@ -2,7 +2,7 @@ import type { FeishuToolsConfig } from "./types.js";
2
2
 
3
3
  /**
4
4
  * Default tool configuration.
5
- * - doc, wiki, drive, scopes, task: enabled by default
5
+ * - doc, wiki, drive, scopes, task, chat: enabled by default
6
6
  * - perm: disabled by default (sensitive operation)
7
7
  */
8
8
  export const DEFAULT_TOOLS_CONFIG: Required<FeishuToolsConfig> = {
@@ -12,11 +12,18 @@ export const DEFAULT_TOOLS_CONFIG: Required<FeishuToolsConfig> = {
12
12
  perm: false,
13
13
  scopes: true,
14
14
  task: true,
15
+ chat: true,
16
+ urgent: true,
15
17
  };
16
18
 
17
19
  /**
18
20
  * Resolve tools config with defaults.
21
+ * Only includes keys that are present in the input config or defaults.
19
22
  */
20
23
  export function resolveToolsConfig(cfg?: FeishuToolsConfig): Required<FeishuToolsConfig> {
21
- return { ...DEFAULT_TOOLS_CONFIG, ...cfg };
24
+ const res = {} as Required<FeishuToolsConfig>;
25
+ for (const key in DEFAULT_TOOLS_CONFIG) {
26
+ res[key] = cfg?.[key] ?? DEFAULT_TOOLS_CONFIG[key];
27
+ }
28
+ return res;
22
29
  }
package/src/types.ts CHANGED
@@ -30,13 +30,17 @@ export type FeishuMessageContext = {
30
30
  senderId: string;
31
31
  senderOpenId: string;
32
32
  senderName?: string;
33
- chatType: "p2p" | "group";
33
+ chatType: "p2p" | "group" | "private";
34
34
  mentionedBot: boolean;
35
35
  hasAnyMention?: boolean;
36
36
  rootId?: string;
37
37
  parentId?: string;
38
38
  content: string;
39
39
  contentType: string;
40
+ /** Media placeholder token (e.g. <media:audio>) when inbound message is media. */
41
+ placeholder?: string;
42
+ /** Raw provider payload string (original event.message.content). */
43
+ rawPayload?: string;
40
44
  /** Mention forward targets (excluding the bot itself) */
41
45
  mentionTargets?: MentionTarget[];
42
46
  /** Extracted message body (after removing @ placeholders) */
@@ -69,6 +73,9 @@ export type FeishuToolsConfig = {
69
73
  perm?: boolean;
70
74
  scopes?: boolean;
71
75
  task?: boolean;
76
+ chat?: boolean;
77
+ /** Enable the feishu_urgent tool (buzz/urgent notifications). Enabled by default. */
78
+ urgent?: boolean;
72
79
  };
73
80
 
74
81
  export type DynamicAgentCreationConfig = {
package/src/typing.ts CHANGED
@@ -7,13 +7,94 @@ import { resolveFeishuAccount } from "./accounts.js";
7
7
  // Full list: https://github.com/go-lark/lark/blob/main/emoji.go
8
8
  const TYPING_EMOJI = "Typing"; // Typing indicator emoji
9
9
 
10
+ /**
11
+ * Feishu API error codes that indicate the caller should back off.
12
+ * These must propagate to the typing circuit breaker so the keepalive loop
13
+ * can trip and stop retrying.
14
+ *
15
+ * - 99991400: Rate limit (too many requests per second)
16
+ * - 99991403: Monthly API call quota exceeded
17
+ * - 429: Standard HTTP 429 returned as a Feishu SDK error code
18
+ *
19
+ * @see https://open.feishu.cn/document/server-docs/api-call-guide/generic-error-code
20
+ */
21
+ const FEISHU_BACKOFF_CODES = new Set([99991400, 99991403, 429]);
22
+
23
+ /**
24
+ * Custom error class for Feishu backoff conditions detected from non-throwing
25
+ * SDK responses. Carries a numeric `.code` so that `isFeishuBackoffError()`
26
+ * recognises it when the error is caught downstream.
27
+ */
28
+ export class FeishuBackoffError extends Error {
29
+ code: number;
30
+ constructor(code: number) {
31
+ super(`Feishu API backoff: code ${code}`);
32
+ this.name = "FeishuBackoffError";
33
+ this.code = code;
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Check whether an error represents a rate-limit or quota-exceeded condition
39
+ * from the Feishu API that should stop the typing keepalive loop.
40
+ *
41
+ * Handles two shapes:
42
+ * 1. AxiosError with `response.status` and `response.data.code`
43
+ * 2. Feishu SDK error with a top-level `code` property
44
+ */
45
+ export function isFeishuBackoffError(err: unknown): boolean {
46
+ if (typeof err !== "object" || err === null) {
47
+ return false;
48
+ }
49
+
50
+ // AxiosError shape: err.response.status / err.response.data.code
51
+ const response = (err as { response?: { status?: number; data?: { code?: number } } }).response;
52
+ if (response) {
53
+ if (response.status === 429) {
54
+ return true;
55
+ }
56
+ if (typeof response.data?.code === "number" && FEISHU_BACKOFF_CODES.has(response.data.code)) {
57
+ return true;
58
+ }
59
+ }
60
+
61
+ // Feishu SDK error shape: err.code
62
+ const code = (err as { code?: number }).code;
63
+ if (typeof code === "number" && FEISHU_BACKOFF_CODES.has(code)) {
64
+ return true;
65
+ }
66
+
67
+ return false;
68
+ }
69
+
70
+ /**
71
+ * Check whether a Feishu SDK response object contains a backoff error code.
72
+ *
73
+ * The Feishu SDK sometimes returns a normal response (no throw) with an
74
+ * API-level error code in the response body. This must be detected so the
75
+ * circuit breaker can trip.
76
+ */
77
+ export function getBackoffCodeFromResponse(response: unknown): number | undefined {
78
+ if (typeof response !== "object" || response === null) {
79
+ return undefined;
80
+ }
81
+ const code = (response as { code?: number }).code;
82
+ if (typeof code === "number" && FEISHU_BACKOFF_CODES.has(code)) {
83
+ return code;
84
+ }
85
+ return undefined;
86
+ }
87
+
10
88
  export type TypingIndicatorState = {
11
89
  messageId: string;
12
90
  reactionId: string | null;
13
91
  };
14
92
 
15
93
  /**
16
- * Add a typing indicator (reaction) to a message
94
+ * Add a typing indicator (reaction) to a message.
95
+ *
96
+ * Rate-limit and quota errors are re-thrown so the circuit breaker in
97
+ * `createTypingCallbacks` can trip and stop the keepalive loop.
17
98
  */
18
99
  export async function addTypingIndicator(params: {
19
100
  cfg: ClawdbotConfig;
@@ -36,17 +117,33 @@ export async function addTypingIndicator(params: {
36
117
  },
37
118
  });
38
119
 
120
+ // Feishu SDK may return a normal response with an API-level error code
121
+ // instead of throwing. Detect backoff codes and throw to trip the breaker.
122
+ const backoffCode = getBackoffCodeFromResponse(response);
123
+ if (backoffCode !== undefined) {
124
+ console.log(
125
+ `[feishu] typing indicator response contains backoff code ${backoffCode}, stopping keepalive`,
126
+ );
127
+ throw new FeishuBackoffError(backoffCode);
128
+ }
129
+
39
130
  const reactionId = (response as any)?.data?.reaction_id ?? null;
40
131
  return { messageId, reactionId };
41
132
  } catch (err) {
42
- // Silently fail - typing indicator is not critical
133
+ if (isFeishuBackoffError(err)) {
134
+ console.log(`[feishu] typing indicator hit rate-limit/quota, stopping keepalive`);
135
+ throw err;
136
+ }
137
+ // Silently fail for other non-critical errors (e.g. message deleted, permission issues)
43
138
  console.log(`[feishu] failed to add typing indicator: ${err}`);
44
139
  return { messageId, reactionId: null };
45
140
  }
46
141
  }
47
142
 
48
143
  /**
49
- * Remove a typing indicator (reaction) from a message
144
+ * Remove a typing indicator (reaction) from a message.
145
+ *
146
+ * Rate-limit and quota errors are re-thrown for the same reason as above.
50
147
  */
51
148
  export async function removeTypingIndicator(params: {
52
149
  cfg: ClawdbotConfig;
@@ -62,14 +159,27 @@ export async function removeTypingIndicator(params: {
62
159
  const client = createFeishuClient(account);
63
160
 
64
161
  try {
65
- await client.im.messageReaction.delete({
162
+ const result = await client.im.messageReaction.delete({
66
163
  path: {
67
164
  message_id: state.messageId,
68
165
  reaction_id: state.reactionId,
69
166
  },
70
167
  });
168
+
169
+ // Check for backoff codes in non-throwing SDK responses
170
+ const backoffCode = getBackoffCodeFromResponse(result);
171
+ if (backoffCode !== undefined) {
172
+ console.log(
173
+ `[feishu] typing indicator removal response contains backoff code ${backoffCode}, stopping keepalive`,
174
+ );
175
+ throw new FeishuBackoffError(backoffCode);
176
+ }
71
177
  } catch (err) {
72
- // Silently fail - cleanup is not critical
178
+ if (isFeishuBackoffError(err)) {
179
+ console.log(`[feishu] typing indicator removal hit rate-limit/quota, stopping keepalive`);
180
+ throw err;
181
+ }
182
+ // Silently fail for other non-critical errors
73
183
  console.log(`[feishu] failed to remove typing indicator: ${err}`);
74
184
  }
75
185
  }