mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(feishu): one-click app creation via official SDK device-flow registration
Saves the user the entire 'go to the open platform -> create an enterprise
app -> copy App ID and Secret' detour. Click a button in the channel form,
scan the QR code, confirm authorization, credentials are auto-filled.
Backend
- Bump com.larksuite.oapi:oapi-sdk from 2.5.3 to 2.6.1, which adds the
scene/registration package wrapping the device-code flow.
- New FeishuAppRegistrationService: each begin() creates a sessionId,
spawns a worker thread, runs the SDK's blocking RegisterApp.register
with onQRCode and onStatusChange wired into a per-session state machine
(PENDING -> WAITING -> CONFIRMED / EXPIRED / DENIED / ERROR). The
session caches the QR data URI so ZXing only encodes once per attempt.
Sessions evict after 5 minutes so closed browsers don't leak the map.
- Two new webhook endpoints under /api/v1/channels/webhook/feishu:
POST /register/begin returns session_id, GET /register/status returns
status + qrcode_img (data URI base64 PNG, ZXing-encoded from the SDK's
verification URL — the raw URL would render as a broken image, so the
encoding step matches the WeCom flow).
- SDK detail caught the hard way: don't pass .domain() or .larkDomain().
The SDK defaults are accounts.feishu.cn / accounts.larksuite.com (the
registration endpoints). open.feishu.cn is the open-API endpoint, a
completely different service. Passing the wrong one makes the SDK parse
HTML as JSON and emit invalid_response.
Frontend
- channelApi: feishuRegisterBegin / feishuRegisterStatus.
- New useFeishuAppRegister composable: state machine that begins the
session, polls status every 2s, prefers qrcode_img over qrcode_url for
the <img> src, stops on terminal status, fires onConfirmed with
{appId, appSecret}.
- ChannelEditModal: a new feishu-register-card above the wecom one. The
composable's onConfirmed writes channelConfig.app_id / app_secret, so
the existing form fields update reactively.
- i18n: channels.feishuRegister.* keys for title / hint / button states /
scan / confirmed / expired / denied / error.
This commit is contained in:
parent
4081469e15
commit
a27898507c
@ -202,7 +202,7 @@
|
||||
<dependency>
|
||||
<groupId>com.larksuite.oapi</groupId>
|
||||
<artifactId>oapi-sdk</artifactId>
|
||||
<version>2.5.3</version>
|
||||
<version>2.6.1</version>
|
||||
</dependency>
|
||||
|
||||
<!-- ===== Caffeine Cache(用于 skill runtime 缓存) ===== -->
|
||||
|
||||
@ -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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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<String> telegramWebhook(@RequestBody Map<String, Object> payload) {
|
||||
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
* 飞书"一键应用注册"服务
|
||||
* <p>
|
||||
* 包装 oapi-sdk 2.6.x 的 {@link RegisterApp#register} 流程:用户扫码后飞书侧自动建好
|
||||
* 自建应用并把 client_id/client_secret 回传,省掉用户手动到开放平台建应用 + 复制 ID/Secret
|
||||
* 的步骤。
|
||||
* <p>
|
||||
* 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<String, RegistrationSession> 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<Map.Entry<String, RegistrationSession>> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 ====================
|
||||
|
||||
@ -116,6 +116,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 飞书一键应用注册(oapi-sdk 2.6+) -->
|
||||
<div v-if="form.channelType === 'feishu'" class="feishu-register-card">
|
||||
<div class="feishu-register-header">
|
||||
<strong>{{ t('channels.feishuRegister.title') }}</strong>
|
||||
</div>
|
||||
<p class="feishu-register-hint">{{ t('channels.feishuRegister.hint') }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="feishu-register-btn"
|
||||
@click="feishuRegister.start(channelConfig.domain || 'feishu')"
|
||||
:disabled="feishuRegister.loading.value || feishuRegister.status.value === 'waiting'"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
|
||||
<rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="3" height="3"/>
|
||||
<line x1="21" y1="14" x2="21" y2="17"/><line x1="14" y1="21" x2="17" y2="21"/>
|
||||
<line x1="21" y1="21" x2="21" y2="21"/>
|
||||
</svg>
|
||||
{{ feishuRegister.loading.value
|
||||
? t('channels.feishuRegister.buttonLoading')
|
||||
: t('channels.feishuRegister.button') }}
|
||||
</button>
|
||||
<div v-if="feishuRegister.qrcodeUrl.value" class="feishu-register-qrcode">
|
||||
<img :src="feishuRegister.qrcodeUrl.value" :alt="t('channels.feishuRegister.button')" class="feishu-register-qrcode-img" />
|
||||
<p class="feishu-register-status" :class="feishuRegister.status.value">
|
||||
<template v-if="feishuRegister.status.value === 'confirmed'">{{ t('channels.feishuRegister.confirmed') }}</template>
|
||||
<template v-else-if="feishuRegister.status.value === 'expired'">{{ t('channels.feishuRegister.expired') }}</template>
|
||||
<template v-else-if="feishuRegister.status.value === 'denied'">{{ t('channels.feishuRegister.denied') }}</template>
|
||||
<template v-else-if="feishuRegister.status.value === 'error'">{{ t('channels.feishuRegister.error') }}</template>
|
||||
<template v-else>{{ t('channels.feishuRegister.scanHint') }}</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 企业微信扫码授权 -->
|
||||
<div v-if="form.channelType === 'wecom'" class="wecom-auth-card">
|
||||
<p class="wecom-auth-hint">{{ t('channels.wecom.authHint') }}</p>
|
||||
@ -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<ChannelFieldDef[]>(() => {
|
||||
@ -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; }
|
||||
|
||||
113
mateclaw-ui/src/composables/channels/useFeishuAppRegister.ts
Normal file
113
mateclaw-ui/src/composables/channels/useFeishuAppRegister.ts
Normal file
@ -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<FeishuRegisterStatus>('')
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | 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 }
|
||||
}
|
||||
@ -1643,6 +1643,18 @@ export default {
|
||||
step4: 'After starting the channel, auto-receives messages via <b>WebSocket long connection</b>, <b>no public IP or callback URL required</b>',
|
||||
},
|
||||
},
|
||||
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',
|
||||
|
||||
@ -1653,6 +1653,18 @@ export default {
|
||||
step4: '启动渠道后通过 <b>WebSocket 长连接</b>自动接收消息,<b>无需公网 IP 和回调 URL</b>',
|
||||
},
|
||||
},
|
||||
feishuRegister: {
|
||||
title: '一键创建飞书应用',
|
||||
hint: '点击按钮获取二维码,使用飞书扫一扫并确认授权后,会自动在你的企业里创建自建应用,App ID / App Secret 自动填入下方。',
|
||||
button: '扫码自动创建应用',
|
||||
buttonLoading: '正在准备二维码…',
|
||||
scanHint: '请使用飞书 App 扫描上方二维码并确认授权',
|
||||
confirmed: '应用创建成功,App ID 和 Secret 已自动填入',
|
||||
expired: '二维码已过期,请重新生成',
|
||||
denied: '授权被拒绝',
|
||||
error: '注册过程出错,请稍后重试',
|
||||
startFailed: '获取二维码失败,请检查网络',
|
||||
},
|
||||
feishu: {
|
||||
perm: {
|
||||
message: '获取与发送单聊、群组消息',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user