diff --git a/mateclaw-server/pom.xml b/mateclaw-server/pom.xml index 4f10cad5..3bc9241d 100644 --- a/mateclaw-server/pom.xml +++ b/mateclaw-server/pom.xml @@ -202,7 +202,7 @@ com.larksuite.oapi oapi-sdk - 2.5.3 + 2.6.1 diff --git a/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelWebhookController.java b/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelWebhookController.java index aa6e0dea..07bebb0c 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelWebhookController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelWebhookController.java @@ -10,6 +10,7 @@ import vip.mate.channel.ChannelAdapter; import vip.mate.channel.ChannelManager; import vip.mate.channel.dingtalk.DingTalkChannelAdapter; import vip.mate.channel.discord.DiscordChannelAdapter; +import vip.mate.channel.feishu.FeishuAppRegistrationService; import vip.mate.channel.feishu.FeishuChannelAdapter; import vip.mate.channel.telegram.TelegramChannelAdapter; import vip.mate.channel.weixin.ILinkClient; @@ -47,6 +48,7 @@ import java.util.Optional; public class ChannelWebhookController { private final ChannelManager channelManager; + private final FeishuAppRegistrationService feishuAppRegistrationService; @Operation(summary = "钉钉消息回调") @PostMapping("/dingtalk") @@ -80,6 +82,62 @@ public class ChannelWebhookController { return ResponseEntity.ok(Map.of("code", 0)); } + // ==================== 飞书一键应用注册(oapi-sdk 2.6+) ==================== + + @Operation(summary = "启动飞书扫码注册应用流程") + @PostMapping("/feishu/register/begin") + public ResponseEntity> feishuRegisterBegin( + @RequestParam(value = "domain", defaultValue = "feishu") String domain) { + try { + String sessionId = feishuAppRegistrationService.begin(domain); + return ResponseEntity.ok(Map.of("session_id", sessionId)); + } catch (Exception e) { + log.error("[feishu-register] begin failed: {}", e.getMessage(), e); + return ResponseEntity.internalServerError() + .body(Map.of("error", "Failed to start registration: " + e.getMessage())); + } + } + + @Operation(summary = "查询飞书扫码注册状态") + @GetMapping("/feishu/register/status") + public ResponseEntity> feishuRegisterStatus(@RequestParam("session") String sessionId) { + FeishuAppRegistrationService.RegistrationSession session = feishuAppRegistrationService.getSession(sessionId); + if (session == null) { + return ResponseEntity.ok(Map.of("status", "expired", "error", "session not found or expired")); + } + Map body = new LinkedHashMap<>(); + body.put("status", session.status.name().toLowerCase()); + if (session.qrcodeUrl != null) { + body.put("qrcode_url", session.qrcodeUrl); + body.put("qrcode_expire_seconds", session.qrcodeExpireSeconds); + // SDK gives us a verification URL string (verification_uri_complete); + // browsers can't render that as an image, so encode it into a PNG QR + // here just like the WeChat flow does. Cache the encoded image on the + // session so we only run ZXing once per registration attempt. + if (session.qrcodeImgDataUri == null) { + try { + String base64 = generateQrCodeBase64(session.qrcodeUrl); + session.qrcodeImgDataUri = "data:image/png;base64," + base64; + } catch (Exception e) { + log.warn("[feishu-register] QR encode failed: {}", e.getMessage()); + } + } + if (session.qrcodeImgDataUri != null) { + body.put("qrcode_img", session.qrcodeImgDataUri); + } + } + if (session.status == FeishuAppRegistrationService.Status.CONFIRMED) { + body.put("client_id", session.clientId); + body.put("client_secret", session.clientSecret); + if (session.userOpenId != null) body.put("user_open_id", session.userOpenId); + if (session.userTenantBrand != null) body.put("user_tenant_brand", session.userTenantBrand); + } + if (session.errorMessage != null) { + body.put("error", session.errorMessage); + } + return ResponseEntity.ok(body); + } + @Operation(summary = "Telegram 消息回调") @PostMapping("/telegram") public ResponseEntity telegramWebhook(@RequestBody Map payload) { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuAppRegistrationService.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuAppRegistrationService.java new file mode 100644 index 00000000..3dec9e85 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuAppRegistrationService.java @@ -0,0 +1,176 @@ +package vip.mate.channel.feishu; + +import com.lark.oapi.scene.registration.AccessDeniedException; +import com.lark.oapi.scene.registration.ExpiredException; +import com.lark.oapi.scene.registration.QRCodeInfo; +import com.lark.oapi.scene.registration.RegisterApp; +import com.lark.oapi.scene.registration.RegisterAppOptions; +import com.lark.oapi.scene.registration.RegisterAppResult; +import com.lark.oapi.scene.registration.StatusChangeInfo; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Iterator; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 飞书"一键应用注册"服务 + *

+ * 包装 oapi-sdk 2.6.x 的 {@link RegisterApp#register} 流程:用户扫码后飞书侧自动建好 + * 自建应用并把 client_id/client_secret 回传,省掉用户手动到开放平台建应用 + 复制 ID/Secret + * 的步骤。 + *

+ * SDK 的 register() 是阻塞的(内部轮询直到扫码确认或超时),所以这里用一个工作线程执行, + * QR URL 通过 onQRCode 回调写到 session,前端按 token 轮询 status 端点查最终结果。 + * + * @author MateClaw Team + */ +@Slf4j +@Service +public class FeishuAppRegistrationService { + + /** 会话最长存活时间:5 分钟(QR 自身约 3 分钟过期,留足缓冲让前端读到 expired 状态) */ + private static final long SESSION_TTL_MS = 5 * 60_000L; + + /** session_id → 注册会话状态 */ + private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + + /** + * 启动一次注册流程:spawn 后台线程跑 SDK register(),立即返回 sessionId。 + * QR URL 在 onQRCode 回调里被写入 session,调用方需要轮询 {@link #getSession(String)} 拿。 + * + * @param domainKey "feishu"(国内)或 "lark"(国际) —— 仅用作记录, + * SDK 内部会把 domain/larkDomain 都试一遍(QR 二维码扫码端 + * 本来就支持飞书 / Lark 两种 App 互通),所以这里**不传** + * 域名让 SDK 自动用默认 accounts.feishu.cn / accounts.larksuite.com。 + * 之前误传开放平台域名 https://open.feishu.cn 会让 SDK 拿到 + * HTML 当 JSON 解析直接 invalid_response。 + * @return 新创建的 sessionId,前端拿这个去查询 QR / status + */ + public String begin(String domainKey) { + evictExpiredSessions(); + + String sessionId = UUID.randomUUID().toString(); + RegistrationSession session = new RegistrationSession(sessionId); + sessions.put(sessionId, session); + + // 不传 .domain()/.larkDomain():SDK 默认用 accounts.feishu.cn 和 accounts.larksuite.com + // 这两个是注册账号端点,open.feishu.cn 是开放 API 端点 —— 完全不是一个东西 + RegisterAppOptions options = RegisterAppOptions.newBuilder() + .source("mateclaw") + .onQRCode(qr -> session.onQRCode(qr)) + .onStatusChange(status -> session.onStatusChange(status)) + .build(); + + Thread worker = new Thread(() -> { + try { + RegisterAppResult result = RegisterApp.register(options); + session.onSuccess(result); + } catch (ExpiredException e) { + session.onTerminal(Status.EXPIRED, "QR code expired"); + } catch (AccessDeniedException e) { + session.onTerminal(Status.DENIED, "User denied authorization"); + } catch (Exception e) { + log.warn("[feishu-register] register() failed: {}", e.getMessage()); + session.onTerminal(Status.ERROR, e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()); + } + }, "feishu-register-" + sessionId.substring(0, 8)); + worker.setDaemon(true); + worker.start(); + + return sessionId; + } + + public RegistrationSession getSession(String sessionId) { + evictExpiredSessions(); + return sessions.get(sessionId); + } + + /** + * Drop sessions that have lived past SESSION_TTL_MS. Keeps the map bounded + * even if the user closes the browser mid-flow without ever polling the + * terminal state. + */ + private void evictExpiredSessions() { + long cutoff = System.currentTimeMillis() - SESSION_TTL_MS; + Iterator> it = sessions.entrySet().iterator(); + while (it.hasNext()) { + if (it.next().getValue().createdAtMs < cutoff) it.remove(); + } + } + + public enum Status { + /** Worker started but onQRCode hasn't fired yet — QR not ready */ + PENDING, + /** QR available, polling for user scan + confirmation */ + WAITING, + /** User confirmed, clientId/clientSecret returned */ + CONFIRMED, + /** QR expired before user finished scanning */ + EXPIRED, + /** User denied authorization on the feishu side */ + DENIED, + /** Network or SDK error */ + ERROR + } + + /** + * 一次注册流程的可观察状态。线程安全:所有写操作都在 SDK 工作线程,读操作在 HTTP 轮询线程, + * 字段全部 volatile 即可。 + */ + public static class RegistrationSession { + public final String sessionId; + final long createdAtMs = System.currentTimeMillis(); + + public volatile Status status = Status.PENDING; + public volatile String qrcodeUrl; + public volatile int qrcodeExpireSeconds; + /** Cached "data:image/png;base64,..." rendered from qrcodeUrl by the controller. */ + public volatile String qrcodeImgDataUri; + public volatile String clientId; + public volatile String clientSecret; + public volatile String userOpenId; + public volatile String userTenantBrand; + public volatile String errorMessage; + public volatile long lastUpdateMs = System.currentTimeMillis(); + + RegistrationSession(String sessionId) { + this.sessionId = sessionId; + } + + void onQRCode(QRCodeInfo qr) { + this.qrcodeUrl = qr.getUrl(); + this.qrcodeExpireSeconds = qr.getExpireIn(); + this.status = Status.WAITING; + this.lastUpdateMs = System.currentTimeMillis(); + log.info("[feishu-register] QR ready, expires in {}s", qr.getExpireIn()); + } + + void onStatusChange(StatusChangeInfo info) { + // SDK 状态码:POLLING / SLOW_DOWN / DOMAIN_SWITCHED。三种都是"还在等",没有更细的 + // "已扫码未确认"信号 —— 飞书侧扫码确认在同一步完成。我们只用 lastUpdateMs 让 + // 前端知道连接还活着,状态本身保持 WAITING 直到终态。 + this.lastUpdateMs = System.currentTimeMillis(); + } + + void onSuccess(RegisterAppResult result) { + this.clientId = result.getClientId(); + this.clientSecret = result.getClientSecret(); + if (result.getUserInfo() != null) { + this.userOpenId = result.getUserInfo().getOpenId(); + this.userTenantBrand = result.getUserInfo().getTenantBrand(); + } + this.status = Status.CONFIRMED; + this.lastUpdateMs = System.currentTimeMillis(); + log.info("[feishu-register] confirmed, clientId={}", clientId); + } + + void onTerminal(Status terminalStatus, String message) { + this.status = terminalStatus; + this.errorMessage = message; + this.lastUpdateMs = System.currentTimeMillis(); + } + } +} diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 18bc31dd..0603f01c 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -232,6 +232,11 @@ export const channelApi = { weixinQrcode: () => http.get('/channels/webhook/weixin/qrcode'), weixinQrcodeStatus: (qrcode: string) => http.get(`/channels/webhook/weixin/qrcode/status?qrcode=${encodeURIComponent(qrcode)}`), + // Feishu one-click app registration (oapi-sdk 2.6+ scene/registration) + feishuRegisterBegin: (domain: string) => + http.post(`/channels/webhook/feishu/register/begin?domain=${encodeURIComponent(domain)}`), + feishuRegisterStatus: (sessionId: string) => + http.get(`/channels/webhook/feishu/register/status?session=${encodeURIComponent(sessionId)}`), } // ==================== MCP Server ==================== diff --git a/mateclaw-ui/src/components/channels/ChannelEditModal.vue b/mateclaw-ui/src/components/channels/ChannelEditModal.vue index 60d640e1..474dfce7 100644 --- a/mateclaw-ui/src/components/channels/ChannelEditModal.vue +++ b/mateclaw-ui/src/components/channels/ChannelEditModal.vue @@ -116,6 +116,40 @@ + +

+
+ {{ t('channels.feishuRegister.title') }} +
+

{{ t('channels.feishuRegister.hint') }}

+ +
+ +

+ + + + + +

+
+
+

{{ t('channels.wecom.authHint') }}

@@ -361,6 +395,7 @@ import { } from '@/utils/channelConfigJson' import { useWeixinQrcodePoll } from '@/composables/channels/useWeixinQrcodePoll' import { useWecomBotAuth } from '@/composables/channels/useWecomBotAuth' +import { useFeishuAppRegister } from '@/composables/channels/useFeishuAppRegister' interface Props { modelValue: boolean @@ -431,6 +466,13 @@ const wecom = useWecomBotAuth((bot) => { channelConfig.value.secret = bot.secret }) +// Feishu one-click app registration: scan-to-create flow that returns +// app_id/app_secret without the user ever touching the developer console. +const feishuRegister = useFeishuAppRegister(({ appId, appSecret }) => { + channelConfig.value.app_id = appId + channelConfig.value.app_secret = appSecret +}) + // ========== Field defs (derived) ========== const currentFieldDefs = computed(() => { @@ -693,6 +735,22 @@ function save() { .guide-steps :deep(code) { font-size: 12px; background: var(--mc-bg-sunken); padding: 1px 5px; border-radius: 3px; } .guide-steps :deep(b) { color: var(--mc-text-primary); font-weight: 600; } +/* Feishu one-click register */ +.feishu-register-card { background: linear-gradient(135deg, rgba(0,128,255,0.05), rgba(99,102,241,0.05)); border: 1px solid rgba(99,102,241,0.2); border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; } +.feishu-register-header { font-size: 13px; color: var(--mc-text-primary); margin-bottom: 6px; } +.feishu-register-hint { font-size: 12px; color: var(--mc-text-secondary); margin: 0 0 10px 0; line-height: 1.6; } +.feishu-register-btn { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 10px 16px; background: #2563eb; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s; } +.feishu-register-btn:hover:not(:disabled) { background: #1d4ed8; transform: translateY(-1px); box-shadow: 0 2px 8px rgba(37,99,235,0.3); } +.feishu-register-btn:active:not(:disabled) { transform: translateY(0); } +.feishu-register-btn:disabled { opacity: 0.6; cursor: not-allowed; } +.feishu-register-qrcode { display: flex; flex-direction: column; align-items: center; margin-top: 16px; padding: 16px; background: #fff; border-radius: 8px; border: 1px solid var(--mc-border); } +.feishu-register-qrcode-img { width: 200px; height: 200px; border-radius: 4px; } +.feishu-register-status { font-size: 13px; color: var(--mc-text-secondary); margin-top: 10px; transition: color 0.2s; text-align: center; } +.feishu-register-status.confirmed { color: #10b981; font-weight: 500; } +.feishu-register-status.expired { color: #f56c6c; } +.feishu-register-status.denied { color: #f56c6c; } +.feishu-register-status.error { color: #f56c6c; } + /* WeCom auth */ .wecom-auth-card { background: var(--mc-primary-bg, rgba(217,119,87,0.06)); border: 1px solid var(--mc-primary-light, rgba(217,119,87,0.2)); border-radius: 10px; padding: 14px 16px; margin-bottom: 16px; } .wecom-auth-hint { font-size: 13px; color: var(--mc-text-secondary); margin: 0 0 10px 0; line-height: 1.6; } diff --git a/mateclaw-ui/src/composables/channels/useFeishuAppRegister.ts b/mateclaw-ui/src/composables/channels/useFeishuAppRegister.ts new file mode 100644 index 00000000..e9d6dafe --- /dev/null +++ b/mateclaw-ui/src/composables/channels/useFeishuAppRegister.ts @@ -0,0 +1,113 @@ +import { ref, onBeforeUnmount } from 'vue' +import { useI18n } from 'vue-i18n' +import { ElMessage } from 'element-plus' +import { channelApi } from '@/api' + +export type FeishuRegisterStatus = '' | 'pending' | 'waiting' | 'confirmed' | 'expired' | 'denied' | 'error' + +export interface FeishuRegisterResult { + appId: string + appSecret: string +} + +/** + * 飞书"一键应用注册"前端状态机: + * 1. POST /feishu/register/begin → 拿到 sessionId(后端起 worker,开始向飞书拉 QR) + * 2. 每 2s 轮询 /feishu/register/status → 拿到 qrcode_url 渲染二维码 / 拿到 confirmed 时回调 + * 3. 终态(confirmed / expired / denied / error)后停止轮询 + * + * 跟微信的 useWeixinQrcodePoll 同构,但 SDK 不同:飞书是 oapi-sdk RegisterApp, + * 微信是 iLink Bot HTTP。 + */ +export function useFeishuAppRegister(onConfirmed: (r: FeishuRegisterResult) => void) { + const { t } = useI18n() + const qrcodeUrl = ref('') + const loading = ref(false) + const status = ref('') + + let pollTimer: ReturnType | null = null + let confirmedFired = false + + function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } + } + + function reset() { + stopPolling() + qrcodeUrl.value = '' + status.value = '' + confirmedFired = false + } + + async function start(domain: string) { + reset() + loading.value = true + + let sessionId = '' + try { + const res: any = await channelApi.feishuRegisterBegin(domain) + sessionId = res?.data?.session_id || res?.session_id || '' + if (!sessionId) { + ElMessage.error(t('channels.feishuRegister.startFailed')) + return + } + status.value = 'pending' + } catch { + ElMessage.error(t('channels.feishuRegister.startFailed')) + return + } finally { + loading.value = false + } + + pollTimer = setInterval(async () => { + try { + const res: any = await channelApi.feishuRegisterStatus(sessionId) + const data = res?.data || res || {} + const s = (data.status as FeishuRegisterStatus) || 'pending' + + // Prefer the backend-rendered base64 PNG (data: URI). The raw qrcode_url + // is the verification URL that needs to be encoded into a QR image — + // browsers can't render plain text as an image. Fall back to the URL + // only as a defensive last resort. + const img = data.qrcode_img || data.qrcode_url + if (img && qrcodeUrl.value !== img) { + qrcodeUrl.value = img + } + status.value = s + + if (s === 'confirmed') { + if (confirmedFired) return + confirmedFired = true + stopPolling() + const appId = data.client_id || '' + const appSecret = data.client_secret || '' + if (appId && appSecret) { + onConfirmed({ appId, appSecret }) + ElMessage.success(t('channels.feishuRegister.confirmed')) + } + return + } + + if (s === 'expired') { + stopPolling() + ElMessage.warning(t('channels.feishuRegister.expired')) + } else if (s === 'denied') { + stopPolling() + ElMessage.warning(t('channels.feishuRegister.denied')) + } else if (s === 'error') { + stopPolling() + ElMessage.error(t('channels.feishuRegister.error')) + } + } catch { + // Silent — transient network errors should not abort the loop. + } + }, 2000) + } + + onBeforeUnmount(stopPolling) + + return { qrcodeUrl, loading, status, start, reset, stopPolling } +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index fabc0260..478676dc 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1643,6 +1643,18 @@ export default { step4: 'After starting the channel, auto-receives messages via WebSocket long connection, no public IP or callback URL required', }, }, + feishuRegister: { + title: 'One-click Feishu App Creation', + hint: 'Click the button to get a QR code. Scan it with Feishu and confirm authorization — an in-house app will be created in your tenant and the App ID / App Secret will be auto-filled below.', + button: 'Scan to Create App', + buttonLoading: 'Preparing QR code…', + scanHint: 'Scan the QR code above with the Feishu app and confirm authorization', + confirmed: 'App created. App ID and Secret have been auto-filled.', + expired: 'QR code expired, please try again', + denied: 'Authorization was denied', + error: 'Registration failed, please retry', + startFailed: 'Failed to fetch QR code, please check your network', + }, feishu: { perm: { message: 'Send and receive messages', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index d5cc5f8c..4aed029c 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1653,6 +1653,18 @@ export default { step4: '启动渠道后通过 WebSocket 长连接自动接收消息,无需公网 IP 和回调 URL', }, }, + feishuRegister: { + title: '一键创建飞书应用', + hint: '点击按钮获取二维码,使用飞书扫一扫并确认授权后,会自动在你的企业里创建自建应用,App ID / App Secret 自动填入下方。', + button: '扫码自动创建应用', + buttonLoading: '正在准备二维码…', + scanHint: '请使用飞书 App 扫描上方二维码并确认授权', + confirmed: '应用创建成功,App ID 和 Secret 已自动填入', + expired: '二维码已过期,请重新生成', + denied: '授权被拒绝', + error: '注册过程出错,请稍后重试', + startFailed: '获取二维码失败,请检查网络', + }, feishu: { perm: { message: '获取与发送单聊、群组消息',