@openclaw/feishu 2026.3.13 → 2026.5.1-beta.1

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 (188) hide show
  1. package/api.ts +31 -0
  2. package/channel-entry.ts +20 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +16 -0
  5. package/index.ts +70 -53
  6. package/openclaw.plugin.json +1653 -4
  7. package/package.json +32 -7
  8. package/runtime-api.ts +55 -0
  9. package/secret-contract-api.ts +5 -0
  10. package/security-contract-api.ts +1 -0
  11. package/session-key-api.ts +1 -0
  12. package/setup-api.ts +3 -0
  13. package/setup-entry.test.ts +14 -0
  14. package/setup-entry.ts +13 -0
  15. package/src/accounts.test.ts +95 -7
  16. package/src/accounts.ts +199 -117
  17. package/src/app-registration.ts +331 -0
  18. package/src/approval-auth.test.ts +24 -0
  19. package/src/approval-auth.ts +25 -0
  20. package/src/async.test.ts +35 -0
  21. package/src/async.ts +43 -1
  22. package/src/audio-preflight.runtime.ts +9 -0
  23. package/src/bitable.test.ts +131 -0
  24. package/src/bitable.ts +59 -22
  25. package/src/bot-content.ts +474 -0
  26. package/src/bot-group-name.test.ts +108 -0
  27. package/src/bot-runtime-api.ts +12 -0
  28. package/src/bot-sender-name.ts +125 -0
  29. package/src/bot.broadcast.test.ts +463 -0
  30. package/src/bot.card-action.test.ts +519 -5
  31. package/src/bot.checkBotMentioned.test.ts +92 -20
  32. package/src/bot.helpers.test.ts +118 -0
  33. package/src/bot.stripBotMention.test.ts +13 -21
  34. package/src/bot.test.ts +1334 -401
  35. package/src/bot.ts +778 -775
  36. package/src/card-action.ts +408 -40
  37. package/src/card-interaction.test.ts +129 -0
  38. package/src/card-interaction.ts +159 -0
  39. package/src/card-test-helpers.ts +47 -0
  40. package/src/card-ux-approval.ts +65 -0
  41. package/src/card-ux-launcher.test.ts +99 -0
  42. package/src/card-ux-launcher.ts +121 -0
  43. package/src/card-ux-shared.ts +33 -0
  44. package/src/channel-runtime-api.ts +16 -0
  45. package/src/channel.runtime.ts +47 -0
  46. package/src/channel.test.ts +914 -3
  47. package/src/channel.ts +1252 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +84 -28
  50. package/src/chat.ts +68 -10
  51. package/src/client.test.ts +212 -103
  52. package/src/client.ts +115 -21
  53. package/src/comment-dispatcher-runtime-api.ts +6 -0
  54. package/src/comment-dispatcher.test.ts +169 -0
  55. package/src/comment-dispatcher.ts +107 -0
  56. package/src/comment-handler-runtime-api.ts +3 -0
  57. package/src/comment-handler.test.ts +486 -0
  58. package/src/comment-handler.ts +309 -0
  59. package/src/comment-reaction.test.ts +166 -0
  60. package/src/comment-reaction.ts +259 -0
  61. package/src/comment-shared.test.ts +182 -0
  62. package/src/comment-shared.ts +365 -0
  63. package/src/comment-target.ts +44 -0
  64. package/src/config-schema.test.ts +63 -1
  65. package/src/config-schema.ts +31 -4
  66. package/src/conversation-id.test.ts +18 -0
  67. package/src/conversation-id.ts +199 -0
  68. package/src/dedup-runtime-api.ts +1 -0
  69. package/src/dedup.ts +32 -94
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +119 -20
  72. package/src/directory.ts +61 -91
  73. package/src/doc-schema.ts +1 -1
  74. package/src/docx-batch-insert.test.ts +39 -38
  75. package/src/docx-batch-insert.ts +55 -19
  76. package/src/docx-color-text.ts +9 -4
  77. package/src/docx-table-ops.test.ts +53 -0
  78. package/src/docx-table-ops.ts +52 -34
  79. package/src/docx-types.ts +38 -0
  80. package/src/docx.account-selection.test.ts +12 -3
  81. package/src/docx.test.ts +314 -74
  82. package/src/docx.ts +278 -122
  83. package/src/drive-schema.ts +47 -1
  84. package/src/drive.test.ts +1219 -0
  85. package/src/drive.ts +614 -13
  86. package/src/dynamic-agent.ts +10 -4
  87. package/src/event-types.ts +45 -0
  88. package/src/external-keys.ts +1 -1
  89. package/src/lifecycle.test-support.ts +220 -0
  90. package/src/media.test.ts +375 -26
  91. package/src/media.ts +434 -88
  92. package/src/mention-target.types.ts +5 -0
  93. package/src/mention.ts +32 -51
  94. package/src/message-action-contract.ts +13 -0
  95. package/src/monitor-state-runtime-api.ts +7 -0
  96. package/src/monitor-transport-runtime-api.ts +7 -0
  97. package/src/monitor.account.ts +218 -312
  98. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
  99. package/src/monitor.bot-identity.ts +86 -0
  100. package/src/monitor.bot-menu-handler.ts +165 -0
  101. package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
  102. package/src/monitor.bot-menu.test.ts +178 -0
  103. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
  104. package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
  105. package/src/monitor.cleanup.test.ts +376 -0
  106. package/src/monitor.comment-notice-handler.ts +105 -0
  107. package/src/monitor.comment.test.ts +937 -0
  108. package/src/monitor.comment.ts +1386 -0
  109. package/src/monitor.lifecycle.test.ts +4 -0
  110. package/src/monitor.message-handler.ts +339 -0
  111. package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
  112. package/src/monitor.reaction.test.ts +108 -48
  113. package/src/monitor.reply-once.lifecycle.test-support.ts +190 -0
  114. package/src/monitor.startup.test.ts +11 -9
  115. package/src/monitor.startup.ts +26 -16
  116. package/src/monitor.state.ts +20 -5
  117. package/src/monitor.synthetic-error.ts +18 -0
  118. package/src/monitor.test-mocks.ts +2 -2
  119. package/src/monitor.transport.ts +220 -60
  120. package/src/monitor.ts +15 -10
  121. package/src/monitor.webhook-e2e.test.ts +65 -7
  122. package/src/monitor.webhook-security.test.ts +122 -0
  123. package/src/monitor.webhook.test-helpers.ts +44 -26
  124. package/src/outbound-runtime-api.ts +1 -0
  125. package/src/outbound.test.ts +616 -37
  126. package/src/outbound.ts +623 -81
  127. package/src/perm-schema.ts +1 -1
  128. package/src/perm.ts +1 -7
  129. package/src/pins.ts +108 -0
  130. package/src/policy.test.ts +297 -117
  131. package/src/policy.ts +142 -29
  132. package/src/post.ts +7 -6
  133. package/src/probe.test.ts +14 -9
  134. package/src/probe.ts +26 -16
  135. package/src/processing-claims.ts +59 -0
  136. package/src/qr-terminal.ts +1 -0
  137. package/src/reactions.ts +4 -34
  138. package/src/reasoning-preview.test.ts +59 -0
  139. package/src/reasoning-preview.ts +20 -0
  140. package/src/reply-dispatcher-runtime-api.ts +7 -0
  141. package/src/reply-dispatcher.test.ts +660 -29
  142. package/src/reply-dispatcher.ts +407 -154
  143. package/src/runtime.ts +6 -3
  144. package/src/secret-contract.ts +145 -0
  145. package/src/secret-input.ts +1 -13
  146. package/src/security-audit-shared.ts +69 -0
  147. package/src/security-audit.test.ts +61 -0
  148. package/src/security-audit.ts +1 -0
  149. package/src/send-result.ts +1 -1
  150. package/src/send-target.test.ts +9 -3
  151. package/src/send-target.ts +10 -4
  152. package/src/send.reply-fallback.test.ts +77 -2
  153. package/src/send.test.ts +386 -4
  154. package/src/send.ts +399 -86
  155. package/src/sequential-key.test.ts +72 -0
  156. package/src/sequential-key.ts +28 -0
  157. package/src/sequential-queue.test.ts +92 -0
  158. package/src/sequential-queue.ts +16 -0
  159. package/src/session-conversation.ts +42 -0
  160. package/src/session-route.ts +48 -0
  161. package/src/setup-core.ts +51 -0
  162. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  163. package/src/setup-surface.ts +581 -0
  164. package/src/streaming-card.test.ts +138 -2
  165. package/src/streaming-card.ts +134 -18
  166. package/src/subagent-hooks.test.ts +603 -0
  167. package/src/subagent-hooks.ts +397 -0
  168. package/src/targets.ts +3 -13
  169. package/src/test-support/lifecycle-test-support.ts +479 -0
  170. package/src/thread-bindings.test.ts +143 -0
  171. package/src/thread-bindings.ts +330 -0
  172. package/src/tool-account-routing.test.ts +66 -8
  173. package/src/tool-account.test.ts +44 -0
  174. package/src/tool-account.ts +40 -17
  175. package/src/tool-factory-test-harness.ts +11 -8
  176. package/src/tool-result.ts +3 -1
  177. package/src/tools-config.ts +1 -1
  178. package/src/types.ts +16 -15
  179. package/src/typing.ts +10 -6
  180. package/src/wiki-schema.ts +1 -1
  181. package/src/wiki.ts +1 -7
  182. package/subagent-hooks-api.ts +31 -0
  183. package/tsconfig.json +16 -0
  184. package/src/feishu-command-handler.ts +0 -59
  185. package/src/onboarding.status.test.ts +0 -25
  186. package/src/onboarding.ts +0 -489
  187. package/src/send-message.ts +0 -71
  188. package/src/targets.test.ts +0 -70
