barryZZJ/command_handler

基于字符串解析的通用机器人交互框架

★ 0Forks 0PythonGitHub ↗Compare

README

基于字符串解析的机器人交互框架

通过对字符串的解析,执行第一个符合指定规则的回调函数。

基本逻辑参考telegrambot的各个handler, 包括了MessageHandler、CommandHandler和ConversationHandler。

此外,对CommandHandler增加了多级指令的支持。

说明

telegrambot示例参考

  • 构造输入字符串:Update(message=Message(1, datetime.now(), 'some user defined string'))

  • 回调函数定义:def callback(update: Update, context: CallbackContext)

    通常无返回值,只有与ConversationHandler使用时返回int。

    • 通过Update.message.text: str获取输入字符串

    • 使用CommandHandler时,通过context.args: List[str]获取指令对应的参数

    • 使用MessageHandler时,通过context.match: re.Match、context.matches: List[re.Match]获取正则filter的匹配结果

  • 支持的filters过滤器(主要用于MessageHandler):

    属性 说明
    ALL 任意内容
    TEXT 非空字符串
    Text(Iterable[str]) 包含指定子字符串
    Regex(pattern: Union[str, Pattern[str]]) 匹配正则表达式

    可以通过位运算符 (& for and, | for or, ~ for not) 连接多个filter。

处理普通消息,通过设置filter过滤消息字符串。

用法示例:

from datetime import datetime

from command_handler import Application, Update, Message, ContextTypes, User, Chat, filters
from command_handler import MessageHandler


def gender(update: Update, context: ContextTypes.DEFAULT_TYPE):
    print('input msg:', update.message.text)
    print('input gender is:', context.matches[0].group(1))


user = User(0, 'user1')
chat = Chat(0, username=user.username)

app = Application()

app.add_handler(MessageHandler(filters.Text(['I am', "I'm"]) | filters.Regex('(boy|girl)$'), gender))

update = Update(0, Message(0, datetime.now(), chat, user, 'I am a boy'))
app.process_update(update)
# input msg: I am a boy
# input gender is: boy

update2 = Update(1, Message(1, datetime.now(), chat, user, 'I am a girl'))
app.process_update(update2)
# input msg: I am a girl
# input gender is: girl

输入字符串作为指令处理,如果指令匹配,后续的部分视为参数列表,可通过回调函数的context.args获取。注:双引号(不含)括起来的部分不会被分割。

支持链式连接,以处理多级指令,如/git remote add <...> <...>

参数说明:

  • command: str | Collection[str] - 待识别的指令,需要明确指定是否以/开头,大小写不敏感,可以是列表。命名规则为:除开头的/外,其余为1-32位小写英文字母/数字/下划线_/短横线-,如果为空字符串则匹配任意内容。不能与sub_command_handlers同时使用。
  • sub_command_handlers: List[CommandHandler] - 下一级的需要匹配的指令,开头可以不包含'/',可以继续嵌套次级CommandHandler。不能与callback同时使用。
  • default: function (optional) - 默认回调函数。仅与sub_command_handlers同时使用,当所有的次级指令都不匹配时,执行该函数。从不匹配位置起的用户输入都将视为参数包含在context.args内。(注意有多个handler时default的设置是否符合要求,可以考虑额外设置一个handler兜底,或者分成多个group)

用法示例: 一级指令:

from datetime import datetime

from command_handler import Application, Update, Message, ContextTypes
from command_handler import CommandHandler

def set_gender(update: Update, context: ContextTypes.DEFAULT_TYPE):
    print(f'You are a {context.args[0]}.')

one_level_handler = CommandHandler('/set-gender', set_gender)

app = Application()
app.add_handler(one_level_handler)

msg = Update(message=Message(1, datetime.now(), text='/set-gender boy'))

app.process_update(msg)
# You are a boy.

多级指令:

from datetime import datetime

from command_handler import Application, Update, Message, ContextTypes, User, Chat
from command_handler import CommandHandler

