mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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.
288 lines
13 KiB
Java
288 lines
13 KiB
Java
package vip.mate.channel.controller;
|
||
|
||
import io.swagger.v3.oas.annotations.Operation;
|
||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||
import lombok.RequiredArgsConstructor;
|
||
import lombok.extern.slf4j.Slf4j;
|
||
import org.springframework.http.ResponseEntity;
|
||
import org.springframework.web.bind.annotation.*;
|
||
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;
|
||
import vip.mate.channel.weixin.WeixinChannelAdapter;
|
||
import com.google.zxing.BarcodeFormat;
|
||
import com.google.zxing.EncodeHintType;
|
||
import com.google.zxing.client.j2se.MatrixToImageWriter;
|
||
import com.google.zxing.common.BitMatrix;
|
||
import com.google.zxing.qrcode.QRCodeWriter;
|
||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
|
||
|
||
import java.io.ByteArrayOutputStream;
|
||
import java.net.URLEncoder;
|
||
import java.nio.charset.StandardCharsets;
|
||
import java.util.LinkedHashMap;
|
||
import java.util.Map;
|
||
import java.util.Optional;
|
||
|
||
/**
|
||
* 渠道 Webhook 回调接口
|
||
* <p>
|
||
* 接收来自各 IM 平台的消息推送回调。
|
||
* 各平台(钉钉、飞书、Telegram 等)将此 URL 配置为消息回调地址。
|
||
* <p>
|
||
* URL 格式:/api/v1/channels/webhook/{channelType}
|
||
* 此接口不需要 JWT 认证(由各平台的签名/Token 机制保障安全)。
|
||
*
|
||
* @author MateClaw Team
|
||
*/
|
||
@Tag(name = "渠道Webhook")
|
||
@Slf4j
|
||
@RestController
|
||
@RequestMapping("/api/v1/channels/webhook")
|
||
@RequiredArgsConstructor
|
||
public class ChannelWebhookController {
|
||
|
||
private final ChannelManager channelManager;
|
||
private final FeishuAppRegistrationService feishuAppRegistrationService;
|
||
|
||
@Operation(summary = "钉钉消息回调")
|
||
@PostMapping("/dingtalk")
|
||
public ResponseEntity<Map<String, Object>> dingtalkWebhook(@RequestBody Map<String, Object> payload) {
|
||
log.debug("[webhook] DingTalk callback received");
|
||
Optional<ChannelAdapter> adapter = channelManager.getAdapterByType("dingtalk");
|
||
if (adapter.isPresent() && adapter.get() instanceof DingTalkChannelAdapter dingtalk) {
|
||
dingtalk.handleWebhook(payload);
|
||
return ResponseEntity.ok(Map.of("status", "ok"));
|
||
}
|
||
log.warn("[webhook] DingTalk channel not active, ignoring callback");
|
||
return ResponseEntity.ok(Map.of("status", "channel_not_active"));
|
||
}
|
||
|
||
@Operation(summary = "飞书消息回调")
|
||
@PostMapping("/feishu")
|
||
public ResponseEntity<Map<String, Object>> feishuWebhook(@RequestBody Map<String, Object> payload) {
|
||
log.debug("[webhook] Feishu callback received");
|
||
Optional<ChannelAdapter> adapter = channelManager.getAdapterByType("feishu");
|
||
if (adapter.isPresent() && adapter.get() instanceof FeishuChannelAdapter feishu) {
|
||
Map<String, Object> result = feishu.handleWebhook(payload);
|
||
return ResponseEntity.ok(result);
|
||
}
|
||
// 即使渠道未激活,也需要响应 URL 验证
|
||
String type = (String) payload.get("type");
|
||
if ("url_verification".equals(type)) {
|
||
String challenge = (String) payload.get("challenge");
|
||
return ResponseEntity.ok(Map.of("challenge", challenge != null ? challenge : ""));
|
||
}
|
||
log.warn("[webhook] Feishu channel not active, ignoring callback");
|
||
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) {
|
||
log.debug("[webhook] Telegram callback received");
|
||
Optional<ChannelAdapter> adapter = channelManager.getAdapterByType("telegram");
|
||
if (adapter.isPresent() && adapter.get() instanceof TelegramChannelAdapter telegram) {
|
||
telegram.handleWebhook(payload);
|
||
} else {
|
||
log.warn("[webhook] Telegram channel not active, ignoring callback");
|
||
}
|
||
return ResponseEntity.ok("ok");
|
||
}
|
||
|
||
@Operation(summary = "Discord 消息回调(已废弃:Discord 已切换为 Gateway WebSocket 模式)")
|
||
@PostMapping("/discord")
|
||
public ResponseEntity<Map<String, Object>> discordWebhook(@RequestBody Map<String, Object> payload) {
|
||
// Discord Interaction PING 仍需响应(防止 Discord 删除 Interaction URL)
|
||
Integer type = (Integer) payload.get("type");
|
||
if (type != null && type == 1) {
|
||
return ResponseEntity.ok(Map.of("type", 1));
|
||
}
|
||
|
||
// Discord 已切换为 Gateway WebSocket,webhook 回调不再用于接收消息
|
||
log.warn("[webhook] Discord webhook called, but messages are now received via Gateway WebSocket");
|
||
Optional<ChannelAdapter> adapter = channelManager.getAdapterByType("discord");
|
||
if (adapter.isPresent() && adapter.get() instanceof DiscordChannelAdapter discord) {
|
||
discord.handleWebhook(payload);
|
||
}
|
||
return ResponseEntity.ok(Map.of("status", "ok"));
|
||
}
|
||
|
||
@Operation(summary = "企业微信消息回调(智能机器人模式不使用,保留兼容)")
|
||
@PostMapping("/wecom")
|
||
public ResponseEntity<String> wecomWebhook(@RequestBody Map<String, Object> payload) {
|
||
// 智能机器人模式通过 WebSocket 长连接接收消息,不再使用 HTTP 回调
|
||
log.debug("[webhook] WeCom callback received (not used in bot mode, messages are received via WebSocket)");
|
||
return ResponseEntity.ok("success");
|
||
}
|
||
|
||
@Operation(summary = "Slack Events API 回调")
|
||
@PostMapping("/slack")
|
||
public ResponseEntity<Map<String, Object>> slackWebhook(@RequestBody Map<String, Object> payload) {
|
||
log.debug("[webhook] Slack callback received");
|
||
// URL Verification challenge
|
||
if ("url_verification".equals(payload.get("type"))) {
|
||
return ResponseEntity.ok(Map.of("challenge", payload.getOrDefault("challenge", "")));
|
||
}
|
||
Optional<ChannelAdapter> adapter = channelManager.getAdapterByType("slack");
|
||
if (adapter.isPresent() && adapter.get() instanceof vip.mate.channel.slack.SlackChannelAdapter slack) {
|
||
Map<String, Object> result = slack.handleWebhook(payload);
|
||
return ResponseEntity.ok(result);
|
||
}
|
||
log.warn("[webhook] Slack channel not active, ignoring callback");
|
||
return ResponseEntity.ok(Map.of("status", "channel_not_active"));
|
||
}
|
||
|
||
// ==================== 微信 iLink Bot ====================
|
||
|
||
/** 微信扫码深链接模板 */
|
||
private static final String WEIXIN_SCAN_URL_TEMPLATE =
|
||
"https://liteapp.weixin.qq.com/q/7GiQu1?qrcode=%s&bot_type=3";
|
||
|
||
/**
|
||
* 创建临时 ILinkClient 用于 QR 码操作(不依赖渠道是否已启动)
|
||
*/
|
||
private ILinkClient createWeixinClient() {
|
||
return new ILinkClient("", ILinkClient.DEFAULT_BASE_URL,
|
||
new com.fasterxml.jackson.databind.ObjectMapper());
|
||
}
|
||
|
||
/**
|
||
* 使用 ZXing 生成 QR 码 PNG 图片并返回 Base64 编码
|
||
*/
|
||
private String generateQrCodeBase64(String content) throws Exception {
|
||
QRCodeWriter writer = new QRCodeWriter();
|
||
Map<EncodeHintType, Object> hints = Map.of(
|
||
EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M,
|
||
EncodeHintType.MARGIN, 2
|
||
);
|
||
BitMatrix bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, 300, 300, hints);
|
||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||
MatrixToImageWriter.writeToStream(bitMatrix, "PNG", baos);
|
||
return java.util.Base64.getEncoder().encodeToString(baos.toByteArray());
|
||
}
|
||
|
||
@Operation(summary = "获取微信登录二维码")
|
||
@GetMapping("/weixin/qrcode")
|
||
public ResponseEntity<Map<String, Object>> weixinQrcode() {
|
||
try {
|
||
ILinkClient client = createWeixinClient();
|
||
Map<String, Object> apiResult = client.getBotQrcode();
|
||
|
||
String qrcode = String.valueOf(apiResult.getOrDefault("qrcode", ""));
|
||
if (qrcode.isBlank()) {
|
||
return ResponseEntity.internalServerError()
|
||
.body(Map.of("error", "iLink API returned empty qrcode"));
|
||
}
|
||
|
||
// 从 qrcode 构建微信扫码深链接,再生成 QR 码图片
|
||
String scanUrl;
|
||
Object urlObj = apiResult.get("url");
|
||
if (urlObj != null && urlObj.toString().startsWith("http")) {
|
||
scanUrl = urlObj.toString();
|
||
} else {
|
||
String encoded = URLEncoder.encode(qrcode, StandardCharsets.UTF_8);
|
||
scanUrl = String.format(WEIXIN_SCAN_URL_TEMPLATE, encoded);
|
||
}
|
||
|
||
String qrCodeImgBase64 = generateQrCodeBase64(scanUrl);
|
||
|
||
Map<String, Object> result = new LinkedHashMap<>();
|
||
result.put("qrcode", qrcode);
|
||
result.put("qrcode_img", qrCodeImgBase64);
|
||
return ResponseEntity.ok(result);
|
||
} catch (Exception e) {
|
||
log.error("[webhook] WeChat QR code fetch failed: {}", e.getMessage(), e);
|
||
return ResponseEntity.internalServerError()
|
||
.body(Map.of("error", "Failed to get QR code: " + e.getMessage()));
|
||
}
|
||
}
|
||
|
||
@Operation(summary = "查询微信二维码扫码状态")
|
||
@GetMapping("/weixin/qrcode/status")
|
||
public ResponseEntity<Map<String, Object>> weixinQrcodeStatus(@RequestParam String qrcode) {
|
||
try {
|
||
ILinkClient client = createWeixinClient();
|
||
Map<String, Object> apiResult = client.getQrcodeStatus(qrcode);
|
||
|
||
// 只返回前端需要的字段
|
||
Map<String, Object> result = new LinkedHashMap<>();
|
||
result.put("status", apiResult.getOrDefault("status", "waiting"));
|
||
result.put("bot_token", apiResult.getOrDefault("bot_token", ""));
|
||
result.put("base_url", apiResult.getOrDefault("baseurl", ""));
|
||
return ResponseEntity.ok(result);
|
||
} catch (Exception e) {
|
||
log.error("[webhook] WeChat QR code status check failed: {}", e.getMessage(), e);
|
||
return ResponseEntity.internalServerError()
|
||
.body(Map.of("error", "Failed to check QR code status: " + e.getMessage()));
|
||
}
|
||
}
|
||
|
||
@Operation(summary = "获取渠道运行状态")
|
||
@GetMapping("/status")
|
||
public ResponseEntity<Map<String, Object>> status() {
|
||
return ResponseEntity.ok(channelManager.getStatus());
|
||
}
|
||
}
|