@@ -1,4 +1,4 @@
1
- import { Type, type Static } from "@sinclair/typebox";
1
+ import { Type, type Static } from "typebox";
2
2
 
3
3
  const TokenType = Type.Union([
4
4
  Type.Literal("doc"),
package/src/perm.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type * as Lark from "@larksuiteoapi/node-sdk";
2
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk/feishu";
2
+ import type { OpenClawPluginApi } from "../runtime-api.js";
3
3
  import { listEnabledFeishuAccounts } from "./accounts.js";
4
4
  import { FeishuPermSchema, type FeishuPermParams } from "./perm-schema.js";
5
5
  import { createFeishuToolClient, resolveAnyEnabledFeishuToolsConfig } from "./tool-account.js";
@@ -114,19 +114,16 @@ async function removeMember(
114
114
 
115
115
  export function registerFeishuPermTools(api: OpenClawPluginApi) {
116
116
  if (!api.config) {
117
- api.logger.debug?.("feishu_perm: No config available, skipping perm tools");
118
117
  return;
119
118
  }
120
119
 
121
120
  const accounts = listEnabledFeishuAccounts(api.config);
122
121
  if (accounts.length === 0) {
123
- api.logger.debug?.("feishu_perm: No Feishu accounts configured, skipping perm tools");
124
122
  return;
125
123
  }
126
124
 
127
125
  const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts);
128
126
  if (!toolsCfg.perm) {
129
- api.logger.debug?.("feishu_perm: perm tool disabled in config (default: false)");
130
127
  return;
131
128
  }
132
129
 
@@ -160,7 +157,6 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) {
160
157
  await removeMember(client, p.token, p.type, p.member_type, p.member_id),
161
158
  );
162
159
  default:
163
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- exhaustive check fallback
164
160
  return unknownToolActionResult((p as { action?: unknown }).action);
165
161
  }
166
162
  } catch (err) {
@@ -171,6 +167,4 @@ export function registerFeishuPermTools(api: OpenClawPluginApi) {
171
167
  },
172
168
  { name: "feishu_perm" },
173
169
  );
