mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-31 04:45:23 +08:00
339 lines
13 KiB
Python
339 lines
13 KiB
Python
# Auto generated by LangBot Plugin SDK.
|
|
# Please refer to https://langbot.app/docs/en/plugin/dev/tutor.html for more details.
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import datetime
|
|
import itertools
|
|
import logging
|
|
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.proxies.invocation import (
|
|
bind_invocation,
|
|
current_binding,
|
|
current_config,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Prefix used for every notification. Language does not change it: the previous
|
|
# per-config lookup returned this same literal for both supported languages.
|
|
NOTIFY_PREFIX = "[Notify] "
|
|
|
|
# How often each installation's scheduler wakes up to fire due events.
|
|
CHECK_INTERVAL_SECONDS = 60
|
|
|
|
# Scope used when the active invocation carries no installation binding.
|
|
LEGACY_SCOPE = ""
|
|
|
|
# Bounded wait for a revoked installation's loop to observe cancellation.
|
|
LOOP_STOP_TIMEOUT = 5.0
|
|
|
|
# One installation's in-memory queue, and the reminder text kept per event, are
|
|
# bounded so a single session cannot grow the shared worker's memory without limit.
|
|
MAX_EVENTS_PER_INSTALLATION = 100
|
|
MAX_MESSAGE_LENGTH = 2000
|
|
|
|
|
|
def session_key(bot_uuid: Any, target_type: Any, target_id: Any) -> tuple[str, str, str]:
|
|
"""Return the full session identity a reminder belongs to.
|
|
|
|
A target id alone does not identify a session: two bots of one installation,
|
|
or the person/group namespaces, can reuse the same id. The tuple is always
|
|
derived from the trusted platform session, never from user parameters.
|
|
"""
|
|
return (str(bot_uuid or ""), str(target_type or ""), str(target_id or ""))
|
|
|
|
|
|
def _event_session_key(event: dict[str, Any]) -> tuple[str, str, str]:
|
|
return session_key(event.get("bot_uuid"), event.get("target_type"), event.get("target_id"))
|
|
|
|
|
|
class ScheNotify(BasePlugin):
|
|
"""
|
|
Schedule notification plugin.
|
|
Allows users to schedule notifications using natural language through LLM.
|
|
|
|
One object graph serves every installation of this artifact digest, so the
|
|
schedule and its background checker belong to the installation that created
|
|
them and are released in ``on_installation_revoked``.
|
|
"""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
# Installation-keyed process state, released on revocation.
|
|
self._events: dict[str, list[dict[str, Any]]] = {}
|
|
self._loops: dict[str, asyncio.Task] = {}
|
|
# Binding each loop was started under, so a worker upgrade (same scope
|
|
# token, new revision) restarts it instead of keeping stale authority.
|
|
self._loop_bindings: dict[str, Any] = {}
|
|
# Stable per-process event ids, so a delivery can be matched to exactly
|
|
# the event it sent even when a sibling invocation edits the list.
|
|
self._event_ids = itertools.count(1)
|
|
|
|
async def initialize(self) -> None:
|
|
"""Process-scoped initialization only.
|
|
|
|
The scheduler loop is bound to an installation, so it is started lazily
|
|
by the first scheduling invocation of that installation, never here.
|
|
"""
|
|
logger.info("ScheNotify plugin initialized")
|
|
|
|
async def on_installation_revoked(self, binding) -> None:
|
|
"""Stop one installation's scheduler and drop its pending schedule."""
|
|
scope = self._scope(binding)
|
|
await self._stop_loop(scope)
|
|
self._events.pop(scope, None)
|
|
|
|
def __del__(self):
|
|
logger.info("ScheNotify plugin unloaded")
|
|
for task in list(getattr(self, "_loops", {}).values()):
|
|
if not task.done():
|
|
task.cancel()
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Installation scoping
|
|
# ------------------------------------------------------------------ #
|
|
def _scope(self, binding: Any | None = None) -> str:
|
|
"""Return the full installation scope of this invocation.
|
|
|
|
The whole binding is used so two Workspaces that reuse an installation
|
|
UUID never share a schedule. Only the installation identity matters: a
|
|
worker upgrade revokes the superseded binding.
|
|
"""
|
|
if binding is None:
|
|
binding = self.get_installation_binding()
|
|
if binding is None:
|
|
return LEGACY_SCOPE
|
|
return f"{binding.instance_uuid}:{binding.workspace_uuid}:{binding.installation_uuid}"
|
|
|
|
def _events_for_scope(self, scope: str) -> list[dict[str, Any]]:
|
|
return self._events.get(scope, [])
|
|
|
|
def session_key(self, bot_uuid: Any, target_type: Any, target_id: Any) -> tuple[str, str, str]:
|
|
"""Return the trusted session identity used by the schedule commands.
|
|
|
|
Components pass the platform session fields here; the result is the same
|
|
key ``add_scheduled_event`` stores, so listing and deleting never match
|
|
another session of the installation.
|
|
"""
|
|
return session_key(bot_uuid, target_type, target_id)
|
|
|
|
def _ensure_loop(self, scope: str) -> None:
|
|
"""Start (or refresh) the scheduler of the invoking installation.
|
|
|
|
The invocation's handler, config and binding are captured here: the
|
|
detached loop re-enters them at fire time so every Host send stays
|
|
attributable to the installation that scheduled the reminder. A worker
|
|
upgrade keeps the scope token (the revision is excluded from it) but
|
|
presents a new binding, so the loop is restarted under the new authority
|
|
instead of sending with the superseded revision.
|
|
"""
|
|
handler = getattr(self, "plugin_runtime_handler", None)
|
|
config = current_config(handler)
|
|
binding = current_binding(handler)
|
|
|
|
task = self._loops.get(scope)
|
|
if task is not None and not task.done():
|
|
if self._loop_bindings.get(scope) == binding:
|
|
return
|
|
task.cancel()
|
|
self._loop_bindings[scope] = binding
|
|
self._loops[scope] = asyncio.create_task(
|
|
self._check_loop_wrapper(scope, handler, config, binding)
|
|
)
|
|
|
|
async def _stop_loop(self, scope: str) -> None:
|
|
task = self._loops.pop(scope, None)
|
|
self._loop_bindings.pop(scope, None)
|
|
if task is None:
|
|
return
|
|
task.cancel()
|
|
done, _pending = await asyncio.wait({task}, timeout=LOOP_STOP_TIMEOUT)
|
|
if not done:
|
|
logger.warning(
|
|
"ScheNotify scheduler for installation scope %s did not stop in %ss",
|
|
scope,
|
|
LOOP_STOP_TIMEOUT,
|
|
)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Scheduler
|
|
# ------------------------------------------------------------------ #
|
|
async def _check_loop_wrapper(self, scope: str, handler: Any, config: Any, binding: Any):
|
|
"""Check one installation's due events until that binding is revoked.
|
|
|
|
A detached task keeps no usable invocation of its own — the invocation
|
|
that started it has ended and is marked inactive. It therefore re-enters
|
|
the handler, config and binding captured when it started, so every send
|
|
this loop makes carries the scheduling installation's authority instead
|
|
of falling back to the worker's connection default. ``on_installation_revoked``
|
|
cancels the task, which exits this scope and drops the authority with it.
|
|
"""
|
|
with bind_invocation(handler, config=config, binding=binding):
|
|
while True:
|
|
try:
|
|
await self._check_scheduled_events(scope)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error in check loop: {e}", exc_info=True)
|
|
await asyncio.sleep(CHECK_INTERVAL_SECONDS) # Check every minute
|
|
|
|
async def _check_scheduled_events(self, scope: str):
|
|
"""Send notifications for this installation's due events."""
|
|
now = datetime.datetime.now()
|
|
logger.info(f"Checking scheduled events at {now}")
|
|
events = self._events.get(scope)
|
|
if not events:
|
|
return
|
|
# Log the queue size, not the reminder text: the content is tenant data.
|
|
logger.debug("Installation scope %s has %d pending events", scope, len(events))
|
|
|
|
# Snapshot: a sibling invocation of the same installation may append or
|
|
# delete entries while this loop is awaiting a send.
|
|
delivered_ids = []
|
|
for event in list(events):
|
|
if now >= event["time"]:
|
|
# Send notification
|
|
try:
|
|
await self._send_notification(event)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e:
|
|
# Keep the failed event queued and record the failure: dropping
|
|
# it here would silently lose the tenant's reminder.
|
|
event["failed_attempts"] = event.get("failed_attempts", 0) + 1
|
|
event["last_error"] = str(e)
|
|
logger.error(
|
|
"Failed to send notification %s: %s", event["id"], e, exc_info=True
|
|
)
|
|
continue
|
|
delivered_ids.append(event["id"])
|
|
|
|
# Remove exactly the events this pass delivered, by stable id: a sibling
|
|
# invocation may already have removed them, and the list may have grown.
|
|
delivered = set(delivered_ids)
|
|
events[:] = [event for event in events if event["id"] not in delivered]
|
|
|
|
async def _send_notification(self, event: dict[str, Any]):
|
|
"""
|
|
Send notification message to the session.
|
|
|
|
Args:
|
|
event: Event dict containing session info and message
|
|
"""
|
|
message_chain = platform_message.MessageChain([
|
|
platform_message.Plain(text=f"{NOTIFY_PREFIX}{event['message']}")
|
|
])
|
|
|
|
# Send message using saved session info. Failures propagate so the
|
|
# scheduler can keep the event queued and record the failed attempt.
|
|
await self.send_message(
|
|
bot_uuid=event["bot_uuid"],
|
|
target_type=event["target_type"],
|
|
target_id=event["target_id"],
|
|
message_chain=message_chain
|
|
)
|
|
|
|
async def add_scheduled_event(
|
|
self,
|
|
time: datetime.datetime,
|
|
message: str,
|
|
bot_uuid: str,
|
|
target_type: str,
|
|
target_id: str
|
|
) -> bool:
|
|
"""
|
|
Add a scheduled event (called from schedule_notify tool).
|
|
|
|
Args:
|
|
time: Scheduled time
|
|
message: Notification message
|
|
bot_uuid: Bot UUID
|
|
target_type: "person" or "group"
|
|
target_id: Target ID
|
|
|
|
Returns:
|
|
True when the event was queued, False when this installation already
|
|
holds the maximum number of pending events.
|
|
"""
|
|
scope = self._scope()
|
|
events = self._events.setdefault(scope, [])
|
|
if len(events) >= MAX_EVENTS_PER_INSTALLATION:
|
|
logger.warning(
|
|
"Installation scope %s reached the %d-event limit; reminder rejected",
|
|
scope,
|
|
MAX_EVENTS_PER_INSTALLATION,
|
|
)
|
|
return False
|
|
events.append({
|
|
"id": next(self._event_ids),
|
|
"time": time,
|
|
"message": str(message)[:MAX_MESSAGE_LENGTH],
|
|
"bot_uuid": bot_uuid,
|
|
"target_type": target_type,
|
|
"target_id": target_id,
|
|
"failed_attempts": 0,
|
|
})
|
|
self._ensure_loop(scope)
|
|
return True
|
|
|
|
async def get_scheduled_events(
|
|
self, session_key: tuple[str, str, str] | None = None
|
|
) -> list[dict[str, Any]]:
|
|
"""
|
|
Get this installation's scheduled events, optionally filtered by session.
|
|
|
|
Args:
|
|
session_key: Optional ``(bot_uuid, target_type, target_id)`` of one
|
|
trusted session, as returned by ``session_key()``
|
|
|
|
Returns:
|
|
A new list of the pending event records; the records are the live
|
|
dicts, so callers must not mutate them.
|
|
"""
|
|
scope = self._scope()
|
|
events = self._events_for_scope(scope)
|
|
|
|
if session_key is None:
|
|
return list(events)
|
|
|
|
# Filter by the full session identity, never by target_id alone.
|
|
return [
|
|
event for event in events
|
|
if _event_session_key(event) == session_key
|
|
]
|
|
|
|
async def delete_scheduled_event(
|
|
self,
|
|
event: dict[str, Any],
|
|
session_key: tuple[str, str, str] | None = None,
|
|
) -> bool:
|
|
"""
|
|
Delete a scheduled event of this installation.
|
|
|
|
Args:
|
|
event: Event to delete
|
|
session_key: When given, the caller's trusted session identity; the
|
|
event is only removed when it belongs to that session.
|
|
|
|
Returns:
|
|
True when the event was removed from this installation's schedule.
|
|
"""
|
|
scope = self._scope()
|
|
events = self._events_for_scope(scope)
|
|
if session_key is not None and _event_session_key(event) != session_key:
|
|
return False
|
|
|
|
event_id = event.get("id")
|
|
for index, existing in enumerate(events):
|
|
if existing is event or (
|
|
event_id is not None and existing.get("id") == event_id
|
|
):
|
|
del events[index]
|
|
return True
|
|
return False
|