refactor(channels): make built-in channels self-contained (#4908)

* refactor(channels): own setup and instance contracts

* refactor(channels): isolate management contracts

* refactor(channels): normalize activation contracts

* fix(channels): enforce management contracts

* refactor(channels): finish setup ownership migration

* fix(channels): harden management contracts

* fix(channels): enforce lazy loading and runtime ownership

* fix(feishu): make multi-instance startup idempotent

* fix(webui): render channel setup contracts cleanly

* fix(feishu): stop websocket clients cleanly

* fix(channels): enforce persistence and activation gates

* fix(channels): preserve global feature action scope

* fix(channels): apply defaults for single plugins

* fix(channels): enforce management contract boundaries

* refactor(feishu): remove identity helper indirection

* fix(channels): preserve management setup contracts

* refactor(channels): generalize instance settings UI

* refactor(channels): package channel plugins with web UI metadata

* refactor(channels): make built-ins self-contained packages

* test(channels): colocate tests with channel packages

* fix(dingtalk): use official brand icon

* feat(channels): colocate webui translations

* docs(channels): clarify plugin ownership

* test(exec): remove output wait race

* refactor(channels): unify plugin descriptors

* fix(channels): enforce descriptor-owned contracts

* refactor(channels): finish package-owned plugin setup

* refactor(channels): use repository-owned packages only

* fix(channels): self-describe dependencies and runtime state

* fix(channels): warn about legacy entry points
This commit is contained in:
chengyongru
2026-07-19 23:30:49 +08:00
committed by GitHub
parent 7aaac37bca
commit 462a0dfb0f
388 changed files with 17093 additions and 5110 deletions
+1
View File
@@ -0,0 +1 @@
"""Telegram channel package."""
+30
View File
@@ -0,0 +1,30 @@
"""Telegram management contract."""
from nanobot.channels._manifest import GROUP_POLICIES, field, required
from nanobot.channels.contracts import ChannelSetupSpec
from nanobot.channels.plugin import ChannelPlugin
from nanobot.channels.telegram.validation import validate
SETUP_SPEC = ChannelSetupSpec(
fields={
"token": field("secret"),
"allowFrom": field("list"),
"groupPolicy": field("enum", choices=GROUP_POLICIES, default="mention"),
},
required=(required("token"),),
official_url="https://t.me/BotFather",
validator=validate,
)
PLUGIN = ChannelPlugin(
name="telegram",
display_name="Telegram",
runtime=f"{__package__}.runtime:TelegramChannel",
setup=SETUP_SPEC,
dependencies=(
"python-telegram-bot[socks,webhooks]>=22.6,<23.0",
"socksio>=1.0.0,<2.0.0",
"python-socks[asyncio]>=2.8.0,<3.0.0; sys_platform != 'win32'",
),
webui="webui/index.ts",
)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
"""Tests for the Telegram channel package."""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,46 @@
from __future__ import annotations
import httpx
import pytest
from nanobot.channels.telegram import validation as telegram_validation
from nanobot.channels.validation import validate_channel_config
from nanobot.config.loader import save_config
from nanobot.config.schema import Config
def test_validate_telegram_bad_token_is_invalid(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
result = validate_channel_config("telegram", {"channels.telegram.token": "not-a-token"})
assert result["status"] == "invalid"
assert result["can_enable"] is False
assert result["missing_fields"] == []
def test_validate_telegram_does_not_expose_saved_token_in_http_errors(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
token = "123456:abcdefghijklmnopqrstuvwxyz"
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({"channels": {"telegram": {"token": token}}}),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def raise_http_error(url: str, **_kwargs) -> dict:
request = httpx.Request("GET", url)
response = httpx.Response(401, request=request)
raise httpx.HTTPStatusError("unauthorized", request=request, response=response)
monkeypatch.setattr(telegram_validation, "http_get", raise_http_error)
result = validate_channel_config("telegram", {"channels.telegram.token": ""})
assert token not in str(result)
assert any("HTTP 401" in check.get("message", "") for check in result["checks"])
+84
View File
@@ -0,0 +1,84 @@
"""Telegram setup validation owned by the channel package."""
import re
from typing import Any
import httpx
from nanobot.channels.contracts import ChannelValidationContext
from nanobot.channels.validation import (
check,
http_get,
message_from_response,
payload,
required_checks,
status_from_checks,
string_value,
)
def validate(values: dict[str, Any], _context: ChannelValidationContext) -> dict[str, Any]:
checks, missing = required_checks("telegram", values)
token = string_value(values.get("token"))
if token:
if not re.match(r"^\d+:[A-Za-z0-9_-]{20,}$", token):
checks.append(
check(
"token_format",
"Token format",
"fail",
"Telegram tokens look like 123456:ABC...",
)
)
else:
checks.append(
check("token_format", "Token format", "pass", "Looks like a BotFather token.")
)
try:
data = http_get(f"https://api.telegram.org/bot{token}/getMe")
if data.get("ok") and isinstance(data.get("result"), dict):
bot = data["result"]
identity = {
"name": bot.get("username") or bot.get("first_name"),
"account": str(bot.get("id") or ""),
}
checks.append(
check("get_me", "Bot identity", "pass", "Telegram accepted the bot token.")
)
return payload(
"telegram",
"connected",
checks,
identity=identity,
missing_fields=missing,
)
checks.append(
check(
"get_me",
"Bot identity",
"fail",
message_from_response(data, "Telegram rejected the token."),
)
)
except httpx.HTTPStatusError as exc:
checks.append(
check(
"get_me",
"Bot identity",
"warn",
f"Telegram could not verify the token: HTTP {exc.response.status_code}.",
)
)
except Exception:
checks.append(
check(
"get_me",
"Bot identity",
"warn",
"Could not reach Telegram now. Try again later.",
)
)
return status_from_checks("telegram", checks, missing)
__all__ = ["validate"]
+20
View File
@@ -0,0 +1,20 @@
import type { ChannelUiContribution } from "@/channel-plugins/types";
import { chatAppGuideUrl } from "@/components/settings/channels/catalog";
export default {
presentation: {
displayName: "Telegram",
initials: "TG",
color: "#229ED9",
logoUrl: "https://telegram.org/favicon.ico",
setup: {
mode: "credentials",
docsUrl: chatAppGuideUrl("telegram"),
fields: [
{ key: "channels.telegram.token" },
{ key: "channels.telegram.allowFrom" },
{ key: "channels.telegram.groupPolicy" },
],
},
},
} satisfies ChannelUiContribution;
@@ -0,0 +1,35 @@
{
"description": "Chat with nanobot from Telegram chats.",
"requirements": "Bot token, allowed users, gateway",
"setup": {
"docsLabel": "Open Telegram setup",
"officialLabel": "Open BotFather",
"tryIt": "Send /start or a short DM to your Telegram bot.",
"summary": "Enable turns on Telegram support. Telegram still needs a BotFather token before messages can flow.",
"steps": [
"Create a bot with BotFather and copy its token.",
"Add the token and choose who can message the bot.",
"Save and enable Telegram, then send the bot a direct message or mention it in a group."
],
"fields": {
"token": {
"label": "Bot token",
"placeholder": "123456:ABC...",
"help": "Create it with BotFather."
},
"allowFrom": {
"label": "Allowed users",
"placeholder": "* or Telegram user IDs",
"help": "Leave empty to use pairing codes."
},
"groupPolicy": {
"label": "Group behavior",
"choices": {
"mention": "Mention only",
"open": "All messages",
"allowlist": "Allowlist"
}
}
}
}
}
@@ -0,0 +1,35 @@
{
"description": "Chatea con nanobot desde Telegram.",
"requirements": "Token del bot, usuarios permitidos y gateway",
"setup": {
"docsLabel": "Abrir guía de Telegram",
"officialLabel": "Abrir BotFather",
"tryIt": "Envía /start o un DM corto a tu bot de Telegram.",
"summary": "Activar habilita Telegram. Aún necesitas un token de BotFather para intercambiar mensajes.",
"steps": [
"Crea un bot con BotFather y copia su token.",
"Añade el token y elige quién puede escribir al bot.",
"Guarda y activa Telegram; después envía un DM o menciona el bot en un grupo."
],
"fields": {
"token": {
"label": "Token del bot",
"placeholder": "123456:ABC...",
"help": "Créalo con BotFather."
},
"allowFrom": {
"label": "Usuarios permitidos",
"placeholder": "* o ID de usuario de Telegram",
"help": "Déjalo vacío para usar códigos de vinculación."
},
"groupPolicy": {
"label": "Comportamiento en grupos",
"choices": {
"mention": "Solo menciones",
"open": "Todos los mensajes",
"allowlist": "Lista permitida"
}
}
}
}
}
@@ -0,0 +1,35 @@
{
"description": "Discutez avec nanobot depuis Telegram.",
"requirements": "Jeton du bot, utilisateurs autorisés et passerelle",
"setup": {
"docsLabel": "Ouvrir le guide Telegram",
"officialLabel": "Ouvrir BotFather",
"tryIt": "Envoyez /start ou un court message privé à votre bot Telegram.",
"summary": "Lactivation ouvre la prise en charge de Telegram. Un jeton BotFather reste nécessaire pour échanger des messages.",
"steps": [
"Créez un bot avec BotFather et copiez son jeton.",
"Ajoutez le jeton et choisissez qui peut contacter le bot.",
"Enregistrez et activez Telegram, puis envoyez un message privé ou mentionnez le bot dans un groupe."
],
"fields": {
"token": {
"label": "Jeton du bot",
"placeholder": "123456:ABC...",
"help": "Créez-le avec BotFather."
},
"allowFrom": {
"label": "Utilisateurs autorisés",
"placeholder": "* ou ID utilisateur Telegram",
"help": "Laissez vide pour utiliser les codes dassociation."
},
"groupPolicy": {
"label": "Comportement en groupe",
"choices": {
"mention": "Mentions uniquement",
"open": "Tous les messages",
"allowlist": "Liste dautorisation"
}
}
}
}
}
@@ -0,0 +1,35 @@
{
"description": "Mengobrol dengan nanobot dari Telegram.",
"requirements": "Token bot, pengguna yang diizinkan, dan gateway",
"setup": {
"docsLabel": "Buka panduan Telegram",
"officialLabel": "Buka BotFather",
"tryIt": "Kirim /start atau DM singkat ke bot Telegram Anda.",
"summary": "Mengaktifkan akan menyalakan dukungan Telegram. Token BotFather tetap diperlukan untuk bertukar pesan.",
"steps": [
"Buat bot dengan BotFather dan salin tokennya.",
"Tambahkan token dan pilih siapa yang boleh mengirim pesan ke bot.",
"Simpan dan aktifkan Telegram, lalu kirim DM atau sebut bot di grup."
],
"fields": {
"token": {
"label": "Token bot",
"placeholder": "123456:ABC...",
"help": "Buat dengan BotFather."
},
"allowFrom": {
"label": "Pengguna yang diizinkan",
"placeholder": "* atau ID pengguna Telegram",
"help": "Kosongkan untuk memakai kode pairing."
},
"groupPolicy": {
"label": "Perilaku grup",
"choices": {
"mention": "Hanya sebutan",
"open": "Semua pesan",
"allowlist": "Daftar izin"
}
}
}
}
}
@@ -0,0 +1,35 @@
{
"description": "Telegram のチャットから nanobot と会話します。",
"requirements": "ボットトークン、許可するユーザー、ゲートウェイ",
"setup": {
"docsLabel": "Telegram 設定ガイドを開く",
"officialLabel": "BotFather を開く",
"tryIt": "Telegram ボットに /start または短い DM を送信します。",
"summary": "有効化すると Telegram 対応がオンになります。メッセージの送受信には BotFather トークンが必要です。",
"steps": [
"BotFather でボットを作成し、トークンをコピーします。",
"トークンを追加し、ボットに連絡できるユーザーを選びます。",
"保存して Telegram を有効にし、DM を送るかグループでメンションします。"
],
"fields": {
"token": {
"label": "ボットトークン",
"placeholder": "123456:ABC...",
"help": "BotFather で作成します。"
},
"allowFrom": {
"label": "許可するユーザー",
"placeholder": "* または Telegram ユーザー ID",
"help": "ペアリングコードを使う場合は空欄にします。"
},
"groupPolicy": {
"label": "グループでの動作",
"choices": {
"mention": "メンションのみ",
"open": "すべてのメッセージ",
"allowlist": "許可リスト"
}
}
}
}
}
@@ -0,0 +1,35 @@
{
"description": "Telegram 채팅에서 nanobot과 대화합니다.",
"requirements": "봇 토큰, 허용된 사용자 및 게이트웨이",
"setup": {
"docsLabel": "Telegram 설정 가이드 열기",
"officialLabel": "BotFather 열기",
"tryIt": "Telegram 봇에 /start 또는 짧은 DM을 보내세요.",
"summary": "활성화하면 Telegram 지원이 켜집니다. 메시지를 주고받으려면 BotFather 토큰이 필요합니다.",
"steps": [
"BotFather로 봇을 만들고 토큰을 복사하세요.",
"토큰을 추가하고 봇에 메시지를 보낼 사용자를 선택하세요.",
"저장하고 Telegram을 활성화한 다음 DM을 보내거나 그룹에서 멘션하세요."
],
"fields": {
"token": {
"label": "봇 토큰",
"placeholder": "123456:ABC...",
"help": "BotFather에서 생성하세요."
},
"allowFrom": {
"label": "허용된 사용자",
"placeholder": "* 또는 Telegram 사용자 ID",
"help": "페어링 코드를 사용하려면 비워 두세요."
},
"groupPolicy": {
"label": "그룹 동작",
"choices": {
"mention": "멘션만",
"open": "모든 메시지",
"allowlist": "허용 목록"
}
}
}
}
}
@@ -0,0 +1,35 @@
{
"description": "Converse com o nanobot pelo Telegram.",
"requirements": "Token do bot, usuários permitidos e gateway",
"setup": {
"docsLabel": "Abrir guia do Telegram",
"officialLabel": "Abrir BotFather",
"tryIt": "Envie /start ou uma DM curta ao seu bot do Telegram.",
"summary": "Ativar liga o suporte ao Telegram. Um token do BotFather ainda é necessário para trocar mensagens.",
"steps": [
"Crie um bot com o BotFather e copie o token.",
"Adicione o token e escolha quem pode enviar mensagens ao bot.",
"Salve e ative o Telegram; depois, envie uma DM ou mencione o bot em um grupo."
],
"fields": {
"token": {
"label": "Token do bot",
"placeholder": "123456:ABC...",
"help": "Crie-o com o BotFather."
},
"allowFrom": {
"label": "Usuários permitidos",
"placeholder": "* ou IDs de usuário do Telegram",
"help": "Deixe vazio para usar códigos de pareamento."
},
"groupPolicy": {
"label": "Comportamento em grupos",
"choices": {
"mention": "Somente menções",
"open": "Todas as mensagens",
"allowlist": "Lista de permissão"
}
}
}
}
}
@@ -0,0 +1,35 @@
{
"description": "Trò chuyện với nanobot từ Telegram.",
"requirements": "Token bot, người dùng được phép và gateway",
"setup": {
"docsLabel": "Mở hướng dẫn Telegram",
"officialLabel": "Mở BotFather",
"tryIt": "Gửi /start hoặc tin nhắn riêng ngắn đến bot Telegram.",
"summary": "Bật sẽ kích hoạt hỗ trợ Telegram. Bạn vẫn cần token BotFather để trao đổi tin nhắn.",
"steps": [
"Tạo bot bằng BotFather và sao chép token.",
"Thêm token và chọn người có thể nhắn cho bot.",
"Lưu và bật Telegram, sau đó gửi tin nhắn riêng hoặc nhắc bot trong nhóm."
],
"fields": {
"token": {
"label": "Token bot",
"placeholder": "123456:ABC...",
"help": "Tạo bằng BotFather."
},
"allowFrom": {
"label": "Người dùng được phép",
"placeholder": "* hoặc ID người dùng Telegram",
"help": "Để trống để dùng mã ghép nối."
},
"groupPolicy": {
"label": "Hành vi trong nhóm",
"choices": {
"mention": "Chỉ khi được nhắc",
"open": "Mọi tin nhắn",
"allowlist": "Danh sách cho phép"
}
}
}
}
}
@@ -0,0 +1,35 @@
{
"description": "通过 Telegram 会话与 nanobot 聊天。",
"requirements": "机器人令牌、允许的用户和网关",
"setup": {
"docsLabel": "打开 Telegram 配置指南",
"officialLabel": "打开 BotFather",
"tryIt": "向 Telegram 机器人发送 /start 或一条简短私信。",
"summary": "启用只会打开 Telegram 支持;收发消息前仍需填写 BotFather 令牌。",
"steps": [
"使用 BotFather 创建机器人并复制令牌。",
"填写令牌并选择哪些用户可以向机器人发送消息。",
"保存并启用 Telegram,然后向机器人发送私信或在群组中提及它。"
],
"fields": {
"token": {
"label": "机器人令牌",
"placeholder": "123456:ABC...",
"help": "使用 BotFather 创建。"
},
"allowFrom": {
"label": "允许的用户",
"placeholder": "* 或 Telegram 用户 ID",
"help": "留空则使用配对码。"
},
"groupPolicy": {
"label": "群组行为",
"choices": {
"mention": "仅提及时",
"open": "所有消息",
"allowlist": "白名单"
}
}
}
}
}
@@ -0,0 +1,35 @@
{
"description": "透過 Telegram 對話與 nanobot 聊天。",
"requirements": "機器人權杖、允許的使用者和閘道",
"setup": {
"docsLabel": "開啟 Telegram 設定指南",
"officialLabel": "開啟 BotFather",
"tryIt": "向 Telegram 機器人傳送 /start 或一則簡短私訊。",
"summary": "啟用只會開啟 Telegram 支援;收發訊息前仍需填入 BotFather 權杖。",
"steps": [
"使用 BotFather 建立機器人並複製權杖。",
"填入權杖並選擇哪些使用者可以向機器人傳送訊息。",
"儲存並啟用 Telegram,然後傳送私訊或在群組中提及機器人。"
],
"fields": {
"token": {
"label": "機器人權杖",
"placeholder": "123456:ABC...",
"help": "使用 BotFather 建立。"
},
"allowFrom": {
"label": "允許的使用者",
"placeholder": "* 或 Telegram 使用者 ID",
"help": "留空則使用配對碼。"
},
"groupPolicy": {
"label": "群組行為",
"choices": {
"mention": "僅提及時",
"open": "所有訊息",
"allowlist": "允許清單"
}
}
}
}
}