feat(plugins): consolidate official plugins with independent releases (LANG-180) (#2797)

Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
RockChinQ
2026-10-28 17:32:24 +08:00
committed by GitHub
co-authored by multica-agent
parent d099033d79
commit 3a77306c3c
1144 changed files with 142049 additions and 130 deletions
+41
View File
@@ -0,0 +1,41 @@
# Keyword Alert
Monitor group chat messages for specific keywords and receive instant private message alerts.
## Features
- 🔔 Real-time keyword monitoring across group chats
- 📋 Configurable keyword lists (comma-separated)
- 🎯 Monitor specific groups or all groups
- 🤖 Choose which bot sends alerts
- ⏱️ Cooldown to prevent alert spam
- 🔤 Optional case-sensitive matching
## How It Works
1. Configure keywords you want to monitor (e.g., `bug,urgent,help`)
2. Set your user/session ID as the admin to receive alerts
3. When someone sends a message containing a keyword in a monitored group, you get a private message alert with the full context
## Configuration
| Option | Description | Default |
|---|---|---|
| Keywords | Comma-separated keywords to monitor | (required) |
| Group IDs | Comma-separated group IDs (empty = all) | All groups |
| Admin Session ID | Who receives the alerts | (required) |
| Alert Bot | Which bot sends the alert | First available |
| Case Sensitive | Case-sensitive matching | Off |
| Cooldown | Seconds between same-keyword alerts per group | 60 |
## Alert Format
```
🔔 关键词告警
━━━━━━━━━━━━━━
关键词: urgent
群组: 123456789
发送者: 987654321
━━━━━━━━━━━━━━
Hey, this is urgent, the server is down!
```
@@ -0,0 +1,60 @@
# Shared runtime placement (`shared-runtime-v1` + `stateless-v1`)
This source declares `execution.sharedRuntime: shared-runtime-v1` and
`execution.componentModel: stateless-v1` in `manifest.yaml`. It is a source
declaration only: it is **not certified**, has no signed archive, and has not been
accepted against a live multi-Workspace deployment. The exact archive still needs
independent review and acceptance runs.
Under shared placement one `KeywordAlert` object and one `KeywordMonitor` event
listener serve every installation of the artifact digest. Installation attachments
are lightweight contexts; they do not create another object graph.
## Per invocation
- `keywords`, `group_ids`, `admin_id`, `bot`, `case_sensitive` and
`cooldown_seconds` are read through `self.get_config()` on every group-message
invocation, from the plugin or from the listener component's
`get_plugin_config()`. No config value is stored on the shared object.
- `get_installation_binding()` is read per invocation and never retained past the
call; it is read when the cooldown table for the current installation is resolved.
- `initialize()` is process-scoped and only logs: it runs once per worker with an
empty config, before any installation is known.
## Per binding, released on revocation
- `_cooldowns` is keyed by the installation scope
`<instance_uuid>:<workspace_uuid>:<installation_uuid>` and then by
`(group_id, keyword)`. `runtime_revision` is deliberately excluded from the token
so a worker upgrade does not hand a tenant a fresh cooldown window.
- `BasePlugin.on_installation_revoked(binding)` pops exactly that scope and touches
process-local state only — no Host API is available during revocation. Nothing is
persisted, so no durable row is involved.
- Dedicated (binding-less) workers keep a single binding-less cooldown table, i.e.
their existing behaviour.
## Known limits
- Cooldowns are in-memory and process-lifetime only (unchanged pre-existing
behaviour): a worker restart clears them, and revocation clears a revoked
installation's window.
- The per-installation cooldown table grows with the number of distinct
(group, keyword) pairs seen by that installation; it is freed by revocation. The
runtime revokes on uninstall, disable, Workspace removal and upgrade.
- Revocation is serialized per installation but may overlap with sibling
installations' invocations; per-installation state is isolated, nothing is shared.
- Alert delivery runs inside the group-message invocation and therefore uses that
invocation's authority; the plugin creates no detached task.
## Tests
`tests/test_installation_cooldown.py` drives one plugin object plus one real
`KeywordMonitor` component object through two distinct `InstallationBinding`s (same
instance and Workspace, different installation uuids) with the installed SDK's
`bind_invocation`, over a stub Host that records alert relays. It asserts that the
same message in the same group produces one alert **per** installation (pre-fix:
one relay total, because A's process-global `(group, keyword)` cooldown suppressed
B), that the cooldown still suppresses a repeat inside one installation, that
revocation releases only the revoked installation's table (so its next alert is
allowed again while a sibling is untouched), and that the scope token is the full
binding.
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<circle cx="50" cy="50" r="45" fill="#E74C3C" />
<text x="50" y="58" text-anchor="middle" font-size="40" fill="white" font-family="Arial">🔔</text>
</svg>

