feat: add Telegram push support with multi-target routes

Add a platform-aware route target system so a single route can forward
to several destinations at once (e.g. a Discord channel and a Telegram
group). Route.target becomes Route.targets[] with per-entry platform,
channelId/threadId for Discord and chatId/topicId for Telegram; the
legacy single-target format is normalized on load and accepted by the
admin API.

Implement the Telegram driver with HTML rendering and Bot API
sendMessage (chat_id + message_thread_id for topics, retry on 429/5xx),
plus /gh commands served over POST /telegram/webhook: login, logout,
comment, merge and close. The comment/merge/close commands resolve the
issue or PR from the replied-to notification message. OAuth binding now
stores a D1 telegram_links mapping and replies with a confirmation.

Sync the Telegram webhook from the scheduled trigger via setWebhook.
This commit is contained in:
RhenCloud 2026-08-03 05:17:59 +08:00
parent dcbe93be91
commit bd7a8f2632
No known key found for this signature in database
GPG key ID: A574A617378C4E0B
39 changed files with 1165 additions and 162 deletions

View file

@ -9,6 +9,13 @@
<span class="route-name">{{ route.name || t("route.untitled") }}</span>
<span class="route-id">{{ route.id }}</span>
<span v-if="route.lang" class="badge lang">{{ route.lang }}</span>
<span
v-for="(tg, i) in route.targets"
:key="i"
class="badge"
:class="tg.platform === 'telegram' ? 'fallback' : 'lang'"
>{{ tg.platform === "telegram" ? "Telegram" : "Discord" }}</span
>
<span v-if="route.fallback" class="badge fallback">{{ t("route.fallback") }}</span>
</div>
<div class="card-actions">
@ -36,14 +43,20 @@
>
</div>
<div class="target">
<span
><b>{{ t("route.channel") }}</b
><code>{{ route.target.channelId }}</code></span
>
<span v-if="route.target.threadId"
><b>{{ t("route.thread") }}</b
><code>{{ route.target.threadId }}</code></span
>
<div v-for="(tg, i) in route.targets" :key="i" class="target-group">
<span class="target-plat"
><b>{{ tg.platform === "telegram" ? t("route.chat") : t("route.channel") }}</b
><code>{{ tg.platform === "telegram" ? tg.chatId : tg.channelId }}</code></span
>
<span v-if="tg.platform === 'telegram' && tg.topicId"
><b>{{ t("route.topic") }}</b
><code>{{ tg.topicId }}</code></span
>
<span v-else-if="tg.platform !== 'telegram' && tg.threadId"
><b>{{ t("route.thread") }}</b
><code>{{ tg.threadId }}</code></span
>
</div>
</div>
</article>
</template>

View file

