@m1heng-clawd/feishu 0.1.13 → 0.1.15

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.
@@ -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
  }
@@ -0,0 +1,84 @@
1
+ import type * as Lark from "@larksuiteoapi/node-sdk";
2
+ import { runFeishuApiCall, type FeishuApiResponse } from "../tools-common/feishu-api.js";
3
+
4
+ /**
5
+ * Urgency type for Feishu urgent messages.
6
+ * - "app": In-app buzz notification (default, no extra cost)
7
+ * - "sms": SMS push to the recipient's phone
8
+ * - "phone": Voice call to the recipient's phone
9
+ */
10
+ export type FeishuUrgentType = "app" | "sms" | "phone";
11
+
12
+ type UrgentPayload = {
13
+ data: { user_id_list: string[] };
14
+ params: { user_id_type: "open_id" | "user_id" | "union_id" };
15
+ path: { message_id: string };
16
+ };
17
+
18
+ interface UrgentResponse extends FeishuApiResponse {
19
+ data?: { invalid_user_id_list?: string[] };
20
+ }
21
+
22
+ type LarkMessageWithUrgent = Lark.Client["im"]["message"] & {
23
+ urgentApp?: (payload: UrgentPayload) => Promise<UrgentResponse>;
24
+ urgentSms?: (payload: UrgentPayload) => Promise<UrgentResponse>;
25
+ urgentPhone?: (payload: UrgentPayload) => Promise<UrgentResponse>;
26
+ };
27
+
28
+ /**
29
+ * Send an urgent (buzz) notification for an existing Feishu message.
30
+ *
31
+ * Calls the Feishu "urgent" API which sends a strong push notification
32
+ * to the specified recipients. The message must already be sent.
33
+ *
34
+ * Requires `im:message.urgent` scope (or `im:message.urgent:sms` / `im:message.urgent:phone` variants).
35
+ *
36
+ * Common errors:
37
+ * - Code 230024: Quota exceeded ("Reach the upper limit of urgent message").
38
+ * Check tenant quota in Feishu admin console > Cost Center.
39
+ * - Invalid user IDs cause HTTP 400 with descriptive message (not returned in
40
+ * `invalid_user_id_list` as documented).
41
+ *
42
+ * @see https://open.feishu.cn/document/server-docs/im-v1/message/urgent_app
43
+ * @see https://open.feishu.cn/document/server-docs/im-v1/message/urgent_sms
44
+ * @see https://open.feishu.cn/document/server-docs/im-v1/message/urgent_phone
45
+ */
46
+ export async function urgentMessageFeishu(params: {
47
+ client: Lark.Client;
48
+ messageId: string;
49
+ userIds: string[];
50
+ urgentType?: FeishuUrgentType;
51
+ }): Promise<{ invalidUserList: string[] }> {
52
+ const { client, messageId, userIds, urgentType = "app" } = params;
53
+
54
+ const larkMessage = client.im.message as LarkMessageWithUrgent;
55
+
56
+ const payload: UrgentPayload = {
57
+ path: { message_id: messageId },
58
+ params: { user_id_type: "open_id" },
59
+ data: { user_id_list: userIds },
60
+ };
61
+
62
+ const methodMap = {
63
+ app: larkMessage.urgentApp?.bind(larkMessage),
64
+ sms: larkMessage.urgentSms?.bind(larkMessage),
65
+ phone: larkMessage.urgentPhone?.bind(larkMessage),
66
+ } as const;
67
+
68
+ const method = methodMap[urgentType];
69
+ if (typeof method !== "function") {
70
+ throw new Error(
71
+ `Feishu urgent: SDK method not available for urgentType="${urgentType}". ` +
72
+ `Check that @larksuiteoapi/node-sdk is up to date.`,
73
+ );
74
+ }
75
+
76
+ const response = await runFeishuApiCall<UrgentResponse>(
77
+ `Feishu urgent message (${urgentType})`,
78
+ () => method(payload),
79
+ );
80
+
81
+ return {
82
+ invalidUserList: response.data?.invalid_user_id_list ?? [],
83
+ };
84
+ }
@@ -0,0 +1,3 @@
1
+ export { registerFeishuUrgentTools } from "./register.js";
2
+ export { urgentMessageFeishu, type FeishuUrgentType } from "./actions.js";
3
+ export { FeishuUrgentSchema, type FeishuUrgentParams } from "./schemas.js";
@@ -0,0 +1,64 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { hasFeishuToolEnabledForAnyAccount, withFeishuToolClient } from "../tools-common/tool-exec.js";
3
+ import { errorResult, json } from "../tools-common/feishu-api.js";
4
+ import { urgentMessageFeishu } from "./actions.js";
5
+ import { FeishuUrgentSchema, type FeishuUrgentParams } from "./schemas.js";
6
+
7
+ export function registerFeishuUrgentTools(api: OpenClawPluginApi) {
8
+ if (!api.config) {
9
+ api.logger.debug?.("feishu_urgent: No config available, skipping");
10
+ return;
11
+ }
12
+
13
+ if (!hasFeishuToolEnabledForAnyAccount(api.config)) {
14
+ api.logger.debug?.("feishu_urgent: No Feishu accounts configured, skipping");
15
+ return;
16
+ }
17
+
18
+ if (!hasFeishuToolEnabledForAnyAccount(api.config, "urgent")) {
19
+ api.logger.debug?.("feishu_urgent: urgent tool disabled in config");
20
+ return;
21
+ }
22
+
23
+ api.registerTool(
24
+ {
25
+ name: "feishu_urgent",
26
+ label: "Feishu Urgent",
27
+ description:
28
+ "Send an urgent (buzz) notification for an existing Feishu message. " +
29
+ "Supported urgent_type values: app (in-app buzz, default), sms (SMS push), phone (voice call). " +
30
+ "Requires the message_id of an already-sent message and the open_id list of recipients to buzz. " +
31
+ "Use this to escalate important messages that require immediate attention.",
32
+ parameters: FeishuUrgentSchema,
33
+ async execute(_toolCallId, params) {
34
+ const p = params as FeishuUrgentParams;
35
+ try {
36
+ return await withFeishuToolClient({
37
+ api,
38
+ toolName: "feishu_urgent",
39
+ requiredTool: "urgent",
40
+ run: async ({ client }) => {
41
+ const result = await urgentMessageFeishu({
42
+ client,
43
+ messageId: p.message_id,
44
+ userIds: p.user_ids,
45
+ urgentType: p.urgent_type ?? "app",
46
+ });
47
+ return json({
48
+ ok: true,
49
+ message_id: p.message_id,
50
+ urgent_type: p.urgent_type ?? "app",
51
+ invalid_user_list: result.invalidUserList,
52
+ });
53
+ },
54
+ });
55
+ } catch (err) {
56
+ return errorResult(err);
57
+ }
58
+ },
59
+ },
60
+ { name: "feishu_urgent" },
61
+ );
62
+
63
+ api.logger.debug?.("feishu_urgent: Registered feishu_urgent tool");
64
+ }
@@ -0,0 +1,25 @@
1
+ import { Type, type Static } from "@sinclair/typebox";
2
+
3
+ const URGENT_TYPE_VALUES = ["app", "sms", "phone"] as const;
4
+
5
+ export const FeishuUrgentSchema = Type.Object({
6
+ message_id: Type.String({
7
+ description: "Message ID to send urgent notification for (e.g. om_xxx). The message must already be sent.",
8
+ }),
9
+ user_ids: Type.Array(Type.String(), {
10
+ description:
11
+ "List of open_id values to buzz. Recipients must be members of the chat where the message was sent.",
12
+ minItems: 1,
13
+ }),
14
+ urgent_type: Type.Optional(
15
+ Type.Unsafe<(typeof URGENT_TYPE_VALUES)[number]>({
16
+ type: "string",
17
+ enum: [...URGENT_TYPE_VALUES],
18
+ description:
19
+ "Urgency delivery method: app (in-app buzz, default), sms (SMS push), phone (voice call). Note: sms and phone may incur cost on the tenant.",
20
+ default: "app",
21
+ }),
22
+ ),
23
+ });
24
+
25
+ export type FeishuUrgentParams = Static<typeof FeishuUrgentSchema>;