Skip to content

新功能提议 #367

@FlanChanXwO

Description

@FlanChanXwO

详细描述

  1. 增加像nonebot2那样的命令过滤器,对于一些简单的命令,我觉得写正则太麻烦了。针对于该命令过滤器应支持配置多个命令前缀,以灵活匹配命令,以及增加命令分隔符,使得你可以更方便提取消息
  2. 增加一个等待用户输入的状态处理器
  3. 希望大改目前的插件装配方式,现在的插件编写,你需要继承BotPlugin类,我希望后续能够像nonebot2那样基于注解式开发插件
  4. 尽管 shiro 并不提供状态管理,但是我觉得可以让插件类继承一个名为PluginMetaInfo的类/接口, 又或是通过一个额外的注解来添加元信息,来暴露你的插件的元信息,比如说作者,使用方式,甚至包括你的插件使用到的配置类
  5. 插件热重载,这可能需要修改现有的插件管理器类才可以实现

基本示例

1. 命令过滤器

你可以配置命令前缀prefixs和分隔符splits

shiro:
    command:
       prefixs: ["/", ""]
       splits: [",","|"," "]

代码示例

@Component
@Shiro
@Slf4j
public class TestPlugin {

    @AnyMessageHandler
    @CommandHandlerFilter(types = {MsgTypeEnum.reply, MsgTypeEnum.text}, cmd = {"echo", "测试"}) //会匹配 "/echo","echo",同时提取时可以通过"/echo,测试消息"和"/echo 测试消息"
    public void test(Bot bot, AnyMessageEvent messageEvent , CommandArgs args) {
        log.info("{}", messageEvent);
        String reply =  args.get(0);
        bot.sendMsg(messageEvent, reply ,false);
    }
}

2. 状态处理器 Conversation

使用编程式开发

package com.mikuac.shiro.test;

import com.mikuac.shiro.annotation.AnyMessageHandler;
import com.mikuac.shiro.annotation.MessageHandlerFilter;
import com.mikuac.shiro.annotation.common.Shiro;
import com.mikuac.shiro.core.Bot;
import com.mikuac.shiro.dto.event.message.AnyMessageEvent;
import com.mikuac.shiro.core.Conversation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Component
@Shiro
@Slf4j
public class WeatherPlugin {

    /**
     * 天气查询流程
     */
    @AnyMessageHandler
    @MessageHandlerFilter(cmd = "^/天气$")
    public void queryWeather(Bot bot, AnyMessageEvent event,  Matcher matcher, Conversation conv) {
        // 1. 获取参数 "city"
        // 逻辑:如果当前 event 已经包含参数(例如 /天气 北京),则直接获取
        // 如果没有,Conversation 会自动发送 prompt 提示,并挂起当前线程,等待用户下一条消息
        String city = conv.got("city", "请输入您想查询的城市:");
        // 此时代码继续执行,说明已经拿到了 city
        // 如果用户发了 "/天气",这里会暂停,bot 发送提示,用户回 "上海",这里 city 就是 "上海"
        // 2. 甚至可以连续 got (多轮对话),你还可以设置最大超时时间,如果超时了,则会抛出异常给开发者自己处理
        String date = conv.got("date", "请输入查询日期(今天/明天):", TimeUnit.SECONDS, 3);
        // 3. 业务逻辑
        log.info("正在查询 {} 的 {} 天气...", city, date);
        String weatherResult = mockWeatherApi(city, date);
        // 4. 发送最终结果
        conv.finish(weatherResult); // finish 代表结束当前会话
    }

    // 模拟 API
    private String mockWeatherApi(String city, String date) {
        return String.format("【%s】%s的天气是:晴转多云,25℃", city, date);
    }
}

使用注解式开发

为了更好使用注解式开发,我们需要让 shiro 框架注入 ConversationContext类来管理状态上下文,以及 @State 来跟踪这些上下文的流向 (注意,@State注解不可用跨类,只能在同个类下与其它状态输入合作)

package com.mikuac.shiro.test;

