Files
shirobana-bot/bot-utility/notebook.py
T
RRRosmontis 34089d0042 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.
2026-11-11 00:25:30 +08:00

428 lines
17 KiB
Python
Raw 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.
# -*- 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",
]