Files
LangBot/plugins/misc/GroupChatSummary/main.py
T

543 lines
19 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import os
import time
from collections import defaultdict
from typing import Any
from langbot_plugin.api.definition.plugin import BasePlugin
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot_plugin.api.entities.builtin.provider import message as provider_message
from langbot_plugin.api.proxies.invocation import (
bind_invocation,
current_binding,
current_config,
)
BUFFERS_KEY = "message_buffers"
WATERMARK_KEY = "auto_summary_watermark"
# Process-cache slot used when no installation binding is available (dedicated
# placement). It maps to the legacy storage keys so existing dedicated installs
# keep reading their own rows.
DEDICATED_SCOPE = "dedicated"
# Written by the SDK worker launcher. A shared worker must always carry a trusted
# invocation binding; without one it must refuse to touch tenant state instead of
# falling back to a binding-less scope shared by every installation.
RUNTIME_PROFILE_ENV = "LANGBOT_PLUGIN_RUNTIME_PROFILE"
def installation_scope(binding) -> str:
"""Return the stable scope token for one installation binding.
``runtime_revision`` changes on every worker upgrade, so only the stable
installation identity triple is used; including it would orphan persisted
rows and make the revoked binding impossible to release.
"""
return f"{binding.instance_uuid}:{binding.workspace_uuid}:{binding.installation_uuid}"
def storage_key(scope: str, name: str) -> str:
"""Return the plugin-storage key for one scope.
Host rows are keyed by ``[instance, workspace, owner_type, owner, key]`` with
no installation dimension, so the installation scope has to live in the
plugin's own key. The binding-less dedicated scope keeps the legacy key.
"""
if scope == DEDICATED_SCOPE:
return name
return f"{name}:{scope}"
class _InstallationState:
"""Process-local buffers belonging to exactly one installation binding.
The same object graph serves every installation of the artifact, so this is
cached per binding and dropped in ``on_installation_revoked``.
"""
__slots__ = ("scope", "message_buffer", "auto_summary_watermark", "lock", "loaded")
def __init__(self, scope: str) -> None:
self.scope = scope
# {group_key: [{"sender": str, "text": str, "time": float}, ...]}
self.message_buffer: dict[str, list[dict[str, Any]]] = defaultdict(list)
# {group_key: last_auto_summary_index}
self.auto_summary_watermark: dict[str, int] = {}
self.lock = asyncio.Lock()
self.loaded = False
class GroupChatSummary(BasePlugin):
"""Group chat message collector and summarizer.
Collects messages from group chats and provides LLM-powered summaries
via commands, tool calls, or automatic triggers. Buffers are kept per
installation binding and loaded lazily per invocation, so the command, tool
and listener components sharing this object always see the invoking
installation's own groups.
"""
def __init__(self):
super().__init__()
self._states: dict[str, _InstallationState] = {}
self._tasks: dict[str, set[asyncio.Task]] = {}
self.logger = logging.getLogger(__name__)
# ------------------------------------------------------------- lifecycle
async def initialize(self) -> None:
"""Process-wide initialization only.
Under shared placement this runs once per worker with an empty config and
no installation context, so tenant buffers are loaded lazily per
invocation (and per binding) instead.
"""
return None
async def on_installation_revoked(self, binding) -> None:
"""Drop the revoked installation's buffers and stop its detached tasks.
There is no active invocation, so no Host API is available; only the
process-local cache and task registry keyed by this binding are released.
"""
scope = installation_scope(binding)
self._states.pop(scope, None)
tasks = self._tasks.pop(scope, None) or set()
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
async def destroy(self) -> None:
"""Stop every detached task and drop every cached installation (process end)."""
pending = [task for tasks in self._tasks.values() for task in tasks]
self._tasks.clear()
self._states.clear()
for task in pending:
task.cancel()
if pending:
await asyncio.gather(*pending, return_exceptions=True)
# ------------------------------------------------------------ state scope
def _current_scope(self) -> str:
binding = self.get_installation_binding()
if binding is not None:
return installation_scope(binding)
if os.environ.get(RUNTIME_PROFILE_ENV) == "shared":
raise RuntimeError(
"shared invocation has no trusted installation binding; "
"refusing to read or write tenant state"
)
return DEDICATED_SCOPE
def _state_sync(self) -> _InstallationState:
scope = self._current_scope()
st = self._states.get(scope)
if st is None:
st = _InstallationState(scope)
self._states[scope] = st
return st
async def _state(self) -> _InstallationState:
"""Return the invoking installation's state, loading it on first use."""
st = self._state_sync()
if not st.loaded:
async with st.lock:
if not st.loaded:
await self._load(st)
st.loaded = True
return st
async def ensure_loaded(self) -> None:
"""Load the invoking installation's persisted buffers on demand."""
await self._state()
@property
def message_buffer(self) -> dict[str, list[dict[str, Any]]]:
"""Buffers of the invoking installation (empty until loaded)."""
return self._state_sync().message_buffer
@property
def auto_summary_watermark(self) -> dict[str, int]:
"""Auto-summary watermarks of the invoking installation."""
return self._state_sync().auto_summary_watermark
async def _load(self, st: _InstallationState) -> None:
"""Load persisted buffers for one installation scope."""
try:
data = await self.get_plugin_storage(storage_key(st.scope, BUFFERS_KEY))
if data:
loaded = json.loads(data.decode("utf-8"))
for k, v in loaded.items():
st.message_buffer[k] = v
self.logger.info(
f"Loaded message buffers for {len(loaded)} groups "
f"(scope={st.scope})"
)
except Exception:
self.logger.info("No persisted message buffers found, starting fresh")
try:
data = await self.get_plugin_storage(storage_key(st.scope, WATERMARK_KEY))
if data:
st.auto_summary_watermark = json.loads(data.decode("utf-8"))
except Exception:
pass
def _group_key(self, launcher_type: str, launcher_id: str | int) -> str:
"""Generate a unique key for a group."""
return f"{launcher_type}_{launcher_id}"
def _get_max_messages(self) -> int:
config = self.get_config()
return config.get("max_messages", 500)
def _get_default_summary_count(self) -> int:
config = self.get_config()
return config.get("default_summary_count", 100)
async def record_message(
self,
launcher_type: str,
launcher_id: str | int,
sender_id: str | int,
sender_name: str,
text: str,
bot_uuid: str | None = None,
) -> None:
"""Record a group message into the buffer.
Args:
launcher_type: "group" or "person"
launcher_id: Group ID
sender_id: Sender's user ID
sender_name: Display name of sender
text: Message text content
bot_uuid: Bot UUID for auto-summary
"""
if not text or not text.strip():
return
st = await self._state()
key = self._group_key(launcher_type, launcher_id)
max_messages = self._get_max_messages()
st.message_buffer[key].append({
"sender_id": str(sender_id),
"sender": sender_name or str(sender_id),
"text": text.strip(),
"time": time.time(),
})
# Trim buffer if too large
if len(st.message_buffer[key]) > max_messages:
st.message_buffer[key] = st.message_buffer[key][-max_messages:]
# Persist periodically (every 10 messages)
if len(st.message_buffer[key]) % 10 == 0:
await self._persist_buffers()
# Check auto-summary trigger
config = self.get_config()
if config.get("auto_summary_enabled", False) and bot_uuid:
threshold = config.get("auto_summary_threshold", 200)
watermark = st.auto_summary_watermark.get(key, 0)
current_count = len(st.message_buffer[key])
if current_count - watermark >= threshold:
st.auto_summary_watermark[key] = current_count
await self._persist_watermark()
# Fire auto-summary under the invoking binding, tracked for revocation.
self._spawn_auto_summary(
st.scope, key, bot_uuid, launcher_type, str(launcher_id)
)
def _spawn_auto_summary(
self,
scope: str,
group_key: str,
bot_uuid: str,
target_type: str,
target_id: str,
) -> None:
"""Run one auto-summary bound to the invoking installation.
The SDK does not track detached work, so the task is recorded under its
installation scope and cancelled by ``on_installation_revoked``; it
re-enters an invocation scope carrying the captured config and binding so
its Host calls cannot escape to another tenant.
"""
handler = getattr(self, "plugin_runtime_handler", None)
config = current_config(handler)
binding = current_binding(handler)
async def run() -> None:
with bind_invocation(handler, config=config, binding=binding):
await self._auto_summarize(
group_key, bot_uuid, target_type, target_id
)
task = asyncio.create_task(run())
tasks = self._tasks.setdefault(scope, set())
tasks.add(task)
def _discard(_task: asyncio.Task) -> None:
remaining = self._tasks.get(scope)
if remaining is None:
return
remaining.discard(_task)
if not remaining:
self._tasks.pop(scope, None)
task.add_done_callback(_discard)
async def _persist_buffers(self) -> None:
"""Persist the invoking installation's message buffers to plugin storage."""
st = self._state_sync()
try:
data = json.dumps(dict(st.message_buffer), ensure_ascii=False)
await self.set_plugin_storage(
storage_key(st.scope, BUFFERS_KEY), data.encode("utf-8")
)
except Exception as e:
self.logger.error(f"Failed to persist message buffers: {e}")
async def _persist_watermark(self) -> None:
"""Persist the invoking installation's auto-summary watermark."""
st = self._state_sync()
try:
data = json.dumps(st.auto_summary_watermark, ensure_ascii=False)
await self.set_plugin_storage(
storage_key(st.scope, WATERMARK_KEY), data.encode("utf-8")
)
except Exception as e:
self.logger.error(f"Failed to persist watermark: {e}")
def get_recent_messages(
self,
launcher_type: str,
launcher_id: str | int,
count: int | None = None,
hours: float | None = None,
) -> list[dict[str, Any]]:
"""Get recent messages from a group.
Args:
launcher_type: "group" or "person"
launcher_id: Group ID
count: Number of recent messages (default from config)
hours: Filter messages from last N hours
Returns:
List of message dicts
"""
key = self._group_key(launcher_type, launcher_id)
messages = self.message_buffer.get(key, [])
if hours is not None:
cutoff = time.time() - hours * 3600
messages = [m for m in messages if m["time"] >= cutoff]
if count is not None:
messages = messages[-count:]
else:
default_count = self._get_default_summary_count()
messages = messages[-default_count:]
return messages
def get_message_count(self, launcher_type: str, launcher_id: str | int) -> int:
"""Get the number of stored messages for a group."""
key = self._group_key(launcher_type, launcher_id)
return len(self.message_buffer.get(key, []))
def _get_summary_language(self) -> str:
config = self.get_config()
lang = config.get("language", "zh_Hans")
lang_map = {
"zh_Hans": "Chinese (Simplified)",
"en_US": "English",
"ja_JP": "Japanese",
}
return lang_map.get(lang, "Chinese (Simplified)")
def build_summary_prompt(
self, messages: list[dict[str, Any]], language: str | None = None
) -> str:
"""Build the LLM prompt for summarizing messages.
Args:
messages: List of message dicts
language: Override language for summary
Returns:
Formatted prompt string
"""
if not language:
language = self._get_summary_language()
# Format messages into readable text
lines = []
for msg in messages:
ts = time.strftime("%H:%M", time.localtime(msg["time"]))
lines.append(f"[{ts}] {msg['sender']}: {msg['text']}")
chat_log = "\n".join(lines)
prompt = f"""Please summarize the following group chat conversation.
Focus on:
1. Key topics discussed
2. Important decisions or conclusions
3. Action items or tasks mentioned
4. Notable opinions or disagreements
Output the summary in {language}.
Keep it concise but comprehensive. Use bullet points for clarity.
If there are multiple topics, group them with headers.
---
Chat Log ({len(messages)} messages):
{chat_log}
---"""
return prompt
async def generate_summary(
self,
launcher_type: str,
launcher_id: str | int,
count: int | None = None,
hours: float | None = None,
) -> str:
"""Generate a summary using LLM.
Args:
launcher_type: "group" or "person"
launcher_id: Group ID
count: Number of messages to summarize
hours: Summarize messages from last N hours
Returns:
Summary text
"""
await self._state()
messages = self.get_recent_messages(launcher_type, launcher_id, count, hours)
if not messages:
return self._get_no_messages_text()
if len(messages) < 3:
return self._get_too_few_messages_text(len(messages))
prompt = self.build_summary_prompt(messages)
# Use LLM to generate summary
try:
# Use configured model or fall back to first available
configured_model = self.get_config().get("model")
if configured_model:
llm_model_uuid = configured_model
else:
llm_models = await self.get_llm_models()
if not llm_models:
return "Error: No LLM model available."
llm_model_uuid = llm_models[0]
response = await self.invoke_llm(
llm_model_uuid=llm_model_uuid,
messages=[
provider_message.Message(
role="user",
content=[provider_message.ContentElement.from_text(prompt)],
)
],
)
# Extract text from response
if response.content:
if isinstance(response.content, str):
return response.content
elif isinstance(response.content, list):
parts = []
for elem in response.content:
if hasattr(elem, "text") and elem.text:
parts.append(elem.text)
return "\n".join(parts) if parts else "Failed to generate summary."
return "Failed to generate summary."
except Exception as e:
self.logger.error(f"LLM invocation failed: {e}", exc_info=True)
return f"Error generating summary: {str(e)}"
async def _auto_summarize(
self,
group_key: str,
bot_uuid: str,
target_type: str,
target_id: str,
) -> None:
"""Auto-summarize and send to group."""
try:
parts = group_key.split("_", 1)
if len(parts) != 2:
return
launcher_type, launcher_id = parts
summary = await self.generate_summary(
launcher_type=launcher_type,
launcher_id=launcher_id,
)
config = self.get_config()
lang = config.get("language", "zh_Hans")
prefix = "📋 Auto Summary" if lang == "en_US" else "📋 自动总结"
message_chain = platform_message.MessageChain([
platform_message.Plain(text=f"{prefix}\n\n{summary}")
])
await self.send_message(
bot_uuid=bot_uuid,
target_type=target_type,
target_id=target_id,
message_chain=message_chain,
)
except Exception as e:
self.logger.error(f"Auto-summary failed: {e}", exc_info=True)
def _get_no_messages_text(self) -> str:
config = self.get_config()
lang = config.get("language", "zh_Hans")
if lang == "en_US":
return "No messages recorded in this group yet."
elif lang == "ja_JP":
return "このグループにはまだメッセージが記録されていません。"
return "当前群聊还没有记录到消息。"
def _get_too_few_messages_text(self, count: int) -> str:
config = self.get_config()
lang = config.get("language", "zh_Hans")
if lang == "en_US":
return f"Only {count} messages recorded, too few to summarize meaningfully."
elif lang == "ja_JP":
return f"メッセージが {count} 件しか記録されていません。要約するには少なすぎます。"
return f"仅记录了 {count} 条消息,内容太少无法生成有意义的总结。"
def __del__(self):
for tasks in getattr(self, "_tasks", {}).values():
for task in tasks:
if not task.done():
task.cancel()
logger = getattr(self, "logger", None)
if logger is not None:
logger.info("GroupChatSummary plugin unloaded")