@ -74,27 +74,48 @@
</button>
<div class="err">{{ filterError }}</div>
</div>
<div class="row2">
<div class="field">
<label>{{ t("routeEditor.channel") }}</label>
<input
v-model="form.channelId"
type="text"
:placeholder="t('routeEditor.channelPlaceholder')"
required
/>
</div>
<div class="field">
<label
>{{ t("routeEditor.thread") }}
<span class="lbl-note">{{ t("routeEditor.threadNote") }}</span></label
>
<input
v-model="form.threadId"
type="text"
:placeholder="t('routeEditor.threadPlaceholder')"
/>
<div class="field">
<label
>{{ t("routeEditor.targets") }}
<span class="lbl-note">{{ t("routeEditor.targetsNote") }}</span></label
>
<div v-for="(tg, i) in form.targets" :key="i" class="filter-row">
<select v-model="tg.platform">
<option value="discord">Discord</option>
<option value="telegram">Telegram</option>
</select>
<template v-if="tg.platform === 'discord'">
<input
v-model="tg.channelId"
type="text"
:placeholder="t('routeEditor.channelPlaceholder')"
/>
<input
v-model="tg.threadId"
type="text"
:placeholder="t('routeEditor.threadPlaceholder')"
/>
</template>
<template v-else>
<input
v-model="tg.chatId"
type="text"
:placeholder="t('routeEditor.chatPlaceholder')"
/>
<input
v-model="tg.topicId"
type="text"
:placeholder="t('routeEditor.topicPlaceholder')"
/>
</template>
<button type="button" class="icon-btn danger" @click="form.targets.splice(i, 1)">
</button>
</div>
<button type="button" class="btn btn-ghost add-filter" @click="addTarget">
{{ t("routeEditor.addTarget") }}
</button>
<div class="err">{{ targetError }}</div>
</div>
<div class="err">{{ formError }}</div>
</form>
@ -113,7 +134,7 @@
<script setup lang="ts">
import { reactive, watch } from "vue";
import type { Filter, Route } from "~/types";
import type { Filter, Route, RouteTarget } from "~/types";
import { FILTER_TYPES, fmtMatch } from "~/types";
interface FilterForm extends Filter {
@ -129,16 +150,30 @@ const emit = defineEmits<{
const { t } = useI18n();
const isEdit = computed(() => props.route != null);
const filterError = ref("");
const targetError = ref("");
const formError = ref("");
interface TargetForm extends RouteTarget {
platform: "discord" | "telegram";
}
function blankTarget(): TargetForm {
return {
platform: "discord",
channelId: "",
threadId: "",
chatId: "",
topicId: "",
};
}
const form = reactive({
id: "",
name: "",
lang: "",
enabled: true,
fallback: false,
channelId: "",
threadId: "",
targets: [] as TargetForm[],
filters: [] as FilterForm[],
});
@ -160,6 +195,11 @@ function addFilter(): void {
filterError.value = "";
}
function addTarget(): void {
form.targets.push(blankTarget());
targetError.value = "";
}
watch(
() => props.open,
(open) => {
@ -170,8 +210,10 @@ watch(
form.lang = r?.lang ?? "";
form.enabled = r?.enabled ?? true;
form.fallback = r?.fallback ?? false;
form.channelId = r?.target.channelId ?? "";
form.threadId = r?.target.threadId ?? "";
form.targets =
r && r.targets.length
? r.targets.map((tg) => ({ ...blankTarget(), ...tg }))
: [blankTarget()];
form.filters = (
r && r.filters.length
? r.filters
@ -180,6 +222,7 @@ watch(
: [{ type: "event", match: "", exclude: false }]
).map((f) => ({ ...f, matchText: fmtMatch(f.match) })) as FilterForm[];
filterError.value = "";
targetError.value = "";
formError.value = "";
},
);
@ -214,6 +257,20 @@ function collect(): Route | null {
filterError.value = t("routeEditor.errAddFilter");
return null;
}
const targets: RouteTarget[] = [];
for (let i = 0; i < form.targets.length; i++) {
const tg = form.targets[i]!;
targets.push({
platform: tg.platform,
channelId: tg.channelId.trim() || undefined,
threadId: tg.threadId.trim() || undefined,
chatId: tg.chatId.trim() || undefined,
topicId: tg.topicId.trim() || undefined,
});
}
targetError.value = "";
return {
id: form.id.trim(),
name: form.name.trim(),
@ -221,10 +278,7 @@ function collect(): Route | null {
fallback: form.fallback || undefined,
lang: form.lang.trim() || undefined,
filters,
target: {
channelId: form.channelId.trim(),
threadId: form.threadId.trim() || undefined,
},
targets,
};
}
@ -240,10 +294,23 @@ function save(): void {
formError.value = t("routeEditor.errName");
return;
}
if (!route.target.channelId) {
formError.value = t("routeEditor.errChannel");
if (!route.targets.length) {
targetError.value = t("routeEditor.errTargets");
return;
}
for (let i = 0; i < route.targets.length; i++) {
const tg = route.targets[i]!;
if (tg.platform === "telegram") {
if (!tg.chatId) {
targetError.value = t("routeEditor.errChat", { n: i + 1 });
return;
}
} else if (!tg.channelId) {
targetError.value = t("routeEditor.errChannel", { n: i + 1 });
return;
}
}
emit("save", route);
}
</script>

View file

@ -34,6 +34,8 @@ const en: Dict = {
"route.noFilters": "no filters",
"route.channel": "CHANNEL",
"route.thread": "THREAD",
"route.chat": "CHAT",
"route.topic": "TOPIC",
"route.fallback": "fallback",
"filter.event": "Event",
"filter.repo": "Repo",
@ -64,13 +66,24 @@ const en: Dict = {
"routeEditor.thread": "Thread ID",
"routeEditor.threadNote": "(optional)",
"routeEditor.threadPlaceholder": "Optional thread ID",
"routeEditor.platform": "Platform",
"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.errChat": "Target {n} chat ID is required",
"routeEditor.targets": "Targets",
"routeEditor.targetsNote": "(one or more destinations)",
"routeEditor.addTarget": "+ Add target",
"routeEditor.errTargets": "Add at least one target",
"routeEditor.cancel": "Cancel",
"routeEditor.save": "Save route",
"routeEditor.errFilterMatch": "Filter {n} needs a match value",
"routeEditor.errAddFilter": "Add at least one filter",
"routeEditor.errIdFormat": "ID must be a-z / 0-9 / dashes",
"routeEditor.errName": "Name is required",
"routeEditor.errChannel": "Channel ID is required",
"routeEditor.errChannel": "Target {n} channel ID is required",
"groupEditor.editTitle": "Edit group",
"groupEditor.newTitle": "New group",
"groupEditor.close": "Close",
@ -161,6 +174,8 @@ const zh: Dict = {
"route.noFilters": "无过滤器",
"route.channel": "频道",
"route.thread": "子区",
"route.chat": "群组",
"route.topic": "话题",
"route.fallback": "兜底",
"filter.event": "事件",
"filter.repo": "仓库",
@ -191,13 +206,24 @@ const zh: Dict = {
"routeEditor.thread": "子区 ID",
"routeEditor.threadNote": "(可选)",
"routeEditor.threadPlaceholder": "可选的子区 ID",
"routeEditor.platform": "平台",
"routeEditor.chat": "群组 ID",
"routeEditor.chatPlaceholder": "Telegram 群组聊天 ID",
"routeEditor.topic": "话题 ID",
"routeEditor.topicNote": "(可选)",
"routeEditor.topicPlaceholder": "可选的话题 (message_thread_id)",
"routeEditor.errChat": "第 {n} 个目标群组 ID 为必填项",
"routeEditor.targets": "目标",
"routeEditor.targetsNote": "(一个或多个推送目标)",
"routeEditor.addTarget": "+ 添加目标",
"routeEditor.errTargets": "至少添加一个目标",
"routeEditor.cancel": "取消",
"routeEditor.save": "保存路由",
"routeEditor.errFilterMatch": "第 {n} 个过滤器需要匹配值",
"routeEditor.errAddFilter": "至少添加一个过滤器",
"routeEditor.errIdFormat": "ID 只能是 a-z / 0-9 / 短横线",
"routeEditor.errName": "名称为必填项",
"routeEditor.errChannel": "频道 ID 为必填项",
"routeEditor.errChannel": "第 {n} 个目标频道 ID 为必填项",
"groupEditor.editTitle": "编辑分组",
"groupEditor.newTitle": "新建分组",
"groupEditor.close": "关闭",

View file

@ -4,15 +4,20 @@ export interface Filter {
exclude?: boolean;
}
export interface RouteTarget {
platform?: "discord" | "telegram";
channelId?: string;
threadId?: string;
chatId?: string;
topicId?: string;
}
export interface Route {
id: string;
name: string;
enabled: boolean;
filters: Filter[];
target: {
channelId: string;
threadId?: string;
};
targets: RouteTarget[];
lang?: string;
groupId?: string;
fallback?: boolean;