nonebot-plugin-smart-message-storage 1.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.
- nonebot_plugin_smart_message_storage/__init__.py +40 -0
- nonebot_plugin_smart_message_storage/config.py +30 -0
- nonebot_plugin_smart_message_storage/constants.py +17 -0
- nonebot_plugin_smart_message_storage/db.py +15 -0
- nonebot_plugin_smart_message_storage/handlers/__init__.py +7 -0
- nonebot_plugin_smart_message_storage/handlers/notices.py +110 -0
- nonebot_plugin_smart_message_storage/handlers/recognize.py +139 -0
- nonebot_plugin_smart_message_storage/handlers/search.py +111 -0
- nonebot_plugin_smart_message_storage/handlers/store.py +62 -0
- nonebot_plugin_smart_message_storage/models.py +22 -0
- nonebot_plugin_smart_message_storage/prompt.py +44 -0
- nonebot_plugin_smart_message_storage/services/__init__.py +2 -0
- nonebot_plugin_smart_message_storage/services/contacts.py +31 -0
- nonebot_plugin_smart_message_storage/services/context.py +138 -0
- nonebot_plugin_smart_message_storage/services/image_tasks.py +46 -0
- nonebot_plugin_smart_message_storage/services/images.py +69 -0
- nonebot_plugin_smart_message_storage/services/message_utils.py +45 -0
- nonebot_plugin_smart_message_storage/services/pending.py +321 -0
- nonebot_plugin_smart_message_storage/vision.py +129 -0
- nonebot_plugin_smart_message_storage-1.1.0.dist-info/METADATA +990 -0
- nonebot_plugin_smart_message_storage-1.1.0.dist-info/RECORD +24 -0
- nonebot_plugin_smart_message_storage-1.1.0.dist-info/WHEEL +4 -0
- nonebot_plugin_smart_message_storage-1.1.0.dist-info/entry_points.txt +2 -0
- nonebot_plugin_smart_message_storage-1.1.0.dist-info/licenses/LICENSE +674 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
from nonebot import get_driver, require
|
|
5
|
+
from nonebot.log import logger
|
|
6
|
+
from nonebot.plugin import PluginMetadata
|
|
7
|
+
|
|
8
|
+
require("nonebot_plugin_localstore")
|
|
9
|
+
|
|
10
|
+
from .config import MessageStorageConfig
|
|
11
|
+
from .db import init_db
|
|
12
|
+
from .services.pending import start_stale_flush_loop
|
|
13
|
+
|
|
14
|
+
__plugin_meta__ = PluginMetadata(
|
|
15
|
+
name="智能消息存储",
|
|
16
|
+
description="支持群聊/私聊消息归档、检索和 AI 图片理解总结的 NoneBot2 插件。",
|
|
17
|
+
usage=(
|
|
18
|
+
"/查消息 关键词\n"
|
|
19
|
+
"/查消息 群号 关键词\n"
|
|
20
|
+
"/识别\n"
|
|
21
|
+
"/立即识别\n"
|
|
22
|
+
"/立即识别 全部"
|
|
23
|
+
),
|
|
24
|
+
type="application",
|
|
25
|
+
config=MessageStorageConfig,
|
|
26
|
+
supported_adapters={"~onebot.v11"},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
init_db()
|
|
30
|
+
|
|
31
|
+
driver = get_driver()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@driver.on_startup
|
|
35
|
+
async def _startup() -> None:
|
|
36
|
+
start_stale_flush_loop()
|
|
37
|
+
logger.debug("Smart message storage plugin started.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
from . import handlers as handlers
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from nonebot import get_plugin_config
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MessageStorageConfig(BaseModel):
|
|
11
|
+
ai_base_url: str = "https://api.exesim.com/v1"
|
|
12
|
+
ai_api_key: str = ""
|
|
13
|
+
ai_model: str = "gemini-3.5-flash"
|
|
14
|
+
image_batch_size: int = 5
|
|
15
|
+
image_flush_seconds: int = 30 * 60
|
|
16
|
+
image_context_before_chars: int = 100
|
|
17
|
+
image_context_after_chars: int = 100
|
|
18
|
+
db_url: str = "sqlite:///qq_messages.db"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
config = get_plugin_config(MessageStorageConfig)
|
|
22
|
+
|
|
23
|
+
config.ai_base_url = os.getenv("AI_BASE_URL", config.ai_base_url)
|
|
24
|
+
config.ai_api_key = os.getenv("AI_API_KEY", config.ai_api_key)
|
|
25
|
+
config.ai_model = os.getenv("AI_MODEL", config.ai_model)
|
|
26
|
+
config.db_url = os.getenv("DB_URL", config.db_url)
|
|
27
|
+
config.image_batch_size = int(os.getenv("IMAGE_BATCH_SIZE", config.image_batch_size))
|
|
28
|
+
config.image_flush_seconds = int(os.getenv("IMAGE_FLUSH_SECONDS", config.image_flush_seconds))
|
|
29
|
+
config.image_context_before_chars = int(os.getenv("IMAGE_CONTEXT_BEFORE_CHARS", config.image_context_before_chars))
|
|
30
|
+
config.image_context_after_chars = int(os.getenv("IMAGE_CONTEXT_AFTER_CHARS", config.image_context_after_chars))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
from nonebot.log import logger
|
|
5
|
+
from nonebot_plugin_localstore import get_data_dir
|
|
6
|
+
|
|
7
|
+
from .config import config
|
|
8
|
+
|
|
9
|
+
DATA_DIR = get_data_dir("nonebot_plugin_smart_message_storage")
|
|
10
|
+
IMAGE_CACHE_DIR = DATA_DIR / "image_cache"
|
|
11
|
+
PENDING_FILE = DATA_DIR / "pending_images.json"
|
|
12
|
+
|
|
13
|
+
IMAGE_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
|
|
15
|
+
logger.info(f"Smart message storage data dir: {DATA_DIR}")
|
|
16
|
+
if not config.ai_api_key:
|
|
17
|
+
logger.info("Smart message storage AI image recognition is disabled: ai_api_key is not configured.")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import create_engine
|
|
5
|
+
from sqlalchemy.orm import sessionmaker
|
|
6
|
+
|
|
7
|
+
from .config import config
|
|
8
|
+
from .models import Base
|
|
9
|
+
|
|
10
|
+
engine = create_engine(config.db_url, echo=False, future=True)
|
|
11
|
+
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def init_db() -> None:
|
|
15
|
+
Base.metadata.create_all(bind=engine)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from nonebot import on_notice
|
|
7
|
+
from nonebot.adapters.onebot.v11 import (
|
|
8
|
+
Bot,
|
|
9
|
+
FriendRecallNoticeEvent,
|
|
10
|
+
GroupDecreaseNoticeEvent,
|
|
11
|
+
GroupIncreaseNoticeEvent,
|
|
12
|
+
GroupRecallNoticeEvent,
|
|
13
|
+
PokeNotifyEvent,
|
|
14
|
+
)
|
|
15
|
+
from nonebot.log import logger
|
|
16
|
+
|
|
17
|
+
from ..db import SessionLocal
|
|
18
|
+
from ..models import GroupMessage
|
|
19
|
+
from ..services.contacts import get_display_name
|
|
20
|
+
from ..services.message_utils import conversation_group_id
|
|
21
|
+
|
|
22
|
+
notice_logger = on_notice(priority=100)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_poke_raw_message(event: PokeNotifyEvent, target_name: str) -> str:
|
|
26
|
+
action = "戳了戳"
|
|
27
|
+
suffix = ""
|
|
28
|
+
raw_info = event.dict().get("raw_info") or []
|
|
29
|
+
texts = [
|
|
30
|
+
item["txt"]
|
|
31
|
+
for item in raw_info
|
|
32
|
+
if isinstance(item, dict) and item.get("type") == "nor" and item.get("txt")
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
if texts:
|
|
36
|
+
action = texts[0]
|
|
37
|
+
if len(texts) >= 2:
|
|
38
|
+
suffix = "".join(texts[1:])
|
|
39
|
+
if not texts:
|
|
40
|
+
action = getattr(event, "action", "戳了戳")
|
|
41
|
+
suffix = getattr(event, "suffix", "")
|
|
42
|
+
|
|
43
|
+
return f"[戳一戳]{action}{event.target_id}({target_name}){suffix}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_notice_raw_message(event) -> str:
|
|
47
|
+
if isinstance(event, GroupDecreaseNoticeEvent):
|
|
48
|
+
if event.operator_id == event.user_id:
|
|
49
|
+
return f"[退群]用户{event.user_id}自己退群"
|
|
50
|
+
return f"[被踢]用户{event.user_id}被{event.operator_id}移出本群"
|
|
51
|
+
|
|
52
|
+
if isinstance(event, GroupIncreaseNoticeEvent):
|
|
53
|
+
return f"[加群]用户{event.user_id}由{event.operator_id}同意加入本群"
|
|
54
|
+
|
|
55
|
+
if isinstance(event, GroupRecallNoticeEvent):
|
|
56
|
+
return f"[撤回]消息{event.message_id}被用户{event.operator_id}撤回"
|
|
57
|
+
|
|
58
|
+
if isinstance(event, FriendRecallNoticeEvent):
|
|
59
|
+
return f"[撤回]消息{event.message_id}被用户{event.user_id}撤回"
|
|
60
|
+
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@notice_logger.handle()
|
|
65
|
+
async def log_notice_event(bot: Bot, event):
|
|
66
|
+
if not isinstance(
|
|
67
|
+
event,
|
|
68
|
+
(
|
|
69
|
+
PokeNotifyEvent,
|
|
70
|
+
GroupDecreaseNoticeEvent,
|
|
71
|
+
GroupIncreaseNoticeEvent,
|
|
72
|
+
GroupRecallNoticeEvent,
|
|
73
|
+
FriendRecallNoticeEvent,
|
|
74
|
+
),
|
|
75
|
+
):
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
group_id = conversation_group_id(event)
|
|
79
|
+
session = SessionLocal()
|
|
80
|
+
try:
|
|
81
|
+
if isinstance(event, PokeNotifyEvent):
|
|
82
|
+
target_name = await get_display_name(bot, event.target_id, group_id)
|
|
83
|
+
raw_message = build_poke_raw_message(event, target_name)
|
|
84
|
+
else:
|
|
85
|
+
raw_message = build_notice_raw_message(event)
|
|
86
|
+
|
|
87
|
+
if not raw_message:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
sender_nickname = await get_display_name(bot, event.user_id, group_id)
|
|
91
|
+
sender_card = sender_nickname if group_id == -1 else ""
|
|
92
|
+
msg = GroupMessage(
|
|
93
|
+
time=datetime.now(),
|
|
94
|
+
self_id=event.self_id,
|
|
95
|
+
user_id=event.user_id,
|
|
96
|
+
group_id=group_id,
|
|
97
|
+
raw_message=raw_message,
|
|
98
|
+
sender_nickname=sender_nickname,
|
|
99
|
+
sender_card=sender_card,
|
|
100
|
+
message_id=-1,
|
|
101
|
+
reply_id=-1,
|
|
102
|
+
)
|
|
103
|
+
session.add(msg)
|
|
104
|
+
session.commit()
|
|
105
|
+
logger.debug(f"[DB] 已记录 notice 到消息表: group_id={group_id}, raw_message={raw_message}")
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.error(f"[DB] 保存 notice 到消息表失败: {e}")
|
|
108
|
+
session.rollback()
|
|
109
|
+
finally:
|
|
110
|
+
session.close()
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
from nonebot import get_driver, on_command
|
|
5
|
+
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, Message, MessageEvent, PrivateMessageEvent
|
|
6
|
+
from nonebot.log import logger
|
|
7
|
+
from nonebot.params import CommandArg
|
|
8
|
+
|
|
9
|
+
from ..db import SessionLocal
|
|
10
|
+
from ..models import GroupMessage
|
|
11
|
+
from ..services.image_tasks import collect_pending_images
|
|
12
|
+
from ..services.message_utils import IMAGE_CQ_RE, conversation_group_id
|
|
13
|
+
from ..services.pending import flush_pending
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _reply_command_rule(event: MessageEvent) -> bool:
|
|
17
|
+
return isinstance(event, MessageEvent) and _has_reply(event)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
recognize_now = on_command("立即识别", priority=5, block=True)
|
|
21
|
+
recognize_reply = on_command("识别", priority=5, block=True, rule=_reply_command_rule)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@recognize_now.handle()
|
|
25
|
+
async def handle_recognize_now(event: MessageEvent, args: Message = CommandArg()):
|
|
26
|
+
text = str(args).strip()
|
|
27
|
+
group_id = conversation_group_id(event)
|
|
28
|
+
|
|
29
|
+
if text == "全部":
|
|
30
|
+
superusers = {str(user) for user in get_driver().config.superusers}
|
|
31
|
+
if str(event.user_id) not in superusers:
|
|
32
|
+
await recognize_now.finish("只有 SUPERUSERS 可以执行全局识别。")
|
|
33
|
+
count = await flush_pending(reason="command_all", all_conversations=True)
|
|
34
|
+
await recognize_now.finish(f"已提交全局待识别图片 {count} 张。")
|
|
35
|
+
|
|
36
|
+
if not isinstance(event, (GroupMessageEvent, PrivateMessageEvent)):
|
|
37
|
+
await recognize_now.finish("当前会话不支持立即识别。")
|
|
38
|
+
|
|
39
|
+
count = await flush_pending(reason="command", group_id=group_id, user_id=event.user_id)
|
|
40
|
+
await recognize_now.finish(f"已提交当前会话待识别图片 {count} 张。")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@recognize_reply.handle()
|
|
44
|
+
async def handle_recognize_reply(bot: Bot, event: MessageEvent):
|
|
45
|
+
reply_message_id = _get_reply_message_id(event)
|
|
46
|
+
if reply_message_id is None:
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
msg = _get_stored_message(reply_message_id)
|
|
50
|
+
if not msg:
|
|
51
|
+
await recognize_reply.finish("没有在数据库中找到回复的消息。")
|
|
52
|
+
|
|
53
|
+
raw_message = msg.raw_message or ""
|
|
54
|
+
if not IMAGE_CQ_RE.search(raw_message):
|
|
55
|
+
if _has_recognized_image(raw_message):
|
|
56
|
+
await _like_command(bot, event, "320")
|
|
57
|
+
await recognize_reply.finish(raw_message)
|
|
58
|
+
await recognize_reply.finish("回复的消息似乎不包含图片哦")
|
|
59
|
+
|
|
60
|
+
await _like_command(bot, event, "314")
|
|
61
|
+
reply_message = await _get_reply_message(bot, event, reply_message_id, raw_message)
|
|
62
|
+
if reply_message:
|
|
63
|
+
await collect_pending_images(bot, reply_message_id, reply_message, msg)
|
|
64
|
+
|
|
65
|
+
await flush_pending(reason="reply_command", group_id=msg.group_id, user_id=msg.user_id)
|
|
66
|
+
refreshed = _get_stored_message(reply_message_id)
|
|
67
|
+
await recognize_reply.finish((refreshed.raw_message if refreshed else raw_message) or raw_message)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _has_reply(event: MessageEvent) -> bool:
|
|
71
|
+
if event.reply:
|
|
72
|
+
return True
|
|
73
|
+
return any(seg.type == "reply" for seg in event.message)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _get_reply_message_id(event: MessageEvent) -> int | None:
|
|
77
|
+
if event.reply:
|
|
78
|
+
return event.reply.message_id
|
|
79
|
+
for seg in event.message:
|
|
80
|
+
if seg.type != "reply":
|
|
81
|
+
continue
|
|
82
|
+
try:
|
|
83
|
+
return int(seg.data["id"])
|
|
84
|
+
except Exception:
|
|
85
|
+
logger.warning(f"读取回复消息 ID 失败: {seg.data}")
|
|
86
|
+
return None
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def _get_reply_message(bot: Bot, event: MessageEvent, message_id: int, fallback_raw_message: str) -> Message | None:
|
|
91
|
+
if event.reply and event.reply.message:
|
|
92
|
+
return event.reply.message
|
|
93
|
+
|
|
94
|
+
for seg in event.message:
|
|
95
|
+
if seg.type == "reply" and str(seg.data.get("id", "")) == str(message_id):
|
|
96
|
+
try:
|
|
97
|
+
msg = await bot.get_msg(message_id=message_id)
|
|
98
|
+
return Message(msg["message"])
|
|
99
|
+
except Exception as e:
|
|
100
|
+
logger.opt(exception=e).warning(f"通过 get_msg 获取回复消息失败: message_id={message_id}")
|
|
101
|
+
break
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
return Message(fallback_raw_message)
|
|
105
|
+
except Exception as e:
|
|
106
|
+
logger.opt(exception=e).warning(f"从数据库 raw_message 解析回复消息失败: message_id={message_id}")
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _get_stored_message(message_id: int) -> GroupMessage | None:
|
|
111
|
+
session = SessionLocal()
|
|
112
|
+
try:
|
|
113
|
+
return (
|
|
114
|
+
session.query(GroupMessage)
|
|
115
|
+
.filter(GroupMessage.message_id == message_id)
|
|
116
|
+
.order_by(GroupMessage.id.desc())
|
|
117
|
+
.first()
|
|
118
|
+
)
|
|
119
|
+
finally:
|
|
120
|
+
session.close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _has_recognized_image(raw_message: str) -> bool:
|
|
124
|
+
return "[image:{" in raw_message
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def _like_command(bot: Bot, event: MessageEvent, emoji_id: str) -> None:
|
|
128
|
+
if not isinstance(event, GroupMessageEvent):
|
|
129
|
+
return
|
|
130
|
+
try:
|
|
131
|
+
await bot.call_api(
|
|
132
|
+
"set_msg_emoji_like",
|
|
133
|
+
group_id=event.group_id,
|
|
134
|
+
message_id=event.message_id,
|
|
135
|
+
emoji_id=emoji_id,
|
|
136
|
+
set=True,
|
|
137
|
+
)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logger.opt(exception=e).warning("贴表情失败")
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import io
|
|
6
|
+
import re
|
|
7
|
+
import textwrap
|
|
8
|
+
|
|
9
|
+
from nonebot import on_command
|
|
10
|
+
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, Message, MessageEvent, MessageSegment, PrivateMessageEvent
|
|
11
|
+
from nonebot.exception import FinishedException
|
|
12
|
+
from nonebot.log import logger
|
|
13
|
+
from nonebot.params import CommandArg
|
|
14
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
15
|
+
from sqlalchemy import Integer, cast, desc
|
|
16
|
+
|
|
17
|
+
from ..db import SessionLocal
|
|
18
|
+
from ..models import GroupMessage
|
|
19
|
+
from ..services.message_utils import conversation_group_id
|
|
20
|
+
|
|
21
|
+
search_message = on_command("查消息", priority=5)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def text_to_base64_image(lines: list[str]) -> str:
|
|
25
|
+
font = ImageFont.truetype("simkai.ttf", size=20)
|
|
26
|
+
wrapped_lines = []
|
|
27
|
+
for line in lines:
|
|
28
|
+
wrapped = textwrap.wrap(line, width=80)
|
|
29
|
+
if not wrapped:
|
|
30
|
+
wrapped = [""]
|
|
31
|
+
wrapped_lines.extend(wrapped)
|
|
32
|
+
|
|
33
|
+
line_height = 30
|
|
34
|
+
img_width = 1200
|
|
35
|
+
img_height = max(100, len(wrapped_lines) * line_height + 40)
|
|
36
|
+
img = Image.new("RGB", (img_width, img_height), "white")
|
|
37
|
+
draw = ImageDraw.Draw(img)
|
|
38
|
+
y = 20
|
|
39
|
+
for line in wrapped_lines:
|
|
40
|
+
draw.text((20, y), line, fill="black", font=font)
|
|
41
|
+
y += line_height
|
|
42
|
+
|
|
43
|
+
buffer = io.BytesIO()
|
|
44
|
+
img.save(buffer, format="PNG")
|
|
45
|
+
return "base64://" + base64.b64encode(buffer.getvalue()).decode()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@search_message.handle()
|
|
49
|
+
async def handle_search(bot: Bot, event: MessageEvent, args: Message = CommandArg()):
|
|
50
|
+
if not isinstance(event, (GroupMessageEvent, PrivateMessageEvent)):
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
args_text = str(args).strip()
|
|
54
|
+
if not args_text:
|
|
55
|
+
await search_message.finish("用法:/查消息 关键词\n或\n/查消息 群号 关键词")
|
|
56
|
+
|
|
57
|
+
if isinstance(event, GroupMessageEvent):
|
|
58
|
+
try:
|
|
59
|
+
await bot.call_api(
|
|
60
|
+
"set_msg_emoji_like",
|
|
61
|
+
group_id=event.group_id,
|
|
62
|
+
message_id=event.message_id,
|
|
63
|
+
emoji_id="269",
|
|
64
|
+
set=True,
|
|
65
|
+
)
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.warning(f"贴表情失败: {e}")
|
|
68
|
+
|
|
69
|
+
target_group_id = conversation_group_id(event)
|
|
70
|
+
target_user_id = event.user_id
|
|
71
|
+
keyword = args_text
|
|
72
|
+
|
|
73
|
+
m = re.match(r"^(\d{8,11})\s+(.+)$", args_text)
|
|
74
|
+
if m:
|
|
75
|
+
target_group_id = int(m.group(1))
|
|
76
|
+
target_user_id = None
|
|
77
|
+
keyword = m.group(2).strip()
|
|
78
|
+
|
|
79
|
+
if not keyword:
|
|
80
|
+
await search_message.finish("请输入关键词")
|
|
81
|
+
|
|
82
|
+
session = SessionLocal()
|
|
83
|
+
try:
|
|
84
|
+
query = session.query(GroupMessage).filter(
|
|
85
|
+
GroupMessage.group_id == cast(target_group_id, Integer),
|
|
86
|
+
GroupMessage.raw_message.like(f"%{keyword}%"),
|
|
87
|
+
)
|
|
88
|
+
if target_group_id == -1 and target_user_id is not None:
|
|
89
|
+
query = query.filter(GroupMessage.user_id == target_user_id)
|
|
90
|
+
|
|
91
|
+
results = query.order_by(desc(GroupMessage.time)).limit(100).all()
|
|
92
|
+
if not results:
|
|
93
|
+
await search_message.finish("七七翻遍了消息也没找到这个关键词哇/(ㄒoㄒ)/~~")
|
|
94
|
+
|
|
95
|
+
lines = []
|
|
96
|
+
for msg in reversed(results):
|
|
97
|
+
name = msg.sender_card or msg.sender_nickname
|
|
98
|
+
lines.append(
|
|
99
|
+
f"[{msg.time.strftime('%Y-%m-%d %H:%M:%S')}] "
|
|
100
|
+
f"{name}({msg.user_id}): "
|
|
101
|
+
f"{msg.raw_message}"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
await search_message.finish(MessageSegment.image(text_to_base64_image(lines)))
|
|
105
|
+
except FinishedException:
|
|
106
|
+
pass
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.error(f"查消息失败: {e}")
|
|
109
|
+
await search_message.finish("查询失败")
|
|
110
|
+
finally:
|
|
111
|
+
session.close()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from nonebot import on_message
|
|
7
|
+
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, MessageEvent, PrivateMessageEvent
|
|
8
|
+
from nonebot.log import logger
|
|
9
|
+
|
|
10
|
+
from ..config import config
|
|
11
|
+
from ..db import SessionLocal
|
|
12
|
+
from ..models import GroupMessage
|
|
13
|
+
from ..services.contacts import get_display_name
|
|
14
|
+
from ..services.image_tasks import collect_pending_images
|
|
15
|
+
from ..services.message_utils import conversation_group_id, raw_message_text
|
|
16
|
+
from ..services.pending import maybe_flush_batch_pending
|
|
17
|
+
|
|
18
|
+
message_logger = on_message(priority=100)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@message_logger.handle()
|
|
22
|
+
async def log_message(bot: Bot, event: MessageEvent):
|
|
23
|
+
if not isinstance(event, (GroupMessageEvent, PrivateMessageEvent)):
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
group_id = conversation_group_id(event)
|
|
27
|
+
session = SessionLocal()
|
|
28
|
+
msg = None
|
|
29
|
+
try:
|
|
30
|
+
reply_id = None
|
|
31
|
+
if event.reply:
|
|
32
|
+
reply_id = event.reply.message_id
|
|
33
|
+
|
|
34
|
+
sender_nickname = getattr(event.sender, "nickname", "") or await get_display_name(bot, event.user_id, group_id)
|
|
35
|
+
sender_card = getattr(event.sender, "card", "") or ""
|
|
36
|
+
raw_message = raw_message_text(event)
|
|
37
|
+
|
|
38
|
+
msg = GroupMessage(
|
|
39
|
+
time=datetime.now(),
|
|
40
|
+
self_id=event.self_id,
|
|
41
|
+
user_id=event.user_id,
|
|
42
|
+
group_id=group_id,
|
|
43
|
+
raw_message=raw_message,
|
|
44
|
+
sender_nickname=sender_nickname,
|
|
45
|
+
sender_card=sender_card,
|
|
46
|
+
message_id=event.message_id,
|
|
47
|
+
reply_id=reply_id,
|
|
48
|
+
)
|
|
49
|
+
session.add(msg)
|
|
50
|
+
session.commit()
|
|
51
|
+
session.refresh(msg)
|
|
52
|
+
logger.debug(f"[DB] 已记录消息: group_id={group_id}, user_id={event.user_id}, message_id={event.message_id}")
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.error(f"[DB] 保存消息失败: {e}")
|
|
55
|
+
session.rollback()
|
|
56
|
+
return
|
|
57
|
+
finally:
|
|
58
|
+
session.close()
|
|
59
|
+
|
|
60
|
+
if msg and config.ai_api_key:
|
|
61
|
+
await collect_pending_images(bot, event.message_id, event.message, msg)
|
|
62
|
+
await maybe_flush_batch_pending()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
from sqlalchemy import Column, DateTime, Integer, String, Text
|
|
5
|
+
from sqlalchemy.orm import declarative_base
|
|
6
|
+
|
|
7
|
+
Base = declarative_base()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GroupMessage(Base):
|
|
11
|
+
__tablename__ = "group_messages"
|
|
12
|
+
|
|
13
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
14
|
+
time = Column(DateTime)
|
|
15
|
+
self_id = Column(Integer)
|
|
16
|
+
user_id = Column(Integer)
|
|
17
|
+
group_id = Column(Integer)
|
|
18
|
+
raw_message = Column(Text)
|
|
19
|
+
sender_nickname = Column(String(255))
|
|
20
|
+
sender_card = Column(String(255))
|
|
21
|
+
message_id = Column(Integer)
|
|
22
|
+
reply_id = Column(Integer, nullable=True)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def build_vision_prompt(items: list[dict], timeline: list[dict]) -> str:
|
|
8
|
+
return f"""你是聊天图片总结助手。你会收到一组聊天图片,以及按真实聊天顺序排列的聊天时间线。只输出 JSON,不要输出 Markdown 或解释文字。
|
|
9
|
+
|
|
10
|
+
目标:
|
|
11
|
+
- 尽可能反映图片里的文字、人物/物体、场景、截图中的关键信息。
|
|
12
|
+
- 如果图片是聊天截图、通知、网页、表格、账单、游戏截图或软件界面,提炼核心结论,并结合上下文推断这张图在聊天里的作用。
|
|
13
|
+
- 如果图片是表情包、梗图、二创图或网络流行图,尽量说明它通常表达的情绪、梗义或用法;若知道是梗图但无法确定梗义,把不确定说明写入 tip。
|
|
14
|
+
- 如果图片没有文字,也要描述能确定的画面内容、主体、动作、风格和可能用途。
|
|
15
|
+
- 如果看不清、被遮挡、分辨率太低或信息不足,不要猜测;能确定的写入 summary,警告和不确定性写入 tip。
|
|
16
|
+
- summary 用自然中文,可以多句,尽量具体;tip 只放不确定性、风险、看不清、疑似梗但不理解等提醒,没有则为空字符串。
|
|
17
|
+
|
|
18
|
+
示例风格:
|
|
19
|
+
summary: "写于2021年3月30日,是作者与“周家葆”在一起的第9天。文字表达了虽有摩擦但被偏爱的幸福,以及对未来共同完成愿望的期许。右侧写有寄语:“人类自私又善变 而我 永远屈服于温柔和真诚”。卡片上盖有两处“换酒”字样的三角形印章,暗示撰写地点可能是一家名为“换酒”的店。"
|
|
20
|
+
tip: ""
|
|
21
|
+
|
|
22
|
+
聊天时间线 JSON:
|
|
23
|
+
{json.dumps(timeline, ensure_ascii=False, indent=2)}
|
|
24
|
+
|
|
25
|
+
图片任务 JSON:
|
|
26
|
+
{json.dumps(items, ensure_ascii=False, indent=2)}
|
|
27
|
+
|
|
28
|
+
返回格式必须是:
|
|
29
|
+
{{
|
|
30
|
+
"images": [
|
|
31
|
+
{{
|
|
32
|
+
"imageIndex": 0,
|
|
33
|
+
"summary": "",
|
|
34
|
+
"tip": ""
|
|
35
|
+
}}
|
|
36
|
+
]
|
|
37
|
+
}}
|
|
38
|
+
|
|
39
|
+
严格要求:
|
|
40
|
+
- images 数量必须等于图片数量,imageIndex 从 0 开始对应上传顺序。
|
|
41
|
+
- 聊天时间线中的 {{"type":"image","index":0}} 对应 imageIndex=0 的输入图片。
|
|
42
|
+
- 聊天时间线已按真实聊天顺序排列;请结合图片前后的文字理解图片用途。
|
|
43
|
+
- summary 和 tip 都必须是字符串。
|
|
44
|
+
- 不要输出 JSON 以外的任何文字。"""
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
from nonebot.adapters.onebot.v11 import Bot
|
|
7
|
+
from nonebot.log import logger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def get_display_name(bot: Bot, user_id: int, group_id: Optional[int] = None) -> str:
|
|
11
|
+
if group_id is not None and group_id != -1:
|
|
12
|
+
try:
|
|
13
|
+
member = await bot.get_group_member_info(group_id=group_id, user_id=user_id, no_cache=False)
|
|
14
|
+
return member.get("card") or member.get("nickname") or str(user_id)
|
|
15
|
+
except Exception as e:
|
|
16
|
+
logger.warning(f"[DB] 获取群成员信息失败: group={group_id}, user={user_id}, error={e}")
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
friends = await bot.get_friend_list()
|
|
20
|
+
for friend in friends:
|
|
21
|
+
if int(friend.get("user_id", 0)) == int(user_id):
|
|
22
|
+
return friend.get("remark") or friend.get("nickname") or str(user_id)
|
|
23
|
+
except Exception as e:
|
|
24
|
+
logger.warning(f"[DB] 获取好友列表失败: user={user_id}, error={e}")
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
stranger = await bot.get_stranger_info(user_id=user_id, no_cache=False)
|
|
28
|
+
return stranger.get("nickname") or str(user_id)
|
|
29
|
+
except Exception as e:
|
|
30
|
+
logger.warning(f"[DB] 获取陌生人信息失败: user={user_id}, error={e}")
|
|
31
|
+
return str(user_id)
|