import com.mikuac.shiro.annotation.AnyMessageHandler;
import com.mikuac.shiro.annotation.MessageHandlerFilter;
import com.mikuac.shiro.annotation.State; 
import com.mikuac.shiro.enums.StateEventType; 
import com.mikuac.shiro.annotation.common.Shiro;
import com.mikuac.shiro.core.Bot;
import com.mikuac.shiro.core.ConversationContext; 
import com.mikuac.shiro.dto.event.message.AnyMessageEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Component
@Shiro
@Slf4j
public class WeatherPlugin {

    // 定义状态常量
    private static final String STATE_WAIT_CITY = "WAIT_CITY";
    private static final String STATE_WAIT_DATE = "WAIT_DATE";

    /**
     * 第一步:入口
     * 触发命令:/天气
     */
    @AnyMessageHandler
    @MessageHandlerFilter(cmd = "^/天气$")
    public void startWeatherFlow(Bot bot, AnyMessageEvent event, ConversationContext ctx) {
        // 1. 发送提示
        bot.sendMsg(event, "请输入您想查询的城市:", false);
        
        // 2. 将用户置入 "WAIT_CITY" 状态
        // 该方法结束后,框架会记录:UserId=123, State=WAIT_CITY, HandlerClass=WeatherPlugin
        ctx.nextState(STATE_WAIT_CITY); 
    }

    /**
     * 第二步:处理城市输入
     * 只有当用户处于 WAIT_CITY 状态,且消息发送到当前类时,才会触发此方法
     */
    @State(value = STATE_WAIT_CITY)
    public void handleCityInput(Bot bot, AnyMessageEvent event, ConversationContext ctx) {
        String city = event.getMessage(); // 获取用户输入的城市

        // 校验逻辑(可选)
        if (city.length() > 10) {
            bot.sendMsg(event, "城市名称太长啦,请重新输入:", false);
            ctx.keepState(); // 保持当前状态,等待下一次输入
            return;
        }

        // 保存数据到上下文,供后续步骤使用
        ctx.put("city", city);

        bot.sendMsg(event, "收到,请输入查询日期(例如:今天):", false);
        
        // 进入下一个状态
        ctx.nextState(STATE_WAIT_DATE);
    }

    /**
     * 第三步:处理日期输入 & 结束
     */
    @State(value = STATE_WAIT_DATE)
    public void handleDateInput(Bot bot, AnyMessageEvent event, ConversationContext ctx) {
        String date = event.getMessage();
        
        // 从上下文中取出上一步存的 city
        String city = ctx.get("city", String.class);
        
        log.info("查询天气: city={}, date={}", city, date);
        
        // 模拟业务逻辑
        String result = String.format("【%s】%s的天气是:大雨转暴雨 🌧️", city, date);
        bot.sendMsg(event, result, false);

        // 结束流程,清除状态
        ctx.finish(); 
    }

    // 如果你不想为整个状态或者单独的状态单独定义一个超时处理器,那么你可以这样
    @State(value = STATE_WAIT_DATE, timeout = 30)
    public void handleDateInput(Bot bot, AnyMessageEvent event, ConversationContext ctx, StateEventType type) {
        // 1. 先判断事件类型
        if (type == StateEventType.TIMEOUT) {
            bot.sendMsg(event, "等太久啦,天气查询已取消。", false);
            ctx.finish(); // 结束状态
            return;
        }
      
        // 2. 正常处理消息逻辑
        String date = event.getMessage();
        String city = ctx.get("city", String.class);
          
        bot.sendMsg(event, "查询结果: " + city + date + " 天气不错", false);
        ctx.finish();
     }

    
    /**
     * 可选:处理超时(比如用户 30秒 没理你)
     * 这里的 value 可以是一个特殊标记或者通用配置
     */
    @State(value = STATE_WAIT_CITY, type = StateEventType.TIMEOUT, timeunit = TimeUnit.SECONDS , timeout = 30)
    public void onTimeout(Bot bot, AnyMessageEvent event) {
        bot.sendMsg(event, "输入超时,天气查询已取消。", false);
    }
}

3. 更好的外部插件开发方式,基于注解

@Component
@Shiro
@Slf4j
public class TestPlugin { // 你不再需要去继承`BotPlugin`类了

    @AnyMessageHandler
    @MessageHandlerFilter(types = {MsgTypeEnum.reply, MsgTypeEnum.text}, cmd = "^/echo$")
    public void test(Bot bot, AnyMessageEvent messageEvent , Matcher matcher) {
        log.info("{}", messageEvent);
        log.info("rawmsg = {}", messageEvent.getRawMessage());
        log.info("msg = {}", messageEvent.getMessage());
    }
}