174
-
175
- api.logger.info?.(`feishu_perm: Registered feishu_perm tool`);
176
170
  }
package/src/pins.ts ADDED
@@ -0,0 +1,108 @@
1
+ import type { ClawdbotConfig } from "../runtime-api.js";
2
+ import { resolveFeishuRuntimeAccount } from "./accounts.js";
3
+ import { createFeishuClient } from "./client.js";
4
+
5
+ type FeishuPin = {
6
+ messageId: string;
7
+ chatId?: string;
8
+ operatorId?: string;
9
+ operatorIdType?: string;
10
+ createTime?: string;
11
+ };
12
+
13
+ function assertFeishuPinApiSuccess(response: { code?: number; msg?: string }, action: string) {
14
+ if (response.code !== 0) {
15
+ throw new Error(`Feishu ${action} failed: ${response.msg || `code ${response.code}`}`);
16
+ }
17
+ }
18
+
19
+ function normalizePin(pin: {
20
+ message_id: string;
21
+ chat_id?: string;
22
+ operator_id?: string;
23
+ operator_id_type?: string;
24
+ create_time?: string;
25
+ }): FeishuPin {
26
+ return {
27
+ messageId: pin.message_id,
28
+ chatId: pin.chat_id,
29
+ operatorId: pin.operator_id,
30
+ operatorIdType: pin.operator_id_type,
31
+ createTime: pin.create_time,
32
+ };
33
+ }
34
+
35
+ export async function createPinFeishu(params: {
36
+ cfg: ClawdbotConfig;
37
+ messageId: string;
38
+ accountId?: string;
39
+ }): Promise<FeishuPin | null> {
40
+ const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId });
41
+ if (!account.configured) {
42
+ throw new Error(`Feishu account "${account.accountId}" not configured`);
43
+ }
44
+
45
+ const client = createFeishuClient(account);
46
+ const response = await client.im.pin.create({
47
+ data: {
48
+ message_id: params.messageId,
49
+ },
50
+ });
51
+ assertFeishuPinApiSuccess(response, "pin create");
52
+ return response.data?.pin ? normalizePin(response.data.pin) : null;
53
+ }
54
+
55
+ export async function removePinFeishu(params: {
56
+ cfg: ClawdbotConfig;
57
+ messageId: string;
58
+ accountId?: string;
59
+ }): Promise<void> {
60
+ const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId });
61
+ if (!account.configured) {
62
+ throw new Error(`Feishu account "${account.accountId}" not configured`);
63
+ }
64
+
65
+ const client = createFeishuClient(account);
66
+ const response = await client.im.pin.delete({
67
+ path: {
68
+ message_id: params.messageId,
69
+ },
70
+ });
71
+ assertFeishuPinApiSuccess(response, "pin delete");
72
+ }
73
+
74
+ export async function listPinsFeishu(params: {
75
+ cfg: ClawdbotConfig;
76
+ chatId: string;
77
+ startTime?: string;
78
+ endTime?: string;
79
+ pageSize?: number;
80
+ pageToken?: string;
81
+ accountId?: string;
82
+ }): Promise<{ chatId: string; pins: FeishuPin[]; hasMore: boolean; pageToken?: string }> {
83
+ const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId });
84
+ if (!account.configured) {
85
+ throw new Error(`Feishu account "${account.accountId}" not configured`);
86
+ }
87
+
88
+ const client = createFeishuClient(account);
89
+ const response = await client.im.pin.list({
90
+ params: {
91
+ chat_id: params.chatId,
92
+ ...(params.startTime ? { start_time: params.startTime } : {}),
93
+ ...(params.endTime ? { end_time: params.endTime } : {}),
94
+ ...(typeof params.pageSize === "number"
95
+ ? { page_size: Math.max(1, Math.min(100, Math.floor(params.pageSize))) }
96
+ : {}),
97
+ ...(params.pageToken ? { page_token: params.pageToken } : {}),
98
+ },
99
+ });
100
+ assertFeishuPinApiSuccess(response, "pin list");
101
+
102
+ return {
103
+ chatId: params.chatId,
104
+ pins: (response.data?.items ?? []).map(normalizePin),
105
+ hasMore: response.data?.has_more === true,
106
+ pageToken: response.data?.page_token,
107
+ };
108
+ }
@@ -1,154 +1,334 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
1
2
  import { describe, expect, it } from "vitest";