def git_help(update: Update, context: ContextTypes.DEFAULT_TYPE):
    print('function: git_help')
    print('args:', str(context.args))

def git_add(update: Update, context: ContextTypes.DEFAULT_TYPE):
    print('function: git_add')
    print('args:', str(context.args))

def git_remote_help(update: Update, context: ContextTypes.DEFAULT_TYPE):
    print('function: git_remote_help')
    print('args:', str(context.args))

def git_remote_add(update: Update, context: ContextTypes.DEFAULT_TYPE):
    print('function: git_remote_add')
    print('args:', str(context.args))

multilevel_handler = CommandHandler(
    '/git',
    sub_command_handlers=[
        CommandHandler('add', git_add),
        CommandHandler(
            'remote',
            sub_command_handlers=[
                CommandHandler('add', git_remote_add)
            ],
            default=git_remote_help
        )
    ],
    default=git_help
)

user = User(0, 'user1')
chat = Chat(0, username=user.username)  # 注意chat id在一场对话中不能变

app = Application()
app.add_handler(multilevel_handler)

update = Update(0, Message(0, datetime.now(), chat, user, '/git add file.py'))
app.process_update(update)
# function: git_add
# args: ['file.py']

update2 = Update(1, Message(1, datetime.now(), chat, user, '/git remote add https://www.gihub.com/zhimengsub/command_handler.git'))
app.process_update(update2)
# function: git_remote_add
# args: ['https://www.gihub.com/zhimengsub/command_handler.git']

update3 = Update(2, Message(2, datetime.now(), chat, user, '/git not supported'))
app.process_update(update3)
# function: git_help
# args: ['not', 'supported']

update4 = Update(3, Message(3, datetime.now(), chat, user, '/git remote also not supported'))
app.process_update(update4)
# function: git_remote_help
# args: ['also', 'not', 'supported']

可用的参数:

  • entry_points
  • states
  • fallbacks
  • allow_reentry
  • per_chat
  • per_user
  • name
  • map_to_parent

使用方法与官方文档基本一致,可参考conversationbot。

此外,增加了通过context.current_state获取当前所在状态。

用法示例: (注意chat id在一场对话中不能变)

#!/usr/bin/env python
import logging
from datetime import datetime

from command_handler import Update, User, Chat, Message, filters
from command_handler import (
    Application,
    CommandHandler,
    ContextTypes,
    ConversationHandler,
    MessageHandler,
)

# Enable logging
logging.basicConfig(
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.DEBUG
)
logger = logging.getLogger(__name__)

GENDER, PHOTO, LOCATION, BIO = range(4)


def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
    """Starts the conversation and asks the user about their gender."""
    print(
        "Hi! My name is Professor Bot. I will hold a conversation with you. "
        "Send /cancel to stop talking to me.\n\n"
        "Are you a boy or a girl?",
    )

    return GENDER