4. 插件元信息,对于一款规范的插件,应该具有插件元信息

/**
 * 标注插件元信息
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Shiro
@Component
public @interface Plugin {
    /**
     *  插件名称
     */
    String name();
    // 插件描述信息
    String description() default "暂无描述";
    // 版本
    String version() default "1.0.0";
    // 作者
    String author() default ""; 
    // 用法
    String usage() default "";
    // 其它信息
    Map<String,String> extra() default Map.of();
    /**
     * 指定该插件对应的配置类
     * 必须继承 BasePluginConfig
     * 默认为 Void.class 表示该插件没有配置
     */
    Class<? extends BasePluginConfig> config() default BasePluginConfig.None.class;
}


/* 
 *  复读机插件
 */
@Plugin(
        name = "复读机",
        description = "提供复读跟随功能和群消息复读功能。",
        version = "1.0.0",
        author = "FlanChan",
        usage = "使用\"/开启复读跟随\"开启复读,机器人会复读你的消息"
        extra = Map.of("homepage", "https://xxxx"),
        config = RepeaterConfig.class // 你可以暴露你的配置类,这使得其它插件或者shiro框架能够获取你的插件配置信息,如果可以的话,甚至可以让shiro框架帮你修改配置信息
)
public class RepeaterPlugin {}

如果该插件需要暴露自己使用的配置类,那么配置类必须去继承配置基类,该配置类还可以基于注解来描述自己的配置类信息

/**
 * 插件配置的基类 (规范)
 * 所有暴露的插件配置类必须继承此方类。
 */
@Data
public abstract class BasePluginConfig {

    public static final class None extends BasePluginConfig {} // 防止没有指定配置类的时候出现空指针
}

/**
 * 标记一个类为插件的配置类
 */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Component
public @interface PluginConfig {
    String value() default "";  
}

/**
 * 标记字段为具体的配置项
 */
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ConfigItem {
    String key();           // 相对 Key,如 "enabled" (实际存储为 prefix.key)

    String description();   // 描述,比如你可用于 WebUI 显示

    String defaultValue();  // 默认值 (String 形式)
}

@Data
@PluginConfig("复读机配置")
public class RepeaterConfig extends BasePluginConfig {

    @ConfigItem(key = "limit", defaultValue = "10", description = "连续复读次数限制")
    private Integer limit;
}

5. 插件热重载

开启热重载

可以让shiro框架热重载插件

shiro:
    plugins:
        plugin-dir: "/plugins" # 插件路径
        hotreload: true # 默认为false

需要注意的是,尽管有热重载,但是如果插件作者自己本身的代码在释放资源这方面不太行的话,那么就会造成内存泄漏的问题,shiro 框架只能帮你去取消注册spring容器,调用jvm进行gc清除那些内存,如果该插件使用了线程池,有一些正在运行中的线程,热重载开启后,如果插件被卸载了,那么线程不会被清理,这需要开发者自己定义销毁资源的方法

插件卸载时必须要合理释放资源

private ExecutorService threadPool;
private volatile boolean running = true;


    @PostConstruct
    public void init() {
        log.info("插件组件初始化...");
        threadPool = Executors.newVirtualThreadPerTaskExecutor();
        
        threadPool.submit(() -> {
            while (running) {
                try {
                    Thread.sleep(1000);
                    log.info("插件后台任务运行中...");
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        });
    }

    /**
     * 当 jar 包被移除 -> context.close() 被调用 -> 此方法自动触发
     */
    @PreDestroy
    public void cleanup() {
        log.info("检测到插件卸载,正在清理资源...");
        
        // 1. 停止标志位
        this.running = false;
        
        // 2. 关闭线程池
        if (threadPool != null) {
            threadPool.shutdownNow();
        }
        
        log.info("资源清理完毕!");
    }

其它
基于 shiro 框架的注解式开发应该更加易用,以及我有能力贡献这些功能/特性
此外,如果加入了这些新的功能/特性,那么想必也需要在文档中进行说明,可否允许我获得文档编辑的权限,这些功能的使用方式将由我来编写

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions