mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-30 04:15:45 +08:00
383 lines
15 KiB
Python
383 lines
15 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import copy
|
|
import json
|
|
import os
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
from langbot_plugin.api.definition.plugin import BasePlugin
|
|
|
|
STATE_KEY = "daily_limit_state"
|
|
|
|
# Process-cache slot used when no installation binding is available (dedicated
|
|
# placement). It maps to the legacy storage key 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"
|
|
|
|
DEFAULT_LIMIT_MESSAGE = "您今天的对话次数已达上限,请明天再来吧~"
|
|
|
|
|
|
def installation_scope(binding) -> str:
|
|
"""Return the stable scope token for one installation binding.
|
|
|
|
Only the installation identity triple is used. ``runtime_revision`` changes on
|
|
every worker upgrade, so including it would orphan the tenant's persisted rows
|
|
after a restart of the loader.
|
|
"""
|
|
|
|
return f"{binding.instance_uuid}:{binding.workspace_uuid}:{binding.installation_uuid}"
|
|
|
|
|
|
def storage_key(scope: 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 unchanged.
|
|
"""
|
|
|
|
if scope == DEDICATED_SCOPE:
|
|
return STATE_KEY
|
|
return f"{STATE_KEY}:{scope}"
|
|
|
|
|
|
class _InstallationState:
|
|
"""Process-local state 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", "settings", "sessions", "lock", "loaded")
|
|
|
|
def __init__(self, scope: str) -> None:
|
|
self.scope = scope
|
|
self.settings: dict = {}
|
|
self.sessions: dict = {}
|
|
self.lock = asyncio.Lock()
|
|
self.loaded = False
|
|
|
|
|
|
class DailyLimitPlugin(BasePlugin):
|
|
"""Daily conversation limit per session.
|
|
|
|
Tracks per-session daily usage, supports per-session limit overrides,
|
|
a global default limit for new sessions, manual reset, silent mode and
|
|
timezone-aware daily rollover. State is kept per installation binding and
|
|
persisted via plugin storage, so the management Page component (which shares
|
|
the plugin object) sees the state of the installation that invoked it.
|
|
|
|
State shape (persisted JSON, one row per installation scope):
|
|
|
|
{
|
|
"settings": {
|
|
"default_limit": 50,
|
|
"limit_message": "...",
|
|
"silent_mode": false,
|
|
"tz_offset": 8,
|
|
"reset_hour": 0
|
|
},
|
|
"sessions": {
|
|
"<session_id>": {
|
|
"label": "person:12345",
|
|
"limit": null, # null = use default_limit; int = override
|
|
"count": 3,
|
|
"date": "2026-06-20", # logical day the count belongs to
|
|
"last_active": "2026-06-20T11:20:00+08:00"
|
|
}
|
|
}
|
|
}
|
|
|
|
The row lives under ``daily_limit_state:<instance>:<workspace>:<installation>``
|
|
(or the legacy ``daily_limit_state`` for dedicated, binding-less workers).
|
|
"""
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self._states: dict[str, _InstallationState] = {}
|
|
|
|
# ----------------------------------------------------------------- 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 state is loaded lazily per invocation
|
|
(and per binding) instead.
|
|
"""
|
|
|
|
return None
|
|
|
|
async def on_installation_revoked(self, binding) -> None:
|
|
"""Drop the cache of the revoked installation; no Host API is available."""
|
|
|
|
self._states.pop(installation_scope(binding), None)
|
|
|
|
async def destroy(self) -> None:
|
|
self._states.clear()
|
|
|
|
# --------------------------------------------------------------- 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
|
|
|
|
async def _state(self) -> _InstallationState:
|
|
"""Return the invoking installation's state, loading it on first use."""
|
|
|
|
scope = self._current_scope()
|
|
st = self._states.get(scope)
|
|
if st is None:
|
|
st = _InstallationState(scope)
|
|
self._states[scope] = st
|
|
if not st.loaded:
|
|
async with st.lock:
|
|
if not st.loaded:
|
|
await self._load(st)
|
|
st.loaded = True
|
|
return st
|
|
|
|
async def _load(self, st: _InstallationState) -> None:
|
|
loaded = None
|
|
try:
|
|
raw = await self.get_plugin_storage(storage_key(st.scope))
|
|
if raw:
|
|
loaded = json.loads(raw.decode("utf-8"))
|
|
except Exception as e:
|
|
print(f"[DailyLimit] No saved state for {st.scope} ({e}); seeding from config", flush=True)
|
|
|
|
cfg = self.get_config() or {}
|
|
seed = {
|
|
"default_limit": int(cfg.get("daily_limit", 50)),
|
|
"limit_message": cfg.get("limit_message") or DEFAULT_LIMIT_MESSAGE,
|
|
"silent_mode": bool(cfg.get("silent_mode", False)),
|
|
"tz_offset": int(cfg.get("reset_timezone_offset", 8)),
|
|
"reset_hour": int(cfg.get("reset_hour", 0)),
|
|
}
|
|
|
|
if loaded and isinstance(loaded, dict):
|
|
st.settings = {**seed, **(loaded.get("settings") or {})}
|
|
st.sessions = loaded.get("sessions") or {}
|
|
else:
|
|
st.settings = seed
|
|
st.sessions = {}
|
|
self._normalize_settings(st.settings)
|
|
print(
|
|
f"[DailyLimit] initialized scope={st.scope}: "
|
|
f"default_limit={st.settings['default_limit']}, "
|
|
f"{len(st.sessions)} tracked sessions",
|
|
flush=True,
|
|
)
|
|
|
|
# ------------------------------------------------------------------- helpers
|
|
|
|
def _normalize_settings(self, settings: dict) -> None:
|
|
settings["default_limit"] = max(0, int(settings.get("default_limit", 50)))
|
|
settings["tz_offset"] = max(-12, min(14, int(settings.get("tz_offset", 8))))
|
|
settings["reset_hour"] = max(0, min(23, int(settings.get("reset_hour", 0))))
|
|
settings["silent_mode"] = bool(settings.get("silent_mode", False))
|
|
if not settings.get("limit_message"):
|
|
settings["limit_message"] = DEFAULT_LIMIT_MESSAGE
|
|
|
|
def _logical_today(self, st: _InstallationState) -> str:
|
|
tz = timezone(timedelta(hours=st.settings["tz_offset"]))
|
|
now_local = datetime.now(tz)
|
|
if now_local.hour < st.settings["reset_hour"]:
|
|
now_local -= timedelta(days=1)
|
|
return now_local.strftime("%Y-%m-%d")
|
|
|
|
def _now_iso(self, st: _InstallationState) -> str:
|
|
tz = timezone(timedelta(hours=st.settings["tz_offset"]))
|
|
return datetime.now(tz).strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
|
|
def _effective_limit(self, st: _InstallationState, sess: dict) -> int:
|
|
override = sess.get("limit")
|
|
if override is None:
|
|
return st.settings["default_limit"]
|
|
return int(override)
|
|
|
|
async def persist(self, st: _InstallationState) -> None:
|
|
await self.set_plugin_storage(
|
|
storage_key(st.scope),
|
|
json.dumps(
|
|
{"settings": st.settings, "sessions": st.sessions},
|
|
ensure_ascii=False,
|
|
).encode("utf-8"),
|
|
)
|
|
|
|
async def _transaction(self, mutate, *, rollback: bool = True):
|
|
"""Apply one state change of one installation under its installation lock.
|
|
|
|
Read, modify, serialize and await the Host write inside the same critical
|
|
section. ``persist`` overwrites the whole row, so two overlapping writers
|
|
of one installation must not interleave: an admin write that serialized an
|
|
older snapshot would otherwise be able to land after a newer one and roll
|
|
it back. Holding the lock across the Host call makes the durable row match
|
|
call order; nothing is ever serialized from state that another writer has
|
|
already moved past.
|
|
|
|
``mutate`` returns ``(changed, result)``; ``result`` is what this method
|
|
returns. With ``rollback`` the in-memory state is restored when the
|
|
mutation raises, the write fails or the call is cancelled, so memory stays
|
|
equal to the last snapshot that actually landed instead of diverging from
|
|
a write that never happened.
|
|
|
|
The counter path opts out of ``rollback`` on purpose: a message that was
|
|
already answered keeps consuming quota (never back-credits usage), and the
|
|
next successful write persists the whole snapshot again.
|
|
"""
|
|
|
|
st = await self._state()
|
|
async with st.lock:
|
|
before = (copy.deepcopy(st.settings), copy.deepcopy(st.sessions)) if rollback else None
|
|
try:
|
|
changed, result = mutate(st)
|
|
if changed:
|
|
await self.persist(st)
|
|
except BaseException:
|
|
if before is not None:
|
|
st.settings, st.sessions = before
|
|
raise
|
|
return result
|
|
|
|
# --------------------------------------------------------- runtime (listener)
|
|
|
|
async def check_and_count(self, session_id: str, label: str) -> tuple[bool, str]:
|
|
"""Check the session against its limit and increment if allowed.
|
|
|
|
Returns ``(allowed, message)``. When ``allowed`` is False and the
|
|
message is non-empty, the listener should reply with it; an empty
|
|
message means "block silently".
|
|
"""
|
|
|
|
def count(st: _InstallationState) -> tuple[bool, tuple[bool, str]]:
|
|
today = self._logical_today(st)
|
|
sess = st.sessions.get(session_id)
|
|
if sess is None:
|
|
sess = {"label": label, "limit": None, "count": 0, "date": today}
|
|
st.sessions[session_id] = sess
|
|
|
|
# Roll over the day if needed
|
|
if sess.get("date") != today:
|
|
sess["date"] = today
|
|
sess["count"] = 0
|
|
sess["label"] = label
|
|
sess["last_active"] = self._now_iso(st)
|
|
|
|
limit = self._effective_limit(st, sess)
|
|
|
|
# 0 means unlimited
|
|
if limit <= 0:
|
|
sess["count"] = int(sess.get("count", 0)) + 1
|
|
return True, (True, "")
|
|
|
|
if int(sess.get("count", 0)) >= limit:
|
|
if st.settings.get("silent_mode"):
|
|
return True, (False, "")
|
|
return True, (False, st.settings.get("limit_message") or DEFAULT_LIMIT_MESSAGE)
|
|
|
|
sess["count"] = int(sess.get("count", 0)) + 1
|
|
return True, (True, "")
|
|
|
|
return await self._transaction(count, rollback=False)
|
|
|
|
# ----------------------------------------------------------- management (page)
|
|
|
|
async def snapshot(self) -> dict:
|
|
"""Return a JSON-serializable view of the invoking installation's state."""
|
|
st = await self._state()
|
|
async with st.lock:
|
|
today = self._logical_today(st)
|
|
rows = []
|
|
for sid, sess in st.sessions.items():
|
|
count = int(sess.get("count", 0)) if sess.get("date") == today else 0
|
|
rows.append(
|
|
{
|
|
"id": sid,
|
|
"label": sess.get("label", sid),
|
|
"limit": sess.get("limit"),
|
|
"effective_limit": self._effective_limit(st, sess),
|
|
"count": count,
|
|
"date": sess.get("date"),
|
|
"last_active": sess.get("last_active"),
|
|
}
|
|
)
|
|
rows.sort(key=lambda r: (r.get("last_active") or ""), reverse=True)
|
|
return {
|
|
"settings": dict(st.settings),
|
|
"today": today,
|
|
"sessions": rows,
|
|
}
|
|
|
|
async def update_settings(self, patch: dict) -> None:
|
|
allowed = {"default_limit", "limit_message", "silent_mode", "tz_offset", "reset_hour"}
|
|
|
|
def apply(st: _InstallationState) -> tuple[bool, None]:
|
|
candidate = dict(st.settings)
|
|
for k, v in (patch or {}).items():
|
|
if k in allowed and v is not None:
|
|
candidate[k] = v
|
|
# Validate on the copy first, then swap: a rejected value must not
|
|
# leave a half-updated settings dict behind.
|
|
self._normalize_settings(candidate)
|
|
st.settings = candidate
|
|
return True, None
|
|
|
|
await self._transaction(apply)
|
|
|
|
async def set_session_limit(self, session_id: str, limit) -> bool:
|
|
def apply(st: _InstallationState) -> tuple[bool, bool]:
|
|
sess = st.sessions.get(session_id)
|
|
if sess is None:
|
|
return False, False
|
|
if limit is None or limit == "":
|
|
sess["limit"] = None
|
|
else:
|
|
sess["limit"] = max(0, int(limit))
|
|
return True, True
|
|
|
|
return await self._transaction(apply)
|
|
|
|
async def reset_session(self, session_id: str) -> bool:
|
|
def apply(st: _InstallationState) -> tuple[bool, bool]:
|
|
sess = st.sessions.get(session_id)
|
|
if sess is None:
|
|
return False, False
|
|
sess["count"] = 0
|
|
sess["date"] = self._logical_today(st)
|
|
return True, True
|
|
|
|
return await self._transaction(apply)
|
|
|
|
async def reset_all(self) -> int:
|
|
def apply(st: _InstallationState) -> tuple[bool, int]:
|
|
today = self._logical_today(st)
|
|
for sess in st.sessions.values():
|
|
sess["count"] = 0
|
|
sess["date"] = today
|
|
return True, len(st.sessions)
|
|
|
|
return await self._transaction(apply)
|
|
|
|
async def delete_session(self, session_id: str) -> bool:
|
|
def apply(st: _InstallationState) -> tuple[bool, bool]:
|
|
if session_id not in st.sessions:
|
|
return False, False
|
|
del st.sessions[session_id]
|
|
return True, True
|
|
|
|
return await self._transaction(apply)
|