chenbhao commited on
Commit
44e0492
·
1 Parent(s): 73bc263

feat: feishu

Browse files
bun.lock CHANGED
@@ -21,6 +21,7 @@
21
  "@azure/identity": "^4.13.1",
22
  "@commander-js/extra-typings": "^14.0.0",
23
  "@growthbook/growthbook": "^1.6.5",
 
24
  "@larksuiteoapi/node-sdk": "^1.60.0",
25
  "@modelcontextprotocol/sdk": "^1.29.0",
26
  "@opentelemetry/api": "^1.9.1",
@@ -81,6 +82,7 @@
81
  "plist": "^3.1.0",
82
  "proper-lockfile": "^4.1.2",
83
  "qrcode": "^1.5.4",
 
84
  "react": "^19.2.4",
85
  "react-reconciler": "^0.33.0",
86
  "semver": "^7.7.4",
@@ -325,6 +327,8 @@
325
 
326
  "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
327
 
 
 
328
  "@larksuiteoapi/node-sdk": ["@larksuiteoapi/node-sdk@1.66.1", "", { "dependencies": { "axios": "~1.13.3", "lodash.identity": "^3.0.0", "lodash.merge": "^4.6.2", "lodash.pickby": "^4.6.0", "protobufjs": "^7.2.6", "qs": "^6.14.2", "ws": "^8.19.0" } }, "sha512-W1rIAs/8Oc/rEYuWc0sxGvR8iLwd8p5D2RS4ODMqs8htIxK8yIa8sb22EDv/OEBqUpKXZaNLydTz7Oq8HOQROg=="],
329
 
330
  "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
@@ -939,10 +943,14 @@
939
 
940
  "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
941
 
 
 
942
  "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
943
 
944
  "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
945
 
 
 
946
  "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
947
 
948
  "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
@@ -1227,6 +1235,8 @@
1227
 
1228
  "@inquirer/select/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
1229
 
 
 
1230
  "@larksuiteoapi/node-sdk/axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
1231
 
1232
  "@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="],
@@ -1427,6 +1437,8 @@
1427
 
1428
  "@inquirer/select/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
1429
 
 
 
1430
  "@larksuiteoapi/node-sdk/axios/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
1431
 
1432
  "@smithy/eventstream-codec/@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@3.0.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-utf8-browser": "^3.0.0", "tslib": "^1.11.1" } }, "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w=="],
 
21
  "@azure/identity": "^4.13.1",
22
  "@commander-js/extra-typings": "^14.0.0",
23
  "@growthbook/growthbook": "^1.6.5",
24
+ "@larksuite/channel": "^0.1.2",
25
  "@larksuiteoapi/node-sdk": "^1.60.0",
26
  "@modelcontextprotocol/sdk": "^1.29.0",
27
  "@opentelemetry/api": "^1.9.1",
 
82
  "plist": "^3.1.0",
83
  "proper-lockfile": "^4.1.2",
84
  "qrcode": "^1.5.4",
85
+ "qrcode-terminal": "^0.12.0",
86
  "react": "^19.2.4",
87
  "react-reconciler": "^0.33.0",
88
  "semver": "^7.7.4",
 
327
 
328
  "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
329
 
330
+ "@larksuite/channel": ["@larksuite/channel@0.1.2", "", { "dependencies": { "@larksuiteoapi/node-sdk": "^1.66.1", "https-proxy-agent": "^9.0.0" } }, "sha512-9bhxF8lCFsYiOq+E9Swg1/5nqJLJNPsVLTGIdEE8hcqRKg/Dwazh4UKP0jHjiFf8yI8KKTfsEkA2P6PUFmSZQw=="],
331
+
332
  "@larksuiteoapi/node-sdk": ["@larksuiteoapi/node-sdk@1.66.1", "", { "dependencies": { "axios": "~1.13.3", "lodash.identity": "^3.0.0", "lodash.merge": "^4.6.2", "lodash.pickby": "^4.6.0", "protobufjs": "^7.2.6", "qs": "^6.14.2", "ws": "^8.19.0" } }, "sha512-W1rIAs/8Oc/rEYuWc0sxGvR8iLwd8p5D2RS4ODMqs8htIxK8yIa8sb22EDv/OEBqUpKXZaNLydTz7Oq8HOQROg=="],
333
 
334
  "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
 
943
 
944
  "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
945
 
946
+ "proxy-agent-negotiate": ["proxy-agent-negotiate@1.1.0", "", { "peerDependencies": { "kerberos": "^2.0.0" }, "optionalPeers": ["kerberos"] }, "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ=="],
947
+
948
  "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
949
 
950
  "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
951
 
952
+ "qrcode-terminal": ["qrcode-terminal@0.12.0", "", { "bin": { "qrcode-terminal": "./bin/qrcode-terminal.js" } }, "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ=="],
953
+
954
  "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
955
 
956
  "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
 
1235
 
1236
  "@inquirer/select/ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="],
1237
 
1238
+ "@larksuite/channel/https-proxy-agent": ["https-proxy-agent@9.1.0", "", { "dependencies": { "agent-base": "9.0.0", "debug": "^4.3.4", "proxy-agent-negotiate": "1.1.0" } }, "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA=="],
1239
+
1240
  "@larksuiteoapi/node-sdk/axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="],
1241
 
1242
  "@opentelemetry/exporter-logs-otlp-grpc/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="],
 
1437
 
1438
  "@inquirer/select/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="],
1439
 
1440
+ "@larksuite/channel/https-proxy-agent/agent-base": ["agent-base@9.0.0", "", {}, "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA=="],
1441
+
1442
  "@larksuiteoapi/node-sdk/axios/proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
1443
 
1444
  "@smithy/eventstream-codec/@aws-crypto/crc32/@aws-crypto/util": ["@aws-crypto/util@3.0.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-utf8-browser": "^3.0.0", "tslib": "^1.11.1" } }, "sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w=="],
package.json CHANGED
@@ -39,6 +39,7 @@
39
  "@azure/identity": "^4.13.1",
40
  "@commander-js/extra-typings": "^14.0.0",
41
  "@growthbook/growthbook": "^1.6.5",
 
42
  "@larksuiteoapi/node-sdk": "^1.60.0",
43
  "@modelcontextprotocol/sdk": "^1.29.0",
44
  "@opentelemetry/api": "^1.9.1",
@@ -99,6 +100,7 @@
99
  "plist": "^3.1.0",
100
  "proper-lockfile": "^4.1.2",
101
  "qrcode": "^1.5.4",
 
102
  "react": "^19.2.4",
103
  "react-reconciler": "^0.33.0",
104
  "semver": "^7.7.4",
 
39
  "@azure/identity": "^4.13.1",
40
  "@commander-js/extra-typings": "^14.0.0",
41
  "@growthbook/growthbook": "^1.6.5",
42
+ "@larksuite/channel": "^0.1.2",
43
  "@larksuiteoapi/node-sdk": "^1.60.0",
44
  "@modelcontextprotocol/sdk": "^1.29.0",
45
  "@opentelemetry/api": "^1.9.1",
 
100
  "plist": "^3.1.0",
101
  "proper-lockfile": "^4.1.2",
102
  "qrcode": "^1.5.4",
103
+ "qrcode-terminal": "^0.12.0",
104
  "react": "^19.2.4",
105
  "react-reconciler": "^0.33.0",
106
  "semver": "^7.7.4",
src/commands/feishu/feishu.tsx CHANGED
@@ -12,6 +12,7 @@ import {
12
  maskFeishuAppSecret,
13
  saveFeishuConfig,
14
  } from '../../services/feishu/feishuConfig.js'
 
