feat(feishu): add Feishu inbound webhook, /gh commands and card actions

- add feishu_links D1 table and link store helpers
- implement X-Lark-Signature verification, url_verification, /gh login|logout|comment|merge|close and card.action.trigger Merge/Close
- render interactive cards with clickable title link, inline links and callback buttons (no whole-card card_link)
- bind Feishu account in OAuth callback
- document event subscription and required scopes
This commit is contained in:
RhenCloud 2026-08-27 00:04:49 +08:00
parent 3437ac5513
commit 4b99f6d33d
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
38 changed files with 2449 additions and 1412 deletions

View file

@ -104,7 +104,10 @@ function fmtTime(ts: number): string {
}
function platformLabel(p?: string): string {
return p === "telegram" ? "TG" : p === "discord" ? "DC" : "—";
if (p === "telegram") return "TG";
if (p === "feishu") return "FS";
if (p === "discord") return "DC";
return "—";
}
function routeEvents(r: Route): string[] {

View file

@ -186,6 +186,7 @@
<option value="">{{ t("groupEditor.logDisabled") }}</option>
<option value="discord">Discord</option>
<option value="telegram">Telegram</option>
<option value="feishu">{{ t("groupEditor.logFeishu") }}</option>
</select>
<template v-if="form.logPlatform === 'discord'">
<input
@ -201,7 +202,7 @@
:placeholder="t('routeEditor.threadPlaceholder')"
/>
</template>
<template v-else-if="form.logPlatform === 'telegram'">
<template v-else-if="form.logPlatform === 'telegram' || form.logPlatform === 'feishu'">
<input
v-model="form.logChatId"
type="text"
@ -209,6 +210,7 @@
:placeholder="t('routeEditor.chatPlaceholder')"
/>
<input
v-if="form.logPlatform === 'telegram'"
v-model="form.logTopicId"
type="text"
class="input mt-2"
@ -263,7 +265,7 @@ const form = reactive({
emoji: true,
forgeSources: [] as ForgeSource[],
lang: "",
logPlatform: "" as "" | "discord" | "telegram",
logPlatform: "" as "" | "discord" | "telegram" | "feishu",
logChannelId: "",
logThreadId: "",
logChatId: "",
@ -323,11 +325,8 @@ function save(): void {
}
let logTarget:
| { platform: "discord"; channelId: string; threadId?: string }
| {
platform: "telegram";
chatId: string;
topicId?: string;
}
| { platform: "telegram"; chatId: string; topicId?: string }
| { platform: "feishu"; chatId: string }
| undefined;
if (form.logPlatform === "discord") {
const channelId = form.logChannelId.trim();
@ -343,6 +342,13 @@ function save(): void {
return;
}
logTarget = { platform: "telegram", chatId, topicId: form.logTopicId.trim() || undefined };
} else if (form.logPlatform === "feishu") {
const chatId = form.logChatId.trim();
if (!chatId) {
formError.value = t("groupEditor.errLogFeishuChat");
return;
}
logTarget = { platform: "feishu", chatId };
}
const installationText = form.installationId.trim();
let installationId: number | undefined;

View file

@ -16,13 +16,25 @@
v-for="(tg, i) in route.targets"
:key="i"
class="route-badge"
:class="tg.platform === 'telegram' ? 'route-badge-tg' : 'route-badge-dc'"
:class="
tg.platform === 'telegram'
? 'route-badge-tg'
: tg.platform === 'feishu'
? 'route-badge-fs'
: 'route-badge-dc'
"
>
<span
class="route-badge-dot"
:class="tg.platform === 'telegram' ? 'bg-info' : 'bg-accent'"
:class="
tg.platform === 'telegram'
? 'bg-info'
: tg.platform === 'feishu'
? 'bg-warn'
: 'bg-accent'
"
></span>
{{ tg.platform === "telegram" ? "Telegram" : "Discord" }}
{{ tg.platform === "telegram" ? "Telegram" : tg.platform === "feishu" ? t("route.feishu") : "Discord" }}
</span>
<span v-if="route.fallback" class="route-badge route-badge-fallback">{{
t("route.fallback")
@ -47,12 +59,12 @@
<div v-for="(tg, i) in route.targets" :key="i" class="route-target">
<div class="route-target-row">
<span class="route-target-label">
<template v-if="tg.platform === 'telegram'">{{ t("route.chat") }}</template>
<template v-if="tg.platform === 'telegram' || tg.platform === 'feishu'">{{ t("route.chat") }}</template>
<template v-else-if="tg.threadId">{{ t("route.thread") }}</template>
<template v-else>{{ t("route.channel") }}</template>
</span>
<code class="route-target-id">
<template v-if="tg.platform === 'telegram'">{{ tg.chatId }}</template>
<template v-if="tg.platform === 'telegram' || tg.platform === 'feishu'">{{ tg.chatId }}</template>
<template v-else-if="tg.threadId">{{ tg.threadId }}</template>
<template v-else>{{ tg.channelId }}</template>
</code>

View file

@ -12,7 +12,7 @@ import {
} from "~/composables/useFilterNode";
interface TargetForm {
platform: "discord" | "telegram";
platform: "discord" | "telegram" | "feishu";
channelId: string;
threadId: string;
chatId: string;
@ -129,6 +129,10 @@ function collect(): Route | null {
const chatId = tg.chatId.trim();
if (!chatId) continue;
targets.push({ platform: "telegram", chatId, topicId: tg.topicId.trim() || undefined });
} else if (tg.platform === "feishu") {
const chatId = tg.chatId.trim();
if (!chatId) continue;
targets.push({ platform: "feishu", chatId });
} else {
const channelId = tg.channelId.trim();
if (!channelId) continue;
@ -170,7 +174,7 @@ function save(): void {
return;
}
form.targets.forEach((tg, i) => {
if (tg.platform === "telegram" && !tg.chatId.trim()) {
if ((tg.platform === "telegram" || tg.platform === "feishu") && !tg.chatId.trim()) {
targetError.value = t("routeEditor.errChat", { n: i + 1 });
} else if (tg.platform === "discord" && !tg.channelId.trim()) {
targetError.value = t("routeEditor.errChannel", { n: i + 1 });
@ -283,7 +287,7 @@ watch(
? r.targets.map((tg) => ({
...blankTarget(),
...tg,
platform: tg.platform === "telegram" ? "telegram" : "discord",
platform: tg.platform === "telegram" ? "telegram" : tg.platform === "feishu" ? "feishu" : "discord",
}))
: [blankTarget()];
if (r?.ast) {
@ -481,6 +485,7 @@ watch(
<select v-model="tg.platform" class="tg-select">
<option value="discord">Discord</option>
<option value="telegram">Telegram</option>
<option value="feishu">{{ t("routeEditor.platformFeishu") }}</option>
</select>
<template v-if="tg.platform === 'discord'">
<input
@ -494,7 +499,7 @@ watch(
:placeholder="t('routeEditor.threadPlaceholder')"
/>
</template>
<template v-else>
<template v-else-if="tg.platform === 'telegram'">
<input
v-model="tg.chatId"
class="input tg-in1"
@ -506,6 +511,13 @@ watch(
:placeholder="t('routeEditor.topicPlaceholder')"
/>
</template>
<template v-else>
<input
v-model="tg.chatId"
class="input tg-in1"
:placeholder="t('routeEditor.feishuChatPlaceholder')"
/>
</template>
<button
type="button"
class="icon-btn danger tg-del"

View file

@ -143,6 +143,7 @@ const en: Dict = {
"route.thread": "THREAD",
"route.chat": "CHAT",
"route.topic": "TOPIC",
"route.feishu": "Feishu",
"route.fallback": "fallback",
"route.stop": "stop",
"route.moveUp": "Move up",
@ -224,11 +225,13 @@ const en: Dict = {
"routeEditor.threadNote": "(optional)",
"routeEditor.threadPlaceholder": "Optional thread ID",
"routeEditor.platform": "Platform",
"routeEditor.platformFeishu": "Feishu",
"routeEditor.chat": "Chat ID",
"routeEditor.chatPlaceholder": "Telegram group chat ID",
"routeEditor.topic": "Topic ID",
"routeEditor.topicNote": "(optional)",
"routeEditor.topicPlaceholder": "Optional topic (message_thread_id)",
"routeEditor.feishuChatPlaceholder": "Feishu chat ID (chat_id)",
"routeEditor.errChat": "Target {n} chat ID is required",
"routeEditor.targets": "Targets",
"routeEditor.targetsNote": "(one or more destinations)",
@ -327,10 +330,12 @@ const en: Dict = {
"groupEditor.logTarget": "Webhook log channel",
"groupEditor.logTargetNote": "(optional)",
"groupEditor.logDisabled": "— Disabled —",
"groupEditor.logFeishu": "Feishu",
"groupEditor.logTargetHint":
"A Discord channel/thread or Telegram chat/topic that receives a summary of every webhook this group's routes dispatch.",
"A Discord channel/thread, Telegram chat/topic or Feishu chat that receives a summary of every webhook this group's routes dispatch.",
"groupEditor.errLogChannel": "Log channel ID is required when Discord is selected",
"groupEditor.errLogChat": "Log chat ID is required when Telegram is selected",
"groupEditor.errLogFeishuChat": "Log chat ID is required when Feishu is selected",
"groupEditor.cancel": "Cancel",
"groupEditor.save": "Save group",
"groupEditor.errIdFormat": "ID must be a-z / 0-9 / dashes",
@ -521,6 +526,7 @@ const zh: Dict = {
"route.thread": "子区",
"route.chat": "群组",
"route.topic": "话题",
"route.feishu": "飞书",
"route.fallback": "兜底",
"route.stop": "停止",
"route.moveUp": "上移",
@ -601,11 +607,13 @@ const zh: Dict = {
"routeEditor.threadNote": "(可选)",
"routeEditor.threadPlaceholder": "可选的子区 ID",
"routeEditor.platform": "平台",
"routeEditor.platformFeishu": "飞书",
"routeEditor.chat": "群组 ID",
"routeEditor.chatPlaceholder": "Telegram 群组聊天 ID",
"routeEditor.topic": "话题 ID",
"routeEditor.topicNote": "(可选)",
"routeEditor.topicPlaceholder": "可选的话题 (message_thread_id)",
"routeEditor.feishuChatPlaceholder": "飞书群组 ID (chat_id)",
"routeEditor.errChat": "第 {n} 个目标群组 ID 为必填项",
"routeEditor.targets": "目标",
"routeEditor.targetsNote": "(一个或多个推送目标)",
@ -703,10 +711,12 @@ const zh: Dict = {
"groupEditor.logTarget": "Webhook 日志频道",
"groupEditor.logTargetNote": "(可选)",
"groupEditor.logDisabled": "— 未启用 —",
"groupEditor.logFeishu": "飞书",
"groupEditor.logTargetHint":
"一个 Discord 频道/子区或 Telegram 群组/话题本分组路由分发dispatch的每个 webhook 都会向其发送摘要。",
"一个 Discord 频道/子区、Telegram 群组/话题或飞书群组本分组路由分发dispatch的每个 webhook 都会向其发送摘要。",
"groupEditor.errLogChannel": "选择 Discord 时必须填写日志频道 ID",
"groupEditor.errLogChat": "选择 Telegram 时必须填写日志群组 ID",
"groupEditor.errLogFeishuChat": "选择飞书时必须填写日志群组 ID",
"groupEditor.cancel": "取消",
"groupEditor.save": "保存分组",
"groupEditor.errIdFormat": "ID 只能是 a-z / 0-9 / 短横线",

View file

@ -44,7 +44,7 @@ export interface NamedFragment {
}
export interface RouteTarget {
platform?: "discord" | "telegram";
platform?: "discord" | "telegram" | "feishu";
channelId?: string;
threadId?: string;
chatId?: string;