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
+3
View File
@@ -0,0 +1,3 @@
# This is a .env file example, please copy it to .env file.
# Please refer to https://langbot.app/docs/en/plugin/dev/tutor.html for more details.
DEBUG_RUNTIME_WS_URL=ws://localhost:5401/debug/ws
+176
View File
@@ -0,0 +1,176 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
/data/
+129
View File
@@ -0,0 +1,129 @@
# ScheNotify
Schedule notifications with natural language
## Features
ScheNotify is a LangBot plugin that allows users to set timed reminders through natural language interaction with LLM.
### Main Features
- **Natural Language Interaction**: Understand user's scheduling intentions through LLM
- **Smart Time Parsing**: Automatically get current time and calculate reminder time
- **Multi-language Support**: Support Chinese and English reminder messages
- **Schedule Management Commands**: View and delete scheduled reminders
- **Automatic Notifications**: Automatically send reminder messages at scheduled time
## Configuration
### Language Setting
You can select the language for reminder messages in plugin configuration:
- `zh_Hans` (Simplified Chinese) - Default
- `en_US` (English)
## Usage
### 1. Schedule via LLM
Simply tell the LLM your schedule in natural language:
**Examples:**
```
Remind me to have a meeting at 3 PM tomorrow
Remind me to submit the report at 9 AM the day after tomorrow
Remind me to have lunch at 12 PM next Monday
Remind me about Christmas dinner at 2024-12-25 18:00
```
The LLM will automatically:
1. Call `get_current_time_str` to get current time
2. Parse your time expression and convert to standard format
3. Call `schedule_notify` to create reminder
### 2. View Scheduled Reminders
Use command to view all scheduled reminders:
```
!sche
```
Example output:
```
[Notify] Scheduled reminders:
#1 2024-12-25 18:00:00: Christmas dinner
#2 2024-12-26 09:00:00: Submit report
```
### 3. Delete Reminder
Use command to delete a specific reminder (using the number from `!sche`):
```
!dsche i <number>
```
Example:
```
!dsche i 1 # Delete the 1st reminder
```
## Components
### Tools
1. **get_current_time_str** - Get current time
- Return format: `YYYY-MM-DD HH:MM:SS`
- LLM must call this tool before setting reminders
2. **schedule_notify** - Schedule notification
- Parameters: time string, reminder message
- Automatically obtains session info from Tool's session parameter
### Commands
1. **sche** (alias: s) - List all scheduled reminders
2. **dsche** (alias: d) - Delete specified reminder
## Technical Details
- Check interval: Every 60 seconds
- Time precision: Minute level (checks every minute)
- Session info: Automatically obtained through Tool's session parameter
- Persistence: Currently uses in-memory storage (lost on restart)
## Example Conversation
**User:** Remind me to attend a meeting at 2 PM tomorrow
**LLM:** Sure, I'll set a reminder for you.
*[LLM calls get_current_time_str]*
*[LLM calls schedule_notify(time_str="2024-12-26 14:00:00", message="Attend meeting")]*
**LLM:** Done! I will remind you at 2024-12-26 14:00:00: Attend meeting
*[The next day at 2 PM]*
**Bot:** [Notify] Attend meeting
## Notes
- Reminder time must be in the future, past times will be rejected
- Reminder messages will be sent to the same session where the reminder was set
- Listing and deleting are scoped to the exact session that set the reminder (bot + person/group + target id), so reminders set from another bot or from a group with the same id are neither shown nor deleted
- At most 100 reminders can be pending per installation, and each message is limited to 2000 characters
- A reminder whose delivery fails stays queued and is retried on the next check
- Unsent reminders will be lost after plugin restart (persistence will be supported in future versions)
## Developer Info
- Author: RockChinQ
- Version: 0.2.0
- Plugin Type: LangBot Plugin v1
## License
Part of the LangBot plugin ecosystem.
+104
View File
@@ -0,0 +1,104 @@
# 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 `ScheNotify` object and one of every declared component
serve every installation of the artifact digest. Installation attachments are
lightweight contexts; they do not create another object graph.
## Per invocation
- The `language` config is read through `self.plugin.get_config()` inside the
command and Tool components that use it. No config value is stored on the shared
object or in a scheduled event.
- `get_installation_binding()` is read per invocation and never retained past the
call; it decides which installation's schedule `add_scheduled_event`,
`get_scheduled_events` and `delete_scheduled_event` read and write.
- Inside one installation a reminder belongs to the full session identity
`(bot_uuid, target_type, target_id)` derived from the trusted platform session.
The `!sche` and `!dsche` commands pass that key, and `delete_scheduled_event`
refuses to remove a record that does not belong to the caller's session, so two
bots of one installation, or the person/group namespaces with the same id, are
never treated as the same session.
- `initialize()` is process-scoped and only logs: it runs once per worker with an
empty config, before any installation is known, so it starts no scheduler.
## Per binding, released on revocation
- `_events` is keyed by the installation scope
`<instance_uuid>:<workspace_uuid>:<installation_uuid>` and holds that
installation's pending reminders only. `runtime_revision` is deliberately excluded
from the token, so a worker upgrade does not orphan a tenant's pending reminders
within the process lifetime.
- `_loops` holds one minute-scheduler task per installation scope.
- `BasePlugin.on_installation_revoked(binding)` cancels that installation's
scheduler (bounded 5 s wait) and drops its pending reminders, so a revoked
installation can never fire an already-scheduled notification afterwards.
- The schedule is in-memory only (unchanged pre-existing behaviour): nothing about a
reminder is written to Host storage, so revocation has no durable row to clean up.
- Dedicated (binding-less) workers keep a single binding-less schedule and their
existing behaviour; their schedulers are stopped by process shutdown.
## The detached scheduler
Each installation's scheduler is a detached task, registered per installation and
stopped by revocation. It carries its installation scope as an explicit argument
and re-enters the invocation it was started from: the handler, config and binding
captured at scheduling time are passed back into `bind_invocation(...)` for the
whole life of the loop, so every `SEND_MESSAGE` a firing reminder makes is
attributable to the installation that scheduled it instead of falling back to the
worker's connection default. `on_installation_revoked(binding)` cancels the task,
which exits that scope and drops the authority with it; the same hook drops the
installation's pending reminders, so a revoked (or upgraded) installation can
neither fire nor send afterwards. Because the scope token excludes
`runtime_revision`, a worker upgrade presents a new binding for the same token;
the next scheduling invocation then cancels the superseded task and starts the
loop again under the new binding, while the installation's pending reminders are
kept. The scheduler never reads config or any other invocation-scoped API itself:
delivery is language-independent (`NOTIFY_PREFIX`).
## Known limits
- The scheduler is in-memory: pending reminders are lost on process restart (also
the pre-existing behaviour).
- Editing the `language` config only affects messages produced by later
invocations; notification delivery itself reads no config, because the prefix is
language-independent (the previous per-language lookup produced the same literal
for both supported languages and was removed).
- `get_scheduled_events()` returns a new list of the pending event records; the
records themselves are the live dicts, so callers must not mutate them.
- One installation's queue holds at most `MAX_EVENTS_PER_INSTALLATION` (100)
reminders and each stored message is capped at `MAX_MESSAGE_LENGTH` (2000)
characters; further reminders are rejected with a message instead of growing the
worker's memory. A delivery that the Host refuses is kept queued with its failure
count recorded, so it is retried on the next pass instead of being dropped.
- Per-installation state is only 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.
## Tests
`tests/test_installation_schedule.py` drives two distinct `InstallationBinding`s
(same instance and Workspace, different installation uuids) through one plugin
object with the installed SDK's `bind_invocation`, over a stub Host that records
notification relays. It asserts that B's `get_scheduled_events` returns only B's
reminder (pre-fix it returned both, from one shared list), that A's scheduler fires
only A's due reminder and leaves B's pending, that the scheduler is registered per
binding and stopped by revocation with the revoked installation's schedule dropped
(no late delivery) while a sibling's schedule survives, that `initialize()` starts
no scheduler, and that the scope token is the full binding.
The second-round tests add: the detached relay's Host call carries the scheduling
installation's binding (pre-fix `None`); a worker upgrade restarts the loop under
the new binding instead of delivering with the superseded task; `!sche`/`!dsche`
and the Tool use the full session identity, so another bot or namespace with the
same id is neither listed nor deleted (pre-fix the command listed and deleted the
other session's reminder); a refused send stays queued with its failure count; a
sibling delete during the awaited send no longer raises `ValueError` from
`list.remove`; and the queue is bounded while the reminder text is no longer
written to the debug log.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="36" height="36" fill="rgba(70,146,221,1)"><path d="M12.0001 22.0001C7.02956 22.0001 3.00012 17.9707 3.00012 13.0001C3.00012 8.02956 7.02956 4.00012 12.0001 4.00012C16.9707 4.00012 21.0001 8.02956 21.0001 13.0001C21.0001 17.9707 16.9707 22.0001 12.0001 22.0001ZM12.0001 20.0001C15.8661 20.0001 19.0001 16.8661 19.0001 13.0001C19.0001 9.13412 15.8661 6.00012 12.0001 6.00012C8.13412 6.00012 5.00012 9.13412 5.00012 13.0001C5.00012 16.8661 8.13412 20.0001 12.0001 20.0001ZM13.0001 13.0001H16.0001V15.0001H11.0001V8.00012H13.0001V13.0001ZM1.74707 6.2826L5.2826 2.74707L6.69682 4.16128L3.16128 7.69682L1.74707 6.2826ZM18.7176 2.74707L22.2532 6.2826L20.839 7.69682L17.3034 4.16128L18.7176 2.74707Z"></path></svg>

After

Width:  |  Height:  |  Size: 774 B

@@ -0,0 +1 @@
# Components package
@@ -0,0 +1 @@
# Commands package
@@ -0,0 +1,87 @@
# 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
from typing import AsyncGenerator
from langbot_plugin.api.definition.components.command.command import Command
from langbot_plugin.api.entities.builtin.command.context import ExecuteContext, CommandReturn
class DscheCommand(Command):
"""
Command to delete a scheduled notification by index.
Usage: !dsche <index>
Example: !dsche 1
"""
def __init__(self):
super().__init__()
@self.subcommand(
name="i",
help="Delete a scheduled notification by index",
usage="dsche i <index>",
aliases=["d"],
)
async def delete_notification(self, context: ExecuteContext) -> AsyncGenerator[CommandReturn, None]:
"""Delete a scheduled notification by index"""
# Get language setting
config = self.plugin.get_config()
language = config.get("language", "zh_Hans")
# Check if index parameter is provided
if len(context.crt_params) == 0:
if language == "en_US":
reply = '[Notify] Command format: !dsche <index>, e.g. !dsche 1. Please use !sche to view scheduled notifications first.'
else:
reply = '[Notify] 命令格式: !dsche <index>,例如 !dsche 1。请先使用 !sche 查看计划中的提醒'
yield CommandReturn(text=reply)
return
try:
index = int(context.crt_params[0]) - 1
except ValueError:
if language == "en_US":
reply = '[Notify] Invalid index. Please provide a number.'
else:
reply = '[Notify] 无效的索引,请提供数字'
yield CommandReturn(text=reply)
return
# Get the full session identity from the trusted session: a target id
# alone is shared by other bots and by the person/group namespaces.
session = context.session
session_key = self.plugin.session_key(
session.bot_uuid, session.launcher_type.value, str(session.launcher_id)
)
# Get scheduled events from plugin
scheduled_events = await self.plugin.get_scheduled_events(session_key)
if index < 0 or index >= len(scheduled_events):
if language == "en_US":
reply = '[Notify] Index out of range'
else:
reply = '[Notify] 索引超出范围'
yield CommandReturn(text=reply)
return
event = scheduled_events[index]
# Delete the event; the plugin re-checks that it belongs to this session.
if not await self.plugin.delete_scheduled_event(event, session_key):
if language == "en_US":
reply = '[Notify] Index out of range'
else:
reply = '[Notify] 索引超出范围'
yield CommandReturn(text=reply)
return
if language == "en_US":
reply = f'[Notify] Deleted notification: {event["time"]} {event["message"]}'
else:
reply = f'[Notify] 已删除提醒:{event["time"]} {event["message"]}'
yield CommandReturn(text=reply)
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Command
metadata:
name: dsche
label:
en_US: Delete Scheduled Notification
zh_Hans: 删除计划的通知
description:
en_US: 'Delete a scheduled notification by index'
zh_Hans: '通过索引删除计划的通知'
spec:
execution:
python:
path: dsche.py
attr: DscheCommand
@@ -0,0 +1,59 @@
# 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
from typing import AsyncGenerator
from langbot_plugin.api.definition.components.command.command import Command
from langbot_plugin.api.entities.builtin.command.context import ExecuteContext, CommandReturn
class ScheCommand(Command):
"""
Command to list all scheduled notifications for the current session.
Usage: !sche
"""
def __init__(self):
super().__init__()
@self.subcommand(
name="",
help="List all scheduled notifications",
usage="sche",
aliases=["s"],
)
async def list_notifications(self, context: ExecuteContext) -> AsyncGenerator[CommandReturn, None]:
"""List all scheduled notifications for current session"""
# Get language setting
config = self.plugin.get_config()
language = config.get("language", "zh_Hans")
# Get the full session identity from the trusted session: a target id
# alone is shared by other bots and by the person/group namespaces.
session = context.session
session_key = self.plugin.session_key(
session.bot_uuid, session.launcher_type.value, str(session.launcher_id)
)
# Get scheduled events from plugin
scheduled_events = await self.plugin.get_scheduled_events(session_key)
if not scheduled_events or len(scheduled_events) == 0:
if language == "en_US":
reply = '[Notify] No scheduled notifications'
else:
reply = '[Notify] 没有计划中的提醒'
else:
if language == "en_US":
reply = '[Notify] Scheduled notifications:\n'
else:
reply = '[Notify] 计划中的提醒:\n'
index = 1
for event in scheduled_events:
reply += f'#{index} {event["time"]}:{event["message"]}\n'
index += 1
yield CommandReturn(text=reply)
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Command
metadata:
name: sche
label:
en_US: List Scheduled Notifications
zh_Hans: 列出计划的通知
description:
en_US: 'List all scheduled notifications for the current session'
zh_Hans: '列出当前会话的所有计划通知'
spec:
execution:
python:
path: sche.py
attr: ScheCommand
@@ -0,0 +1 @@
# Tools package
@@ -0,0 +1,34 @@
# 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
from typing import Any
import datetime
from langbot_plugin.api.definition.components.tool.tool import Tool
from langbot_plugin.api.entities.builtin.provider import session as provider_session
class GetCurrentTimeStr(Tool):
"""
Get the current time as a string.
This function should be called:
- When the user directly asks for the current time
- Before scheduling any notification to ensure accurate time calculation
"""
async def call(self, params: dict[str, Any], session: provider_session.Session, query_id: int) -> str:
"""
Get current time string in format: %Y-%m-%d %H:%M:%S
Args:
params: Tool parameters (not used)
session: Current session information
query_id: Query ID
Returns:
str: Current time string
"""
# Return current time
current_time = datetime.datetime.now()
return current_time.strftime("%Y-%m-%d %H:%M:%S")
@@ -0,0 +1,20 @@
apiVersion: v1
kind: Tool
metadata:
name: get_current_time_str
label:
en_US: Get Current Time
zh_Hans: 获取当前时间
description:
en_US: 'Get the current time as a string'
zh_Hans: '获取当前时间的字符串'
spec:
parameters:
type: object
properties: {}
required: []
llm_prompt: 'Get the current time. Call this function before scheduling any notification to ensure you have the latest current time.'
execution:
python:
path: get_current_time_str.py
attr: GetCurrentTimeStr
@@ -0,0 +1,113 @@
# 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
from typing import Any
import time
import datetime
from langbot_plugin.api.definition.components.tool.tool import Tool
from langbot_plugin.api.entities.builtin.provider import session as provider_session
class ScheduleNotify(Tool):
"""
Schedule a notification at a specific time.
This function should be called after getting the current time using get_current_time_str.
The program checks for notifications every minute, so precision is to the minute level.
"""
def _get_message(self, key: str, **kwargs) -> str:
"""
Get localized message based on plugin language setting.
Args:
key: Message key
**kwargs: Format arguments
Returns:
Formatted message string
"""
config = self.plugin.get_config()
language = config.get("language", "zh_Hans")
messages = {
"zh_Hans": {
"time_passed": "计划时间已经过去",
"success": "计划成功:\n将在{time}提醒您:{message}",
"no_session": "错误:无法获取会话信息,请先发送一条消息",
"too_many": "错误:本会话的计划提醒数量已达上限,请先删除一些提醒",
},
"en_US": {
"time_passed": "The scheduled time has already passed",
"success": "Scheduled successfully:\nWill remind you at {time}: {message}",
"no_session": "Error: Cannot get session info, please send a message first",
"too_many": "Error: This installation has reached its scheduled reminder limit; delete some reminders first",
}
}
template = messages.get(language, messages["zh_Hans"]).get(key, "")
return template.format(**kwargs) if kwargs else template
async def call(self, params: dict[str, Any], session: provider_session.Session, query_id: int) -> str:
"""
Schedule a notification.
Args:
params: {
"time_str": Time string in format "%Y-%m-%d %H:%M:%S",
"message": Notification message content
}
session: Current session information
query_id: Query ID
Returns:
str: Success message or error message
"""
time_str = params.get("time_str")
message = params.get("message")
if not time_str or not message:
return "Error: time_str and message are required"
try:
# Convert time string to timestamp
time_stamp = time.mktime(time.strptime(time_str, "%Y-%m-%d %H:%M:%S"))
# Get current timestamp
current_time_stamp = time.time()
# If scheduled time has already passed, return error
if time_stamp <= current_time_stamp:
return self._get_message("time_passed")
real_date = datetime.datetime.fromtimestamp(time_stamp)
# Get session info. The bot uuid is part of the session identity, so
# it is taken from the trusted session (falling back to its active
# conversation) and stored with the target type and id.
bot_uuid = session.bot_uuid or (
session.using_conversation.bot_uuid if session.using_conversation else None
)
target_type = session.launcher_type.value
target_id = str(session.launcher_id)
if not bot_uuid:
return self._get_message("no_session")
# Add to plugin's scheduled events with session info
queued = await self.plugin.add_scheduled_event(
time=real_date,
message=message,
bot_uuid=bot_uuid,
target_type=target_type,
target_id=target_id
)
if not queued:
return self._get_message("too_many")
return self._get_message("success", time=real_date, message=message)
except ValueError as e:
return f"Error: Invalid time format. Expected '%Y-%m-%d %H:%M:%S', got '{time_str}'"
except Exception as e:
return f"Error: {str(e)}"
@@ -0,0 +1,28 @@
apiVersion: v1
kind: Tool
metadata:
name: schedule_notify
label:
en_US: Schedule Notification
zh_Hans: 计划通知
description:
en_US: 'Schedule a notification at a specific time'
zh_Hans: '在指定时间计划一个通知提醒'
spec:
parameters:
type: object
properties:
time_str:
type: string
description: 'Time string in format "%Y-%m-%d %H:%M:%S", e.g. "2024-12-25 18:30:00". MUST call get_current_time_str first to get the current time before scheduling.'
message:
type: string
description: 'The notification message content to remind the user'
required:
- time_str
- message
llm_prompt: 'Schedule a notification at a specific time. IMPORTANT: You MUST call get_current_time_str first to get the latest current time before calling this function.'
execution:
python:
path: schedule_notify.py
attr: ScheduleNotify
+338
View File
@@ -0,0 +1,338 @@
# 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
+62
View File
@@ -0,0 +1,62 @@
apiVersion: v1
kind: Plugin
metadata:
author: langbot-team
name: ScheNotify
repository: https://github.com/langbot-app/LangBot/tree/master/plugins/misc/ScheNotify
version: 0.2.6
description:
en_US: Schedule notifications with natural language. LLM can help you set
reminders at specific times.
zh_Hans: 使用自然语言计划日程。LLM 可以帮助你在特定时间设置提醒。
zh_Hant: 定時通知插件,支援設定定時任務,在指定時間發送通知訊息。
ja_JP: スケジュール通知プラグイン。定期タスクを設定し、指定時刻に通知メッセージを送信します。
vi_VN: Plugin thông báo theo lịch, hỗ trợ thiết lập tác vụ định kỳ và gửi
thông báo vào thời gian chỉ định.
th_TH: ปลั๊กอินแจ้งเตือนตามกำหนดเวลา
รองรับการตั้งงานตามเวลาและส่งข้อความแจ้งเตือนตามเวลาที่กำหนด
es_ES: Plugin de notificaciones programadas que permite configurar tareas
periódicas y enviar mensajes de notificación en horarios específicos.
label:
en_US: ScheNotify
zh_Hans: 日程提醒
zh_Hant: ScheNotify
ja_JP: ScheNotify
vi_VN: ScheNotify
th_TH: ScheNotify
es_ES: ScheNotify
icon: assets/icon.svg
spec:
config:
- name: language
type: select
label:
en_US: Language
zh_Hans: 语言
description:
en_US: Language for notification messages
zh_Hans: 通知消息的语言
required: false
default: zh_Hans
options:
- label:
en_US: English
zh_Hans: 英语
name: en_US
- label:
en_US: Chinese (Simplified)
zh_Hans: 简体中文
name: zh_Hans
components:
Command:
fromDirs:
- path: components/commands/
Tool:
fromDirs:
- path: components/tools/
execution:
python:
path: main.py
attr: ScheNotify
sharedRuntime: shared-runtime-v1
componentModel: stateless-v1
@@ -0,0 +1,126 @@
# ProgramarNotificar
Programar notificaciones con lenguaje natural
## Características
ScheNotify es un complemento de LangBot que permite a los usuarios configurar recordatorios programados mediante la interacción en lenguaje natural con LLM.
### Características principales
-**Interacción en lenguaje natural**: comprenda las intenciones de programación del usuario a través de LLM
-**Análisis inteligente de la hora**: obtiene automáticamente la hora actual y calcula la hora del recordatorio
-**Soporte multilingüe**: Admite mensajes recordatorios en chino e inglés
-**Comandos de gestión de programación**: ver y eliminar recordatorios programados
-**Notificaciones automáticas**: envía mensajes recordatorios automáticamente a la hora programada
## Configuración
### Configuración de idioma
Puede seleccionar el idioma de los mensajes recordatorios en la configuración del complemento:
-`zh_Hans`(chino simplificado) - Predeterminado
-`en_US`(inglés)
## Uso
### 1. Programar a través de LLM
Simplemente dígale al LLM su horario en lenguaje natural:
**Ejemplos:**
```
Remind me to have a meeting at 3 PM tomorrow
Remind me to submit the report at 9 AM the day after tomorrow
Remind me to have lunch at 12 PM next Monday
Remind me about Christmas dinner at 2024-12-25 18:00
```
El LLM automáticamente:
1. Llame a`get_current_time_str`para obtener la hora actual
2. Analice su expresión de tiempo y conviértala al formato estándar.
3. Llame a`schedule_notify`para crear un recordatorio.
### 2. Ver recordatorios programados
Utilice el comando para ver todos los recordatorios programados:
```
!sche
```
Salida de ejemplo:
```
[Notify] Scheduled reminders:
#1 2024-12-25 18:00:00: Christmas dinner
#2 2024-12-26 09:00:00: Submit report
```
### 3. Eliminar recordatorio
Use el comando para eliminar un recordatorio específico (usando el número de`!sche`):
```
!dsche i <number>
```
Ejemplo:
```
!dsche i 1 # Delete the 1st reminder
```
## Componentes
### Herramientas
1.**get_current_time_str**- Obtener la hora actual
- Formato de retorno:`AAAA-MM-DD HH:MM:SS`
- LLM debe llamar a esta herramienta antes de configurar recordatorios
2.**schedule_notify**- Programar notificación
- Parámetros: cadena de tiempo, mensaje recordatorio
- Obtiene automáticamente información de la sesión del parámetro de sesión de la herramienta
### Comandos
1.**sche**(alias: s): enumera todos los recordatorios programados
2.**dsche**(alias: d): eliminar el recordatorio especificado
## Detalles técnicos
- Intervalo de control: Cada 60 segundos
- Precisión del tiempo: nivel de minutos (comprueba cada minuto)
- Información de sesión: obtenida automáticamente a través del parámetro de sesión de la herramienta
- Persistencia: actualmente utiliza almacenamiento en memoria (se pierde al reiniciar)
## Ejemplo de conversación
**Usuario:**Recuérdame asistir a una reunión mañana a las 2 p.m.
**LLM:**Claro, te estableceré un recordatorio.
*[LLM llama a get_current_time_str]*
*[LLAMADA DE LLM Schedule_notify(time_str="2024-12-26 14:00:00", message="Asistir a la reunión")]*
**LLM:**¡Listo! Te lo recordaré el 26-12-2024 14:00:00: Asistir a la reunión
*[Al día siguiente a las 2 p.m.]*
**Bot:**[Notificar] Asistir a la reunión
## Notas
- La hora del recordatorio debe ser en el futuro, las horas pasadas serán rechazadas.
- Los mensajes recordatorios se enviarán a la misma sesión donde se configuró el recordatorio.
- Los recordatorios no enviados se perderán después de reiniciar el complemento (la persistencia será compatible en versiones futuras)
## Información del desarrollador
- Autor: RockChinQ
- Versión: 0.2.0
- Tipo de complemento: complemento LangBot v1
## Licencia
Parte del ecosistema de complementos de LangBot.
@@ -0,0 +1,126 @@
# ScheNotify
自然言語でスケジュール通知を計画
## 機能紹介
ScheNotify は LangBot プラグインで、ユーザーが自然言語で LLM と対話してタイマーリマインダーを設定できます。
### 主な機能
- 🤖 **自然言語対話**:LLM を通じてユーザーのスケジュール意図を理解
- ⏰ **スマートな時間解析**:現在時刻を自動取得し、リマインダー時刻を計算
- 🌐 **多言語サポート**:中国語と英語のリマインダーメッセージに対応
- 📝 **スケジュール管理コマンド**:予定されたリマインダーの表示と削除
- 🔔 **自動通知**:設定時刻になると自動的にリマインダーメッセージを送信
## 設定
### 言語設定
プラグイン設定でリマインダーメッセージの言語を選択できます:
- `zh_Hans` (簡体字中国語) - デフォルト
- `en_US` (英語)
## 使用方法
### 1. LLM でリマインダーを設定
自然言語で LLM にスケジュールを伝えるだけです:
**例:**
```
明日の午後3時に会議のリマインダー
明後日の午前9時にレポート提出のリマインダー
来週月曜日の正午に昼食のリマインダー
2024-12-25 18:00 にクリスマスディナーのリマインダー
```
LLM は自動的に:
1. `get_current_time_str` を呼び出して現在時刻を取得
2. 時間表現を解析して標準形式に変換
3. `schedule_notify` を呼び出してリマインダーを作成
### 2. 予定されたリマインダーを表示
コマンドを使用してすべての予定されたリマインダーを表示:
```
!sche
```
出力例:
```
[Notify] 計画中のリマインダー:
#1 2024-12-25 18:00:00:クリスマスディナー
#2 2024-12-26 09:00:00:レポート提出
```
### 3. リマインダーを削除
コマンドを使用して指定したリマインダーを削除(`!sche` で表示された番号を使用):
```
!dsche i <番号>
```
例:
```
!dsche i 1 # 1番目のリマインダーを削除
```
## コンポーネント説明
### ツール
1. **get_current_time_str** - 現在時刻を取得
- 戻り値の形式:`YYYY-MM-DD HH:MM:SS`
- LLM はリマインダー設定前にこのツールを呼び出す必要があります
2. **schedule_notify** - 通知を計画
- パラメータ:時刻文字列、リマインダー内容
- session パラメータからセッション情報を自動取得してリマインダーを送信
### コマンド
1. **sche** (エイリアス: s) - すべての予定されたリマインダーを一覧表示
2. **dsche** (エイリアス: d) - 指定したリマインダーを削除
## 技術詳細
- 定期チェック間隔:60 秒ごと
- 時間精度:分レベル(プログラムは毎分チェック)
- セッション取得:Tool の session パラメータを通じて自動取得
- 永続化:現在はメモリストレージを使用(再起動後は消失)
## 対話例
**ユーザー:** 明日の午後2時に会議参加のリマインダー
**LLM:** 承知しました、リマインダーを設定します。
*[LLM が get_current_time_str を呼び出し]*
*[LLM が schedule_notify(time_str="2024-12-26 14:00:00", message="会議参加") を呼び出し]*
**LLM:** 設定が完了しました!2024-12-26 14:00:00 にリマインドします:会議参加
*[翌日の午後2時]*
**ボット:** [Notify] 会議参加
## 注意事項
- リマインダー時刻は未来の時刻である必要があり、過去の時刻は拒否されます
- リマインダーメッセージは、リマインダーを設定したセッションと同じセッションに送信されます
- プラグイン再起動後、未送信のリマインダーは消失します(将来のバージョンで永続化をサポート予定)
## 開発者情報
- 作者:RockChinQ
- バージョン:0.2.0
- プラグインタイプ:LangBot Plugin v1
## License
Part of the LangBot plugin ecosystem.
@@ -0,0 +1,126 @@
#แจ้งกำหนดการ
กำหนดเวลาการแจ้งเตือนด้วยภาษาที่เป็นธรรมชาติ
## คุณสมบัติ
ScheNotify เป็นปลั๊กอิน LangBot ที่ช่วยให้ผู้ใช้สามารถตั้งค่าการแจ้งเตือนแบบกำหนดเวลาผ่านการโต้ตอบด้วยภาษาธรรมชาติกับ LLM
### คุณสมบัติหลัก
-**การโต้ตอบด้วยภาษาธรรมชาติ**: ทำความเข้าใจความตั้งใจในการจัดกำหนดการของผู้ใช้ผ่าน LLM
-**การแยกวิเคราะห์เวลาอัจฉริยะ**: รับเวลาปัจจุบันโดยอัตโนมัติและคำนวณเวลาเตือนความจำ
-**รองรับหลายภาษา**: รองรับข้อความเตือนความจำภาษาจีนและอังกฤษ
-**คำสั่งการจัดการกำหนดการ**: ดูและลบการแจ้งเตือนตามกำหนดเวลา
-**การแจ้งเตือนอัตโนมัติ**: ส่งข้อความเตือนความจำอัตโนมัติตามเวลาที่กำหนด
## การกำหนดค่า
### การตั้งค่าภาษา
คุณสามารถเลือกภาษาสำหรับข้อความเตือนได้ในการกำหนดค่าปลั๊กอิน:
-`zh_Hans`(จีนตัวย่อ) - ค่าเริ่มต้น
-`en_US`(อังกฤษ)
## การใช้งาน
### 1. กำหนดเวลาผ่าน LLM
เพียงบอก LLM กำหนดการของคุณเป็นภาษาธรรมชาติ:
**ตัวอย่าง:**
```
Remind me to have a meeting at 3 PM tomorrow
Remind me to submit the report at 9 AM the day after tomorrow
Remind me to have lunch at 12 PM next Monday
Remind me about Christmas dinner at 2024-12-25 18:00
```
LLM จะ: โดยอัตโนมัติ
1. โทร`get_current_time_str`เพื่อดูเวลาปัจจุบัน
2. แยกนิพจน์เวลาของคุณและแปลงเป็นรูปแบบมาตรฐาน
3. โทร`schedule_notify`เพื่อสร้างการแจ้งเตือน
### 2. ดูการแจ้งเตือนตามกำหนดเวลา
ใช้คำสั่งเพื่อดูการแจ้งเตือนตามกำหนดเวลาทั้งหมด:
```
!sche
```
ตัวอย่างผลลัพธ์:
```
[Notify] Scheduled reminders:
#1 2024-12-25 18:00:00: Christmas dinner
#2 2024-12-26 09:00:00: Submit report
```
### 3. ลบการแจ้งเตือน
ใช้คำสั่งเพื่อลบการเตือนความจำเฉพาะ (โดยใช้ตัวเลขจาก`!sche`):
```
!dsche i <number>
```
ตัวอย่าง:
```
!dsche i 1 # Delete the 1st reminder
```
## ส่วนประกอบ
### เครื่องมือ
1.**get_current_time_str**- รับเวลาปัจจุบัน
- รูปแบบการส่งคืน:`YYYY-MM-DD HH:MM:SS`
- LLM ต้องเรียกใช้เครื่องมือนี้ก่อนที่จะตั้งค่าการแจ้งเตือน
2.**schedule_notify**- การแจ้งเตือนกำหนดการ
- พารามิเตอร์: สตริงเวลา ข้อความเตือนความจำ
- รับข้อมูลเซสชันโดยอัตโนมัติจากพารามิเตอร์เซสชันของเครื่องมือ
### คำสั่ง
1.**sche**(นามแฝง: s) - แสดงรายการการแจ้งเตือนที่กำหนดเวลาไว้ทั้งหมด
2.**dsche**(นามแฝง: d) - ลบการแจ้งเตือนที่ระบุ
## รายละเอียดทางเทคนิค
- ตรวจสอบช่วงเวลา: ทุกๆ 60 วินาที
- ความแม่นยำของเวลา: ระดับนาที (ตรวจสอบทุกนาที)
- ข้อมูลเซสชัน: รับโดยอัตโนมัติผ่านพารามิเตอร์เซสชันของเครื่องมือ
- ความคงอยู่: ปัจจุบันใช้ที่เก็บข้อมูลในหน่วยความจำ (หายไปเมื่อรีสตาร์ท)
## ตัวอย่างการสนทนา
**ผู้ใช้:**เตือนให้ฉันเข้าร่วมการประชุมเวลา 14.00 น. ในวันพรุ่งนี้
**LLM:**แน่นอน ฉันจะตั้งการช่วยเตือนให้คุณ
*[LLM โทร get_current_time_str]*
*[LLM โทร schedule_notify(time_str="2024-12-26 14:00:00", message="Attend conference")]*
**LLM:**เสร็จแล้ว! ฉันจะเตือนคุณที่ 2024-12-26 14:00:00: เข้าร่วมการประชุม
*[วันถัดไป เวลา 14.00 น.]*
**Bot:**[แจ้งเตือน] เข้าร่วมการประชุม
## หมายเหตุ
- เวลาเตือนจะต้องเป็นเวลาในอนาคต เวลาที่ผ่านมาจะถูกปฏิเสธ
- ข้อความเตือนจะถูกส่งไปยังเซสชันเดียวกับที่ตั้งการแจ้งเตือนไว้
- การแจ้งเตือนที่ยังไม่ได้ส่งจะหายไปหลังจากรีสตาร์ทปลั๊กอิน (ความคงอยู่จะได้รับการสนับสนุนในเวอร์ชันต่อ ๆ ไป)
## ข้อมูลนักพัฒนา
- ผู้เขียน: RockChinQ
- เวอร์ชัน: 0.2.0
- ประเภทปลั๊กอิน: ปลั๊กอิน LangBot v1
## ใบอนุญาต
ส่วนหนึ่งของระบบนิเวศปลั๊กอิน LangBot
@@ -0,0 +1,126 @@
# Lên lịchThông báo
Lên lịch thông báo bằng ngôn ngữ tự nhiên
## Đặc trưng
ScheNotify là một plugin LangBot cho phép người dùng đặt lời nhắc theo thời gian thông qua tương tác ngôn ngữ tự nhiên với LLM.
### Tính năng chính
-**Tương tác ngôn ngữ tự nhiên**: Hiểu ý định lên lịch của người dùng thông qua LLM
-**Phân tích thời gian thông minh**: Tự động lấy thời gian hiện tại và tính toán thời gian nhắc nhở
-**Hỗ trợ đa ngôn ngữ**: Hỗ trợ tin nhắn nhắc nhở bằng tiếng Trung và tiếng Anh
-**Lệnh quản lý lịch trình**: Xem và xóa lời nhắc đã lên lịch
-**Thông báo tự động**: Tự động gửi tin nhắn nhắc nhở vào thời gian đã lên lịch
## Cấu hình
### Cài đặt ngôn ngữ
Bạn có thể chọn ngôn ngữ cho thông báo nhắc nhở trong cấu hình plugin:
-`zh_Hans`(Tiếng Trung giản thể) - Mặc định
-`en_US`(tiếng Anh)
## Cách sử dụng
### 1. Lên lịch qua LLM
Chỉ cần cho LLM biết lịch trình của bạn bằng ngôn ngữ tự nhiên:
**Ví dụ:**
```
Remind me to have a meeting at 3 PM tomorrow
Remind me to submit the report at 9 AM the day after tomorrow
Remind me to have lunch at 12 PM next Monday
Remind me about Christmas dinner at 2024-12-25 18:00
```
LLM sẽ tự động:
1. Gọi`get_current_time_str`để biết thời gian hiện tại
2. Phân tích biểu thức thời gian của bạn và chuyển đổi sang định dạng chuẩn
3. Gọi`schedule_notify`để tạo lời nhắc
### 2. Xem lời nhắc đã lên lịch
Sử dụng lệnh để xem tất cả các lời nhắc đã lên lịch:
```
!sche
```
Đầu ra ví dụ:
```
[Notify] Scheduled reminders:
#1 2024-12-25 18:00:00: Christmas dinner
#2 2024-12-26 09:00:00: Submit report
```
### 3. Xóa lời nhắc
Sử dụng lệnh để xóa một lời nhắc cụ thể (sử dụng số từ`!sche`):
```
!dsche i <number>
```
Ví dụ:
```
!dsche i 1 # Delete the 1st reminder
```
## Thành phần
### Công cụ
1.**get_current_time_str**- Lấy thời gian hiện tại
- Định dạng trả về:`YYYY-MM-DD HH:MM:SS`
- LLM phải gọi công cụ này trước khi đặt lời nhắc
2.**schedule_notify**- Thông báo lịch trình
- Thông số: chuỗi thời gian, tin nhắn nhắc nhở
- Tự động lấy thông tin phiên từ tham số phiên của Công cụ
### Lệnh
1.**sche**(bí danh: s) - Liệt kê tất cả các lời nhắc đã lên lịch
2.**dsche**(bí danh: d) - Xóa lời nhắc được chỉ định
## Chi tiết kỹ thuật
- Khoảng thời gian kiểm tra: Cứ sau 60 giây
- Độ chính xác về thời gian: Mức phút (kiểm tra mỗi phút)
- Thông tin phiên: Tự động lấy được thông qua tham số phiên của Công cụ
- Persistence: Hiện đang sử dụng bộ nhớ trong (mất khi khởi động lại)
## Cuộc trò chuyện mẫu
**Người dùng:**Nhắc tôi tham dự cuộc họp lúc 2 giờ chiều ngày mai
**LLM:**Chắc chắn rồi, tôi sẽ đặt lời nhắc cho bạn.
*[LLM gọi get_current_time_str]*
*[Cuộc gọi LLM lịch_notify(time_str="2024-12-26 14:00:00", message="Tham dự cuộc họp")]*
**LLM:**Xong rồi! Tôi sẽ nhắc bạn vào lúc 26-12-2024 14:00:00: Tham dự cuộc họp
*[Ngày hôm sau lúc 2 giờ chiều]*
**Bot:**[Thông báo] Tham dự cuộc họp
## Ghi chú
- Thời gian nhắc phải ở tương lai, thời gian đã qua sẽ bị từ chối
- Tin nhắn nhắc nhở sẽ được gửi đến cùng phiên đã đặt lời nhắc
- Lời nhắc chưa gửi sẽ bị mất sau khi khởi động lại plugin (tính bền vững sẽ được hỗ trợ trong các phiên bản sau)
## Thông tin nhà phát triển
- Tác giả: RockChinQ
- Phiên bản: 0.2.0
- Loại plugin: Plugin LangBot v1
## Giấy phép
Một phần của hệ sinh thái plugin LangBot.
@@ -0,0 +1,126 @@
# ScheNotify
使用自然语言计划日程提醒
## 功能介绍
ScheNotify 是一个 LangBot 插件,允许用户通过自然语言与 LLM 交互来设置定时提醒。
### 主要功能
- 🤖 **自然语言交互**:通过 LLM 理解用户的日程安排意图
- ⏰ **智能时间解析**:自动获取当前时间并计算提醒时间
- 🌐 **多语言支持**:支持中文和英文的提醒消息
- 📝 **日程管理命令**:查看和删除已计划的提醒
- 🔔 **自动通知**:到达设定时间后自动发送提醒消息
## 配置
### 语言设置
在插件配置中可以选择提醒消息的语言:
- `zh_Hans` (简体中文) - 默认
- `en_US` (英语)
## 使用方法
### 1. 通过 LLM 设置提醒
直接用自然语言告诉 LLM 你的日程安排:
**示例:**
```
明天下午3点提醒我开会
后天早上9点提醒我交报告
下周一中午12点提醒我吃饭
2024-12-25 18:00 提醒我圣诞晚餐
```
LLM 会自动:
1. 调用 `get_current_time_str` 获取当前时间
2. 解析你的时间表达并转换为标准格式
3. 调用 `schedule_notify` 创建提醒
### 2. 查看计划的提醒
使用命令查看所有计划中的提醒:
```
!sche
```
示例输出:
```
[Notify] 计划中的提醒:
#1 2024-12-25 18:00:00:圣诞晚餐
#2 2024-12-26 09:00:00:交报告
```
### 3. 删除提醒
使用命令删除指定的提醒(使用 `!sche` 查看的序号):
```
!dsche i <序号>
```
示例:
```
!dsche i 1 # 删除第1个提醒
```
## 组件说明
### 工具
1. **get_current_time_str** - 获取当前时间
- 返回格式:`YYYY-MM-DD HH:MM:SS`
- LLM 在设置提醒前必须先调用此工具
2. **schedule_notify** - 计划通知
- 参数:时间字符串、提醒内容
- 自动从 session 参数获取会话信息发送提醒
### 命令
1. **sche** (别名: s) - 列出所有计划的提醒
2. **dsche** (别名: d) - 删除指定的提醒
## 技术细节
- 定时检查间隔:每 60 秒
- 时间精度:分钟级(程序每分钟检查一次)
- 会话获取:通过 Tool 的 session 参数自动获取
- 持久化:当前使用内存存储(重启后丢失)
## 示例对话
**用户:** 明天下午2点提醒我参加会议
**LLM:** 好的,我来为你设置提醒。
*[LLM 调用 get_current_time_str]*
*[LLM 调用 schedule_notify(time_str="2024-12-26 14:00:00", message="参加会议")]*
**LLM:** 已经为你设置好了!将在 2024-12-26 14:00:00 提醒您:参加会议
*[第二天下午2点]*
**机器人:** [Notify] 参加会议
## 注意事项
- 提醒时间必须是未来时间,过去的时间会被拒绝
- 提醒消息会发送到设置提醒时的同一个会话
- 重启插件后,未发送的提醒会丢失(未来版本将支持持久化)
## 开发者信息
- 作者:RockChinQ
- 版本:0.2.0
- 插件类型:LangBot Plugin v1
## License
Part of the LangBot plugin ecosystem.
@@ -0,0 +1,126 @@
# 調度通知
使用自然語言安排通知
## 特徵
ScheNotify是一個LangBot插件,允許使用者透過與LLM的自然語言互動來設定定時提醒。
### 主要特點
-**自然語言互動**:透過LLM了解使用者的調度意圖
-**智慧時間解析**:自動取得目前時間並計算提醒時間
-**多語言支援**:支援中英文提醒訊息
-**日程管理指令**:檢視和刪除預定提醒
-**自動通知**:在預定時間自動發送提醒訊息
## 配置
### 語言設定
您可以在外掛程式配置中選擇提醒訊息的語言:
-`zh_Hans`(簡體中文)- 默認
-`en_US`(英語)
## 用法
### 1. 透過LLM安排
只需用自然語言告訴法學碩士您的日程安排:
**範例:**
```
Remind me to have a meeting at 3 PM tomorrow
Remind me to submit the report at 9 AM the day after tomorrow
Remind me to have lunch at 12 PM next Monday
Remind me about Christmas dinner at 2024-12-25 18:00
```
法學碩士將自動:
1. 呼叫`get_current_time_str`取得目前時間
2. 解析您的時間表達式並轉換為標準格式
3. 呼叫`schedule_notify`建立提醒
### 2.查看預定提醒
使用指令查看所有預定提醒:
```
!sche
```
輸出範例:
```
[Notify] Scheduled reminders:
#1 2024-12-25 18:00:00: Christmas dinner
#2 2024-12-26 09:00:00: Submit report
```
### 3.刪除提醒
使用指令刪除特定提醒(使用「!sche」中的數字):
```
!dsche i <number>
```
例子:
```
!dsche i 1 # Delete the 1st reminder
```
## 成分
### 工具
1.**get_current_time_str**- 取得當前時間
- 回傳格式:`YYYY-MM-DD HH:MM:SS`
- LLM在設定提醒之前必須呼叫此工具
2.**schedule_notify**- 安排通知
- 參數:時間字串、提醒訊息
- 自動從Tool的session參數中取得session信息
### 指令
1.**sche**(別名:s)-列出所有預定的提醒
2.**dsche**(別名:d)-刪除指定提醒
## 技術細節
- 檢查間隔:每 60 秒一次
- 時間精確度:分鐘級(每分鐘檢查一次)
- 會話資訊:透過Tool的會話參數自動取得
- 持久性:目前使用記憶體儲存(重新啟動時遺失)
## 對話範例
**使用者:**提醒我明天下午 2 點參加會議
**法學碩士:**當然,我會為您設定提醒。
*[LLM呼叫get_current_time_str]*
*[LLM呼叫schedule_notify(time_str="2024-12-26 14:00:00", message="參加會議")]*
**法學碩士:**完成!我會在2024-12-26 14:00:00提醒您:參加會議
*[第二天下午 2 點]*
**機器人:**[通知]參加會議
## 註釋
- 提醒時間必須是未來的,過去的時間將被拒絕
- 提醒訊息將發送到設定提醒的相同會話
- 插件重新啟動後未發送的提醒將會遺失(後續版本將支援持久化)
## 開發者訊息
- 作者:RockChinQ
- 版本:0.2.0
- 插件類型:LangBot 插件 v1
## 執照
LangBot 插件生態系的一部分。
+1
View File
@@ -0,0 +1 @@
langbot-plugin
@@ -0,0 +1,632 @@
"""Two installations, one ScheNotify object: schedules never cross over.
Evidence model: the real SDK ``bind_invocation``/``InstallationBinding`` drive
two distinct installation bindings through one plugin object; the stub Host
records every ``SEND_MESSAGE`` relay it receives, so a notification fired for
the wrong installation is visible as an extra relay carrying the other
installation's reminder text.
Pre-fix failure (asserted below): the plugin kept one ``scheduled_events`` list
and one minute-loop on the process-wide object, created in ``initialize()``.
Installation B's ``get_scheduled_events`` therefore returned installation A's
reminders, and the single loop delivered both installations' reminders (the
loop also resolved config outside any invocation). ``on_installation_revoked``
released nothing because nothing was keyed by an installation.
Second-round findings, also asserted below:
* the detached scheduler ran in a fresh empty context and never re-established
the scheduling installation's invocation, so the Host saw ``None`` as the
binding of a relay that carried tenant data
(``test_detached_send_runs_under_the_captured_installation_binding``);
* lookups deleted/listed by ``target_id`` alone, so another bot of the same
installation, or the other namespace with the same id, was treated as the same
session (``test_schedule_commands_do_not_reach_another_session`` and
``test_session_key_distinguishes_bot_and_namespace``);
* a failed send was swallowed and the reminder deleted anyway, the delivery loop
iterated a mutable list across ``await``, and the queue was unbounded with its
content dumped to debug logs (``test_failed_delivery_keeps_the_reminder_queued``,
``test_delivery_snapshot_survives_a_concurrent_delete``,
``test_queue_is_bounded_and_reminder_text_is_not_logged``).
The upgrade case (same scope token, new revision) is covered by
``test_worker_upgrade_restarts_the_loop_under_the_new_binding``.
"""
from __future__ import annotations
import asyncio
import datetime
import importlib.util
import logging
import sys
from pathlib import Path
from types import SimpleNamespace
from langbot_plugin.api.entities.builtin.command.context import ExecuteContext
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes, Session
from langbot_plugin.api.proxies.invocation import bind_invocation, current_binding
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]
if str(PLUGIN_ROOT) not in sys.path:
sys.path.insert(0, str(PLUGIN_ROOT))
from components.commands.dsche import DscheCommand # noqa: E402
from components.commands.sche import ScheCommand # noqa: E402
from components.tools.schedule_notify import ScheduleNotify # noqa: E402
def _load_main():
spec = importlib.util.spec_from_file_location("schenotify_main", PLUGIN_ROOT / "main.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
sche_main = _load_main()
class FlatHost:
"""Stub Host that records notification relays (no live platform call)."""
def __init__(self, *, fail_sends: int = 0, on_send=None):
self.sent: list[dict] = []
# Binding the relay was made under, as the real Host would resolve it.
self.authorities: list = []
self.fail_sends = fail_sends
self.on_send = on_send
async def call_action(self, action, data, *args, **kwargs):
if action == Action.SEND_MESSAGE:
self.authorities.append(current_binding(self))
if self.fail_sends > 0:
self.fail_sends -= 1
raise RuntimeError("host refused the send")
self.sent.append(data)
if self.on_send is not None:
self.on_send(data)
return {"result": "ok"}
raise AssertionError(f"unexpected Host action: {action}")
class _CaptureLogs(logging.Handler):
"""Capture the plugin's own log records so tests can inspect them."""
def __init__(self):
super().__init__(level=logging.DEBUG)
self.messages: list[str] = []
def emit(self, record):
self.messages.append(record.getMessage())
def binding(installation: str, *, workspace: str = "workspace-1", revision: int = 1):
return InstallationBinding(
instance_uuid="instance-1",
workspace_uuid=workspace,
installation_uuid=installation,
placement_generation=1,
runtime_revision=revision,
artifact_digest="a" * 64,
)
def build_plugin(**host_kwargs):
plugin = sche_main.ScheNotify()
host = FlatHost(**host_kwargs)
plugin.plugin_runtime_handler = host
return plugin, host
def _texts(payloads: list[dict]) -> list[str]:
texts = []
for payload in payloads:
texts.append(
"".join(
part.get("text", "")
for part in payload["message_chain"]
if part.get("type") == "Plain"
)
)
return texts
PAST = datetime.datetime(2020, 1, 1, 0, 0, 0)
FUTURE = datetime.datetime(2999, 1, 1, 0, 0, 0)
def test_schedule_is_per_installation():
async def scenario():
plugin, host = build_plugin()
first, second = binding("installation-a"), binding("installation-b")
with bind_invocation(host, config={"language": "en_US"}, binding=first):
await plugin.add_scheduled_event(
time=PAST, message="A reminder", bot_uuid="bot", target_type="person", target_id="1"
)
with bind_invocation(host, config={"language": "zh_Hans"}, binding=second):
await plugin.add_scheduled_event(
time=PAST, message="B reminder", bot_uuid="bot", target_type="person", target_id="2"
)
with bind_invocation(host, config={"language": "zh_Hans"}, binding=second):
# Pre-fix this returned ["A reminder", "B reminder"] from the shared list.
assert [e["message"] for e in await plugin.get_scheduled_events()] == ["B reminder"]
# A's scheduler fires A's reminder only; B's stays pending.
await plugin._check_scheduled_events(plugin._scope(first))
assert _texts(host.sent) == ["[Notify] A reminder"]
with bind_invocation(host, config={"language": "zh_Hans"}, binding=second):
assert [e["message"] for e in await plugin.get_scheduled_events()] == ["B reminder"]
await plugin._check_scheduled_events(plugin._scope(second))
assert _texts(host.sent) == ["[Notify] A reminder", "[Notify] B reminder"]
# Nothing is left pending in either installation.
assert plugin._events[plugin._scope(first)] == []
assert plugin._events[plugin._scope(second)] == []
asyncio.run(scenario())
def test_scheduler_is_registered_per_binding_and_stopped_on_revocation():
async def scenario():
plugin, host = build_plugin()
first, second = binding("installation-a"), binding("installation-b")
started: list[str] = []
async def fake_loop(scope, *_authority):
started.append(scope)
await asyncio.sleep(3600)
plugin._check_loop_wrapper = fake_loop
with bind_invocation(host, config={}, binding=first):
await plugin.add_scheduled_event(
time=FUTURE, message="A reminder", bot_uuid="bot", target_type="person", target_id="1"
)
with bind_invocation(host, config={}, binding=second):
await plugin.add_scheduled_event(
time=FUTURE, message="B reminder", bot_uuid="bot", target_type="person", target_id="2"
)
await asyncio.sleep(0)
assert started == [plugin._scope(first), plugin._scope(second)]
first_task = plugin._loops[plugin._scope(first)]
await plugin.on_installation_revoked(first)
assert first_task.cancelled() or first_task.done()
assert plugin._scope(first) not in plugin._loops
assert plugin._scope(first) not in plugin._events
# A revoked installation's schedule is dropped: no late delivery.
before = len(host.sent)
await plugin._check_scheduled_events(plugin._scope(first))
assert len(host.sent) == before
# The sibling installation is untouched.
assert plugin._scope(second) in plugin._loops
assert [e["message"] for e in plugin._events[plugin._scope(second)]] == ["B reminder"]
await plugin.on_installation_revoked(second)
asyncio.run(scenario())
def test_detached_scheduler_reads_no_invocation_config():
async def scenario():
plugin, host = build_plugin()
first = binding("installation-a")
with bind_invocation(host, config={"language": "en_US"}, binding=first):
await plugin.add_scheduled_event(
time=PAST, message="A reminder", bot_uuid="bot", target_type="person", target_id="1"
)
def refuse():
raise AssertionError("detached scheduler read invocation config")
plugin.get_config = refuse
# Pre-fix _send_notification read self.get_config() on every delivery.
await plugin._check_scheduled_events(plugin._scope(first))
assert _texts(host.sent) == ["[Notify] A reminder"]
asyncio.run(scenario())
def test_scope_key_is_the_full_binding():
plugin, _host = build_plugin()
assert plugin._scope(binding("installation-a")) == "instance-1:workspace-1:installation-a"
assert plugin._scope(binding("installation-a")) != plugin._scope(binding("installation-a", workspace="workspace-2"))
assert plugin._scope() == sche_main.LEGACY_SCOPE
assert isinstance(plugin._scope(SimpleNamespace(instance_uuid="i", workspace_uuid="w", installation_uuid="x")), str)
def test_initialize_starts_no_scheduler():
async def scenario():
plugin, _host = build_plugin()
await plugin.initialize()
assert plugin._loops == {}
asyncio.run(scenario())
def test_dedicated_binding_less_invocation_still_schedules():
"""A dedicated worker's invocation carries no binding; it keeps its own scope."""
async def scenario():
plugin, host = build_plugin()
loop_scopes: list[str] = []
async def fake_loop(scope, *_authority):
loop_scopes.append(scope)
await asyncio.sleep(3600)
plugin._check_loop_wrapper = fake_loop
with bind_invocation(host, config={"language": "en_US"}):
await plugin.add_scheduled_event(
time=PAST, message="dedicated", bot_uuid="bot", target_type="person", target_id="1"
)
await asyncio.sleep(0)
assert loop_scopes == [sche_main.LEGACY_SCOPE]
await plugin._check_scheduled_events(sche_main.LEGACY_SCOPE)
assert _texts(host.sent) == ["[Notify] dedicated"]
await plugin.on_installation_revoked(binding("installation-a"))
assert plugin._loops # a revoked binding does not disturb the legacy scope
asyncio.run(scenario())
def _session(bot_uuid, launcher_type, launcher_id) -> Session:
return Session(launcher_type=launcher_type, launcher_id=launcher_id, bot_uuid=bot_uuid)
def _context(bot_uuid, launcher_type, launcher_id, command, params=()) -> ExecuteContext:
return ExecuteContext(
query_id=1,
session=_session(bot_uuid, launcher_type, launcher_id),
command_text=command,
full_command_text=f"!{command}",
command=command,
crt_command=command,
params=list(params),
crt_params=list(params),
privilege=0,
)
async def _render(component, context) -> list[str]:
return [ret.text async for ret in component._execute(context)]
def test_detached_send_runs_under_the_captured_installation_binding():
"""The background relay carries the scheduling installation's authority.
Pre-fix the scheduler started its task in a fresh ``contextvars.Context()``
and never re-entered an invocation, so the Host resolved the relay's binding
to ``None`` (``host.authorities`` was ``[None]``) although the payload
carried the tenant's reminder text and target.
"""
async def scenario():
plugin, host = build_plugin()
first = binding("installation-a")
with bind_invocation(host, config={"language": "en_US"}, binding=first):
await plugin.add_scheduled_event(
time=PAST, message="A reminder", bot_uuid="bot", target_type="person", target_id="1"
)
# The scheduler is a real detached task: let it run its first pass.
for _ in range(200):
if host.sent:
break
await asyncio.sleep(0.01)
assert _texts(host.sent) == ["[Notify] A reminder"]
assert host.authorities == [first]
assert plugin._events[plugin._scope(first)] == []
# The authority belongs to the loop, so revocation withdraws it with it.
await plugin.on_installation_revoked(first)
assert plugin._loops == {}
asyncio.run(scenario())
def test_session_key_distinguishes_bot_and_namespace():
"""Listing and deleting are keyed by the whole session, not by target id.
Pre-fix ``get_scheduled_events(target_id)`` matched ``target_id`` only, so
each of the three lookups below returned all three records, and a delete
from one session removed another session's event.
"""
async def scenario():
plugin, host = build_plugin()
first = binding("installation-a")
scope = plugin._scope(first)
with bind_invocation(host, config={}, binding=first):
key_bot_a = ("bot-a", "person", "42")
key_bot_b = ("bot-b", "person", "42")
key_group = ("bot-a", "group", "42")
assert len({key_bot_a, key_bot_b, key_group}) == 3
for key, text in (
(key_bot_a, "bot-a person"),
(key_bot_b, "bot-b person"),
(key_group, "group 42"),
):
await plugin.add_scheduled_event(
time=FUTURE, message=text,
bot_uuid=key[0], target_type=key[1], target_id=key[2],
)
assert [e["message"] for e in await plugin.get_scheduled_events(key_bot_a)] == ["bot-a person"]
assert [e["message"] for e in await plugin.get_scheduled_events(key_bot_b)] == ["bot-b person"]
assert [e["message"] for e in await plugin.get_scheduled_events(key_group)] == ["group 42"]
# A delete is refused when the event belongs to another session.
other = (await plugin.get_scheduled_events(key_bot_b))[0]
assert await plugin.delete_scheduled_event(other, key_bot_a) is False
assert [e["message"] for e in await plugin.get_scheduled_events(key_bot_b)] == ["bot-b person"]
assert await plugin.delete_scheduled_event(other, key_bot_b) is True
assert await plugin.get_scheduled_events(key_bot_b) == []
assert len(plugin._events[scope]) == 2
await plugin.on_installation_revoked(first)
asyncio.run(scenario())
def test_session_key_is_the_trusted_triplet():
"""The key the commands build is ``(bot_uuid, target_type, target_id)``.
Pre-fix this API did not exist; the commands filtered by ``target_id``.
"""
plugin, _host = build_plugin()
assert plugin.session_key("bot-a", "person", "42") == ("bot-a", "person", "42")
assert plugin.session_key("bot-a", "group", "42") != plugin.session_key("bot-a", "person", "42")
assert plugin.session_key(None, "person", 42) == ("", "person", "42")
def test_schedule_commands_do_not_reach_another_session():
"""``!sche`` / ``!dsche`` act on the current trusted session only.
Both sessions belong to one installation and share launcher id "42" but use
different bots. Pre-fix ``!sche`` listed ("A reminder", "B reminder") and
``!dsche i 1`` deleted the *other* bot's reminder.
"""
async def scenario():
plugin, host = build_plugin()
first = binding("installation-a")
sche, dsche = ScheCommand(), DscheCommand()
for component in (sche, dsche):
component.plugin = plugin
key_a = ("bot-a", "person", "42")
key_b = ("bot-b", "person", "42")
with bind_invocation(host, config={"language": "en_US"}, binding=first):
for key, text in ((key_a, "A reminder"), (key_b, "B reminder")):
await plugin.add_scheduled_event(
time=FUTURE, message=text,
bot_uuid=key[0], target_type=key[1], target_id=key[2],
)
listed_b = (await _render(sche, _context("bot-b", LauncherTypes.PERSON, "42", "sche")))[0]
assert "B reminder" in listed_b
assert "A reminder" not in listed_b
deleted_b = (
await _render(
dsche, _context("bot-b", LauncherTypes.PERSON, "42", "dsche", ("i", "1"))
)
)[0]
assert "B reminder" in deleted_b
assert [e["message"] for e in await plugin.get_scheduled_events(key_a)] == ["A reminder"]
assert await plugin.get_scheduled_events(key_b) == []
await plugin.on_installation_revoked(first)
asyncio.run(scenario())
def test_tool_schedules_under_the_sessions_own_identity():
"""The Tool stores the session's bot uuid together with type and id.
Pre-fix the reminder could only be found by ``target_id``, so a lookup by the
full identity returned nothing (the plugin had no such key).
"""
async def scenario():
plugin, host = build_plugin()
first = binding("installation-a")
tool = ScheduleNotify()
tool.plugin = plugin
with bind_invocation(host, config={"language": "en_US"}, binding=first):
result = await tool.call(
{"time_str": "2999-01-01 00:00:00", "message": "from bot-a"},
_session("bot-a", LauncherTypes.PERSON, "42"),
1,
)
assert "from bot-a" in result
assert [
e["message"] for e in await plugin.get_scheduled_events(("bot-a", "person", "42"))
] == ["from bot-a"]
assert await plugin.get_scheduled_events(("bot-b", "person", "42")) == []
await plugin.on_installation_revoked(first)
asyncio.run(scenario())
def test_failed_delivery_keeps_the_reminder_queued():
"""A refused Host send must not delete the reminder.
Pre-fix ``_send_notification`` swallowed the failure and the loop removed the
event anyway, so ``plugin._events[scope]`` was empty and the reminder was
lost forever.
"""
async def scenario():
plugin, host = build_plugin(fail_sends=1)
first = binding("installation-a")
scope = plugin._scope(first)
with bind_invocation(host, config={}, binding=first):
await plugin.add_scheduled_event(
time=FUTURE, message="A reminder", bot_uuid="bot", target_type="person", target_id="1"
)
await plugin._stop_loop(scope)
plugin._events[scope][0]["time"] = PAST
await plugin._check_scheduled_events(scope)
pending = plugin._events[scope]
assert [e["message"] for e in pending] == ["A reminder"]
assert pending[0]["failed_attempts"] == 1
assert pending[0]["last_error"] == "host refused the send"
# The next pass retries the still-pending reminder and delivers it.
await plugin._check_scheduled_events(scope)
assert plugin._events[scope] == []
assert _texts(host.sent) == ["[Notify] A reminder"]
asyncio.run(scenario())
def test_delivery_snapshot_survives_a_concurrent_delete():
"""A sibling invocation deleting an event mid-delivery must not break the loop.
Pre-fix the loop iterated the live list and then called
``events.remove(event)`` for every due event it had sent; deleting one during
the awaited send raised ``ValueError: list.remove(x): x not in list``.
"""
async def scenario():
plugin, host = build_plugin()
first = binding("installation-a")
scope = plugin._scope(first)
def concurrent_delete(_payload):
pending = plugin._events[scope]
if len(host.sent) >= 2 and pending and pending[0]["message"] == "one":
# What a sibling invocation's !dsche does while the loop awaits.
del pending[0]
host.on_send = concurrent_delete
with bind_invocation(host, config={}, binding=first):
for text in ("one", "two"):
await plugin.add_scheduled_event(
time=FUTURE, message=text,
bot_uuid="bot", target_type="person", target_id="1",
)
await plugin._stop_loop(scope)
for event in plugin._events[scope]:
event["time"] = PAST
await plugin._check_scheduled_events(scope)
assert _texts(host.sent) == ["[Notify] one", "[Notify] two"]
assert plugin._events[scope] == []
asyncio.run(scenario())
def test_queue_is_bounded_and_reminder_text_is_not_logged():
"""The queue and stored text are bounded, and the queue is logged by size.
Pre-fix the 101st reminder was accepted, the stored message was unbounded,
and the debug line dumped the whole event dict (reminder text included).
"""
async def scenario():
plugin, host = build_plugin()
first = binding("installation-a")
scope = plugin._scope(first)
limit = sche_main.MAX_EVENTS_PER_INSTALLATION
records = _CaptureLogs()
module_logger = logging.getLogger(sche_main.__name__)
module_logger.addHandler(records)
previous_level = module_logger.level
module_logger.setLevel(logging.DEBUG)
try:
with bind_invocation(host, config={}, binding=first):
for index in range(limit):
await plugin.add_scheduled_event(
time=FUTURE, message=f"reminder {index}",
bot_uuid="bot", target_type="person", target_id="1",
)
assert await plugin.add_scheduled_event(
time=FUTURE, message="overflow",
bot_uuid="bot", target_type="person", target_id="1",
) is False
assert len(plugin._events[scope]) == limit
await plugin.delete_scheduled_event(plugin._events[scope][0])
assert await plugin.add_scheduled_event(
time=FUTURE, message="x" * (sche_main.MAX_MESSAGE_LENGTH + 500),
bot_uuid="bot", target_type="person", target_id="1",
) is True
assert len(plugin._events[scope][-1]["message"]) == sche_main.MAX_MESSAGE_LENGTH
await plugin._stop_loop(scope)
await plugin._check_scheduled_events(scope)
finally:
module_logger.removeHandler(records)
module_logger.setLevel(previous_level)
assert not [message for message in records.messages if "reminder 0" in message]
assert [message for message in records.messages if "pending events" in message]
await plugin.on_installation_revoked(first)
asyncio.run(scenario())
def test_worker_upgrade_restarts_the_loop_under_the_new_binding():
"""A worker upgrade keeps the scope token but must replace the authority.
The scope excludes ``runtime_revision``, so an upgraded installation reuses
the same token. Pre-fix ``_ensure_loop`` returned while the superseded task
was alive, so the post-upgrade reminder would have been delivered (or not
delivered at all) by the loop holding the old revision's binding.
"""
async def scenario():
plugin, host = build_plugin()
first = binding("installation-a")
scope = plugin._scope(first)
with bind_invocation(host, config={}, binding=first):
await plugin.add_scheduled_event(
time=FUTURE, message="old revision", bot_uuid="bot", target_type="person", target_id="1"
)
await asyncio.sleep(0)
superseded = plugin._loops[scope]
revised = binding("installation-a", revision=2)
assert plugin._scope(revised) == scope
with bind_invocation(host, config={}, binding=revised):
await plugin.add_scheduled_event(
time=PAST, message="new revision", bot_uuid="bot", target_type="person", target_id="1"
)
await asyncio.sleep(0)
assert plugin._loops[scope] is not superseded
assert superseded.cancelled() or superseded.done()
for _ in range(200):
if host.sent:
break
await asyncio.sleep(0.01)
assert _texts(host.sent) == ["[Notify] new revision"]
assert host.authorities == [revised]
await plugin.on_installation_revoked(revised)
asyncio.run(scenario())