Initial import of Shirobana bot backend
Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""SQLite 笔记本:私聊备忘录/待办与群聊作用域隔离,零第三方依赖。
|
||||
|
||||
部署位置:bot-utility/notebook.py。笔记本只保存和展示记录,不发送定时提醒。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Iterator
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DB_PATH = os.environ.get("NOTEBOOK_DB_PATH", os.path.join(BASE_DIR, "notebook.db"))
|
||||
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||
MAX_CONTENT_LENGTH = 2000
|
||||
_ID_PREFIX = re.compile(r"^[0-9a-fA-F]{1,8}$")
|
||||
|
||||
HELP_TEXT = """笔记本用法:
|
||||
- 添加备忘录:add_memo(ctx, 内容);查看:list_memos(ctx, 可选关键词)。
|
||||
- 添加待办:add_todo(ctx, 内容, 到期时间ISO8601, 可选过期时间ISO8601)。
|
||||
- 删除备忘录/待办、完成待办时使用本会话内唯一的 8 位 hex ID 前缀。
|
||||
- 待办默认在到期后 24 小时过期;过期且未完成的待办会被物理删除。
|
||||
说明:笔记本只记录、查询和提供必要的私聊背景,不会到点发送通知;需要定时提醒请使用提醒/通知功能。"""
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS memo_notes(
|
||||
id TEXT PRIMARY KEY,
|
||||
uid TEXT NOT NULL DEFAULT '',
|
||||
channel TEXT NOT NULL DEFAULT '',
|
||||
msg_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL CHECK(length(content) <= 2000),
|
||||
created_at REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS memo_notes_scope_created
|
||||
ON memo_notes(uid, channel, msg_type, target_id, created_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS todo_items(
|
||||
id TEXT PRIMARY KEY,
|
||||
uid TEXT NOT NULL DEFAULT '',
|
||||
channel TEXT NOT NULL DEFAULT '',
|
||||
msg_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL CHECK(length(content) <= 2000),
|
||||
due_at REAL NOT NULL,
|
||||
expires_at REAL NOT NULL,
|
||||
created_at REAL NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'completed')),
|
||||
completed_at REAL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS todo_items_scope_due
|
||||
ON todo_items(uid, channel, msg_type, target_id, status, due_at);
|
||||
CREATE INDEX IF NOT EXISTS todo_items_expiry
|
||||
ON todo_items(status, expires_at);
|
||||
"""
|
||||
|
||||
|
||||
def _timestamp(value: float | int | None = None) -> float:
|
||||
return time.time() if value is None else float(value)
|
||||
|
||||
|
||||
def _format_time(epoch: float | int) -> str:
|
||||
return datetime.fromtimestamp(float(epoch), SHANGHAI).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def _parse_iso8601(value: str) -> float:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ValueError("时间必须是非空 ISO8601 字符串")
|
||||
text = value.strip()
|
||||
if text.endswith(("Z", "z")):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"无效 ISO8601 时间: {value}") from exc
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=SHANGHAI)
|
||||
return parsed.timestamp()
|
||||
|
||||
|
||||
def _content(value: str) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise TypeError("内容必须是字符串")
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("内容不能为空")
|
||||
if len(value) > MAX_CONTENT_LENGTH:
|
||||
raise ValueError("内容不能超过 2000 个字符")
|
||||
return value
|
||||
|
||||
|
||||
def _memo_from_row(row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {"id": row["id"], "content": row["content"], "created_at": row["created_at"]}
|
||||
|
||||
|
||||
def _todo_from_row(row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"content": row["content"],
|
||||
"due_at": row["due_at"],
|
||||
"expires_at": row["expires_at"],
|
||||
"created_at": row["created_at"],
|
||||
"status": row["status"],
|
||||
"completed_at": row["completed_at"],
|
||||
}
|
||||
|
||||
|
||||
def format_memo(memo: Any) -> str:
|
||||
"""把备忘录记录格式化为单行中文文本。"""
|
||||
return f"[{memo['id']}] {memo['content']}(记录于 {_format_time(memo['created_at'])})"
|
||||
|
||||
|
||||
def format_todo(todo: Any, now: float | int | None = None) -> str:
|
||||
"""把待办记录格式化为单行中文文本,并为当前已逾期项加标记。"""
|
||||
current = _timestamp(now)
|
||||
if todo["status"] == "completed":
|
||||
state = "已完成"
|
||||
elif todo["due_at"] <= current:
|
||||
state = "已逾期"
|
||||
else:
|
||||
state = "待完成"
|
||||
return f"[{todo['id']}] {todo['content']}|到期 {_format_time(todo['due_at'])}|{state}"
|
||||
|
||||
|
||||
class Notebook:
|
||||
"""线程安全的 SQLite 笔记本;首次实际操作时才创建/打开数据库。"""
|
||||
|
||||
def __init__(self, db_path: str | os.PathLike[str] = DB_PATH):
|
||||
self.db_path = os.fspath(db_path)
|
||||
self._lock = threading.RLock()
|
||||
self._con: sqlite3.Connection | None = None
|
||||
|
||||
def _get_connection(self) -> sqlite3.Connection:
|
||||
if self._con is not None:
|
||||
return self._con
|
||||
if self.db_path != ":memory:":
|
||||
parent = os.path.dirname(os.path.abspath(self.db_path))
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
con = sqlite3.connect(self.db_path, timeout=30, check_same_thread=False)
|
||||
con.row_factory = sqlite3.Row
|
||||
con.execute("PRAGMA journal_mode=WAL")
|
||||
con.executescript(_SCHEMA)
|
||||
con.commit()
|
||||
self._con = con
|
||||
return con
|
||||
|
||||
@contextmanager
|
||||
def _db(self) -> Iterator[sqlite3.Connection]:
|
||||
with self._lock:
|
||||
con = self._get_connection()
|
||||
with con:
|
||||
yield con
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭当前连接;后续操作会按原路径自动重连。"""
|
||||
with self._lock:
|
||||
if self._con is not None:
|
||||
self._con.close()
|
||||
self._con = None
|
||||
|
||||
@staticmethod
|
||||
def _scope(ctx: Any) -> tuple[str, str, str, str]:
|
||||
"""返回 (uid, channel, msg_type, target_id),私聊只由 uid 决定。"""
|
||||
msg_type = str(getattr(ctx, "msg_type", "") or "")
|
||||
if msg_type == "private":
|
||||
# ToolContext 使用 user_id;uid 兼容旧调用方。
|
||||
uid = getattr(ctx, "user_id", None)
|
||||
if uid is None:
|
||||
uid = getattr(ctx, "uid", None)
|
||||
if uid is None or str(uid) == "":
|
||||
raise ValueError("私聊上下文缺少 user_id")
|
||||
return str(uid), "", "private", ""
|
||||
raw_channel = getattr(ctx, "channel", "")
|
||||
# ToolContext.channel 是带 name 的对象,也兼容直接传字符串。
|
||||
channel = str(getattr(raw_channel, "name", raw_channel) or "")
|
||||
target_id = getattr(ctx, "target_id", None)
|
||||
if not channel or not msg_type or target_id is None or str(target_id) == "":
|
||||
raise ValueError("群聊上下文必须包含 channel、msg_type、target_id")
|
||||
return "", channel, msg_type, str(target_id)
|
||||
|
||||
@staticmethod
|
||||
def _scope_sql(scope: tuple[str, str, str, str]) -> tuple[str, tuple[str, ...]]:
|
||||
return "uid=? AND channel=? AND msg_type=? AND target_id=?", scope
|
||||
|
||||
@staticmethod
|
||||
def _new_id(con: sqlite3.Connection, table: str) -> str:
|
||||
# table 只由本模块传入常量,不能来自用户输入。
|
||||
for _ in range(20):
|
||||
item_id = secrets.token_hex(4)
|
||||
if con.execute(f"SELECT 1 FROM {table} WHERE id=?", (item_id,)).fetchone() is None:
|
||||
return item_id
|
||||
raise RuntimeError("无法分配唯一 ID")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_id(
|
||||
con: sqlite3.Connection,
|
||||
table: str,
|
||||
prefix: str,
|
||||
scope: tuple[str, str, str, str],
|
||||
) -> str | None:
|
||||
if not isinstance(prefix, str) or not _ID_PREFIX.fullmatch(prefix):
|
||||
raise ValueError("ID 前缀必须是 1 到 8 位 hex")
|
||||
where, params = Notebook._scope_sql(scope)
|
||||
rows = con.execute(
|
||||
f"SELECT id FROM {table} WHERE {where} AND id LIKE ? ORDER BY id",
|
||||
(*params, prefix.lower() + "%"),
|
||||
).fetchall()
|
||||
if len(rows) > 1:
|
||||
raise ValueError("ID 前缀在本会话中不唯一,请输入更多字符")
|
||||
return rows[0]["id"] if rows else None
|
||||
|
||||
def add_memo(self, ctx: Any, content: str) -> dict[str, Any]:
|
||||
scope = self._scope(ctx)
|
||||
content = _content(content)
|
||||
created_at = time.time()
|
||||
with self._db() as con:
|
||||
item_id = self._new_id(con, "memo_notes")
|
||||
con.execute(
|
||||
"""INSERT INTO memo_notes
|
||||
(id,uid,channel,msg_type,target_id,content,created_at)
|
||||
VALUES (?,?,?,?,?,?,?)""",
|
||||
(item_id, *scope, content, created_at),
|
||||
)
|
||||
return {"id": item_id, "content": content, "created_at": created_at}
|
||||
|
||||
def list_memos(
|
||||
self, ctx: Any, query: str | None = None, limit: int = 50
|
||||
) -> list[dict[str, Any]]:
|
||||
scope = self._scope(ctx)
|
||||
limit = int(limit)
|
||||
if limit <= 0:
|
||||
return []
|
||||
where, params = self._scope_sql(scope)
|
||||
sql = f"SELECT id,content,created_at FROM memo_notes WHERE {where}"
|
||||
values: tuple[Any, ...] = params
|
||||
if query is not None and str(query).strip():
|
||||
sql += " AND content LIKE ?"
|
||||
values += (f"%{str(query).strip()}%",)
|
||||
sql += " ORDER BY created_at DESC,id DESC LIMIT ?"
|
||||
values += (limit,)
|
||||
with self._db() as con:
|
||||
return [_memo_from_row(row) for row in con.execute(sql, values).fetchall()]
|
||||
|
||||
def delete_memo(self, ctx: Any, id_prefix: str) -> dict[str, Any]:
|
||||
scope = self._scope(ctx)
|
||||
with self._db() as con:
|
||||
item_id = self._resolve_id(con, "memo_notes", id_prefix, scope)
|
||||
if item_id is None:
|
||||
raise ValueError("未找到该备忘录,或无权在当前会话操作")
|
||||
row = con.execute(
|
||||
"SELECT id,content,created_at FROM memo_notes WHERE id=?", (item_id,)
|
||||
).fetchone()
|
||||
con.execute("DELETE FROM memo_notes WHERE id=?", (item_id,))
|
||||
return _memo_from_row(row)
|
||||
|
||||
def add_todo(
|
||||
self, ctx: Any, content: str, due_at: str, expires_at: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
scope = self._scope(ctx)
|
||||
content = _content(content)
|
||||
due = _parse_iso8601(due_at)
|
||||
now = time.time()
|
||||
if due <= now:
|
||||
raise ValueError("到期时间必须在未来")
|
||||
expires = due + 24 * 3600 if expires_at is None else _parse_iso8601(expires_at)
|
||||
if expires <= due:
|
||||
raise ValueError("过期时间必须晚于到期时间")
|
||||
with self._db() as con:
|
||||
item_id = self._new_id(con, "todo_items")
|
||||
con.execute(
|
||||
"""INSERT INTO todo_items
|
||||
(id,uid,channel,msg_type,target_id,content,due_at,expires_at,
|
||||
created_at,status,completed_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,'active',NULL)""",
|
||||
(item_id, *scope, content, due, expires, now),
|
||||
)
|
||||
return {
|
||||
"id": item_id,
|
||||
"content": content,
|
||||
"due_at": due,
|
||||
"expires_at": expires,
|
||||
"created_at": now,
|
||||
"status": "active",
|
||||
"completed_at": None,
|
||||
}
|
||||
|
||||
def list_todos(
|
||||
self, ctx: Any, include_completed: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
# 读取待办前也清理,避免已过期 active 项继续出现。
|
||||
self.cleanup_expired()
|
||||
scope = self._scope(ctx)
|
||||
where, params = self._scope_sql(scope)
|
||||
sql = (
|
||||
"SELECT id,content,due_at,expires_at,created_at,status,completed_at "
|
||||
f"FROM todo_items WHERE {where}"
|
||||
)
|
||||
if not include_completed:
|
||||
sql += " AND status='active'"
|
||||
sql += " ORDER BY due_at ASC,created_at ASC,id ASC"
|
||||
with self._db() as con:
|
||||
return [_todo_from_row(row) for row in con.execute(sql, params).fetchall()]
|
||||
|
||||
def complete_todo(self, ctx: Any, id_prefix: str) -> dict[str, Any]:
|
||||
scope = self._scope(ctx)
|
||||
now = time.time()
|
||||
# Commit expiry before resolving the ID; raising for a missing item must
|
||||
# not roll its deletion back. The UPDATE predicate separately closes
|
||||
# the race with another notebook instance at the expiry boundary.
|
||||
self.cleanup_expired(now)
|
||||
with self._db() as con:
|
||||
item_id = self._resolve_id(con, "todo_items", id_prefix, scope)
|
||||
if item_id is None:
|
||||
raise ValueError("未找到该待办,或无权在当前会话操作")
|
||||
cur = con.execute(
|
||||
"""UPDATE todo_items SET status='completed',completed_at=?
|
||||
WHERE id=? AND status='active' AND expires_at>?""",
|
||||
(now, item_id, now),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise ValueError("该待办已完成,不能重复完成")
|
||||
row = con.execute(
|
||||
"""SELECT id,content,due_at,expires_at,created_at,status,completed_at
|
||||
FROM todo_items WHERE id=?""",
|
||||
(item_id,),
|
||||
).fetchone()
|
||||
return _todo_from_row(row)
|
||||
|
||||
def delete_todo(self, ctx: Any, id_prefix: str) -> dict[str, Any]:
|
||||
scope = self._scope(ctx)
|
||||
with self._db() as con:
|
||||
item_id = self._resolve_id(con, "todo_items", id_prefix, scope)
|
||||
if item_id is None:
|
||||
raise ValueError("未找到该待办,或无权在当前会话操作")
|
||||
row = con.execute(
|
||||
"""SELECT id,content,due_at,expires_at,created_at,status,completed_at
|
||||
FROM todo_items WHERE id=?""",
|
||||
(item_id,),
|
||||
).fetchone()
|
||||
con.execute("DELETE FROM todo_items WHERE id=?", (item_id,))
|
||||
return _todo_from_row(row)
|
||||
|
||||
def cleanup_expired(self, now: float | int | None = None) -> int:
|
||||
"""物理删除已过期的 active 待办,保留已完成记录。"""
|
||||
current = _timestamp(now)
|
||||
with self._db() as con:
|
||||
cur = con.execute(
|
||||
"DELETE FROM todo_items WHERE status='active' AND expires_at<=?",
|
||||
(current,),
|
||||
)
|
||||
return cur.rowcount
|
||||
|
||||
async def cleanup_worker(self, interval: float = 3600) -> None:
|
||||
"""持续清理;单次存储异常不会终止后台循环。"""
|
||||
while True:
|
||||
try:
|
||||
self.cleanup_expired()
|
||||
except Exception:
|
||||
# 后台清理没有可靠的通知通道,失败时等待下一轮重试。
|
||||
pass
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
def context_for_chat(
|
||||
self, uid: Any, query: str = "", now: float | int | None = None
|
||||
) -> str:
|
||||
"""生成仅用于私聊的紧凑背景;无记录时返回空字符串。
|
||||
|
||||
query 是当前整句聊天,仅为 API 兼容保留,不能用它 LIKE 过滤背景。
|
||||
"""
|
||||
current = _timestamp(now)
|
||||
self.cleanup_expired(current)
|
||||
scope = (str(uid), "", "private", "")
|
||||
where, params = self._scope_sql(scope)
|
||||
|
||||
memo_sql = (
|
||||
f"SELECT id,content,created_at FROM memo_notes WHERE {where} "
|
||||
"ORDER BY created_at DESC,id DESC LIMIT 3"
|
||||
)
|
||||
todo_sql = (
|
||||
"SELECT id,content,due_at,expires_at,created_at,status,completed_at "
|
||||
f"FROM todo_items WHERE {where} AND status='active' "
|
||||
"AND expires_at>? AND due_at<=? "
|
||||
"ORDER BY due_at ASC,created_at ASC,id ASC LIMIT 3"
|
||||
)
|
||||
todo_params: tuple[Any, ...] = (*params, current, current + 72 * 3600)
|
||||
|
||||
with self._db() as con:
|
||||
memos = [_memo_from_row(row) for row in con.execute(memo_sql, params)]
|
||||
todos = [_todo_from_row(row) for row in con.execute(todo_sql, todo_params)]
|
||||
if not memos and not todos:
|
||||
return ""
|
||||
|
||||
lines = [
|
||||
"【笔记本背景】",
|
||||
"⚠️ 重要规则:这些仅是背景,只有用户询问或直接相关时才可参考;"
|
||||
"不主动重复、汇报或改变话题;当前聊天优先,不能喧宾夺主。",
|
||||
]
|
||||
if memos:
|
||||
lines.append("备忘录:")
|
||||
lines.extend(f"- {memo['content']}" for memo in memos)
|
||||
if todos:
|
||||
lines.append("近期待办:")
|
||||
for todo in todos:
|
||||
overdue = "【已逾期】" if todo["due_at"] <= current else ""
|
||||
lines.append(
|
||||
f"- {overdue}{todo['content']}|到期 {_format_time(todo['due_at'])}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# 惰性打开数据库:导入本模块不会创建 notebook.db。
|
||||
notebook = Notebook()
|
||||
|
||||
__all__ = [
|
||||
"Notebook",
|
||||
"notebook",
|
||||
"format_memo",
|
||||
"format_todo",
|
||||
"HELP_TEXT",
|
||||
]
|
||||
Reference in New Issue
Block a user