Files
Telegram-Panel/docs/developer/modules.md

986 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 模块系统(可安装/可卸载)
本项目提供一个“模块系统”框架,用于把**任务能力**与**外部 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 列表会随启用状态变化。
## 长时间运行任务与重启恢复(重要)
如果你的模块实现的是“持续监控 / 长轮询 / 等待条件出现后再执行”的任务,需要注意下面这几个规则:
### 1批量任务框架默认仍然是“一次执行”
- 宿主的 `BatchTaskBackgroundService` 会从数据库里捞出 `pending` 任务,调用对应的 `IModuleTaskHandler.ExecuteAsync(...)`
- **只要你的 `ExecuteAsync(...)` 返回,宿主就会把这条批量任务标记为 `completed``failed`**
- 所以“持续任务”并不是宿主自动帮你持续;而是你的执行器必须自己维持循环,并在适当的时候才返回
换句话说:
- 一次性任务:执行器跑完就返回
- 持续监控任务:执行器自己 `while (...)` 循环,直到达到停止条件、被用户暂停/取消,或者你明确决定结束
### 2持续任务必须轮询 `IsStillRunningAsync(...)`
宿主通过 `IModuleTaskExecutionHost.IsStillRunningAsync(...)` 把“当前任务是否还允许继续跑”暴露给模块。
模块作者在长循环里必须定期检查:
```csharp
while (!cancellationToken.IsCancellationRequested)
{
if (!await host.IsStillRunningAsync(cancellationToken))
return;
// 你的持续监控逻辑
}
```
推荐检查位置:
- 每一轮大循环开始时
- 每次 `Task.Delay(...)` 前后
- 每次外部请求、网络调用、数据库批量操作前
这样用户在任务中心点击“暂停 / 恢复 / 取消”时,模块才能及时响应。
### 3持续任务的运行状态必须写回 `task.Config`
如果你的任务需要跨轮次记住状态,例如:
- 已处理过哪些用户名 / 频道 / 消息
- 上次检查时间
- 当前游标 / offset / pageToken
- 外部系统返回的中间状态
不要只存在内存里,应该定期序列化回 `BatchTask.Config`
宿主提供了 `BatchTaskManagementService.UpdateTaskConfigAsync(...)`,推荐在模块里这样做:
```csharp
var taskManagement = host.Services.GetRequiredService<BatchTaskManagementService>();
config.LastCheckTime = DateTime.UtcNow;
config.ProcessedIds = processedIds.ToList();
await taskManagement.UpdateTaskConfigAsync(
host.TaskId,
JsonSerializer.Serialize(config, new JsonSerializerOptions { WriteIndented = true }));
```
这样做的目的有两个:
- 任务详情里能看到实时状态
- 宿主重启后,任务可以从上次进度继续恢复,而不是从头开始
### 4宿主现在会自动恢复“中断中的 running 任务”
当前宿主实现中,`BatchTaskBackgroundService` 启动时会把数据库里残留的 `running` 批量任务重新置回 `pending`,然后由后台执行器重新拉起。
这意味着:
- 如果程序异常退出 / 重启
- 只要这条任务上次状态还停留在 `running`
- 宿主下次启动后会自动尝试恢复它
因此,**模块作者必须把持续任务写成“可重复进入、可从 Config 恢复”的形式**。
也就是说,不要依赖:
- 进程内静态变量
- 单次启动时生成但未持久化的随机状态
- 只存在内存里的队列 / 集合 / 指针
而应该依赖:
- `task.Config`
- 模块自己的持久化数据目录
- 外部系统里可重复读取的状态
### 5“持续任务”和“Cron 计划任务”不是一回事
宿主里现在有两套概念:
- **批量任务BatchTask**
说明:提交后立即执行一次;是否持续由模块执行器自己决定
- **计划任务ScheduledTask / Cron**
说明:由宿主按 Cron 周期反复创建新的批量任务
适用建议:
- 想要“进程内一直守着等机会”:用持续批量任务
- 想要“每隔一段时间触发一次检查”:用 Cron 计划任务
如果模块页面没有走任务中心的“Cron 计划”创建入口,而是自己直接 `CreateTaskAsync(...)`,那它创建出来的就只是普通批量任务,不会自动变成计划任务。
### 6持续任务的停止条件要写清楚
模块作者最好明确区分以下几种结束原因:
- 用户主动暂停 / 取消
- 达到运行时长上限
- 所有目标都已处理完成
- 当前资源暂时不足,但后续可能恢复
其中最后一种很常见,比如:
- 暂时没有可用私密频道
- 目标接口限流
- 外部站点临时不可达
这类情况如果业务上允许后续继续等待,**不要直接结束任务**,而应该:
1. 写入错误/提示状态到 `Config`
2. 等待一段时间
3. 进入下一轮重试
示例:
```csharp
if (availableChannels.Count == 0)
{
config.Error = "当前没有可用私密频道";
await SaveConfigAsync(taskManagement, host.TaskId, config);
if (!await DelayWithPauseCheckAsync(host, TimeSpan.FromMinutes(5), cancellationToken))
return;
continue;
}
```
### 7给持续任务的一个实践建议
如果你的模块是“监控类任务”,推荐至少维护这些字段:
- `StartedAtUtc`
- `LastCheckTime`
- `Error`
- `Canceled`
- 业务游标(例如 `AssignedUsernames` / `HandledMessageIds` / `LastOffset`
这样无论是排错、前端展示,还是重启恢复,都会清晰很多。
## 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
```json
{
"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
```csharp
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.json`
- `lib/<entry assembly>.dll`(入口程序集)
> 小提示:如果你是“右键压缩整个文件夹”,压缩包里通常会多一层根目录(`<folder>/manifest.json`)。宿主会尝试自动识别并提升这一层;但更推荐直接把 `manifest.json` 和 `lib/` 放在压缩包根目录。
安装流程会先解压到 `staging/` 并做基础校验,然后移动到 `installed/<id>/<version>/`,并将原包存档到 `packages/<id>/<version>.tpm` 便于留档与回滚。
## 模块打包(可选)
仓库内提供了一个基于 Docker 的打包脚本(无需本机安装 `dotnet`),用于把任意模块项目打包为可上传的 `.tpm`
```powershell
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
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
powershell tools/package-module.ps1 -Project "src/YourModule/YourModule.csproj" -Manifest "src/YourModule/manifest.json" -SlimHost
```
## manifest.json示例
```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`
```csharp
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`
```csharp
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`:查看 `Error`
- `Reason` 仅用于日志或调试,不建议模块把它当成业务字段
示例(模块任务里调用宿主 AI 识别按钮):
```csharp
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 生成文本答案):
```csharp
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.AccountExportService`
- `TelegramPanel.Core.Services.AccountManagementService`
核心调用链:
1. 先通过 `AccountManagementService` 获取目标账号(或账号列表)
2. 调用 `AccountExportService.BuildAccountsZipAsync(accounts, ct, format)`
3.`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 导出的实现要点(后续扩展必须保持)
1. `session -> telethon string` 时必须保留 Base64 padding尾部 `=`
2. `telethon string -> tdata` 时必须注入 `session.self.userId`
3. 生成 `telethon string` 时要优先选择“已授权 DCSession”不是任意 DC
否则会出现“包结构看似正常,但 Telegram Desktop 仍要求重新登录”。
## UI 模块项目模板Razor 组件)
如果你的模块需要提供页面(`IModuleUiProvider.GetPages`),推荐把模块做成 `Microsoft.NET.Sdk.Razor` 项目(类似 Razor Class Library例如
```xml
<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`
```csharp
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
```csharp
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` 指向模块配置页:
```csharp
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结构
```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便于你按需读取更多上下文
也就是说,一个组件既可以用于“新建任务”,也可以用于“编辑已存在任务”。最小参数示例:
```razor
@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` 字段,可用于声明该任务在任务中心里希望暴露哪些操作能力:
```csharp
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`:编辑时使用的组件;为空时回退到 `EditorComponentType`
- `AutoPauseBeforeEdit`:如果任务仍在运行,宿主可先暂停再进入编辑
当前建议:
- 对“一次性批量任务”,通常只需要 `CanRerun = true`
- 对“持续任务/常驻任务”,通常建议同时声明 `CanPause / CanResume / CanEdit / CanRerun`
- 如果你把 `EditorComponentType``EditComponentType` 写成空字符串,宿主会在注册阶段自动规范为 `null`
> 注意:这组字段已经进入抽象层,并且内置持续任务已按此方式声明;外部模块也建议遵循相同结构,便于后续宿主统一扩展任务中心行为。
### 6) 宿主内置数据字典与模板变量(推荐优先复用)
如果你的模块任务需要“随机文案 / 队列文案 / 图片变量 / 标题模板 / 用户名模板”等能力,建议优先复用宿主已经内置的数据字典体系,而不是在模块里重复造一套词库配置。
当前宿主已经提供:
- 数据字典管理页面:`/data-dictionaries`
- 文本字典:返回 `string`
- 图片字典:返回图片资产引用(适合头像、图片消息等)
- 读取模式:`random` / `queue`
- 队列游标持久化:`queue` 模式的 `NextIndex` 会写入数据库,重启后继续
- 模板变量语法:固定为 `{name}`
- 内置变量:`{time}`(格式 `yyyyMMddHHmmss`
相关宿主服务:
- `TelegramPanel.Web.Services.DataDictionaryService`
- `TelegramPanel.Web.Services.TemplateRenderingService`
- `TelegramPanel.Web.Services.ImageAssetStorageService`
推荐用法:
```csharp
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`
```csharp
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`
```csharp
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组件不接受宿主注入的参数
```razor
@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 调用),进一步降低崩溃风险。