15
 
16
  type Step =
17
  | { type: 'menu' }
@@ -20,7 +21,11 @@ type Step =
20
  | { type: 'edit-encrypt-key' }
21
  | { type: 'edit-verification-token' }
22
  | { type: 'edit-allowed-users' }
 
 
 
23
  | { type: 'confirm-clear' }
 
24
 
25
  type Notice = {
26
  text: string
@@ -177,6 +182,18 @@ function FeishuDialog({
177
  label: '设置允许访问的用户',
178
  action: () => setStep({ type: 'edit-allowed-users' }),
179
  },
 
 
 
 
 
 
 
 
 
 
 
 
180
  {
181
  label:
182
  serviceState.status === 'running' ? '停止当前飞书 Bot' : '启动当前飞书 Bot',
@@ -207,6 +224,10 @@ function FeishuDialog({
207
  label: '清空飞书配置',
208
  action: () => setStep({ type: 'confirm-clear' }),
209
  },
 
 
 
 
210
  {
211
  label: '关闭',
212
  action: () => onDone(undefined, { display: 'skip' }),
@@ -353,6 +374,75 @@ function FeishuDialog({
353
  [refreshConfig],
354
  )
355
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  const clearConfigAndStop = React.useCallback(async () => {
357
  setBusy(true)
358
  try {
@@ -372,6 +462,18 @@ function FeishuDialog({
372
  }, [refreshConfig])
373
 
374
  useInput((_, key) => {
 
 
 
 
 
 
 
 
 
 
 
 
375
  if (step.type !== 'menu' || busy) return
376
 
377
  if (key.escape) {
@@ -475,6 +577,55 @@ function FeishuDialog({
475
  )
476
  }
477
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
478
  if (step.type === 'confirm-clear') {
479
  return (
480
  <Dialog title="清空飞书配置" onCancel={() => setStep({ type: 'menu' })}>
@@ -486,6 +637,13 @@ function FeishuDialog({
486
  )
487
  }
488
 
 
 
 
 
 
 
 
489
  return (
490
  <Dialog title="飞书 (Feishu)" onCancel={() => onDone(undefined, { display: 'skip' })}>
491
  <Box flexDirection="column" gap={1}>
@@ -518,6 +676,23 @@ function FeishuDialog({
518
  ? config.allowedUsers.join(', ')
519
  : '未配置(使用配对码授权)'}
520
  </Text>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
521
  {config?.pairedUsers?.length ? (
522
  <Text>
523
  已配对用户: {config.pairedUsers.map(u => u.displayName || u.userId).join(', ')}
@@ -541,6 +716,161 @@ function FeishuDialog({
541
  )
542
  }
543
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
544
  export async function call(
545
  onDone: LocalJSXCommandOnDone,
546
  _context: LocalJSXCommandContext,
 
12
  maskFeishuAppSecret,
13
  saveFeishuConfig,
14
  } from '../../services/feishu/feishuConfig.js'
15
+ import QRCode from 'qrcode'
16
 
17
  type Step =
18
  | { type: 'menu' }
 
21
  | { type: 'edit-encrypt-key' }
22
  | { type: 'edit-verification-token' }
23
  | { type: 'edit-allowed-users' }
24
+ | { type: 'edit-admins' }
25
+ | { type: 'edit-allowed-chats' }
26
+ | { type: 'edit-mention-policy' }
27
  | { type: 'confirm-clear' }
28
+ | { type: 'scanning' }
29
 
30
  type Notice = {
31
  text: string
 
182
  label: '设置允许访问的用户',
183
  action: () => setStep({ type: 'edit-allowed-users' }),
184
  },
185
+ {
186
+ label: '设置管理员',
187
+ action: () => setStep({ type: 'edit-admins' }),
188
+ },
189
+ {
190
+ label: '设置允许响应的群',
191
+ action: () => setStep({ type: 'edit-allowed-chats' }),
192
+ },
193
+ {
194
+ label: `@提及要求: ${config?.requireMentionInGroup !== false ? '需 @bot' : '所有消息'}`,
195
+ action: () => setStep({ type: 'edit-mention-policy' }),
196
+ },
197
  {
198
  label:
199
  serviceState.status === 'running' ? '停止当前飞书 Bot' : '启动当前飞书 Bot',
 
224
  label: '清空飞书配置',
225
  action: () => setStep({ type: 'confirm-clear' }),
226
  },
227
+ {
228
+ label: '扫码登录(创建应用)',
229
+ action: () => setStep({ type: 'scanning' }),
230
+ },
231
  {
232
  label: '关闭',
233
  action: () => onDone(undefined, { display: 'skip' }),
 
374
  [refreshConfig],
375
  )
376
 
377
+ const saveAdmins = React.useCallback(
378
+ async (value: string) => {
379
+ const users = value.split(/[,\s]+/).map(s => s.trim()).filter(Boolean)
380
+ setBusy(true)
381
+ try {
382
+ const current = getFeishuConfig()
383
+ saveFeishuConfig({ ...current, admins: users.length > 0 ? users : undefined })
384
+ refreshConfig()
385
+ setNotice({ text: '管理员列表已保存。', tone: 'success' })
386
+ } catch (error) {
387
+ setNotice({
388
+ text: error instanceof Error ? error.message : '保存失败',
389
+ tone: 'error',
390
+ })
391
+ } finally {
392
+ setBusy(false)
393
+ setStep({ type: 'menu' })
394
+ }
395
+ },
396
+ [refreshConfig],
397
+ )
398
+
399
+ const saveAllowedChats = React.useCallback(
400
+ async (value: string) => {
401
+ const chats = value.split(/[,\s]+/).map(s => s.trim()).filter(Boolean)
402
+ setBusy(true)
403
+ try {
404
+ const current = getFeishuConfig()
405
+ saveFeishuConfig({ ...current, allowedChats: chats.length > 0 ? chats : undefined })
406
+ refreshConfig()
407
+ setNotice({ text: '允许响应的群列表已保存。', tone: 'success' })
408
+ } catch (error) {
409
+ setNotice({
410
+ text: error instanceof Error ? error.message : '保存失败',
411
+ tone: 'error',
412
+ })
413
+ } finally {
414
+ setBusy(false)
415
+ setStep({ type: 'menu' })
416
+ }
417
+ },
418
+ [refreshConfig],
419
+ )
420
+
421
+ const toggleMentionPolicy = React.useCallback(
422
+ async () => {
423
+ setBusy(true)
424
+ try {
425
+ const current = getFeishuConfig()
426
+ const next = current.requireMentionInGroup !== false ? false : true
427
+ saveFeishuConfig({ ...current, requireMentionInGroup: next })
428
+ refreshConfig()
429
+ setNotice({
430
+ text: next ? '群聊已设为需 @bot 才响应。' : '群聊已设为响应所有消息。',
431
+ tone: 'success',
432
+ })
433
+ } catch (error) {
434
+ setNotice({
435
+ text: error instanceof Error ? error.message : '保存失败',
436
+ tone: 'error',
437
+ })
438
+ } finally {
439
+ setBusy(false)
440
+ setStep({ type: 'menu' })
441
+ }
442
+ },
443
+ [refreshConfig],
444
+ )
445
+
446
  const clearConfigAndStop = React.useCallback(async () => {
447
  setBusy(true)
448
  try {
 
462
  }, [refreshConfig])
463
 
464
  useInput((_, key) => {
465
+ if (step.type === 'edit-mention-policy') {
466
+ if (key.escape) {
467
+ setStep({ type: 'menu' })
468
+ return
469
+ }
470
+ if (key.return) {
471
+ void toggleMentionPolicy()
472
+ return
473
+ }
474
+ return
475
+ }
476
+
477
  if (step.type !== 'menu' || busy) return
478
 
479
  if (key.escape) {
 
577
  )
578
  }
579
 
580
+ if (step.type === 'edit-admins') {
581
+ return (
582
+ <Dialog title="管理员" onCancel={() => setStep({ type: 'menu' })}>
583
+ <TextInput
584
+ title="管理员 open_id"
585
+ hint="管理员拥有全部权限,可执行 /stop、/config 等管理命令。输入 open_id,多个以空格或逗号分隔。"
586
+ initialValue={config?.admins?.join(', ')}
587
+ onSubmit={value => { void saveAdmins(value) }}
588
+ onCancel={() => setStep({ type: 'menu' })}
589
+ />
590
+ </Dialog>
591
+ )
592
+ }
593
+
594
+ if (step.type === 'edit-allowed-chats') {
595
+ return (
596
+ <Dialog title="允许响应的群聊" onCancel={() => setStep({ type: 'menu' })}>
597
+ <TextInput
598
+ title="群聊 chat_id"
599
+ hint="输入群聊 chat_id,多个以空格或逗号分隔。仅在白名单中的群聊 bot 才会响应。"
600
+ initialValue={config?.allowedChats?.join(', ')}
601
+ onSubmit={value => { void saveAllowedChats(value) }}
602
+ onCancel={() => setStep({ type: 'menu' })}
603
+ />
604
+ </Dialog>
605
+ )
606
+ }
607
+
608
+ if (step.type === 'edit-mention-policy') {
609
+ return (
610
+ <Dialog title="@提及要求" onCancel={() => setStep({ type: 'menu' })}>
611
+ <Box flexDirection="column" gap={1}>
612
+ <Text bold>群聊 @bot 要求</Text>
613
+ <Text dimColor>
614
+ 当前: {config?.requireMentionInGroup !== false ? '需 @bot 才响应' : '响应所有群消息'}
615
+ </Text>
616
+ <Text dimColor marginTop={1}>
617
+ 切换后:
618
+ {'\n'}- 需 @bot: 仅在群聊中被 @ 时才处理消息
619
+ {'\n'}- 响应所有: 群聊中所有消息都会处理
620
+ </Text>
621
+ <Box marginTop={1}>
622
+ <Text>按 Enter 切换,按 Esc 取消</Text>
623
+ </Box>
624
+ </Box>
625
+ </Dialog>
626
+ )
627
+ }
628
+
629
  if (step.type === 'confirm-clear') {
630
  return (
631
  <Dialog title="清空飞书配置" onCancel={() => setStep({ type: 'menu' })}>
 
637
  )
638
  }
639
 
640
+ if (step.type === 'scanning') {
641
+ return <ScanningDialog onCancel={() => setStep({ type: 'menu' })} onSuccess={() => {
642
+ refreshConfig()
643
+ setStep({ type: 'menu' })
644
+ }} />
645
+ }
646
+
647
  return (
648
  <Dialog title="飞书 (Feishu)" onCancel={() => onDone(undefined, { display: 'skip' })}>
649
  <Box flexDirection="column" gap={1}>
 
676
  ? config.allowedUsers.join(', ')
677
  : '未配置(使用配对码授权)'}
678
  </Text>
679
+ <Text>
680
+ 管理员:
681
+ {' '}
682
+ {config?.admins?.length
683
+ ? config.admins.join(', ')
684
+ : '未配置'}
685
+ </Text>
686
+ <Text>
687
+ 允许群聊:
688
+ {' '}
689
+ {config?.allowedChats?.length
690
+ ? `${config.allowedChats.length} 个`
691
+ : '未配置'}
692
+ </Text>
693
+ <Text>
694
+ 群聊 @提及: {config?.requireMentionInGroup !== false ? '需 @bot' : '所有消息'}
695
+ </Text>
696
  {config?.pairedUsers?.length ? (
697
  <Text>
698
  已配对用户: {config.pairedUsers.map(u => u.displayName || u.userId).join(', ')}
 
716
  )
717
  }
718
 
719
+ function ScanningDialog({
720
+ onCancel,
721
+ onSuccess,
722
+ }: {
723
+ onCancel: () => void
724
+ onSuccess: () => void
725
+ }) {
726
+ const [qrUrl, setQrUrl] = React.useState<string | null>(null)
727
+ const [expireIn, setExpireIn] = React.useState<number>(0)
728
+ const [status, setStatus] = React.useState<string>('正在连接飞书...')
729
+ const [error, setError] = React.useState<string | null>(null)
730
+ const [success, setSuccess] = React.useState(false)
731
+ const [completed, setCompleted] = React.useState(false)
732
+
733
+ // Refs to avoid re-triggering the effect when parent passes inline callbacks
734
+ const onSuccessRef = React.useRef(onSuccess)
735
+ onSuccessRef.current = onSuccess
736
+ const onCancelRef = React.useRef(onCancel)
737
+ onCancelRef.current = onCancel
738
+
739
+ React.useEffect(() => {
740
+ let cancelled = false
741
+
742
+ const run = async () => {
743
+ try {
744
+ const { registerApp } = await import('@larksuite/channel')
745
+
746
+ const result = await registerApp({
747
+ source: 'versperclaw',
748
+ onQRCodeReady: (info) => {
749
+ if (cancelled) return
750
+ setQrUrl(info.url)
751
+ setExpireIn(info.expireIn)
752
+ setStatus('请用飞书 App 扫描二维码,并在打开的页面中点击"授权"完成创建')
753
+ },
754
+ onStatusChange: (info) => {
755
+ if (cancelled) return
756
+ if (info.status === 'domain_switched') {
757
+ setStatus('已切换到国际版 (larksuite.com),请继续...')
758
+ } else if (info.status === 'slow_down') {
759
+ setStatus('等待授权确认中...')
760
+ } else if (info.status === 'polling') {
761
+ setStatus('等待授权确认中,请确保已在飞书中点击了"授权"...')
762
+ }
763
+ },
764
+ })
765
+
766
+ if (cancelled) return
767
+
768
+ // Save credentials
769
+ saveFeishuConfig({
770
+ appId: result.client_id,
771
+ appSecret: result.client_secret,
772
+ tenant: (result as { user_info?: { tenant_brand?: string } }).user_info?.tenant_brand,
773
+ })
774
+
775
+ setSuccess(true)
776
+ setStatus('应用创建成功!正在启动 Bot...')
777
+ setCompleted(true)
778
+
779
+ // Start the bot
780
+ await feishuService.stop()
781
+ await feishuService.startFromSavedConfig()
782
+
783
+ if (!cancelled) {
784
+ setTimeout(() => onSuccessRef.current(), 1500)
785
+ }
786
+ } catch (err) {
787
+ if (!cancelled) {
788
+ setError(err instanceof Error ? err.message : '扫码登录失败')
789
+ }
790
+ }
791
+ }
792
+
793
+ run()
794
+
795
+ return () => {
796
+ cancelled = true
797
+ }
798
+ }, []) // Run once on mount only — refs avoid dep instability
799
+
800
+ useInput((_, key) => {
801
+ if (key.escape && !completed) {
802
+ onCancelRef.current()
803
+ }
804
+ })
805
+
806
+ const [qrAscii, setQrAscii] = React.useState<string>('')
807
+ const [qrError, setQrError] = React.useState<string | null>(null)
808
+
809
+ React.useEffect(() => {
810
+ if (!qrUrl) return
811
+
812
+ QRCode.toString(qrUrl, { type: 'terminal', small: true })
813
+ .then(ascii => {
814
+ setQrAscii(ascii)
815
+ setQrError(null)
816
+ })
817
+ .catch(err => {
818
+ console.error('[Feishu] QR generation error:', err)
819
+ setQrError('无法生成二维码,请使用下方链接')
820
+ })
821
+ }, [qrUrl])
822
+
823
+ return (
824
+ <Dialog title="扫码登录飞书" onCancel={completed ? undefined : onCancel}>
825
+ <Box flexDirection="column" gap={1}>
826
+ {error ? (
827
+ <>
828
+ <Text color="red">错误: {error}</Text>
829
+ <Text dimColor>按 Esc 返回</Text>
830
+ </>
831
+ ) : success ? (
832
+ <>
833
+ <Text color="green">✓ {status}</Text>
834
+ </>
835
+ ) : qrError ? (
836
+ <>
837
+ <Text bold color="yellow">二维码生成失败</Text>
838
+ <Text dimColor marginTop={1}>请使用下方链接完成授权:</Text>
839
+ <Box marginTop={1}>
840
+ <Text selectText>{qrUrl}</Text>
841
+ </Box>
842
+ <Text dimColor marginTop={1}>{status}</Text>
843
+ <Text dimColor marginTop={1}>按 Esc 取消</Text>
844
+ </>
845
+ ) : qrAscii ? (
846
+ <>
847
+ <Text bold>请用飞书 App 扫描下方二维码</Text>
848
+ <Text dimColor>扫描后,请在打开的页面中点击【授权】完成应用创建</Text>
849
+ <Box marginTop={1}>
850
+ <Text>{qrAscii}</Text>
851
+ </Box>
852
+ <Text dimColor marginTop={1}>
853
+ 二维码有效期:约 {Math.max(1, Math.round(expireIn / 60))} 分钟
854
+ </Text>
855
+ {qrUrl && (
856
+ <Text dimColor marginTop={1}>
857
+ 或打开链接:{qrUrl.slice(0, 70)}...
858
+ </Text>
859
+ )}
860
+ <Text color="cyan" marginTop={1}>{status}</Text>
861
+ <Text dimColor marginTop={1}>按 Esc 取消</Text>
862
+ </>
863
+ ) : (
864
+ <>
865
+ <Text>{status}</Text>
866
+ <Text dimColor>请稍候...</Text>
867
+ </>
868
+ )}
869
+ </Box>
870
+ </Dialog>
871
+ )
872
+ }
873
+
874
  export async function call(
875
  onDone: LocalJSXCommandOnDone,
876
  _context: LocalJSXCommandContext,
src/hooks/useFeishuBridge.ts CHANGED
@@ -1,23 +1,124 @@
1
  /**
2
- * useFeishuBridge — auto-starts Feishu service if configured.
 
 
 
 
3
  */
4
 
5
  import { useEffect, useRef } from 'react'
6
  import { feishuService } from '../services/feishu/FeishuService.js'
7
  import { getFeishuConfig } from '../services/feishu/feishuConfig.js'
 
 
8
 
9
- export function useFeishuBridge(): void {
10
- const attempted = useRef(false)
 
 
11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  useEffect(() => {
13
- if (attempted.current) return
14
- attempted.current = true
15
 
16
  const config = getFeishuConfig()
17
  if (!config.appId || !config.appSecret) return
18
 
19
- void feishuService.startFromSavedConfig().catch((e) => {
20
  console.warn('[feishu] auto-start failed:', e instanceof Error ? e.message : String(e))
21
  })
22
  }, [])
23
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  /**
2
+ * useFeishuBridge — Feishu inbound/outbound bridge hook.
3
+ *
4
+ * - Auto-starts Feishu service if credentials are saved.
5
+ * - Tracks AI response turns for Feishu-originated messages.
6
+ * - Sends generated responses back to the originating Feishu chat.
7
  */
8
 
9
  import { useEffect, useRef } from 'react'
10
  import { feishuService } from '../services/feishu/FeishuService.js'
11
  import { getFeishuConfig } from '../services/feishu/feishuConfig.js'
12
+ import { getContentText } from '../utils/messages.js'
13
+ import type { Message } from '../types/message.js'
14
 
15
+ type Props = {
16
+ messages: Message[]
17
+ isLoading: boolean
18
+ }
19
 
20
+ type ActiveFeishuTurn = {
21
+ chatId: string
22
+ responseParts: string[]
23
+ }
24
+
25
+ const FEISHU_CHANNEL_SERVER = 'feishu'
26
+
27
+ export function useFeishuBridge({ messages, isLoading }: Props): void {
28
+ const autoStartAttemptedRef = useRef(false)
29
+ const pendingInboundRef = useRef<{ chatId: string }[]>([])
30
+ const activeTurnRef = useRef<ActiveFeishuTurn | null>(null)
31
+ const lastProcessedMessageCountRef = useRef(messages.length)
32
+ const previousLoadingRef = useRef(isLoading)
33
+
34
+ // Auto-start if configured
35
  useEffect(() => {
36
+ if (autoStartAttemptedRef.current) return
37
+ autoStartAttemptedRef.current = true
38
 
39
  const config = getFeishuConfig()
40
  if (!config.appId || !config.appSecret) return
41
 
42
+ void feishuService.startFromSavedConfig().catch(e => {
43
  console.warn('[feishu] auto-start failed:', e instanceof Error ? e.message : String(e))
44
  })
45
  }, [])
46
+
47
+ // Subscribe to inbound events (stores chatId for turn tracking)
48
+ useEffect(() => {
49
+ return feishuService.subscribeToInbound(event => {
50
+ pendingInboundRef.current.push({ chatId: event.chatId })
51
+ })
52
+ }, [])
53
+
54
+ // Process new messages — detect Feishu-originated user messages and collect
55
+ // assistant responses
56
+ useEffect(() => {
57
+ const newMessages = messages.slice(lastProcessedMessageCountRef.current)
58
+
59
+ for (const message of newMessages) {
60
+ if (
61
+ message.type === 'user' &&
62
+ typeof message.origin === 'object' &&
63
+ message.origin !== null &&
64
+ (message.origin as Record<string, unknown>).kind === 'channel' &&
65
+ (message.origin as Record<string, unknown>).server === FEISHU_CHANNEL_SERVER
66
+ ) {
67
+ const inbound = pendingInboundRef.current.shift()
68
+ if (inbound) {
69
+ activeTurnRef.current = {
70
+ chatId: inbound.chatId,
71
+ responseParts: [],
72
+ }
73
+ }
74
+ continue
75
+ }
76
+
77
+ if (message.type === 'assistant' && activeTurnRef.current) {
78
+ const text = getContentText(message.message.content)
79
+ if (text) {
80
+ activeTurnRef.current.responseParts.push(text)
81
+ }
82
+ continue
83
+ }
84
+
85
+ if (
86
+ message.type === 'system' &&
87
+ message.subtype === 'local_command' &&
88
+ activeTurnRef.current
89
+ ) {
90
+ const text = message.content
91
+ if (text) {
92
+ activeTurnRef.current.responseParts.push(text)
93
+ }
94
+ }
95
+ }
96
+
97
+ lastProcessedMessageCountRef.current = messages.length
98
+ }, [messages])
99
+
100
+ // When loading completes, send accumulated response back to Feishu
101
+ useEffect(() => {
102
+ const wasLoading = previousLoadingRef.current
103
+ previousLoadingRef.current = isLoading
104
+
105
+ if (!wasLoading || isLoading || !activeTurnRef.current) return
106
+
107
+ const completedTurn = activeTurnRef.current
108
+ activeTurnRef.current = null
109
+
110
+ void (async () => {
111
+ try {
112
+ const reply =
113
+ completedTurn.responseParts.join('\n\n').trim() ||
114
+ '这一轮没有可回传的文本结果,请查看本地终端会话。'
115
+ await feishuService.sendText(completedTurn.chatId, reply)
116
+ } catch (error) {
117
+ console.warn(
118
+ '[feishu] failed to send outbound reply:',
119
+ error instanceof Error ? error.message : String(error),
120
+ )
121
+ }
122
+ })()
123
+ }, [isLoading])
124
+ }
src/screens/REPL.tsx CHANGED
@@ -111,6 +111,7 @@ import {
111
  } from '../utils/swarm/leaderPermissionBridge.js'
112
  import { endInteractionSpan } from '../utils/telemetry/sessionTracing.js'
113
  import { useLogMessages } from '../hooks/useLogMessages.js'
 
114
  import { useReplBridge } from '../hooks/useReplBridge.js'
115
  import {
116
  type Command,
@@ -5396,6 +5397,8 @@ export function REPL({
5396
 
5397
  useMailboxBridge({ isLoading, onSubmitMessage: handleIncomingPrompt })
5398
 
 
 
5399
  // Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List)
5400
  if (feature('AGENT_TRIGGERS')) {
5401
  // Assistant mode bypasses the isLoading gate (the proactive tick →
 
111
  } from '../utils/swarm/leaderPermissionBridge.js'
112
  import { endInteractionSpan } from '../utils/telemetry/sessionTracing.js'
113
  import { useLogMessages } from '../hooks/useLogMessages.js'
114
+ import { useFeishuBridge } from '../hooks/useFeishuBridge.js'
115
  import { useReplBridge } from '../hooks/useReplBridge.js'
116
  import {
117
  type Command,
 
5397
 
5398
  useMailboxBridge({ isLoading, onSubmitMessage: handleIncomingPrompt })
5399
 
5400
+ useFeishuBridge({ messages, isLoading })
5401
+
5402
  // Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List)
5403
  if (feature('AGENT_TRIGGERS')) {
5404
  // Assistant mode bypasses the isLoading gate (the proactive tick →
src/services/feishu/FeishuService.ts CHANGED
@@ -1,102 +1,427 @@
1
  /**
2
- * FeishuService — in-process Feishu bot using @larksuiteoapi/node-sdk
3
- *
4
- * Uses Lark.WSClient to receive events, enqueues messages to REPL,
5
- * collects responses via polling AppState, sends back to Feishu.
6
  */
7
 
8
- import * as Lark from '@larksuiteoapi/node-sdk'
9
- import { getFeishuConfig, type FeishuRuntimeConfig } from './feishuConfig.js'
10
- import { logForDebugging } from '../../utils/debug.js'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  type FeishuServiceState = {
13
  status: 'stopped' | 'starting' | 'running' | 'error'
14
  appId?: string
 
 
15
  lastError?: string
16
  }
17
 
18
  type Listener = () => void
19
 
20
- function normalizeError(error: unknown): string {
21
- if (error instanceof Error) return error.message
22
- return String(error)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  }
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  class FeishuService {
26
  private listeners = new Set<Listener>()
 
27
  private state: FeishuServiceState = { status: 'stopped' }
28
  private config?: FeishuRuntimeConfig
29
- private client?: Lark.Client
30
- private wsClient?: { stop?: () => void }
 
 
 
 
31
  private runId = 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
 
33
  subscribe = (listener: Listener): (() => void) => {
34
  this.listeners.add(listener)
35
  return () => this.listeners.delete(listener)
36
  }
37
 
 
 
 
 
 
38
  getStateSnapshot = (): FeishuServiceState => this.state
39
 
 
 
40
  async start(config: FeishuRuntimeConfig): Promise<void> {
41
- if (this.state.status === 'running' &&
42
- this.config?.appId === config.appId &&
43
- this.config?.appSecret === config.appSecret) {
 
44
  return
45
  }
46
 
47
  await this.stop()
48
  const runId = ++this.runId
49
  this.config = config
50
- this.client = new Lark.Client({
 
 
51
  appId: config.appId,
52
- appSecret: config.appSecret,
53
- appType: Lark.AppType.SelfBuild,
54
- domain: Lark.Domain.Feishu,
55
  })
56
 
57
- this.setState({ status: 'starting', appId: config.appId, lastError: undefined })
58
-
59
  try {
60
- // Verify credentials
61
- await this.client.request({
62
- method: 'GET',
63
- url: '/open-apis/bot/v3/info',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  })
65
- if (runId !== this.runId) return
66
 
67
- logForDebugging('[feishu] connected successfully')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
- const dispatcher = new Lark.EventDispatcher({
70
- encryptKey: config.encryptKey,
71
- verificationToken: config.verificationToken,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  })
73
 
74
- dispatcher.register({
75
- 'im.message.receive_v1': async (data: any) => {
76
  if (runId !== this.runId) return
77
  try {
78
- await this.handleInbound(data)
79
  } catch (err) {
80
- logForDebugging(`[feishu] inbound error: ${normalizeError(err)}`)
 
 
 
81
  }
82
  },
83
- } as any)
84
-
85
- // @ts-ignore - WSClient instance type varies
86
- this.wsClient = new Lark.WSClient({
87
- appId: config.appId,
88
- appSecret: config.appSecret,
89
- domain: Lark.Domain.Feishu,
90
- loggerLevel: Lark.LoggerLevel.warn,
 
91
  })
92
 
93
- await this.wsClient.start?.({ eventDispatcher: dispatcher })
94
  if (runId !== this.runId) return
95
 
96
- this.setState({ status: 'running', appId: config.appId })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
  } catch (err) {
98
  if (runId !== this.runId) return
99
- this.setState({ status: 'error', appId: config.appId, lastError: normalizeError(err) })
 
 
100
  throw err
101
  }
102
  }
@@ -109,86 +434,81 @@ class FeishuService {
109
  await this.start(config)
110
  }
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  async stop(): Promise<void> {
113
  this.runId++
114
- try { this.wsClient?.stop?.() } catch {}
115
- this.wsClient = undefined
116
- this.client = undefined
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  this.config = undefined
 
118
  if (this.state.status !== 'stopped') {
119
- this.setState({ ...this.state, status: 'stopped' })
120
  }
121
  }
122
 
123
  async sendText(chatId: string, text: string): Promise<void> {
124
- if (!this.client) return
125
- const MAX = 4000
126
- for (let i = 0; i < text.length; i += MAX) {
127
- const chunk = text.slice(i, i + MAX)
128
- await this.client.im.message.create({
129
- data: {
130
- receive_id: chatId,
131
- receive_id_type: 'open_id',
132
- msg_type: 'text',
133
- content: JSON.stringify({ text: chunk }),
134
- },
135
- }).catch((e) => logForDebugging(`[feishu] send error: ${e}`))
136
  }
137
  }
138
 
139
- /** Called from useFeishuBridge with REPL message state */
140
- onMessagesChange(messages: any[], isLoading: boolean): void {
141
- // Currently a no-op — responses are handled by the bridge pattern
142
- // where messages go through enqueue → REPL → AppState → back to Feishu
143
  }
144
 
145
- private async handleInbound(data: any): Promise<void> {
146
- // Debug: log what we receive
147
- console.log('[feishu] raw inbound:', JSON.stringify(data).slice(0, 500))
148
-
149
- const event = data.event as any
150
- const sender = event?.sender as any
151
- const message = event?.message as any
152
- console.log('[feishu] event:', JSON.stringify(event)?.slice(0,200))
153
- console.log('[feishu] sender:', JSON.stringify(sender)?.slice(0,200))
154
- console.log('[feishu] message:', JSON.stringify(message)?.slice(0,200))
155
-
156
- const chatId = sender?.sender_id?.open_id
157
- const userId = sender?.sender_id?.open_id
158
- let text = ''
159
- try {
160
- if (message.content) {
161
- text = JSON.parse(message.content).text?.trim() ?? ''
162
- }
163
- } catch {}
164
-
165
- if (!text || !chatId || !userId) return
166
-
167
- // Check allowed users
168
- if (this.config?.allowedUsers?.length && !this.config.allowedUsers.includes(userId)) {
169
- await this.sendText(chatId, '你没有权限���用此飞书机器人。')
170
- return
171
- }
172
-
173
- logForDebugging(`[feishu] inbound: ${text.slice(0, 50)}`)
174
- console.log('[feishu] enqueueing:', text.slice(0, 50))
175
-
176
- // Enqueue to REPL — will be picked up by the command queue
177
- const { enqueue } = await import('../../utils/messageQueueManager.js')
178
- enqueue({
179
- value: text,
180
- mode: 'prompt',
181
- skipSlashCommands: true,
182
- bridgeOrigin: true,
183
- origin: { kind: 'channel', server: 'feishu' },
184
- })
185
- console.log('[feishu] enqueued, queue length now:', (await import('../../utils/messageQueueManager.js')).getCommandQueueSnapshot().length)
186
- }
187
 
188
- private setState(next: FeishuServiceState): void {
189
  this.state = { ...this.state, ...next }
190
  for (const listener of this.listeners) listener()
191
  }
192
  }
193
 
194
- export const feishuService = new FeishuService()
 
1
  /**
2
+ * FeishuService — in-process Feishu bot using @larksuite/channel
3
+ * Refactored to use vendor modules from lark-coding-agent-bridge.
 
 
4
  */
5
 
6
+ import {
7
+ createLarkChannel,
8
+ type LarkChannel,
9
+ type NormalizedMessage,
10
+ registerApp,
11
+ } from '@larksuite/channel'
12
+ import {
13
+ getFeishuConfig,
14
+ saveFeishuConfig,
15
+ type FeishuRuntimeConfig,
16
+ } from './feishuConfig.js'
17
+
18
+ // Vendor modules (from lark-coding-agent-bridge)
19
+ import { log } from './vendor/core/logger.js'
20
+ import { PendingQueue } from './vendor/bot/pending-queue.js'
21
+ import { startKeepalive } from './vendor/bot/keepalive.js'
22
+
23
+ import { ChatModeCache } from './vendor/bot/chat-mode-cache.js'
24
+ import { createOwnerRefreshController } from './vendor/policy/owner.js'
25
+ import { fetchQuotedContext, renderQuotedBlock } from './vendor/bot/quote.js'
26
+
27
+ // ──────────────────────────────────────────────
28
+ // Minimal RuntimeControls interface (matches vendor/policy/access.ts)
29
+ // ──────────────────────────────────────────────
30
+
31
+ interface RuntimeControls {
32
+ botOwnerId?: string
33
+ ownerRefreshState: 'ok' | 'failed' | 'unknown'
34
+ ownerRefreshedAt?: number
35
+ ownerRefreshError?: string
36
+ }
37
+
38
+ // ──────────────────────────────────────────────
39
+ // Types
40
+ // ──────────────────────────────────────────────
41
 
42
  type FeishuServiceState = {
43
  status: 'stopped' | 'starting' | 'running' | 'error'
44
  appId?: string
45
+ botName?: string
46
+ ownerId?: string
47
  lastError?: string
48
  }
49
 
50
  type Listener = () => void
51
 
52
+ /** Inbound event for bridge hook consumption */
53
+ type FeishuInboundEvent = {
54
+ chatId: string
55
+ text: string
56
+ senderId: string
57
+ }
58
+
59
+ type InboundListener = (event: FeishuInboundEvent) => void
60
+
61
+ type AccessDecision = {
62
+ ok: boolean
63
+ reason: string
64
+ }
65
+
66
+ // Minimal RuntimeControls implementation for owner refresh
67
+ class FeishuRuntimeControls implements RuntimeControls {
68
+ botOwnerId?: string
69
+ ownerRefreshState: 'ok' | 'failed' | 'unknown' = 'unknown'
70
+ ownerRefreshedAt?: number
71
+ ownerRefreshError?: string
72
+ }
73
+
74
+ // ──────────────────────────────────────────────
75
+ // Access control helpers
76
+ // ──────────────────────────────────────────────
77
+
78
+ function isOwner(controls: FeishuRuntimeControls, senderId: string): boolean {
79
+ if (controls.ownerRefreshState === 'unknown') return false
80
+ return Boolean(controls.botOwnerId) && controls.botOwnerId === senderId
81
+ }
82
+
83
+ function canUseDm(
84
+ config: FeishuRuntimeConfig,
85
+ controls: FeishuRuntimeControls,
86
+ senderId: string,
87
+ ): AccessDecision {
88
+ if (isOwner(controls, senderId)) return { ok: true, reason: 'owner' }
89
+ if (config.admins?.includes(senderId)) return { ok: true, reason: 'admin' }
90
+ if (!config.allowedUsers || config.allowedUsers.length === 0) {
91
+ return { ok: true, reason: 'open-dm' }
92
+ }
93
+ if (config.allowedUsers.includes(senderId)) return { ok: true, reason: 'allowed-user' }
94
+ return { ok: false, reason: 'denied-user' }
95
+ }
96
+
97
+ function canUseGroup(
98
+ config: FeishuRuntimeConfig,
99
+ controls: FeishuRuntimeControls,
100
+ chatId: string,
101
+ senderId: string,
102
+ ): AccessDecision {
103
+ if (isOwner(controls, senderId)) return { ok: true, reason: 'owner' }
104
+ if (config.admins?.includes(senderId)) return { ok: true, reason: 'admin' }
105
+ if (config.allowedChats?.includes(chatId)) return { ok: true, reason: 'allowed-chat' }
106
+ return { ok: false, reason: 'denied-chat' }
107
+ }
108
+
109
+ function canRunAdminCommand(
110
+ config: FeishuRuntimeConfig,
111
+ controls: FeishuRuntimeControls,
112
+ senderId: string,
113
+ ): AccessDecision {
114
+ if (isOwner(controls, senderId)) return { ok: true, reason: 'owner' }
115
+ if (config.admins?.includes(senderId)) return { ok: true, reason: 'admin' }
116
+ return { ok: false, reason: 'denied-admin' }
117
  }
118
 
119
+ // ──────────────────────────────────────────────
120
+ // Slash command handler type
121
+ // ──────────────────────────────────────────────
122
+
123
+ type CommandHandler = (
124
+ args: string,
125
+ chatId: string,
126
+ senderId: string,
127
+ channel: LarkChannel,
128
+ ) => Promise<boolean>
129
+
130
+ // ──────────────────────────────────────────────
131
+ // Main service
132
+ // ──────────────────────────────────────────────
133
+
134
  class FeishuService {
135
  private listeners = new Set<Listener>()
136
+ private inboundListeners = new Set<InboundListener>()
137
  private state: FeishuServiceState = { status: 'stopped' }
138
  private config?: FeishuRuntimeConfig
139
+ private channel?: LarkChannel
140
+ private pending?: PendingQueue
141
+ private keepalive?: { stop(): void }
142
+ private ownerRefresh?: { start(): Promise<void>; stop(): void }
143
+ private chatModeCache?: ChatModeCache
144
+ private controls = new FeishuRuntimeControls()
145
  private runId = 0
146
+ private commandHandlers = new Map<string, CommandHandler>()
147
+
148
+ constructor() {
149
+ this.registerDefaultCommands()
150
+ }
151
+
152
+ // ── Default slash commands ──────────────────
153
+
154
+ private registerDefaultCommands(): void {
155
+ this.commandHandlers.set('/stop', async (_args, chatId, _senderId, channel) => {
156
+ await channel.send(chatId, { text: '⏹ 停止命令已收到。' })
157
+ return true
158
+ })
159
+
160
+ this.commandHandlers.set('/reset', async (_args, chatId, _senderId, channel) => {
161
+ await channel.send(chatId, { text: '🔄 已重置会话。' })
162
+ return true
163
+ })
164
+
165
+ this.commandHandlers.set(
166
+ '/status',
167
+ async (_args, chatId, _senderId, channel) => {
168
+ const cfg = this.config
169
+ const lines = [
170
+ `状态: ${this.state.status}`,
171
+ `App ID: ${cfg?.appId ?? '未配置'}`,
172
+ `Bot: ${this.state.botName ?? '未知'}`,
173
+ `Owner: ${this.controls.botOwnerId ?? '未知'}`,
174
+ `DM 策略: ${
175
+ !cfg?.allowedUsers || cfg.allowedUsers.length === 0
176
+ ? '所有人'
177
+ : '白名单'
178
+ }`,
179
+ `群聊策略: ${
180
+ cfg?.requireMentionInGroup !== false ? '需 @bot' : '所有消息'
181
+ }`,
182
+ `配置群白名单: ${cfg?.allowedChats?.length ?? 0} 个`,
183
+ ]
184
+ await channel.send(chatId, { text: lines.join('\n') })
185
+ return true
186
+ },
187
+ )
188
+
189
+ this.commandHandlers.set('/help', async (_args, chatId, _senderId, channel) => {
190
+ const help = [
191
+ '飞书 Bot 命令:',
192
+ '/stop — 中断当前任务',
193
+ '/reset — 重置会话',
194
+ '/status — 查看状态',
195
+ '/help — 显示此帮助',
196
+ ]
197
+ await channel.send(chatId, { text: help.join('\n') })
198
+ return true
199
+ })
200
+ }
201
+
202
+ // ── React sync external store interface ─────
203
 
204
  subscribe = (listener: Listener): (() => void) => {
205
  this.listeners.add(listener)
206
  return () => this.listeners.delete(listener)
207
  }
208
 
209
+ subscribeToInbound = (listener: InboundListener): (() => void) => {
210
+ this.inboundListeners.add(listener)
211
+ return () => this.inboundListeners.delete(listener)
212
+ }
213
+
214
  getStateSnapshot = (): FeishuServiceState => this.state
215
 
216
+ // ── Lifecycle ───────────────────────────────
217
+
218
  async start(config: FeishuRuntimeConfig): Promise<void> {
219
+ if (
220
+ this.state.status === 'running' &&
221
+ this.config?.appId === config.appId
222
+ ) {
223
  return
224
  }
225
 
226
  await this.stop()
227
  const runId = ++this.runId
228
  this.config = config
229
+
230
+ this.setState({
231
+ status: 'starting',
232
  appId: config.appId,
233
+ lastError: undefined,
 
 
234
  })
235
 
 
 
236
  try {
237
+ const domain =
238
+ config.tenant === 'lark'
239
+ ? 'https://open.larksuite.com'
240
+ : 'https://open.feishu.cn'
241
+
242
+ const channel = createLarkChannel({
243
+ appId: config.appId!,
244
+ appSecret: config.appSecret!,
245
+ domain,
246
+ source: 'versperclaw',
247
+ // Use default logger level (info)
248
+ respectProxyEnv: true,
249
+ wsConfig: { pingTimeout: 3 },
250
+ handshakeTimeoutMs: 8000,
251
+ httpTimeoutMs: 30000,
252
+ policy: {
253
+ dmMode: 'open',
254
+ requireMention: false,
255
+ respondToMentionAll: false,
256
+ },
257
+ safety: { chatQueue: { enabled: false } },
258
+ includeRawEvent: true,
259
  })
 
260
 
261
+ const chatModeCache = new ChatModeCache()
262
+ const cfg = config // local ref for closure
263
+
264
+ // Pending queue with block/unblock support
265
+ const pending = new PendingQueue(600, async (scope, batch) => {
266
+ const first = batch[0]
267
+ if (!first) return
268
+
269
+ const chatId = first.chatId
270
+ const senderId = first.senderId
271
+ const text = batch.map(m => m.content).join('\n---\n')
272
+
273
+ log.info('flush', 'start', { scope, batchSize: batch.length })
274
+
275
+ try {
276
+ const mode = await chatModeCache.resolve(channel, chatId)
277
+ const isGroup = mode !== 'p2p'
278
 
279
+ // Access check
280
+ if (isGroup) {
281
+ const access = canUseGroup(cfg, this.controls, chatId, senderId)
282
+ if (!access.ok) {
283
+ if (first.mentionedBot) {
284
+ await channel.send(chatId, {
285
+ text: '当前群未加入响应列表。请联系 Bot owner 或管理员使用 /invite group 加入白名单。',
286
+ })
287
+ }
288
+ log.info('intake', 'denied-group', {
289
+ chatId,
290
+ senderId,
291
+ reason: access.reason,
292
+ })
293
+ return
294
+ }
295
+ // @mention policy — default requires mention in groups
296
+ if (cfg.requireMentionInGroup !== false && !first.mentionedBot) {
297
+ log.info('intake', 'skip-no-mention', { chatId })
298
+ return
299
+ }
300
+ } else {
301
+ const access = canUseDm(cfg, this.controls, senderId)
302
+ if (!access.ok) {
303
+ log.info('intake', 'denied-dm', {
304
+ chatId,
305
+ senderId,
306
+ reason: access.reason,
307
+ })
308
+ return
309
+ }
310
+ }
311
+
312
+ // Slash command routing
313
+ if (text.startsWith('/')) {
314
+ const parts = text.split(/\s+/)
315
+ const cmdName = parts[0] ?? ''
316
+ const args = parts.slice(1).join(' ')
317
+ const handler = this.commandHandlers.get(cmdName)
318
+ if (handler) {
319
+ await handler(args, chatId, senderId, channel)
320
+ return // handled locally
321
+ }
322
+ // Unknown command — let it pass through to the agent
323
+ }
324
+
325
+ // Build prompt with optional quoted context
326
+ let fullText = text
327
+ const replyToId = first.replyToMessageId
328
+ if (replyToId) {
329
+ const quote = await fetchQuotedContext(channel, replyToId)
330
+ if (quote) {
331
+ const quotedBlock = renderQuotedBlock([quote])
332
+ if (quotedBlock) {
333
+ fullText = `${quotedBlock}\n\n${text}`
334
+ }
335
+ }
336
+ }
337
+
338
+ // Notify inbound listeners (bridge hook uses this for chatId tracking)
339
+ for (const listener of this.inboundListeners) {
340
+ listener({ chatId, text: fullText, senderId })
341
+ }
342
+
343
+ // Enqueue to REPL
344
+ const { enqueue } = await import(
345
+ '../../utils/messageQueueManager.js'
346
+ )
347
+ enqueue({
348
+ value: fullText,
349
+ mode: 'prompt',
350
+ skipSlashCommands: true,
351
+ bridgeOrigin: true,
352
+ origin: { kind: 'channel', server: 'feishu' },
353
+ })
354
+ } finally {
355
+ log.info('flush', 'end')
356
+ }
357
  })
358
 
359
+ channel.on({
360
+ message: async (msg: NormalizedMessage) => {
361
  if (runId !== this.runId) return
362
  try {
363
+ pending.push(msg.chatId, msg)
364
  } catch (err) {
365
+ log.fail('intake', err, {
366
+ chatId: msg.chatId,
367
+ msgId: msg.messageId,
368
+ })
369
  }
370
  },
371
+ error: (err: unknown) => {
372
+ log.warn('ws', 'error', { err: String(err) })
373
+ },
374
+ reconnecting: () => {
375
+ log.warn('ws', 'reconnecting')
376
+ },
377
+ reconnected: () => {
378
+ log.info('ws', 'reconnected')
379
+ },
380
  })
381
 
382
+ await channel.connect()
383
  if (runId !== this.runId) return
384
 
385
+ this.channel = channel
386
+ this.pending = pending
387
+ this.chatModeCache = chatModeCache
388
+
389
+ // Keepalive �� defense-in-depth against silent WS issues
390
+ this.keepalive = startKeepalive({
391
+ channel,
392
+ domain,
393
+ forceReconnect: async () => {
394
+ log.info('keepalive', 'reconnect-requested')
395
+ await channel.disconnect()
396
+ await channel.connect()
397
+ },
398
+ })
399
+
400
+ // Owner refresh — periodically fetches app owner from Feishu API
401
+ this.ownerRefresh = createOwnerRefreshController({
402
+ controls: this.controls,
403
+ source: channel,
404
+ appId: config.appId ?? '(unknown)',
405
+ })
406
+ await this.ownerRefresh.start()
407
+
408
+ const botName = channel.botIdentity?.name ?? config.appId
409
+ log.info('ws', 'connected', {
410
+ bot: botName,
411
+ openId: channel.botIdentity?.openId,
412
+ appId: config.appId,
413
+ })
414
+ this.setState({
415
+ status: 'running',
416
+ appId: config.appId,
417
+ botName,
418
+ ownerId: this.controls.botOwnerId,
419
+ })
420
  } catch (err) {
421
  if (runId !== this.runId) return
422
+ const msg = err instanceof Error ? err.message : String(err)
423
+ log.fail('start', err, { appId: config.appId })
424
+ this.setState({ status: 'error', appId: config.appId, lastError: msg })
425
  throw err
426
  }
427
  }
 
434
  await this.start(config)
435
  }
436
 
437
+ async runRegistrationWizard(): Promise<{ appId: string; appSecret: string }> {
438
+ const result = await registerApp({
439
+ source: 'versperclaw',
440
+ onQRCodeReady: info => {
441
+ log.info('wizard', 'qr-ready', {
442
+ url: info.url,
443
+ expireIn: info.expireIn,
444
+ })
445
+ },
446
+ onStatusChange: info => {
447
+ log.info('wizard', 'status', { status: info.status })
448
+ },
449
+ })
450
+
451
+ log.info('wizard', 'complete', { appId: result.client_id })
452
+
453
+ saveFeishuConfig({
454
+ appId: result.client_id,
455
+ appSecret: result.client_secret,
456
+ })
457
+
458
+ return {
459
+ appId: result.client_id,
460
+ appSecret: result.client_secret,
461
+ }
462
+ }
463
+
464
  async stop(): Promise<void> {
465
  this.runId++
466
+ if (this.ownerRefresh) {
467
+ this.ownerRefresh.stop()
468
+ this.ownerRefresh = undefined
469
+ }
470
+ if (this.keepalive) {
471
+ this.keepalive.stop()
472
+ this.keepalive = undefined
473
+ }
474
+ if (this.pending) {
475
+ this.pending.cancelAll()
476
+ this.pending = undefined
477
+ }
478
+ if (this.channel) {
479
+ try {
480
+ await this.channel.disconnect()
481
+ } catch {}
482
+ this.channel = undefined
483
+ }
484
+ this.chatModeCache = undefined
485
  this.config = undefined
486
+ this.controls = new FeishuRuntimeControls()
487
  if (this.state.status !== 'stopped') {
488
+ this.setState({ status: 'stopped' })
489
  }
490
  }
491
 
492
  async sendText(chatId: string, text: string): Promise<void> {
493
+ if (!this.channel) return
494
+ try {
495
+ await this.channel.send(chatId, { text })
496
+ } catch (err) {
497
+ log.fail('send', err, { chatId, textLen: text.length })
 
 
 
 
 
 
 
498
  }
499
  }
500
 
501
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
502
+ onMessagesChange(_messages: unknown[], _isLoading: boolean): void {
503
+ // No-op
 
504
  }
505
 
506
+ // ── Internal ────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
507
 
508
+ private setState(next: Partial<FeishuServiceState>): void {
509
  this.state = { ...this.state, ...next }
510
  for (const listener of this.listeners) listener()
511
  }
512
  }
513
 
514
+ export const feishuService = new FeishuService()
src/services/feishu/feishuConfig.ts CHANGED
@@ -12,13 +12,32 @@ export type PairedUser = {
12
  pairedAt: number
13
  }
14
 
 
 
 
 
 
 
15
  export type FeishuRuntimeConfig = {
16
  appId?: string
17
  appSecret?: string
 
 
18
  encryptKey?: string
19
  verificationToken?: string
 
20
  allowedUsers?: string[]
 
 
 
 
 
 
 
21
  pairedUsers?: PairedUser[]
 
 
 
22
  streamingCard?: boolean
23
  }
24
 
 
12
  pairedAt: number
13
  }
14
 
15
+ export type AuthorizedUser = {
16
+ userId: string
17
+ displayName: string
18
+ authorizedAt: number
19
+ }
20
+
21
  export type FeishuRuntimeConfig = {
22
  appId?: string
23
  appSecret?: string
24
+ /** 'feishu' | 'lark' — saved from registerApp result.user_info?.tenant_brand */
25
+ tenant?: string
26
  encryptKey?: string
27
  verificationToken?: string
28
+ /** DM 白名单用户 open_id 列表(空 = 所有人可私聊) */
29
  allowedUsers?: string[]
30
+ /** 管理员 open_id 列表 */
31
+ admins?: string[]
32
+ /** 允许响应的群聊 chat_id 列表(空 = 所有群都不响应) */
33
+ allowedChats?: string[]
34
+ /** 群聊中是否要求 @bot 才响应(默认 true) */
35
+ requireMentionInGroup?: boolean
36
+ /** 已配对的用户 */
37
  pairedUsers?: PairedUser[]
38
+ /** 已授权的用户 */
39
+ authorizedUsers?: AuthorizedUser[]
40
+ /** 是否使用卡片回复模式 */
41
  streamingCard?: boolean
42
  }
43