def gender(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
    """Stores the selected gender and asks for a photo."""
    user = update.message.from_user
    logger.info("Gender of %s: %s", user.username, update.message.text)
    print(
        "I see! Please send me a photo of yourself, "
        "so I know what you look like, or send /skip if you don't want to.",
    )

    return PHOTO


def skip_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
    """Skips the photo and asks for a location."""
    user = update.message.from_user
    logger.info("User %s did not send a photo.", user.username)
    print(
        "I bet you look great! Now, send me your location please, or send /skip."
    )

    return LOCATION


def skip_location(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
    """Skips the location and asks for info about the user."""
    user = update.message.from_user
    logger.info("User %s did not send a location.", user.username)
    print(
        "You seem a bit paranoid! At last, tell me something about yourself."
    )

    return BIO


def bio(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
    """Stores the info about the user and ends the conversation."""
    user = update.message.from_user
    logger.info("Bio of %s: %s", user.username, update.message.text)
    print("Thank you! I hope we can talk again some day.")

    return ConversationHandler.END


def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
    """Cancels and ends the conversation."""
    user = update.message.from_user
    logger.info("User %s canceled the conversation.", user.username)
    print(
        "Bye! I hope we can talk again some day."
    )

    return ConversationHandler.END


def resend(update: Update, context: ContextTypes.DEFAULT_TYPE):
    print('wrong input at state', context.current_state, 'try again!')


def main() -> None:
    """Run the bot."""
    # Create the Application and pass it your bot's token.
    application = Application()

    # Add conversation handler with the states GENDER, PHOTO, LOCATION and BIO
    conv_handler = ConversationHandler(
        entry_points=[CommandHandler("/start", start)],
        states={
            GENDER: [MessageHandler(filters.Regex("^(Boy|Girl|Other)$"), gender)],
            PHOTO: [CommandHandler("/skip", skip_photo)],
            LOCATION: [
                CommandHandler("/skip", skip_location),
            ],
            BIO: [MessageHandler(filters.TEXT, bio)],
        },
        fallbacks=[CommandHandler("/cancel", cancel), MessageHandler(filters.TEXT, resend)],
    )

    application.add_handler(conv_handler)

    # Run the bot until the user presses Ctrl-C
    user = User(0, 'user1')
    chat = Chat(0, username=user.username)  # 注意chat id在一场对话中不能变

    msgs = [
        '/start',
        'bad input',
        'Boy',
        '/skip',
        '/skip',
        'some thing'
    ]

    for i, msg in enumerate(msgs):
        update = Update(i, Message(i, datetime.now(), chat, user, msg))
        application.process_update(update)


if __name__ == "__main__":
    main()

# 2023-06-11 23:31:49,656 - commandConversationHandler - DEBUG - Selecting conversation (0, 0) with state None
# 2023-06-11 23:31:49,656 - commandConversationHandler - DEBUG - Selecting conversation (0, 0) with state 0
# 2023-06-11 23:31:49,656 - commandConversationHandler - DEBUG - Selecting conversation (0, 0) with state 0
# 2023-06-11 23:31:49,656 - __main__ - INFO - Gender of user1: Boy
# 2023-06-11 23:31:49,656 - commandConversationHandler - DEBUG - Selecting conversation (0, 0) with state 1
# 2023-06-11 23:31:49,656 - __main__ - INFO - User user1 did not send a photo.
# 2023-06-11 23:31:49,657 - commandConversationHandler - DEBUG - Selecting conversation (0, 0) with state 2
# 2023-06-11 23:31:49,657 - __main__ - INFO - User user1 did not send a location.
# 2023-06-11 23:31:49,657 - commandConversationHandler - DEBUG - Selecting conversation (0, 0) with state 3
# 2023-06-11 23:31:49,657 - __main__ - INFO - Bio of user1: some thing
# Hi! My name is Professor Bot. I will hold a conversation with you. Send /cancel to stop talking to me.
# 
# Are you a boy or a girl?
# wrong input at state 0 try again!
# I see! Please send me a photo of yourself, so I know what you look like, or send /skip if you don't want to.
# I bet you look great! Now, send me your location please, or send /skip.
# You seem a bit paranoid! At last, tell me something about yourself.
# Thank you! I hope we can talk again some day.

设置定时任务

安装依赖:pip install apscheduler

用法与JobQueue一致,但基于BackgroundScheduler,通过context.job_queue获取。

用法示例:(app必须一直运行)

import logging
from datetime import datetime

from command_handler import Application, Update, CommandHandler, ContextTypes, Message, User, Chat

# Enable logging
logging.basicConfig(
    format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO
)


# Define a few command handlers. These usually take the two arguments update and
# context.
# Best practice would be to replace context with an underscore,
# since context is an unused local variable.
# This being an example and not having context present confusing beginners,
# we decided to have it present as context.
def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    """Sends explanation on how to use the bot."""
    print("Hi! Use /set <seconds> to set a timer")


def alarm(context: ContextTypes.DEFAULT_TYPE) -> None:
    """Send the alarm message."""
    job = context.job
    print(f"Beep! {job.data} seconds are over!")


def remove_job_if_exists(name: str, context: ContextTypes.DEFAULT_TYPE) -> bool:
    """Remove job with given name. Returns whether job was removed."""
    current_jobs = context.job_queue.get_jobs_by_name(name)
    if not current_jobs:
        return False
    for job in current_jobs:
        job.schedule_removal()
    return True


def set_timer(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    """Add a job to the queue."""
    chat_id = 0
    try:
        # args[0] should contain the time for the timer in seconds
        due = float(context.args[0])
        if due < 0:
            print("Sorry we can not go back to future!")
            return

        job_removed = remove_job_if_exists(str(chat_id), context)
        context.job_queue.run_once(alarm, due, chat_id=chat_id, name=str(chat_id), data=due)

        text = "Timer successfully set!"
        if job_removed:
            text += " Old one was removed."
        print(text)

    except (IndexError, ValueError):
        print("Usage: /set <seconds>")


def unset(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
    """Remove the job if the user changed their mind."""
    chat_id = 0
    job_removed = remove_job_if_exists(str(chat_id), context)
    text = "Timer successfully cancelled!" if job_removed else "You have no active timer."
    print(text)


def main() -> None:
    """Run bot."""
    # Create the Application and pass it your bot's token.
    application = Application()

    # on different commands - answer in Telegram
    application.add_handler(CommandHandler(["/start", "/help"], start))
    application.add_handler(CommandHandler("/set", set_timer))
    application.add_handler(CommandHandler("/unset", unset))

    application.start()
    
    user = User(0, 'user1')
    chat = Chat(0, username=user.username)
    
    update = Update(0, Message(0, datetime.now(), chat, user, '/set 5'))
    application.process_update(update)

if __name__ == "__main__":
    main()
    while True:
        pass

按指定类型解析参数

使用StringArgConverter可以初始化参数的类型、默认值、转换函数等,并在给定真实参数时解析。

具体说明见stringargconverter.py

使用示例:

import datetime
from command_handler import StringArgConverter

def cast_ints(value: str) -> tuple[int, ...]:
    """value: single int, or int separated by ','"""
    return tuple(map(int, value.replace(' ', '').split(',')))

def cast_time(value: str) -> datetime.time:
    # format: HHMMSS or HHMM
    try:
        # raise ValueError for wrong format
        return datetime.datetime.strptime(value, '%H%M').time()
    except ValueError:
        # raise ValueError for wrong format
        return datetime.datetime.strptime(value, '%H%M%S').time()

usage = StringArgConverter(
            '/sub daily <channel> <program> <ids>'
            '[excludeProgram] [detail] [checkTime] [days] [startTime] - 添加每日定时检查任务',
            # required args
            channel=(str,),
            program=(str,),
            # required args with custom cast function
            ids=(list[int], cast_ints),
            # positional args (need default value)
            excludeProgram=(str, None),
            detail=(str, '*'),
            # positional args with default value function evaluated at parsing, and custom cast function
            checkTime=(datetime.time, lambda: datetime.datetime.now().time(), cast_time),
            # positional args with default value and custom cast function
            days=(tuple[int],
                  tuple(range(0, 6+1)),
                  cast_ints),
            startTime=(datetime.time, None, cast_time),
        )
args = ['channel_name', 'program_name', '1,2,3', '[再]', 'days=3,4,5']
if not usage.check_arg_len(args):
    print(usage.usage)
    # Usage: /sub daily <channel> <program> <ids>[excludeProgram] [detail] [checkTime] [days] [startTime] - 添加每日定时检查任务
else:
    try:
        channel, program, ids, \
        excludeProgram, detail, checkTime, days, startTime = usage.parse_args(args)
    except (SyntaxError, TypeError, ValueError) as e:
        print('参数错误!')

TODO async version (JobQueue change to AsyncScheduler)

TODO error handling