nonebot-plugin-botui 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ """记录用的数据结构与序列化工具。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+ from dataclasses import field, dataclass
8
+
9
+ # 会话类型:多聊场景统一为 group,单聊为 private
10
+ KIND_GROUP = 'group'
11
+ KIND_PRIVATE = 'private'
12
+
13
+ # 消息方向:机器人收到 / 机器人发出
14
+ DIR_IN = 'in'
15
+ DIR_OUT = 'out'
16
+
17
+ # 各消息段在聊天列表里的简写
18
+ SEGMENT_LABELS: dict[str, str] = {
19
+ 'image': '[图片]',
20
+ 'voice': '[语音]',
21
+ 'audio': '[音频]',
22
+ 'video': '[视频]',
23
+ 'file': '[文件]',
24
+ 'at': '@',
25
+ 'face': '[表情]',
26
+ 'reply': '[回复]',
27
+ 'json': '[卡片]',
28
+ 'xml': '[卡片]',
29
+ 'forward': '[合并转发]',
30
+ 'music': '[音乐]',
31
+ 'poke': '[戳一戳]',
32
+ 'button': '[按钮]',
33
+ 'unknown': '[消息]',
34
+ }
35
+
36
+
37
+ def now_ts() -> float:
38
+ """当前时间戳(秒)"""
39
+ return time.time()
40
+
41
+
42
+ def chat_key(kind: str, chat_id: str, self_id: str) -> str:
43
+ """会话在数据库中的唯一键,形如 ``12345678:group_123456``。
44
+
45
+ 会话里内嵌机器人 ID,同一平台上的不同机器人各自维护一份会话,互不混淆。
46
+ """
47
+ return f'{self_id}:{kind}_{chat_id}'
48
+
49
+
50
+ def split_chat_key_parts(key: str) -> tuple[str, str, str]:
51
+ """把 ``12345678:group_123456`` 拆成 ``('12345678', 'group', '123456')``。
52
+
53
+ 会话 ID 本身可能含下划线(如 ``group_abc_def``),所以只按**第一个**
54
+ 冒号切分前缀、按**第一个**下划线切分会话类型。
55
+ """
56
+ self_id, _, body = key.partition(':')
57
+ kind, _, chat_id = body.partition('_')
58
+ return self_id, (kind or KIND_GROUP), chat_id
59
+
60
+
61
+ def split_chat_key(key: str) -> tuple[str, str]:
62
+ """把 ``12345678:group_123456`` 拆成 ``('group', '123456')``(丢掉机器人前缀)"""
63
+ _, kind, chat_id = split_chat_key_parts(key)
64
+ return kind, chat_id
65
+
66
+
67
+ def self_id_of(key: str) -> str:
68
+ """取出会话 key 里内嵌的机器人 ID"""
69
+ return split_chat_key_parts(key)[0]
70
+
71
+
72
+ def describe_segments(segments: list[dict[str, Any]]) -> str:
73
+ """把消息段概括成一行文本,用于聊天列表预览"""
74
+ parts: list[str] = []
75
+ for seg in segments:
76
+ stype = str(seg.get('type') or '')
77
+ if stype == 'text':
78
+ parts.append(str(seg.get('text') or ''))
79
+ elif stype == 'at':
80
+ target = str(seg.get('target') or '')
81
+ if target == 'all':
82
+ parts.append('@全体成员')
83
+ else:
84
+ parts.append(f'@{seg.get("name") or target}')
85
+ else:
86
+ parts.append(SEGMENT_LABELS.get(stype, '[消息]'))
87
+ text = ''.join(parts).strip()
88
+ return ' '.join(text.split())
89
+
90
+
91
+ @dataclass(slots=True)
92
+ class BotRecord:
93
+ """一个连接过的机器人账号(WebUI 右上角的切换器用它列机器人)"""
94
+
95
+ self_id: str
96
+ adapter: str = ''
97
+ scope: str = ''
98
+ name: str = ''
99
+ alias: str = ''
100
+ avatar: str = ''
101
+ online: bool = False
102
+ first_seen: float = 0.0
103
+ last_seen: float = 0.0
104
+
105
+ def to_dict(self) -> dict[str, Any]:
106
+ return {
107
+ 'self_id': self.self_id,
108
+ 'adapter': self.adapter or None,
109
+ 'scope': self.scope or None,
110
+ # name 是**展示名**(用户备注优先),raw_name 才是适配器给的名称
111
+ 'name': self.alias or self.name or self.self_id,
112
+ 'alias': self.alias or None,
113
+ 'raw_name': self.name or None,
114
+ 'avatar': self.avatar or None,
115
+ 'online': bool(self.online),
116
+ 'first_seen': self.first_seen,
117
+ 'last_seen': self.last_seen,
118
+ }
119
+
120
+
121
+ @dataclass(slots=True)
122
+ class ChatRecord:
123
+ """一个会话(群聊或私聊)"""
124
+
125
+ key: str
126
+ kind: str
127
+ chat_id: str
128
+ adapter: str = ''
129
+ scope: str = ''
130
+ self_id: str = ''
131
+ parent_id: str = ''
132
+ name: str = ''
133
+ alias: str = ''
134
+ avatar: str = ''
135
+ member_count: int | None = None
136
+ last_text: str = ''
137
+ last_at: float = 0.0
138
+ last_direction: str = ''
139
+ message_count: int = 0
140
+
141
+ def to_dict(self) -> dict[str, Any]:
142
+ return {
143
+ 'key': self.key,
144
+ 'kind': self.kind,
145
+ 'id': self.chat_id,
146
+ # name 是**展示名**(用户备注优先):适配器拿不到真实群名/昵称时,
147
+ # 用户手动设置的备注就顶上来;raw_name 保留适配器给的原始名称。
148
+ 'name': self.alias or self.name or self.chat_id,
149
+ 'alias': self.alias or None,
150
+ 'raw_name': self.name or None,
151
+ 'avatar': self.avatar or None,
152
+ 'adapter': self.adapter or None,
153
+ 'self_id': self.self_id or None,
154
+ 'parent_id': self.parent_id or None,
155
+ 'member_count': self.member_count,
156
+ 'last_text': self.last_text,
157
+ 'last_at': self.last_at,
158
+ 'last_direction': self.last_direction,
159
+ 'unread': 0,
160
+ 'message_count': self.message_count,
161
+ }
162
+
163
+
164
+ @dataclass(slots=True)
165
+ class MessageRecord:
166
+ """一条消息(机器人收到的或发出的)"""
167
+
168
+ chat_key: str
169
+ chat_kind: str
170
+ chat_id: str
171
+ direction: str
172
+ ts: float
173
+ adapter: str = ''
174
+ scope: str = ''
175
+ self_id: str = ''
176
+ parent_id: str = ''
177
+ chat_name: str = ''
178
+ chat_avatar: str = ''
179
+ member_count: int | None = None
180
+ user_id: str = ''
181
+ user_name: str = ''
182
+ user_avatar: str = ''
183
+ role: str | None = None
184
+ is_self: bool = False
185
+ text: str = ''
186
+ segments: list[dict[str, Any]] = field(default_factory=list)
187
+ message_id: str = ''
188
+ # 默认不可撤回:只有机器人自己发出、且拿到了消息 ID 的才可撤回
189
+ recallable: bool = False
190
+ api: str = ''
191
+ recalled: bool = False
192
+ row_id: int = 0
193
+
194
+ def __post_init__(self) -> None:
195
+ # 机器人自己发出、且拿到了消息 ID 的记录才算可撤回
196
+ if self.direction == DIR_OUT and self.message_id:
197
+ self.recallable = True
198
+
199
+ @property
200
+ def preview(self) -> str:
201
+ """用于聊天列表的预览文本"""
202
+ return self.text.strip() or describe_segments(self.segments)
203
+
204
+ def to_dict(self) -> dict[str, Any]:
205
+ return {
206
+ 'id': self.row_id,
207
+ 'chat': self.chat_key,
208
+ 'direction': self.direction,
209
+ 'time': self.ts,
210
+ 'user_id': self.user_id,
211
+ 'user_name': self.user_name or self.user_id,
212
+ 'user_avatar': self.user_avatar or None,
213
+ 'role': self.role,
214
+ 'self': self.is_self,
215
+ 'text': self.text,
216
+ 'message_id': self.message_id or None,
217
+ 'recallable': bool(self.recallable and self.direction == DIR_OUT),
218
+ 'recalled': self.recalled,
219
+ 'segments': self.segments,
220
+ 'adapter': self.adapter or None,
221
+ }
@@ -0,0 +1,36 @@
1
+ """插件用到的全部文件路径,统一由 nonebot-plugin-localstore 提供。
2
+
3
+ 为什么单独放一个模块:localstore 的 ``get_plugin_data_dir()`` 是靠**调用栈**
4
+ 反查「哪个插件在要目录」的(见它的 ``_get_caller_plugin``:逐层向上找帧,
5
+ 按模块名查到已注册的插件为止)。这带来两点麻烦:
6
+
7
+ 1. 只有栈上**存在**插件自己的帧时才查得到;从 ``__main__``、别名模块名或
8
+ 完全外部的回调里调用,会抛 ``RuntimeError: Cannot detect caller plugin``;
9
+ 2. 每次调用都要走一遍栈,结果取决于「谁在调」,属于隐式上下文依赖。
10
+
11
+ 把路径在**插件模块导入时**解析一次并存成常量,就绕开了这两点:插件加载时
12
+ 栈上必然是插件自己的帧,之后全项目只读这几个常量,行为稳定。
13
+
14
+ 存储位置完全交给 localstore 决定,可用它的配置项覆盖,例如::
15
+
16
+ LOCALSTORE_DATA_DIR=/srv/nonebot/data
17
+ LOCALSTORE_PLUGIN_DATA_DIR={"nonebot_plugin_botui": "/srv/botui/data"}
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from pathlib import Path
23
+
24
+ from nonebot_plugin_localstore import (
25
+ get_plugin_data_dir,
26
+ get_plugin_data_file,
27
+ )
28
+
29
+ #: 插件数据目录(localstore 的目录函数自带 mkdir,这里不必再补)
30
+ DATA_DIR: Path = get_plugin_data_dir()
31
+ #: SQLite 数据库文件
32
+ DB_FILE: Path = get_plugin_data_file('botui.sqlite3')
33
+ #: 自动生成的访问令牌文件
34
+ TOKEN_FILE: Path = get_plugin_data_file('token.txt')
35
+ #: 媒体库目录:机器人收到的、WebUI 上传的图片/文件都在这里(见 mediastore.py)
36
+ BLOB_DIR: Path = DATA_DIR / 'blobs'