After

Width:  |  Height:  |  Size: 249 B

@@ -0,0 +1 @@
# AutoTranslate event listener
@@ -0,0 +1,62 @@
from __future__ import annotations
import logging
import traceback
from langbot_plugin.api.definition.components.common.event_listener import EventListener
from langbot_plugin.api.entities import events, context
from langbot_plugin.api.entities.builtin.platform import message as platform_message
logger = logging.getLogger("KeywordAlert.monitor")
class KeywordMonitor(EventListener):
"""Monitors group messages for configured keywords."""
def __init__(self):
super().__init__()
@self.handler(events.GroupMessageReceived)
async def on_group_message(event_context: context.EventContext):
await self._handle(event_context)
async def _handle(self, event_context: context.EventContext):
try:
plugin = self.plugin
event = event_context.event
group_id = str(event.launcher_id)
sender_id = str(event.sender_id)
# Check if this group is monitored
allowed_groups = plugin.parse_group_ids()
if allowed_groups is not None and group_id not in allowed_groups:
return
# Extract text
text_parts = []
for component in event.message_chain:
if isinstance(component, platform_message.Plain):
text_parts.append(component.text)
text = "".join(text_parts).strip()
if not text:
return
# Check keywords
keywords = plugin.parse_keywords()
if not keywords:
return
config = plugin.get_config()
case_sensitive = config.get("case_sensitive", False)
match_text = text if case_sensitive else text.lower()
for keyword in keywords:
match_keyword = keyword if case_sensitive else keyword.lower()
if match_keyword in match_text:
if plugin.check_cooldown(group_id, keyword):
await plugin.send_alert(group_id, sender_id, keyword, text)
break # One alert per message
except Exception as e:
logger.error(f"Error in keyword monitor: {e}\n{traceback.format_exc()}")
@@ -0,0 +1,12 @@
apiVersion: v1
kind: EventListener
metadata:
name: monitor
label:
en_US: Keyword Monitor
zh_Hans: 关键词监控
spec:
execution:
python:
path: monitor.py
attr: KeywordMonitor
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
import logging
import time
from typing import Optional
from langbot_plugin.api.definition.plugin import BasePlugin
logger = logging.getLogger("KeywordAlert")
# Scope used when the active invocation carries no installation binding.
LEGACY_SCOPE = ""
class KeywordAlert(BasePlugin):
"""Monitor group messages for keywords and alert admins via private message."""
def __init__(self):
super().__init__()
# Cooldown tracking, keyed by installation scope then
# (group_id, keyword): one object graph serves every installation of
# this artifact digest, so a cooldown must never suppress a sibling
# installation's alert. Entries are released in
# on_installation_revoked().
self._cooldowns: dict[str, dict[tuple[str, str], float]] = {}
async def initialize(self):
# Process-scoped only: no tenant config or state is read here.
logger.info("KeywordAlert plugin initialized")
async def on_installation_revoked(self, binding) -> None:
"""Release one installation's cooldown table (no Host call is made here)."""
self._cooldowns.pop(self._scope(binding), None)
def _scope(self, binding=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 cooldown. Dedicated placement has no binding and keeps
its process-local scope.
"""
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 parse_keywords(self) -> list[str]:
raw = self.get_config().get("keywords", "")
return [k.strip() for k in raw.split(",") if k.strip()]
def parse_group_ids(self) -> Optional[set[str]]:
raw = self.get_config().get("group_ids", "")
if not raw or not raw.strip():
return None # Monitor all groups
return {g.strip() for g in raw.split(",") if g.strip()}
def check_cooldown(self, group_id: str, keyword: str) -> bool:
"""Returns True if alert is allowed (not in cooldown)."""
config = self.get_config()
cooldown = int(config.get("cooldown_seconds", 60))
key = (group_id, keyword)
now = time.time()
table = self._cooldowns.setdefault(self._scope(), {})
last = table.get(key, 0)
if now - last < cooldown:
return False
table[key] = now
return True
async def send_alert(self, group_id: str, sender_id: str, keyword: str, text: str):
"""Send private alert to admin."""
from langbot_plugin.api.entities.builtin.platform import message as platform_message
config = self.get_config()
admin_id = config.get("admin_id", "")
if not admin_id:
logger.warning("No admin_id configured, skipping alert")
return
bot_uuid = config.get("bot")
if not bot_uuid:
bots = await self.get_bots()
if not bots:
logger.warning("No bots available to send alert")
return
bot_uuid = bots[0]
alert_text = (
f"🔔 关键词告警\n"
f"━━━━━━━━━━━━━━\n"
f"关键词: {keyword}\n"
f"群组: {group_id}\n"
f"发送者: {sender_id}\n"
f"━━━━━━━━━━━━━━\n"
f"{text[:500]}"
)
chain = platform_message.MessageChain([
platform_message.Plain(text=alert_text)
])
try:
await self.send_message(
bot_uuid=bot_uuid,
target_type="person",
target_id=admin_id,
message_chain=chain,
)
logger.info(f"Alert sent: keyword='{keyword}' group={group_id}")
except Exception as e:
logger.error(f"Failed to send alert: {e}")
+101
View File
@@ -0,0 +1,101 @@
apiVersion: v1
kind: Plugin
metadata:
author: langbot-team
name: KeywordAlert
repository: https://github.com/langbot-app/LangBot/tree/master/plugins/misc/KeywordAlert
version: 0.1.3
description:
en_US: 'Monitor group chat messages for keywords and send private alerts to admins.
Supports multiple groups and keyword lists.'
zh_Hans: '监控群聊消息中的关键词,私聊通知管理员。支持配置多个群和多个关键词。'
zh_Hant: 監控群組聊天訊息中的關鍵字並向管理員發送私人警報。支援多個群組和關鍵字清單。
ja_JP: キーワードに関するグループ チャット メッセージを監視し、管理者にプライベート アラートを送信します。複数のグループとキーワード
リストをサポートします。
vi_VN: Theo dõi tin nhắn trò chuyện nhóm để tìm từ khóa và gửi thông báo
riêng tư cho quản trị viên. Hỗ trợ nhiều nhóm và danh sách từ khóa.
th_TH:
ตรวจสอบข้อความแชทกลุ่มเพื่อหาคำสำคัญและส่งการแจ้งเตือนส่วนตัวไปยังผู้ดูแลระบบ
รองรับหลายกลุ่มและรายการคำหลัก
es_ES: Supervise los mensajes de chat grupal en busca de palabras clave y
envíe alertas privadas a los administradores. Admite múltiples grupos y
listas de palabras clave.
label:
en_US: Keyword Alert
zh_Hans: 关键词监控
zh_Hant: Keyword Alert
ja_JP: Keyword Alert
vi_VN: Keyword Alert
th_TH: Keyword Alert
es_ES: Keyword Alert
icon: assets/icon.svg
spec:
config:
- name: keywords
type: string
label:
en_US: 'Keywords (comma-separated)'
zh_Hans: '关键词(逗号分隔)'
description:
en_US: 'Comma-separated keywords to monitor, e.g.: bug,urgent,help'
zh_Hans: '逗号分隔的关键词列表,例如:bug,紧急,求助'
required: true
- name: group_ids
type: string
label:
en_US: 'Group IDs (comma-separated, empty = all groups)'
zh_Hans: '群组 ID(逗号分隔,留空 = 所有群)'
description:
en_US: 'Only monitor these groups. Leave empty to monitor all groups.'
zh_Hans: '仅监控这些群。留空则监控所有群。'
required: false
- name: admin_id
type: string
label:
en_US: 'Admin Session ID'
zh_Hans: '管理员会话 ID'
description:
en_US: 'The user/session ID to receive alert notifications via private message'
zh_Hans: '接收告警私聊通知的用户/会话 ID'
required: true
- name: bot
type: bot-selector
label:
en_US: 'Alert Bot'
zh_Hans: '告警机器人'
description:
en_US: 'Select which bot sends the alert. If not set, the first available bot
will be used.'
zh_Hans: '选择发送告警的机器人。未设置时使用第一个可用的机器人。'
required: false
- name: case_sensitive
type: bool
label:
en_US: 'Case Sensitive'
zh_Hans: '区分大小写'
description:
en_US: 'Whether keyword matching is case sensitive (default: no)'
zh_Hans: '关键词匹配是否区分大小写(默认否)'
required: false
default: false
- name: cooldown_seconds
type: int
label:
en_US: 'Cooldown (seconds)'
zh_Hans: '冷却时间(秒)'
description:
en_US: 'Minimum interval between alerts for the same keyword in the same group
(default: 60)'
zh_Hans: '同群同关键词两次告警最短间隔秒数(默认 60)'
required: false
default: 60
components:
EventListener:
fromDirs:
- path: components/event_listener/
execution:
python:
path: main.py
attr: KeywordAlert
sharedRuntime: shared-runtime-v1
componentModel: stateless-v1
@@ -0,0 +1,41 @@
# Alerta de palabra clave
Supervise los mensajes de chat grupal en busca de palabras clave específicas y reciba alertas instantáneas de mensajes privados.
## Características
- 🔔 Monitoreo de palabras clave en tiempo real en chats grupales
- 📋 Listas de palabras clave configurables (separadas por comas)
- 🎯 Monitorear grupos específicos o todos los grupos
- 🤖 Elige qué bot envía alertas
- ⏱️ Tiempo de reutilización para evitar alertas no deseadas
- 🔤 Coincidencia opcional que distingue entre mayúsculas y minúsculas
## Cómo funciona
1. Configure las palabras clave que desea monitorear (por ejemplo, "error, urgente, ayuda")
2. Configure su ID de usuario/sesión como administrador para recibir alertas.
3. Cuando alguien envía un mensaje que contiene una palabra clave en un grupo monitoreado, recibe una alerta de mensaje privado con el contexto completo.
## Configuración
| Opción | Descripción | Predeterminado |
|---|---|---|
| Palabras clave | Palabras clave separadas por comas para monitorear | (obligatorio) |
| ID de grupo | ID de grupo separados por comas (vacíos = todos) | Todos los grupos |
| ID de sesión de administrador | Quién recibe las alertas | (obligatorio) |
| Bot de alerta | ¿Qué bot envía la alerta? Primero disponible |
| Distingue entre mayúsculas y minúsculas | Coincidencia que distingue entre mayúsculas y minúsculas | Apagado |
| Enfriamiento | Segundos entre alertas de la misma palabra clave por grupo | 60 |
## Formato de alerta
```
🔔 关键词告警
━━━━━━━━━━━━━━
关键词: urgent
群组: 123456789
发送者: 987654321
━━━━━━━━━━━━━━
Hey, this is urgent, the server is down!
```
@@ -0,0 +1,23 @@
# キーワードアラート
グループチャットのメッセージを監視し、キーワードが含まれる場合にプライベートメッセージで管理者に通知します。
## 機能
- 🔔 リアルタイムキーワード監視
- 📋 複数キーワード対応(カンマ区切り)
- 🎯 特定のグループまたは全グループを監視
- 🤖 アラート送信ボットを選択可能
- ⏱️ クールダウンでアラートスパムを防止
- 🔤 大文字小文字の区別オプション
## 設定
| オプション | 説明 | デフォルト |
|---|---|---|
| キーワード | カンマ区切りのキーワード | (必須) |
| グループID | カンマ区切り(空=全グループ) | 全グループ |
| 管理者ID | アラートを受信するユーザー | (必須) |
| ボット | アラートを送信するボット | 最初の利用可能なボット |
| 大文字小文字区別 | 大文字小文字を区別するか | オフ |
| クールダウン | 同じキーワードのアラート間隔(秒) | 60 |
@@ -0,0 +1,41 @@
# การแจ้งเตือนคำหลัก
ตรวจสอบข้อความแชทกลุ่มสำหรับคำสำคัญเฉพาะ และรับการแจ้งเตือนข้อความส่วนตัวทันที
## คุณสมบัติ
- 🔔 การตรวจสอบคำหลักแบบเรียลไทม์ในการแชทกลุ่ม
- 📋 รายการคำหลักที่กำหนดค่าได้ (คั่นด้วยเครื่องหมายจุลภาค)
- 🎯 ติดตามเฉพาะกลุ่มหรือทุกกลุ่ม
- 🤖 เลือกบอทที่จะส่งการแจ้งเตือน
- ⏱️ คูลดาวน์เพื่อป้องกันสแปมการแจ้งเตือน
- 🔤 การจับคู่แบบคำนึงถึงตัวพิมพ์เล็กและใหญ่หรือไม่ก็ได้
## มันทำงานอย่างไร
1. กำหนดค่าคำหลักที่คุณต้องการตรวจสอบ (เช่น`bug,urgent,help`)
2. ตั้งค่า ID ผู้ใช้/เซสชันของคุณเป็นผู้ดูแลระบบเพื่อรับการแจ้งเตือน
3. เมื่อมีคนส่งข้อความที่มีคำสำคัญในกลุ่มที่ได้รับการตรวจสอบ คุณจะได้รับการแจ้งเตือนข้อความส่วนตัวพร้อมบริบททั้งหมด
## การกำหนดค่า
| ตัวเลือก | คำอธิบาย | ค่าเริ่มต้น |
|---|---|---|
| คำสำคัญ | คำหลักที่คั่นด้วยเครื่องหมายจุลภาคเพื่อติดตาม | (จำเป็น) |
| รหัสกลุ่ม | ID กลุ่มที่คั่นด้วยเครื่องหมายจุลภาค (ว่าง = ทั้งหมด) | ทุกกลุ่ม |
| รหัสเซสชันผู้ดูแลระบบ | ใครได้รับการแจ้งเตือน | (จำเป็น) |
| บอทแจ้งเตือน | บอทตัวไหนส่งการแจ้งเตือน | วางจำหน่ายครั้งแรก |
| คำนึงถึงขนาดตัวพิมพ์ | การจับคู่แบบคำนึงถึงตัวพิมพ์เล็กและตัวพิมพ์ใหญ่ | ปิด |
| คูลดาวน์ | วินาทีระหว่างการแจ้งเตือนคำหลักเดียวกันต่อกลุ่ม | 60 |
## รูปแบบการแจ้งเตือน
```
🔔 关键词告警
━━━━━━━━━━━━━━
关键词: urgent
群组: 123456789
发送者: 987654321
━━━━━━━━━━━━━━
Hey, this is urgent, the server is down!
```
@@ -0,0 +1,41 @@
# Cảnh báo từ khóa
Giám sát tin nhắn trò chuyện nhóm để tìm từ khóa cụ thể và nhận thông báo tin nhắn riêng tư ngay lập tức.
## Đặc trưng
- 🔔 Giám sát từ khóa theo thời gian thực trên các cuộc trò chuyện nhóm
- 📋 Danh sách từ khóa có thể định cấu hình (được phân tách bằng dấu phẩy)
- 🎯 Giám sát các nhóm cụ thể hoặc tất cả các nhóm
- 🤖 Chọn bot nào gửi thông báo
- ⏱️ Thời gian hồi chiêu để ngăn chặn cảnh báo spam
- 🔤 Tùy chọn kết hợp phân biệt chữ hoa chữ thường
## Nó hoạt động như thế nào
1. Định cấu hình các từ khóa bạn muốn theo dõi (ví dụ:`lỗi,khẩn cấp,trợ giúp`)
2. Đặt ID người dùng/phiên của bạn làm quản trị viên để nhận thông báo
3. Khi ai đó gửi tin nhắn có chứa từ khóa trong nhóm được giám sát, bạn sẽ nhận được thông báo tin nhắn riêng tư với ngữ cảnh đầy đủ
## Cấu hình
| Tùy chọn | Mô tả | Mặc định |
|---|---|---|
| Từ khóa | Từ khóa được phân tách bằng dấu phẩy để theo dõi | (bắt buộc) |
| ID nhóm | ID nhóm được phân tách bằng dấu phẩy (trống = tất cả) | Tất cả các nhóm |
| ID phiên quản trị viên | Ai nhận được thông báo | (bắt buộc) |
| Cảnh báo Bot | Bot nào gửi cảnh báo | Có sẵn đầu tiên |
| Phân biệt chữ hoa chữ thường | Khớp phân biệt chữ hoa chữ thường | Tắt |
| Thời gian hồi chiêu | Số giây giữa các cảnh báo từ khóa giống nhau cho mỗi nhóm | 60 |
## Định dạng cảnh báo
```
🔔 关键词告警
━━━━━━━━━━━━━━
关键词: urgent
群组: 123456789
发送者: 987654321
━━━━━━━━━━━━━━
Hey, this is urgent, the server is down!
```
@@ -0,0 +1,29 @@
# 关键词监控
监控群聊消息中的关键词,命中时立即私聊通知管理员。
## 功能
- 🔔 实时监控群聊关键词
- 📋 支持多关键词配置(逗号分隔)
- 🎯 可指定监控群组或监控所有群
- 🤖 选择发送告警的机器人
- ⏱️ 冷却机制防止告警轰炸
- 🔤 可选大小写敏感匹配
## 使用方法
1. 配置需要监控的关键词(如 `bug,紧急,求助`)
2. 填写接收告警的管理员会话 ID
3. 当群消息命中关键词时,你会收到私聊告警通知
## 配置
| 选项 | 说明 | 默认值 |
|---|---|---|
| 关键词 | 逗号分隔的关键词列表 | (必填) |
| 群组 ID | 逗号分隔的群组 ID(留空 = 全部群) | 所有群 |
| 管理员会话 ID | 接收告警的用户 ID | (必填) |
| 告警机器人 | 发送告警的 bot | 第一个可用 |
| 大小写敏感 | 是否区分大小写 | 否 |
| 冷却时间 | 同群同关键词最短间隔秒数 | 60 |
@@ -0,0 +1,41 @@
# 關鍵字提醒
監控群組聊天訊息中的特定關鍵字並接收即時私人訊息警報。
## 特徵
- 🔔 跨群聊即時關鍵字監控
- 📋可設定的關鍵字清單(以逗號分隔)
- 🎯 監控特定組或所有組
- 🤖 選擇哪個機器人發送警報
- ⏱️ 冷卻以防止警報垃圾郵件
- 🔤 可選區分大小寫的匹配
## 它是如何工作的
1. 設定要監控的關鍵字(例如`bug,urgent,help`)
2. 將您的使用者/會話 ID 設定為管理員以接收警報
3. 當有人在受監控的群組中傳送包含關鍵字的訊息時,您會收到包含完整上下文的私人訊息警報
## 配置
|選項 |說明 |預設 |
|---|---|---|
|關鍵字|要監控的以逗號分隔的關鍵字 | (必填)|
|組 ID |以逗號分隔的組 ID(空 = 全部)|所有團體|
|管理會話 ID |誰收到警報 | (必填)|
|警報機器人 |哪個機器人發送警報 |首次上市 |
|區分大小寫 |區分大小寫的匹配 |關閉 |
|冷卻時間|每組相同關鍵字警報之間的秒數 | 60|
## 警報格式
```
🔔 关键词告警
━━━━━━━━━━━━━━
关键词: urgent
群组: 123456789
发送者: 987654321
━━━━━━━━━━━━━━
Hey, this is urgent, the server is down!
```
@@ -0,0 +1,187 @@
"""Two installations, one KeywordAlert object graph: alerts never cross cooldowns.
Evidence model: one real plugin object plus one real ``KeywordMonitor``
component object are driven through the real SDK
``bind_invocation``/``InstallationBinding`` with two distinct installation
bindings; the stub Host records every ``SEND_MESSAGE`` relay, so a suppressed
alert is observable as a smaller relay list.
Pre-fix failure (asserted below): ``_cooldowns`` was one process-global dict
keyed by ``(group_id, keyword)`` only. Installation A's first alert therefore
suppressed installation B's alert for the same group/keyword, and
``on_installation_revoked`` had nothing to release. The identical message below
produced one relay instead of two.
"""
from __future__ import annotations
import asyncio
import importlib.util
from pathlib import Path
from types import SimpleNamespace
from langbot_plugin.api.entities import events
from langbot_plugin.api.entities.builtin.platform import message as platform_message
from langbot_plugin.api.proxies.invocation import bind_invocation
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction as Action
from langbot_plugin.entities.io.context import InstallationBinding
PLUGIN_ROOT = Path(__file__).resolve().parents[1]
def _load(path: Path, name: str):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
alert_main = _load(PLUGIN_ROOT / "main.py", "keyword_alert_main")
monitor_module = _load(PLUGIN_ROOT / "components" / "event_listener" / "monitor.py", "keyword_monitor")
class FlatHost:
"""Stub Host that records alert relays (no live platform call)."""
def __init__(self):
self.sent: list[dict] = []
async def call_action(self, action, data, *args, **kwargs):
if action == Action.SEND_MESSAGE:
self.sent.append(data)
return {"result": "ok"}
if action == Action.GET_BOTS:
return {"bots": ["fallback-bot"]}
raise AssertionError(f"unexpected Host action: {action}")
CONFIG = {
"keywords": "urgent",
"group_ids": "",
"admin_id": "admin-1",
"bot": "bot-1",
"case_sensitive": False,
"cooldown_seconds": 3600,
}
def binding(installation: str, *, workspace: str = "workspace-1"):
return InstallationBinding(
instance_uuid="instance-1",
workspace_uuid=workspace,
installation_uuid=installation,
placement_generation=1,
runtime_revision=1,
artifact_digest="a" * 64,
)
def build_graph():
plugin = alert_main.KeywordAlert()
host = FlatHost()
plugin.plugin_runtime_handler = host
monitor = monitor_module.KeywordMonitor()
monitor.plugin = plugin
handler = monitor.registered_handlers[events.GroupMessageReceived][0]
return plugin, host, handler
def group_message(text: str, *, group_id: str = "group-1", sender_id: str = "user-1"):
event = SimpleNamespace(
launcher_id=group_id,
sender_id=sender_id,
message_chain=platform_message.MessageChain([platform_message.Plain(text=text)]),
)
return SimpleNamespace(event=event)
def _texts(payloads: list[dict]) -> list[str]:
return [
"".join(
part.get("text", "")
for part in payload["message_chain"]
if part.get("type") == "Plain"
)
for payload in payloads
]
def test_one_installation_cooldown_does_not_suppress_another():
async def scenario():
plugin, host, handler = build_graph()
first, second = binding("installation-a"), binding("installation-b")
with bind_invocation(host, config=CONFIG, binding=first):
await handler(group_message("this is urgent"))
with bind_invocation(host, config=CONFIG, binding=second):
await handler(group_message("this is urgent"))
# Pre-fix: only one relay, because the shared (group, keyword) key was
# already in cooldown from installation A.
assert len(host.sent) == 2
assert all(payload["target_id"] == "admin-1" for payload in host.sent)
assert _texts(host.sent)[0] == _texts(host.sent)[1]
assert "urgent" in _texts(host.sent)[1]
# Each installation keeps its own cooldown table.
assert set(plugin._cooldowns) == {plugin._scope(first), plugin._scope(second)}
# The cooldown still works inside one installation.
with bind_invocation(host, config=CONFIG, binding=first):
await handler(group_message("urgent again"))
assert len(host.sent) == 2
asyncio.run(scenario())
def test_cooldown_is_released_on_revocation():
async def scenario():
plugin, host, handler = build_graph()
first = binding("installation-a")
with bind_invocation(host, config=CONFIG, binding=first):
await handler(group_message("this is urgent"))
assert len(host.sent) == 1
await plugin.on_installation_revoked(first)
assert plugin._scope(first) not in plugin._cooldowns
with bind_invocation(host, config=CONFIG, binding=first):
await handler(group_message("this is urgent"))
assert len(host.sent) == 2
asyncio.run(scenario())
def test_cooldown_key_includes_the_full_binding():
plugin = alert_main.KeywordAlert()
same_installation_other_workspace = binding("installation-a", workspace="workspace-2")
assert plugin._scope(binding("installation-a")) != plugin._scope(same_installation_other_workspace)
assert plugin._scope(binding("installation-a")) == "instance-1:workspace-1:installation-a"
assert plugin._scope() == alert_main.LEGACY_SCOPE
def test_revocation_of_an_unknown_binding_is_a_no_op():
async def scenario():
plugin, _host, _handler = build_graph()
await plugin.on_installation_revoked(binding("installation-never-seen"))
assert plugin._cooldowns == {}
asyncio.run(scenario())
def test_dedicated_binding_less_invocation_keeps_its_cooldown():
"""A dedicated worker's invocation carries no binding; it keeps its own scope."""
async def scenario():
plugin, host, handler = build_graph()
with bind_invocation(host, config=CONFIG): # no installation binding
await handler(group_message("this is urgent"))
assert len(host.sent) == 1
assert set(plugin._cooldowns) == {alert_main.LEGACY_SCOPE}
with bind_invocation(host, config=CONFIG):
await handler(group_message("urgent again"))
assert len(host.sent) == 1 # the legacy cooldown still suppresses the repeat
asyncio.run(scenario())