diff --git a/.vscode/settings.json b/.vscode/settings.json index 642ff51..b0d64e0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,5 @@ { - "python.REPL.enableREPLSmartSend": false + "python.REPL.enableREPLSmartSend": false, + "python-envs.defaultEnvManager": "ms-python.python:poetry", + "python-envs.defaultPackageManager": "ms-python.python:poetry" } \ No newline at end of file diff --git a/konabot/plugins/syntactic_sugar/__init__.py b/konabot/plugins/syntactic_sugar/__init__.py new file mode 100644 index 0000000..f3088c6 --- /dev/null +++ b/konabot/plugins/syntactic_sugar/__init__.py @@ -0,0 +1,210 @@ +import copy +import re +from pathlib import Path + +import nonebot +from nonebot import on_command +from nonebot.adapters import Bot, Event, Message +from nonebot.log import logger +from nonebot.message import handle_event +from nonebot.params import CommandArg + +from konabot.common.database import DatabaseManager +from konabot.common.longtask import DepLongTaskTarget + +ROOT_PATH = Path(__file__).resolve().parent + +cmd = on_command(cmd="语法糖", aliases={"糖", "sugar"}, block=True) + +db_manager = DatabaseManager() +driver = nonebot.get_driver() + + +@driver.on_startup +async def register_startup_hook(): + await init_db() + + +@driver.on_shutdown +async def register_shutdown_hook(): + await db_manager.close_all_connections() + + +async def init_db(): + await db_manager.execute_by_sql_file(ROOT_PATH / "sql" / "create_table.sql") + + table_info = await db_manager.query("PRAGMA table_info(syntactic_sugar)") + columns = {str(row.get("name")) for row in table_info} + if "channel_id" not in columns: + await db_manager.execute( + "ALTER TABLE syntactic_sugar ADD COLUMN channel_id VARCHAR(255) NOT NULL DEFAULT ''" + ) + + await db_manager.execute("DROP INDEX IF EXISTS idx_syntactic_sugar_name_belong_to") + await db_manager.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_syntactic_sugar_name_channel_target " + "ON syntactic_sugar(name, channel_id, belong_to)" + ) + + +def _extract_reply_plain_text(evt: Event) -> str: + reply = getattr(evt, "reply", None) + if reply is None: + return "" + + reply_message = getattr(reply, "message", None) + if reply_message is None: + return "" + + extract_plain_text = getattr(reply_message, "extract_plain_text", None) + if callable(extract_plain_text): + return extract_plain_text().strip() + return str(reply_message).strip() + + +def _split_variables(tokens: list[str]) -> tuple[list[str], dict[str, str]]: + positional: list[str] = [] + named: dict[str, str] = {} + + for token in tokens: + if "=" in token: + key, value = token.split("=", 1) + key = key.strip() + if key: + named[key] = value + continue + positional.append(token) + + return positional, named + + +def _render_template(content: str, positional: list[str], named: dict[str, str]) -> str: + def replace(match: re.Match[str]) -> str: + key = match.group(1).strip() + if key.isdigit(): + idx = int(key) - 1 + if 0 <= idx < len(positional): + return positional[idx] + return match.group(0) + return named.get(key, match.group(0)) + + return re.sub(r"\{([^{}]+)\}", replace, content) + + +async def _store_sugar(name: str, content: str, belong_to: str, channel_id: str): + await db_manager.execute_by_sql_file( + ROOT_PATH / "sql" / "insert_sugar.sql", + (name, content, belong_to, channel_id), + ) + + +async def _delete_sugar(name: str, belong_to: str, channel_id: str): + await db_manager.execute( + "DELETE FROM syntactic_sugar WHERE name = ? AND belong_to = ? AND channel_id = ?", + (name, belong_to, channel_id), + ) + + +async def _find_sugar(name: str, belong_to: str, channel_id: str) -> str | None: + rows = await db_manager.query( + ( + "SELECT content FROM syntactic_sugar " + "WHERE name = ? AND channel_id = ? " + "ORDER BY CASE WHEN belong_to = ? THEN 0 ELSE 1 END, id ASC " + "LIMIT 1" + ), + (name, channel_id, belong_to), + ) + if not rows: + return None + return rows[0].get("content") + + +async def _reinject_command(bot: Bot, evt: Event, command_text: str) -> bool: + depth = int(getattr(evt, "_syntactic_sugar_depth", 0)) + if depth >= 3: + return False + + try: + cloned_evt = copy.deepcopy(evt) + except Exception: + logger.exception("语法糖克隆事件失败") + return False + + message = getattr(cloned_evt, "message", None) + if message is None: + return False + + try: + msg_obj = type(message)(command_text) + except Exception: + msg_obj = command_text + + setattr(cloned_evt, "message", msg_obj) + if hasattr(cloned_evt, "original_message"): + setattr(cloned_evt, "original_message", msg_obj) + if hasattr(cloned_evt, "raw_message"): + setattr(cloned_evt, "raw_message", command_text) + + setattr(cloned_evt, "_syntactic_sugar_depth", depth + 1) + + try: + await handle_event(bot, cloned_evt) + except Exception: + logger.exception("语法糖回注事件失败") + return False + return True + + +@cmd.handle() +async def _(bot: Bot, evt: Event, target: DepLongTaskTarget, args: Message = CommandArg()): + raw = args.extract_plain_text().strip() + if not raw: + return + + tokens = raw.split() + action = tokens[0] + target_id = target.target_id + channel_id = target.channel_id + + if action == "存入": + if len(tokens) < 2: + await cmd.finish("请提供要存入的名称") + name = tokens[1].strip() + content = " ".join(tokens[2:]).strip() + if not content: + content = _extract_reply_plain_text(evt) + if not content: + await cmd.finish("请提供要存入的内容") + + await _store_sugar(name, content, target_id, channel_id) + await cmd.finish(f"糖已存入:「{name}」!") + + if action == "删除": + if len(tokens) < 2: + await cmd.finish("请提供要删除的名称") + name = tokens[1].strip() + await _delete_sugar(name, target_id, channel_id) + await cmd.finish(f"已删除糖:「{name}」!") + + if action == "查看": + if len(tokens) < 2: + await cmd.finish("请提供要查看的名称") + name = tokens[1].strip() + content = await _find_sugar(name, target_id, channel_id) + if content is None: + await cmd.finish(f"没有糖:「{name}」") + await cmd.finish(f"糖的内容:「{content}」") + + + name = action + content = await _find_sugar(name, target_id, channel_id) + if content is None: + await cmd.finish(f"没有糖:「{name}」") + + positional, named = _split_variables(tokens[1:]) + rendered = _render_template(content, positional, named) + + ok = await _reinject_command(bot, evt, rendered) + if not ok: + await cmd.finish(f"糖的展开结果:「{rendered}」") diff --git a/konabot/plugins/syntactic_sugar/sql/create_table.sql b/konabot/plugins/syntactic_sugar/sql/create_table.sql new file mode 100644 index 0000000..26d0e75 --- /dev/null +++ b/konabot/plugins/syntactic_sugar/sql/create_table.sql @@ -0,0 +1,12 @@ +-- 创建语法糖表 +CREATE TABLE IF NOT EXISTS syntactic_sugar ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + belong_to VARCHAR(255) NOT NULL, + channel_id VARCHAR(255) NOT NULL DEFAULT '' +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_syntactic_sugar_name_channel_target +ON syntactic_sugar(name, channel_id, belong_to); \ No newline at end of file diff --git a/konabot/plugins/syntactic_sugar/sql/insert_sugar.sql b/konabot/plugins/syntactic_sugar/sql/insert_sugar.sql new file mode 100644 index 0000000..3a9a423 --- /dev/null +++ b/konabot/plugins/syntactic_sugar/sql/insert_sugar.sql @@ -0,0 +1,5 @@ +-- 插入语法糖,如果同一用户下名称已存在则更新内容 +INSERT INTO syntactic_sugar (name, content, belong_to, channel_id) +VALUES (?, ?, ?, ?) +ON CONFLICT(name, channel_id, belong_to) DO UPDATE SET + content = excluded.content; \ No newline at end of file