模块系统(可安装/可卸载)¶
本项目提供一个“模块系统”框架,用于把任务能力与外部 API 能力以模块形式分发、安装、启用与回滚,避免因为扩展功能不兼容导致主站不可用。
当前实现为同进程插件(动态加载程序集)。为稳定起见:安装/启用/停用/卸载后通常需要重启服务才能生效。
目标¶
- 可安装/可卸载:面板内上传模块包并管理启用状态
- 版本管理:同一模块可安装多个版本,支持切换
ActiveVersion - 依赖管理:模块声明依赖的模块与版本范围(
>=1.2.3 <2.0.0) - 兼容性:模块声明宿主版本区间(
host.min/host.max) - 失败自动兜底:模块加载失败时自动尝试回滚到
LastGoodVersion,否则自动禁用以避免拖垮系统
面板入口¶
- 「模块管理」:安装/启用/停用/卸载模块(通常需重启生效)
- 「API 管理」:基于已启用模块,创建对应的外部 API 配置项(
X-API-Key鉴权) - 「任务中心」:基于已启用模块,动态展示任务类型与分类
示例扩展(可选)¶
- 外部 API:踢人/封禁(示例模块
builtin.kick-api,接口:POST /api/kick,配置入口:面板左侧菜单「API 管理」) - 模块打包脚本:
powershell tools/package-module.ps1 -Project <csproj> -Manifest <manifest.json>(产物默认输出到artifacts/modules/)
付费扩展模块(不免费开放)¶
以下模块为扩展能力示例的“增强版/商业版”,默认不免费开放;如需获取请联系:TG @SNINKBOT。
- 频道同步转发:按配置将来源频道/群组消息同步转发到目标(更适合多频道矩阵运营)
- 监控频道更新通知:持续监控指定频道更新并向目标 ID 推送通知(支持通知冷却,避免刷屏)
- 验证码 URL 登录:生成可外部访问的验证码获取页面,按需读取账号系统通知(777000)并展示验证码(接码/卖号场景常见用法)
扩展点一览(任务 / API / UI)¶
模块除 ConfigureServices / MapEndpoints 外,还可以选择性实现以下接口(位于 TelegramPanel.Modules.Abstractions):
IModuleTaskProvider:声明模块提供的任务类型(让任务中心可动态展示/创建)IModuleTaskHandler:实现任务中心后台执行器(让后台真正能跑该任务)IModuleTaskRerunBuilder:为“重新运行”提供专用的配置重建逻辑(适合需要清洗旧配置的任务)IModuleApiProvider:声明模块提供的外部 API 类型(让 API 管理页面可动态创建配置项)IModuleUiProvider:声明模块扩展 UI 导航与页面(让面板可挂载模块自定义页面)
说明:模块启用/停用通常需要重启;宿主启动时只会加载“启用”的模块,因此 UI/任务/API 列表会随启用状态变化。
Bot 更新订阅(allowed_updates)¶
如果模块需要消费 Telegram Bot API 的更新(getUpdates / Webhook),不要在模块里对同一个 Bot Token 自行启动轮询器(会导致 409 Conflict)。请通过宿主的 BotUpdateHub 订阅/广播更新。
注意:宿主会为 getUpdates / setWebhook 固定传入 allowed_updates 白名单(见 src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs 的 AllowedUpdatesJson)。当前已包含成员变更与入群请求:chat_member、chat_join_request;后续如你的模块需要其它更新类型,需要先在宿主侧扩展该白名单并发布宿主版本。
配置入口与“窗口编辑”(推荐)¶
如果你的模块需要“配置界面”,推荐以 模块页面(IModuleUiProvider.GetPages)的形式提供,然后在 ModuleTaskDefinition.CreateRoute 中指向该页面的路由:
- 模块页面路由固定为:
/ext/{ModuleId}/{PageKey} - 当
CreateRoute指向/ext/...时: - “新建任务”弹窗会提供“打开窗口/前往页面”两种方式
- “任务中心”会在顶部的“持续任务(可配置)”区域展示该任务,并提供“编辑”按钮直接打开配置窗口
这样可以获得类似“配置窗口”的体验,同时仍复用模块页面渲染能力(DynamicComponent)。
提醒:保存配置应尽量做到“立即生效”;只有模块启用/停用(影响 DI/后台服务装载)才需要重启。
模块目录结构¶
模块默认使用持久化目录(Docker 内默认:/data/modules;可用配置 Modules:RootPath 覆盖):
modules/
state.json
active/ # 预留:当前启用版本(部分实现会用到)
data/ # 模块自有持久化数据(推荐放这里)
packages/
<moduleId>/
<version>.tpm
installed/
<moduleId>/
<version>/
manifest.json
lib/
<entry assembly>.dll
...依赖 dll...
...其他资源文件...
staging/ # 安装中临时目录
trash/ # 删除后回收目录(可手动找回)
state.json 记录模块是否启用、当前使用版本与 last-good:
{
"schemaVersion": 1,
"modules": [
{
"id": "builtin.kick-api",
"enabled": true,
"activeVersion": "1.2.3",
"lastGoodVersion": "1.2.3",
"installedVersions": ["1.2.3"],
"builtIn": true
}
]
}
模块数据持久化(推荐)¶
模块运行时可通过 ModuleHostContext.ModulesRootPath 获取模块系统根目录。推荐把模块自有数据放到:
Path.Combine(context.ModulesRootPath, "data", Manifest.Id)
示例(把路径封装为 Paths 并注入到 DI):
public void ConfigureServices(IServiceCollection services, ModuleHostContext context)
{
var dataRoot = Path.Combine(context.ModulesRootPath, "data", Manifest.Id);
services.AddSingleton(new MyModulePaths(dataRoot));
}
这样可以保证 Docker/本机部署下都能持久化,并且不会污染宿主目录结构。
模块包格式(.tpm / .zip)¶
模块包本质是 Zip 文件(扩展名可为 .tpm 或 .zip),解压后的根目录必须包含:
manifest.jsonlib/<entry assembly>.dll(入口程序集)
小提示:如果你是“右键压缩整个文件夹”,压缩包里通常会多一层根目录(
<folder>/manifest.json)。宿主会尝试自动识别并提升这一层;但更推荐直接把manifest.json和lib/放在压缩包根目录。
安装流程会先解压到 staging/ 并做基础校验,然后移动到 installed/<id>/<version>/,并将原包存档到 packages/<id>/<version>.tpm 便于留档与回滚。
模块打包(可选)¶
仓库内提供了一个基于 Docker 的打包脚本(无需本机安装 dotnet),用于把任意模块项目打包为可上传的 .tpm:
powershell tools/package-module.ps1 -Project "src/YourModule/YourModule.csproj" -Manifest "src/YourModule/manifest.json"
默认会按宿主内置依赖做“轻量化打包”(等价于
-SlimHost)。如确需完整包可传-Full(或-Slim:$false -SlimHost:$false)。
产物默认输出到:artifacts/modules/<moduleId>-<version>.tpm
说明:该脚本依赖 Docker(会拉取/使用
mcr.microsoft.com/dotnet/sdk:8.0镜像)。首次执行会比较慢属正常现象。
轻量打包(推荐)¶
模块运行时会与宿主共享一批“边界程序集”(例如 TelegramPanel.*、Microsoft.Extensions.*、Microsoft.AspNetCore.*、MudBlazor 等)。
这些程序集即使被打进模块包里,宿主也会强制从 Default ALC 解析(避免类型身份不一致),因此携带它们只会徒增包体积。
打包时可加 -Slim 开关自动剔除这类共享程序集:
powershell tools/package-module.ps1 -Project "src/YourModule/YourModule.csproj" -Manifest "src/YourModule/manifest.json" -Slim
更激进的轻量打包(仅限 TelegramPanel 宿主)¶
如果确定目标宿主就是 TelegramPanel 主程序(必带 EFCore/Sqlite/WTelegramClient 等依赖),并且你希望把模块包做到尽可能小,可以使用 -SlimHost:
- 额外剔除:
Microsoft.EntityFrameworkCore*、Microsoft.Data.Sqlite、SQLitePCLRaw*、WTelegramClient、SixLabors.ImageSharp、PhoneNumbers等宿主内置依赖 - 剔除
runtimes/(多平台 SQLite native,体积占比很高) - 剔除
wwwroot/_content/MudBlazor(静态资源由宿主提供)
powershell tools/package-module.ps1 -Project "src/YourModule/YourModule.csproj" -Manifest "src/YourModule/manifest.json" -SlimHost
manifest.json(示例)¶
{
"id": "example.kick-api",
"name": "示例:踢人 API",
"version": "1.0.0",
"host": { "min": "1.0.0", "max": "2.0.0" },
"dependencies": [
{ "id": "builtin.kick-api", "range": ">=1.0.0 <2.0.0" }
],
"entry": {
"assembly": "Example.KickApi.dll",
"type": "Example.KickApi.ExampleKickApiModule"
}
}
版本范围(dependencies[].range)支持:
1.2.3(等于)>=1.2.3>=1.2.3 <2.0.0(空格分隔多个条件)
模块代码示例(入口点)¶
模块入口类型需实现 TelegramPanel.Modules.ITelegramPanelModule:
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using TelegramPanel.Modules;
namespace Example.KickApi;
public sealed class ExampleKickApiModule : ITelegramPanelModule
{
public ModuleManifest Manifest { get; } = new()
{
Id = "example.kick-api",
Name = "示例:踢人 API",
Version = "1.0.0",
Host = new HostCompatibility { Min = "1.0.0", Max = "2.0.0" },
Entry = new ModuleEntryPoint { Assembly = "Example.KickApi.dll", Type = typeof(ExampleKickApiModule).FullName! }
};
public void ConfigureServices(IServiceCollection services, ModuleHostContext context)
{
// 可在这里注册该模块用到的 DI 服务(注意:启用/停用通常需要重启才能生效)
}
public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context)
{
endpoints.MapPost("/api/example", () => Results.Ok(new { ok = true }));
}
}
宿主内置服务(模块可注入)¶
模块与宿主同进程运行,因此模块的 API/任务/页面都可以直接从 DI 获取宿主服务。
获取 Telegram 邮箱验证码(Cloud Mail)¶
宿主提供 ITelegramEmailCodeService 供模块复用“邮箱验证码”能力(例如:部分客户端会把验证码发送到邮箱而非短信)。
前置条件:在面板「系统设置」配置 CloudMail:BaseUrl / CloudMail:Token / CloudMail:Domain。
示例(在模块任意 DI 场景注入即可,如 IModuleTaskHandler / MapEndpoints):
using TelegramPanel.Modules;
public sealed class MyHandler : IModuleTaskHandler
{
public string TaskType => "example.mail-code";
private readonly ITelegramEmailCodeService _emailCodes;
public MyHandler(ITelegramEmailCodeService emailCodes) => _emailCodes = emailCodes;
public async Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken ct)
{
var r = await _emailCodes.TryGetLatestCodeByPhoneDigitsAsync("8413111454444", sinceUtc: DateTimeOffset.UtcNow.AddMinutes(-5), ct);
// r.Success / r.Code
}
}
调用宿主 AI 服务(推荐给模块复用)¶
宿主提供 ITelegramPanelAiService,模块可以直接复用主程序里已配置好的 OpenAI 兼容 AI 能力,不需要在模块里重复保存端点、Key 或自己再接一套 SDK。
前置条件:
- 在面板「系统设置 -> AI 设置」中已配置
AI:OpenAI:Endpoint - 已配置
AI:OpenAI:ApiKey - 已配置全局默认模型,或者模块调用时显式传入
Model - 若系统设置里配置了
AI:OpenAI:RetryCount,模块调用也会自动享受同一套重试策略
当前宿主暴露两类能力:
ChooseActionAsync(...):根据消息文本、按钮列表、可选图片,返回动作决策ReplyTextAsync(...):根据题目、上下文、可选图片,返回最终文本答案
相关契约位于:src/TelegramPanel.Modules.Abstractions/AiServices.cs
ChooseActionAsync(...) 的返回约定:
Success=true且Mode=click_button:使用ButtonIndex(0 基)点击按钮Success=true且Mode=reply_text:使用ReplyText发送文本Success=false:查看ErrorReason仅用于日志或调试,不建议模块把它当成业务字段
示例(模块任务里调用宿主 AI 识别按钮):
using TelegramPanel.Modules;
public sealed class MyAiTaskHandler : IModuleTaskHandler
{
public string TaskType => "example.ai-check";
private readonly ITelegramPanelAiService _ai;
public MyAiTaskHandler(ITelegramPanelAiService ai)
{
_ai = ai;
}
public async Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken ct)
{
var result = await _ai.ChooseActionAsync(
new TelegramPanelAiChooseActionRequest(
Model: null, // null 表示回退到系统设置里的默认模型
MessageText: "请选择正确验证码",
Buttons: new[]
{
new TelegramPanelAiButtonOption(0, "12"),
new TelegramPanelAiButtonOption(1, "18"),
new TelegramPanelAiButtonOption(2, "21")
},
Image: null,
Context: "这是 Telegram 群验证消息,请只返回最可靠动作。"),
ct);
if (!result.Success)
throw new InvalidOperationException(result.Error ?? "AI 决策失败");
if (string.Equals(result.Mode, "click_button", StringComparison.OrdinalIgnoreCase))
{
var buttonIndex = result.ButtonIndex ?? -1;
// 这里结合你自己的 Telegram 调用链执行点击
}
}
}
示例(模块任务里调用宿主 AI 生成文本答案):
using TelegramPanel.Modules;
public sealed class MyAiReplyHandler : IModuleTaskHandler
{
public string TaskType => "example.ai-reply";
private readonly ITelegramPanelAiService _ai;
public MyAiReplyHandler(ITelegramPanelAiService ai)
{
_ai = ai;
}
public async Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken ct)
{
var result = await _ai.ReplyTextAsync(
new TelegramPanelAiReplyTextRequest(
Model: "gpt-4o-mini",
Prompt: "你是 Telegram 验证助手,请只返回最终答案。",
Query: "请计算:12 + 19 = ?",
Image: null,
Context: "不要解释,不要带多余符号。"),
ct);
if (!result.Success)
throw new InvalidOperationException(result.Error ?? "AI 作答失败");
var replyText = result.ReplyText ?? string.Empty;
// 这里结合你自己的 Telegram 调用链发送 replyText
}
}
建议:
- 优先把模型名做成模块配置项;未配置时传
null,回退全局默认模型 - 模块只关心
Success / Error / Mode / ButtonIndex / ReplyText,不要依赖具体提示词实现细节 - 若需要图像识别,传入
TelegramPanelAiImageInput,建议使用 JPEG 字节数组 - 模块不要自己拼
/chat/completions或自己做端点规范化,这些都交给宿主
账号导出下载(Telethon / Tdata)¶
如果模块需要“下载某个账号的数据包”,建议优先使用宿主服务直接生成 Zip(同进程内调用),避免绕 HTTP 鉴权与 Cookie。
推荐方式:模块内直接调用导出服务¶
可注入:
TelegramPanel.Web.Services.AccountExportServiceTelegramPanel.Core.Services.AccountManagementService
核心调用链:
- 先通过
AccountManagementService获取目标账号(或账号列表) - 调用
AccountExportService.BuildAccountsZipAsync(accounts, ct, format) - 将
byte[]按模块自己的场景返回/落盘/上传
其中 format:
AccountExportFormat.Telethon:导出.json + .session (+2fa.txt)AccountExportFormat.Tdata:在以上基础上额外导出tdata/
HTTP 方式(备选)¶
宿主现有下载接口:
GET /downloads/accounts.zip- Query:
ids=1,2,3(可选,不传则导出全部)format=telethon|tdata(不传默认telethon)ts=<timestamp>(可选,建议带上,避免浏览器缓存旧包)
注意:
- 若开启后台登录,接口受登录态保护(需带管理端 Cookie)
- 响应已设置
no-store/no-cache,但调用方仍建议加ts
Tdata 导出的实现要点(后续扩展必须保持)¶
session -> telethon string时必须保留 Base64 padding(尾部=)telethon string -> tdata时必须注入session.self.userId- 生成
telethon string时要优先选择“已授权 DCSession”(不是任意 DC)
否则会出现“包结构看似正常,但 Telegram Desktop 仍要求重新登录”。
UI 模块项目模板(Razor 组件)¶
如果你的模块需要提供页面(IModuleUiProvider.GetPages),推荐把模块做成 Microsoft.NET.Sdk.Razor 项目(类似 Razor Class Library),例如:
<Project Sdk="Microsoft.NET.Sdk.Razor">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../../src/TelegramPanel.Modules.Abstractions/TelegramPanel.Modules.Abstractions.csproj" />
<PackageReference Include="MudBlazor" Version="7.*" />
</ItemGroup>
</Project>
建议在模块根目录放一个 _Imports.razor,把常用命名空间一次性导入(例如 MudBlazor、Microsoft.AspNetCore.Components 等),避免每个页面重复写。
注意:模块项目引用
MudBlazor主要用于编译期;运行时会跟随宿主加载。若模块需要自带静态资源(CSS/JS),宿主不会自动暴露模块的wwwroot,你需要在MapEndpoints中自行提供静态文件访问(或把样式/脚本内联到页面里)。
开发/调试建议¶
模块开发最简单的闭环是:打包 → 在面板中上传/安装 → 重启服务 → 验证。
- 安装/启用/停用外部模块通常需要重启(因为
ConfigureServices在宿主构建 DI 之前执行)。 - 开发阶段可以把版本号(
manifest.json的version)按1.0.0 -> 1.0.1 -> ...递增,避免缓存/回滚机制干扰排查。
任务扩展(Task)¶
1) 声明任务类型(可在“新建任务”中出现)¶
实现 IModuleTaskProvider 返回 ModuleTaskDefinition:
public sealed class MyTaskModule : ITelegramPanelModule, IModuleTaskProvider
{
public IEnumerable<ModuleTaskDefinition> GetTasks(ModuleHostContext context)
{
yield return new ModuleTaskDefinition
{
Category = "user",
TaskType = "my_task_type",
DisplayName = "我的任务",
Description = "自定义任务说明",
Icon = MudBlazor.Icons.Material.Filled.Task,
Order = 100
};
}
}
2) 实现任务执行器(后台真正运行)¶
实现 IModuleTaskHandler 并在 ConfigureServices 注册到 DI:
public sealed class MyTaskHandler : IModuleTaskHandler
{
public string TaskType => "my_task_type";
public async Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken ct)
{
// host.Config 是创建任务时写入的 Config 字符串(建议是 JSON)
// host.Services 可解析宿主的服务(AccountTelegramToolsService 等)
// host.UpdateProgressAsync(...) 用于写入任务中心进度
var completed = 0;
var failed = 0;
// 示例:跑 10 步
for (var i = 0; i < 10; i++)
{
ct.ThrowIfCancellationRequested();
if (!await host.IsStillRunningAsync(ct))
return;
completed++;
await host.UpdateProgressAsync(completed, failed, ct);
}
}
}
public void ConfigureServices(IServiceCollection services, ModuleHostContext context)
{
services.AddSingleton<IModuleTaskHandler, MyTaskHandler>();
}
持续任务(常驻后台能力)模式(推荐)¶
有些能力并不是“一次性批量任务”,而是需要模块启用后长期运行的后台监听/通知等。这类能力建议:
1) 在模块内注册 HostedService 常驻后台运行(ConfigureServices 中 services.AddHostedService<...>())。
2) 不要把它塞进批量任务队列(IModuleTaskHandler),避免队列阻塞或误触发。
3) 仍然可以在“新建任务/任务中心”里提供一个“配置入口”,做法是注册 IModuleTaskProvider 并设置 CreateRoute 指向模块配置页:
public IEnumerable<ModuleTaskDefinition> GetTasks(ModuleHostContext context)
{
yield return new ModuleTaskDefinition
{
Category = "bot",
TaskType = "bot_monitor_notify",
DisplayName = "监控频道更新通知",
Description = "常驻后台监听,不占用批量任务队列;在配置里启用即可生效。",
Icon = MudBlazor.Icons.Material.Filled.NotificationsActive,
CreateRoute = "/ext/pro.bot-monitor-notify/settings",
Order = 100
};
}
这种模式的体验是:
- “新建任务”里点击后打开配置窗口(或跳转配置页)
- “任务中心”顶部可直接编辑该持续任务配置(方便增删频道/目标等)
示例:批量订阅/加群/退群(用户任务)¶
该类任务的典型形态是“多账号 × 多链接”的组合执行,并允许在 UI 中切换操作模式:
join:订阅频道 / 加入群组leave:取消订阅 / 退群
建议的 host.Config(JSON)结构:
{
"Mode": "join",
"AccountIds": [1, 2],
"Links": [
"https://t.me/xxx",
"t.me/+hash",
"@username",
"tg://join?invite=hash"
],
"DelayMs": 2000
}
模块执行器中可直接解析并调用宿主服务(示例):
TelegramPanel.Core.Services.Telegram.AccountTelegramToolsService.JoinChatOrChannelAsync(...)TelegramPanel.Core.Services.Telegram.AccountTelegramToolsService.LeaveChatOrChannelAsync(...)
3) 可选:提供自定义创建器(任务中心内嵌表单)¶
在 ModuleTaskDefinition.EditorComponentType 指定组件类型的 AssemblyQualifiedName,并实现组件参数:
Draft(ModuleTaskDraft)DraftChanged(EventCallback<ModuleTaskDraft>)
宿主会用 DynamicComponent 渲染该组件,提交时使用 Draft.Total / Draft.Config 创建任务。
实用建议(针对“多账号/多目标”类任务):
- 在编辑器里做基础校验:未选择账号、未填写链接时
CanSubmit=false并给出ValidationError Total建议按“账号数 × 链接数”或“账号数 × 用户名数”等可预估的总步数计算,便于任务中心展示进度- 支持筛选:例如“账号分类筛选/搜索”,减少用户选择成本
- 遵循宿主的账号排除规则:默认不展示
Category.ExcludeFromOperations=true的账号(常用于“工作账号”);如你的模块确实需要,也可以提供“包含工作账号”的开关
4) 复用同一个编辑器做“创建 + 编辑”(推荐)¶
最近宿主已经把“任务创建器组件”和“任务编辑弹窗”做了统一约定。推荐你的编辑器组件同时支持以下参数:
- 必选:
Draft - 必选:
DraftChanged - 可选:
InitialConfigJson(编辑场景下传入当前任务的原始Config) - 可选:
TaskId(编辑场景下传入当前任务 ID,便于你按需读取更多上下文)
也就是说,一个组件既可以用于“新建任务”,也可以用于“编辑已存在任务”。最小参数示例:
@code {
[Parameter] public ModuleTaskDraft Draft { get; set; }
[Parameter] public EventCallback<ModuleTaskDraft> DraftChanged { get; set; }
[Parameter] public string? InitialConfigJson { get; set; }
[Parameter] public int TaskId { get; set; }
}
补充说明:
- 创建场景下:宿主只保证传
Draft / DraftChanged - 编辑场景下:宿主会额外尝试注入
InitialConfigJson / TaskId - 这两个可选参数如果组件未声明,宿主会自动跳过,不会报错
5) 任务中心能力声明(建议按新约定填写)¶
ModuleTaskDefinition 现在带有 TaskCenter 字段,可用于声明该任务在任务中心里希望暴露哪些操作能力:
yield return new ModuleTaskDefinition
{
Category = "user",
TaskType = "example.long-running",
DisplayName = "示例:持续任务",
Icon = MudBlazor.Icons.Material.Filled.Tune,
EditorComponentType = typeof(MyTaskEditor).AssemblyQualifiedName,
TaskCenter = new ModuleTaskCenterCapabilities
{
CanPause = true,
CanResume = true,
CanEdit = true,
CanRerun = true,
EditComponentType = typeof(MyTaskEditor).AssemblyQualifiedName,
AutoPauseBeforeEdit = true
}
};
字段说明:
CanPause:任务支持暂停CanResume:任务支持从暂停状态继续运行CanEdit:任务支持在任务中心打开编辑器修改配置CanRerun:任务支持基于历史配置重新创建一个新任务EditComponentType:编辑时使用的组件;为空时回退到EditorComponentTypeAutoPauseBeforeEdit:如果任务仍在运行,宿主可先暂停再进入编辑
当前建议:
- 对“一次性批量任务”,通常只需要
CanRerun = true - 对“持续任务/常驻任务”,通常建议同时声明
CanPause / CanResume / CanEdit / CanRerun - 如果你把
EditorComponentType或EditComponentType写成空字符串,宿主会在注册阶段自动规范为null
注意:这组字段已经进入抽象层,并且内置持续任务已按此方式声明;外部模块也建议遵循相同结构,便于后续宿主统一扩展任务中心行为。
6) 宿主内置数据字典与模板变量(推荐优先复用)¶
如果你的模块任务需要“随机文案 / 队列文案 / 图片变量 / 标题模板 / 用户名模板”等能力,建议优先复用宿主已经内置的数据字典体系,而不是在模块里重复造一套词库配置。
当前宿主已经提供:
- 数据字典管理页面:
/data-dictionaries - 文本字典:返回
string - 图片字典:返回图片资产引用(适合头像、图片消息等)
- 读取模式:
random/queue - 队列游标持久化:
queue模式的NextIndex会写入数据库,重启后继续 - 模板变量语法:固定为
{name} - 内置变量:
{time}(格式yyyyMMddHHmmss)
相关宿主服务:
TelegramPanel.Web.Services.DataDictionaryServiceTelegramPanel.Web.Services.TemplateRenderingServiceTelegramPanel.Web.Services.ImageAssetStorageService
推荐用法:
var templateRendering = host.Services.GetRequiredService<TemplateRenderingService>();
var title = await templateRendering.RenderTextTemplateAsync("临时频道{time}_{city}", cancellationToken);
var avatar = await templateRendering.ResolveImageTemplateAsync("{avatar_dict}", cancellationToken);
约束说明:
- 标题、描述、公开用户名这类文本字段,只能解析到文本值
- 头像、图片消息这类图片字段,只能使用固定图片或图片字典变量
- 文本字典和图片字典严格分型,不要混用
- 未知变量、空字典、已停用字典、类型不匹配,宿主会直接抛出校验失败
- 图片变量必须是单个 token,例如
{avatar},不能写成头像_{avatar}
如果你的模块也提供任务编辑器,建议:
- 在 UI 中直接提示“支持
{time}与{字典名}” - 文本输入框只展示文本字典变量
- 图片输入框只展示图片字典变量
- 让最终配置 JSON 只保存模板字符串 / 字典 token,不要把解析后的随机结果提前固化进配置
这样做的好处是:
- 宿主统一管理字典内容,模块间可以复用同一份变量源
- 后续扩展新变量 provider 时,模块通常不需要改协议
- 计划任务、一次性任务、模块页面都能复用同一套解析规则
7) 为“重新运行”提供专用构建器(适合复杂任务)¶
如果你的任务配置在运行过程中会写回运行态字段,或者重跑前需要清洗旧配置,建议额外实现 IModuleTaskRerunBuilder:
public sealed class MyTaskRerunBuilder : IModuleTaskRerunBuilder
{
public string TaskType => "example.long-running";
public ModuleTaskCreateRequest Build(ModuleTaskSnapshot task)
{
// 这里把历史任务快照重新整理为新的创建请求
return new ModuleTaskCreateRequest
{
TaskType = TaskType,
Total = Math.Max(0, task.Total),
Config = task.Config
};
}
}
public void ConfigureServices(IServiceCollection services, ModuleHostContext context)
{
services.AddSingleton<IModuleTaskRerunBuilder, MyTaskRerunBuilder>();
}
这种方式适合:
- 运行中会把“最近失败/暂停标记/错误信息”等运行态字段写回
Config - 重跑前需要把旧配置从“运行态 JSON”还原为“创建态 JSON”
- 需要在重跑时动态修正
Total
说明:
IModuleTaskRerunBuilder已进入抽象层,适合新模块提前按该约定实现;这样后续宿主统一接入时不需要再回头改模块结构。
外部 API 扩展(API)¶
1) 声明 API 类型(可在“API 管理→新建 API”中出现)¶
实现 IModuleApiProvider 返回 ModuleApiTypeDefinition:
public IEnumerable<ModuleApiTypeDefinition> GetApis(ModuleHostContext context)
{
yield return new ModuleApiTypeDefinition
{
Type = "my_api",
DisplayName = "我的 API",
Route = "/api/my",
Description = "自定义接口说明",
Order = 100
};
}
2) 映射 endpoints 并读取配置项¶
宿主会把 API 配置写入 ExternalApi:Apis(含 Type / Enabled / ApiKey / Config(JSON object))。模块在 endpoint 里自行按 X-API-Key 匹配对应配置项并执行。
内置 kick 接口提供了一个参考实现:
src/TelegramPanel.Web/ExternalApi/KickApi.cs
UI 扩展(页面/导航)¶
1) 添加导航链接(可选)¶
实现 IModuleUiProvider.GetNavItems 返回 ModuleNavItem(Title/Href/Icon/Group/Order)。
2) 添加模块页面(推荐)¶
实现 IModuleUiProvider.GetPages 返回 ModulePageDefinition:
Key:页面键(模块内唯一)ComponentType:组件类型AssemblyQualifiedName
宿主提供统一入口路由:/ext/{moduleId}/{pageKey},会动态加载并渲染模块组件。
3) 模块页面参数约定(非常重要)¶
宿主会把 ModuleId 与 PageKey 作为组件参数注入,因此模块页面组件必须声明以下两个参数,否则运行时会 500(组件不接受宿主注入的参数):
@code {
[Parameter] public string ModuleId { get; set; } = "";
[Parameter] public string PageKey { get; set; } = "";
}
如果你的页面完全不需要这两个值,也必须保留参数声明。
依赖与加载(外部模块)¶
外部模块会从 installed/<id>/<version>/lib/ 通过独立的 AssemblyLoadContext 加载入口程序集。
实践建议:
- 把入口程序集及其依赖(包含第三方 NuGet)都放进
lib/,最简单方式是对模块项目执行dotnet publish(打包脚本已内置)。 - 避免依赖宿主的同名 DLL(版本不一致时容易出错)。
- 如果模块需要引用宿主工程里的类型,推荐通过
ProjectReference引用TelegramPanel.Modules.Abstractions/TelegramPanel.Core/TelegramPanel.Data等项目(按需即可),并随模块一起发布到lib/。
认证/授权(端点安全)¶
- 模块页面:作为面板的一部分渲染,通常受宿主的后台登录控制(管理员登录开启时会要求授权)。
- 模块 API 端点(
MapEndpoints):请显式选择: AllowAnonymous():公开接口(务必自行做好鉴权/限流/防泄露)- 或
RequireAuthorization():跟随宿主后台登录鉴权
如果是“外置链接/匿名链接”类能力,建议:
- 使用随机 token 作为访问凭证
- 做好限流(按 token + IP)
- 返回
no-store防缓存
运行时行为(启用/回滚)¶
- 启用模块会进行宿主版本校验与依赖校验(依赖模块必须存在且版本满足范围)。
- 启动时加载模块:
- 加载失败会尝试回滚到
LastGoodVersion; - 回滚也失败则自动
Enabled=false(避免拖垮系统)。
安全与稳定提示¶
同进程插件无法做到“绝对不崩”。为了降低风险:
- 只安装可信来源的模块包
- 出现异常时先停用模块并重启
- 建议在生产环境使用“灰度/备份”方式试装模块
后续如需更强隔离,可以把模块改为“独立进程 Module Host”模式(主站通过 HTTP/gRPC 调用),进一步降低崩溃风险。