3
+ import { FeishuConfigSchema } from "./config-schema.js";
2
4
  import {
5
+ hasExplicitFeishuGroupConfig,
3
6
  isFeishuGroupAllowed,
4
7
  resolveFeishuAllowlistMatch,
5
8
  resolveFeishuGroupConfig,
9
+ resolveFeishuReplyPolicy,
6
10
  } from "./policy.js";
7
11
  import type { FeishuConfig } from "./types.js";
8
12
 
9
- describe("feishu policy", () => {
10
- describe("resolveFeishuGroupConfig", () => {
11
- it("falls back to wildcard group config when direct match is missing", () => {
12
- const cfg = {
13
- groups: {
14
- "*": { requireMention: false },
15
- "oc-explicit": { requireMention: true },
16
- },
17
- } as unknown as FeishuConfig;
18
-
19
- const resolved = resolveFeishuGroupConfig({
20
- cfg,
21
- groupId: "oc-missing",
22
- });
23
-
24
- expect(resolved).toEqual({ requireMention: false });
25
- });
13
+ function createCfg(feishu: Record<string, unknown>): OpenClawConfig {
14
+ return {
15
+ channels: {
16
+ feishu,
17
+ },
18
+ } as OpenClawConfig;
19
+ }
26
20
 
27
- it("prefers exact group config over wildcard", () => {
28
- const cfg = {
29
- groups: {
30
- "*": { requireMention: false },
31
- "oc-explicit": { requireMention: true },
32
- },
33
- } as unknown as FeishuConfig;
21
+ function createFeishuConfig(overrides: Partial<FeishuConfig>): FeishuConfig {
22
+ return FeishuConfigSchema.parse(overrides);
23
+ }
34
24
 
35
- const resolved = resolveFeishuGroupConfig({
36
- cfg,
37
- groupId: "oc-explicit",
38
- });
25
+ describe("resolveFeishuReplyPolicy", () => {
26
+ it("defaults open groups to no mention when unset", () => {
27
+ expect(
28
+ resolveFeishuReplyPolicy({
29
+ isDirectMessage: false,
30
+ cfg: createCfg({ groupPolicy: "open" }),
31
+ groupPolicy: "open",
32
+ groupId: "oc_1",
33
+ }),
34
+ ).toEqual({ requireMention: false });
35
+ });
39
36
 
40
- expect(resolved).toEqual({ requireMention: true });
41
- });
37
+ it("keeps explicit top-level mention gating in open groups", () => {
38
+ expect(
39
+ resolveFeishuReplyPolicy({
40
+ isDirectMessage: false,
41
+ cfg: createCfg({ groupPolicy: "open", requireMention: true }),
42
+ groupPolicy: "open",
43
+ groupId: "oc_1",
44
+ }),
45
+ ).toEqual({ requireMention: true });
46
+ });
42
47
 
43
- it("keeps case-insensitive matching for explicit group ids", () => {
44
- const cfg = {
45
- groups: {
46
- "*": { requireMention: false },
47
- OC_UPPER: { requireMention: true },
48
- },
49
- } as unknown as FeishuConfig;
48
+ it("keeps explicit account mention gating in open groups", () => {
49
+ expect(
50
+ resolveFeishuReplyPolicy({
51
+ isDirectMessage: false,
52
+ cfg: createCfg({
53
+ groupPolicy: "allowlist",
54
+ requireMention: false,
55
+ accounts: {
56
+ work: {
57
+ groupPolicy: "open",
58
+ requireMention: true,
59
+ },
60
+ },
61
+ }),
62
+ accountId: "work",
63
+ groupPolicy: "open",
64
+ groupId: "oc_1",
65
+ }),
66
+ ).toEqual({ requireMention: true });
67
+ });
50
68
 
51
- const resolved = resolveFeishuGroupConfig({
52
- cfg,
53
- groupId: "oc_upper",
54
- });
69
+ it("keeps explicit per-group mention gating in open groups", () => {
70
+ expect(
71
+ resolveFeishuReplyPolicy({
72
+ isDirectMessage: false,
73
+ cfg: createCfg({
74
+ groupPolicy: "open",
75
+ groups: { oc_1: { requireMention: true } },
76
+ }),
77
+ groupPolicy: "open",
78
+ groupId: "oc_1",
79
+ }),
80
+ ).toEqual({ requireMention: true });
81
+ });
55
82
 
56
- expect(resolved).toEqual({ requireMention: true });
57
- });
83
+ it("defaults allowlist groups to require mentions", () => {
84
+ expect(
85
+ resolveFeishuReplyPolicy({
86
+ isDirectMessage: false,
87
+ cfg: createCfg({ groupPolicy: "allowlist" }),
88
+ groupPolicy: "allowlist",
89
+ groupId: "oc_1",
90
+ }),
91
+ ).toEqual({ requireMention: true });
58
92
  });
93
+ });
59
94
 
60
- describe("resolveFeishuAllowlistMatch", () => {
61
- it("allows wildcard", () => {
62
- expect(
63
- resolveFeishuAllowlistMatch({
64
- allowFrom: ["*"],
65
- senderId: "ou-attacker",
66
- }),
67
- ).toEqual({ allowed: true, matchKey: "*", matchSource: "wildcard" });
95
+ describe("resolveFeishuGroupConfig", () => {
96
+ it("falls back to wildcard group config when direct match is missing", () => {
97
+ const cfg = createFeishuConfig({
98
+ groups: {
99
+ "*": { requireMention: false },
100
+ "oc-explicit": { requireMention: true },
101
+ },
68
102
  });
69
103
 
70
- it("matches normalized ID entries", () => {
71
- expect(
72
- resolveFeishuAllowlistMatch({
73
- allowFrom: ["feishu:user:OU_ALLOWED"],
74
- senderId: "ou_allowed",
75
- }),
76
- ).toEqual({ allowed: true, matchKey: "ou_allowed", matchSource: "id" });
104
+ const resolved = resolveFeishuGroupConfig({
105
+ cfg,
106
+ groupId: "oc-missing",
77
107
  });
78
108
 
79
- it("supports user_id as an additional immutable sender candidate", () => {
80
- expect(
81
- resolveFeishuAllowlistMatch({
82
- allowFrom: ["on_user_123"],
83
- senderId: "ou_other",
84
- senderIds: ["on_user_123"],
85
- }),
86
- ).toEqual({ allowed: true, matchKey: "on_user_123", matchSource: "id" });
87
- });
109
+ expect(resolved).toEqual({ requireMention: false });
110
+ });
88
111
 
89
- it("does not authorize based on display-name collision", () => {
90
- const victimOpenId = "ou_4f4ec5aa111122223333444455556666";
112
+ it("prefers exact group config over wildcard", () => {
113
+ const cfg = createFeishuConfig({
114
+ groups: {
115
+ "*": { requireMention: false },
116
+ "oc-explicit": { requireMention: true },
117
+ },
118
+ });
91
119
 
92
- expect(
93
- resolveFeishuAllowlistMatch({
94
- allowFrom: [victimOpenId],
95
- senderId: "ou_attacker_real_open_id",
96
- senderIds: ["on_attacker_user_id"],
97
- senderName: victimOpenId,
98
- }),
99
- ).toEqual({ allowed: false });
120
+ const resolved = resolveFeishuGroupConfig({
121
+ cfg,
122
+ groupId: "oc-explicit",
100
123
  });
124
+
125
+ expect(resolved).toEqual({ requireMention: true });
101
126
  });
102
127
 
103
- describe("isFeishuGroupAllowed", () => {
104
- it("matches group IDs with chat: prefix", () => {
105
- expect(
106
- isFeishuGroupAllowed({
107
- groupPolicy: "allowlist",
108
- allowFrom: ["chat:oc_group_123"],
109
- senderId: "oc_group_123",
110
- }),
111
- ).toBe(true);
128
+ it("keeps case-insensitive matching for explicit group ids", () => {
129
+ const cfg = createFeishuConfig({
130
+ groups: {
131
+ "*": { requireMention: false },
132
+ OC_UPPER: { requireMention: true },
133
+ },
112
134
  });
113
135
 
114
- it("allows group when groupPolicy is 'open'", () => {
115
- expect(
116
- isFeishuGroupAllowed({
117
- groupPolicy: "open",
118
- allowFrom: [],
119
- senderId: "oc_group_999",
120
- }),
121
- ).toBe(true);
136
+ const resolved = resolveFeishuGroupConfig({
137
+ cfg,
138
+ groupId: "oc_upper",
122
139
  });
123
140
 
124
- it("treats 'allowall' as equivalent to 'open'", () => {
125
- expect(
126
- isFeishuGroupAllowed({
127
- groupPolicy: "allowall",
128
- allowFrom: [],
129
- senderId: "oc_group_999",
130
- }),
131
- ).toBe(true);
141
+ expect(resolved).toEqual({ requireMention: true });
142
+ });
143
+ });
144
+
145
+ describe("hasExplicitFeishuGroupConfig", () => {
146
+ it("matches direct and case-insensitive group ids", () => {
147
+ const cfg = createFeishuConfig({
148
+ groups: {
149
+ OC_UPPER: { requireMention: true },
150
+ },
132
151
  });
133
152
 
134
- it("rejects group when groupPolicy is 'disabled'", () => {
135
- expect(
136
- isFeishuGroupAllowed({
137
- groupPolicy: "disabled",
138
- allowFrom: ["oc_group_999"],
139
- senderId: "oc_group_999",
140
- }),
141
- ).toBe(false);
153
+ expect(hasExplicitFeishuGroupConfig({ cfg, groupId: "OC_UPPER" })).toBe(true);
154
+ expect(hasExplicitFeishuGroupConfig({ cfg, groupId: "oc_upper" })).toBe(true);
155
+ });
156
+
157
+ it("does not treat wildcard group defaults as explicit admission", () => {
158
+ const cfg = createFeishuConfig({
159
+ groups: {
160
+ "*": { requireMention: false },
161
+ },
142
162
  });
143
163
 
144
- it("rejects group when groupPolicy is 'allowlist' and allowFrom is empty", () => {
164
+ expect(hasExplicitFeishuGroupConfig({ cfg, groupId: "oc_any" })).toBe(false);
165
+ });
166
+ });
167
+
168
+ describe("resolveFeishuAllowlistMatch", () => {
169
+ it("allows wildcard", () => {
170
+ expect(
171
+ resolveFeishuAllowlistMatch({
172
+ allowFrom: ["*"],
173
+ senderId: "ou-attacker",
174
+ }),
175
+ ).toEqual({ allowed: true, matchKey: "*", matchSource: "wildcard" });
176
+ });
177
+
178
+ it("allows provider-prefixed wildcard entries", () => {
179
+ expect(
180
+ resolveFeishuAllowlistMatch({
181
+ allowFrom: ["feishu:*", "lark:*"],
182
+ senderId: "ou_anyone",
183
+ }),
184
+ ).toEqual({ allowed: true, matchKey: "*", matchSource: "wildcard" });
185
+ });
186
+
187
+ it("treats typed wildcard aliases as bare wildcards", () => {
188
+ for (const wildcard of [
189
+ "chat:*",
190
+ "group:*",
191
+ "channel:*",
192
+ "user:*",
193
+ "dm:*",
194
+ "open_id:*",
195
+ "feishu:user:*",
196
+ ]) {
145
197
  expect(
146
- isFeishuGroupAllowed({
147
- groupPolicy: "allowlist",
148
- allowFrom: [],
149
- senderId: "oc_group_999",
198
+ resolveFeishuAllowlistMatch({
199
+ allowFrom: [wildcard],
200
+ senderId: "ou_anyone",
150
201
  }),
151
- ).toBe(false);
152
- });
202
+ ).toEqual({ allowed: true, matchKey: "*", matchSource: "wildcard" });
203
+ }
204
+ });
205
+
206
+ it("matches normalized ID entries", () => {
207
+ expect(
208
+ resolveFeishuAllowlistMatch({
209
+ allowFrom: ["feishu:user:ou_ALLOWED"],
210
+ senderId: "ou_ALLOWED",
211
+ }),
212
+ ).toEqual({ allowed: true, matchKey: "user:ou_ALLOWED", matchSource: "id" });
213
+ });
214
+
215
+ it("accepts repeated provider prefixes for legacy allowlist entries", () => {
216
+ expect(
217
+ resolveFeishuAllowlistMatch({
218
+ allowFrom: ["feishu:feishu:user:ou_ALLOWED"],
219
+ senderId: "ou_ALLOWED",
220
+ }),
221
+ ).toEqual({ allowed: true, matchKey: "user:ou_ALLOWED", matchSource: "id" });
222
+ });
223
+
224
+ it("does not fold opaque IDs to lowercase", () => {
225
+ expect(
226
+ resolveFeishuAllowlistMatch({
227
+ allowFrom: ["user:OU_ALLOWED"],
228
+ senderId: "ou_ALLOWED",
229
+ }),
230
+ ).toEqual({ allowed: false });
231
+ });
232
+
233
+ it("keeps user and chat allowlist namespaces distinct", () => {
234
+ expect(
235
+ resolveFeishuAllowlistMatch({
236
+ allowFrom: ["user:oc_group_123"],
237
+ senderId: "oc_group_123",
238
+ }),
239
+ ).toEqual({ allowed: false });
240
+ });
241
+
242
+ it("supports user_id as an additional immutable sender candidate", () => {
243
+ expect(
244
+ resolveFeishuAllowlistMatch({
245
+ allowFrom: ["on_user_123"],
246
+ senderId: "ou_other",
247
+ senderIds: ["on_user_123"],
248
+ }),
249
+ ).toEqual({ allowed: true, matchKey: "user:on_user_123", matchSource: "id" });
250
+ });
251
+
252
+ it("auto-detects bare open_id entries as user allowlist matches", () => {
253
+ expect(
254
+ resolveFeishuAllowlistMatch({
255
+ allowFrom: ["ou_BARE"],
256
+ senderId: "ou_BARE",
257
+ }),
258
+ ).toEqual({ allowed: true, matchKey: "user:ou_BARE", matchSource: "id" });
259
+ });
260
+
261
+ it("auto-detects bare chat_id entries as chat allowlist matches", () => {
262
+ expect(
263
+ resolveFeishuAllowlistMatch({
264
+ allowFrom: ["oc_group_123"],
265
+ senderId: "oc_group_123",
266
+ }),
267
+ ).toEqual({ allowed: true, matchKey: "chat:oc_group_123", matchSource: "id" });
268
+ });
269
+
270
+ it("does not authorize based on display-name collision", () => {
271
+ const victimOpenId = "ou_4f4ec5aa111122223333444455556666";
272
+
273
+ expect(
274
+ resolveFeishuAllowlistMatch({
275
+ allowFrom: [victimOpenId],
276
+ senderId: "ou_attacker_real_open_id",
277
+ senderIds: ["on_attacker_user_id"],
278
+ senderName: victimOpenId,
279
+ }),
280
+ ).toEqual({ allowed: false });
281
+ });
282
+ });
283
+
284
+ describe("isFeishuGroupAllowed", () => {
285
+ it("matches group IDs with chat: prefix", () => {
286
+ expect(
287
+ isFeishuGroupAllowed({
288
+ groupPolicy: "allowlist",
289
+ allowFrom: ["chat:oc_group_123"],
290
+ senderId: "oc_group_123",
291
+ }),
292
+ ).toBe(true);
293
+ });
294
+
295
+ it("allows group when groupPolicy is 'open'", () => {
296
+ expect(
297
+ isFeishuGroupAllowed({
298
+ groupPolicy: "open",
299
+ allowFrom: [],
300
+ senderId: "oc_group_999",
301
+ }),
302
+ ).toBe(true);
303
+ });
304
+
305
+ it("treats 'allowall' as equivalent to 'open'", () => {
306
+ expect(
307
+ isFeishuGroupAllowed({
308
+ groupPolicy: "allowall",
309
+ allowFrom: [],
310
+ senderId: "oc_group_999",
311
+ }),
312
+ ).toBe(true);
313
+ });
314
+
315
+ it("rejects group when groupPolicy is 'disabled'", () => {
316
+ expect(
317
+ isFeishuGroupAllowed({
318
+ groupPolicy: "disabled",
319
+ allowFrom: ["oc_group_999"],
320
+ senderId: "oc_group_999",
321
+ }),
322
+ ).toBe(false);
323
+ });
324
+
325
+ it("rejects group when groupPolicy is 'allowlist' and allowFrom is empty", () => {
326
+ expect(
327
+ isFeishuGroupAllowed({
328
+ groupPolicy: "allowlist",
329
+ allowFrom: [],
330
+ senderId: "oc_group_999",
331
+ }),
332
+ ).toBe(false);
153
333
  });
154
334
  });