简体中文 English 繁體中文 日本語 Русский
本文为静态镜像,内容以交互版为准 在交互式文档中心打开 →

SendDSL Explained

SendDSL is a fluent-style message sending interface provided by the ErisPulse adapter.

Basic Calling Methods

1. Specify Type and ID

await adapter.Send.To("group", "123").Text("Hello")

2. Specify ID Only

await adapter.Send.To("123").Text("Hello")

3. Specify Sender Account

await adapter.Send.Using("bot1").Text("Hello")

4. Combine Usage

await adapter.Send.Using("bot1").To("group", "123").Text("Hello")

Method Chaining

flowchart LR
    A["Using / Account<br/>(Optional sender account, optional)"] --> B["To<br/>(Optional target type and ID)"]
    B --> C["Modifier Methods<br/>At / Reply / Expire / ForMember, etc."]
    C --> D["Sending Methods<br/>Text / Image / Voice / Raw_ob12"]
    D --> E["Returns asyncio.Task"]

Sending Methods

All sending methods return an asyncio.Task object.

Basic Methods (Built-in in Base Class)

The following standard methods are implemented by the SendDSL base class. By default, they are delegated to Raw_ob12, so adapter subclasses do not need to re-implement them and can use them directly, with IDE auto-completion available:

Method Name Description Return Value
Text(text: str) Send text message asyncio.Task
Image(file: bytes | str) Send image asyncio.Task
Voice(file: bytes | str) Send voice (OneBot12 audio segment) asyncio.Task
Video(file: bytes | str) Send video asyncio.Task
File(file: bytes | str, filename: str = None) Send file asyncio.Task

Adapters can override individual standard methods to provide platform-specific logic:

class Send(SendDSL):
    def Raw_ob12(self, message, **kwargs):
        # Must be implemented
        ...

    # Optional: Override Text to provide platform-specific logic
    # def Text(self, text: str):
    #     return self.Raw_ob12([{"type": "text", "data": {"text": text}}])

Protocol Methods

Method Name Description Return Value Required
Raw_ob12(message) Send OneBot12 formatted message asyncio.Task Must be implemented

Important: Raw_ob12 is the core method of the adapter and must be implemented. It serves as the unified entry point for reverse transformation (OneBot12 → platform). If not implemented, the base class will log an error and return a standard error response (status: "failed", retcode: 10002). Standard methods (Text, Image, etc.) are delegated to Raw_ob12 by default.

Platform-Specific Methods

Adapters can add platform-specific sending methods in the Send subclass (these will be recognized by event.supports() / event.available_methods()):

class Send(SendDSL):
    def Raw_ob12(self, message, **kwargs): ...

    # Platform-specific method
    def Sticker(self, sticker_id: str):
        return self.Raw_ob12([{"type": "sticker", "data": {"id": sticker_id}}])

Modifiers

Modifiers return self to support method chaining.

At Method

# @single user
await adapter.Send.To("group", "123").At("456").Text("Hello")

# @multiple users
await adapter.Send.To("group", "123").At("456").At("789").Text("Hello everyone")

AtAll Method

# @all group members
await adapter.Send.To("group", "123").AtAll().Text("Hello everyone")

Reply Method

# Reply to a message
await adapter.Send.To("group", "123").Reply("msg_id").Text("Reply content")

Combined Modifiers

await adapter.Send.To("group", "123").At("456").Reply("msg_id").Text("Reply to the @ message")

Platform-Specific Modifier Methods

In addition to the built-in At/AtAll/Reply, adapters can define platform-specific modifier methods. These methods only need to return self and do not require any decorators — the framework automatically recognizes them:

class Send(SendDSL):
    def Raw_ob12(self, message, **kwargs): ...

    # Modifier method: returns self, does not send
    def Expire(self, seconds: int):
        self._expire = seconds
        return self

    def ForMember(self, user_id: str):
        self._member = user_id
        return self

    # Send method: returns Task, depends on states set by modifier methods
    def Board(self, content: str, **kwargs):
        return self.Raw_ob12([{"type": "board", "data": {"text": content}}])

Usage:

# Modifier methods can be chained continuously
await adapter.Send.To("group", "big").Expire(3600).ForMember("114").Board("Board content")

Using Modifier Methods in Event Wrapper Classes

Note


The reply(via=) and event.send_chain() features require ErisPulse 2.7.0+.

By default, event.reply() exposes only built-in modifier parameters such as at_sender/at_users/at_all/quote. To use platform-specific modifier methods, there are two approaches:

Method 1: The via Parameter in reply()

Suitable for a small number of known modifier methods:

await event.reply("Board content", method="Board",
                  via=[("Expire", 3600), ("ForMember", "114514")])

The via parameter is a list, where each element can take one of the following forms:

Format Equivalent Chain Call
"Name" .Name()
("Name", arg1, arg2) .Name(arg1, arg2)
("Name", (arg1,), {kw: val}) .Name(arg1, kw=val)

Method 2: event.send_chain()

Suitable for multiple consecutive modifier methods or action-type methods without content parameters (such as recall or delete). The send_chain() method returns a send chain already configured with To/Using, allowing you to freely append arbitrary modifier methods and send methods:

# Platform-specific modifier methods + board message
await event.send_chain().Expire(3600).Board("Expires in one hour")

# Multiple consecutive modifier methods
await (event.send_chain()
       .Expire(3600)
       .ForMember("114514")
       .Board("Board content", content_type="markdown"))

# Built-in modifier methods are also available
await event.send_chain().At("123").Reply("msg_id").Text("hi")

# Action-type methods without content parameters
await event.send_chain().DismissBoard()

The send_chain() method returns a complete SendDSL instance, so all chainable features are available—not just modifier methods, but also send rules and batch building:

# Send rules: retry + timeout + success callback
await (event.send_chain()
       .Retry(3).Timeout(10)
       .Hook(lambda r: print("Message sent successfully"))
       .Text("Reliable message"))

# Delayed message + platform modifier + board
await event.send_chain().Defer(5).Expire(3600).Board("Delayed board")

# Batch building mode
results = await (event.send_chain()
                 .Build()
                 .Text("First sentence").Image("pic.jpg").Text("Second sentence")
                 .send_all())

Account Management

Using Method

The Using() method is used to specify the account for sending messages. The identifier passed in will be matched through _resolve_account() in the following priority order:

  1. Account Name — The key name in the configuration (e.g., "default", "bot1")
  2. Runtime Injected bot_id — The identifier automatically injected during event conversion
  3. Any str Field — Any other string field in the configuration
  4. Fallback — The first enabled account
# Using account name
await adapter.Send.Using("account1").To("user", "123").Text("Hello")

# Using bot_id (i.e., self.user_id from the event)
await adapter.Send.Using("bot_123").To("user", "123").Text("Hello")

Account Method

The Account method is equivalent to Using:

await adapter.Send.Account("account1").To("user", "123").Text("Hello")

Asynchronous Processing

Not Waiting for Results

# Message is sent in the background
task = adapter.Send.To("user", "123").Text("Hello")

# Continue executing other operations
# ...

Waiting for Results

# Await directly to get the result
result = await adapter.Send.To("user", "123").Text("Hello")
print(f"Send result: {result}")

# Save the Task first, then wait later
task = adapter.Send.To("user", "123").Text("Hello")
# ... other operations ...
result = await task

Send Rule System

SendDSL includes a built-in set of send rule decorators. Rules are attached via chainable methods and applied collectively when the final send operation is executed. These rules cover common production scenarios: timeout control, retry on failure, success callbacks, delayed sending, priority-based dropping, and progress monitoring.

Rule methods return self (like At/AtAll/Reply), and must be called before the send method (Text/Image, etc.). Rules propagate along with new instances created by To/Using/Account.

List of Rule Methods

Method Description
.Hook(callback) Callback executed on successful send (can be called multiple times, executed in order)
.Retry(times=1) Automatically retry N times on failure (total N+1 attempts, including the first)
.Timeout(seconds) Single send timeout; cancels current attempt if exceeded (can be combined with Retry)
.Defer(seconds=1.0) Delayed send (in-process timer, not persisted)
.Priority(level, drop_if_busy=False) Set priority; messages may be dropped during congestion
.OnProgress(callback) Progress callback at each stage (receives SendContext)
.OnError(callback) Error callback triggered only once on final failure

Executing Logic After Successful Send (Hook)

# Synchronous callback
await (adapter.Send.To("user", "123")
       .Hook(lambda r: print(f"Send successful, message ID: {r['message_id']}"))
       .Text("Hello"))

# Asynchronous callback
async def deduct_points(result):
    await db.update(user_id="123", points=-1)

await adapter.Send.To("user", "123").Hook(deduct_points).Text("Deduct points")

The Hook is only executed when the send operation is ultimately successful (including after retries); it is not triggered on failure, timeout, or cancellation.

Automatic Retry on Failure (Retry)

# Retry 2 times after the first failure, for a total of 3 attempts
result = await adapter.Send.To("user", "123").Retry(2).Text("With retry")

Retry is triggered when an exception is thrown during send, when the send times out, or when the send returns a response with status == "failed".

Automatic Cancellation on Timeout (Timeout)

# Cancel if a single send exceeds 10 seconds
await adapter.Send.To("user", "123").Timeout(10).Text("With timeout")

# Timeout + Retry: Each attempt lasts up to 10 seconds, with a maximum of 3 attempts
await adapter.Send.To("user", "123").Timeout(10).Retry(2).Text("Timeout retry")

Progress Monitoring (OnProgress / OnError)

def on_progress(ctx):
    print(f"Stage: {ctx.stage}, Attempt: {ctx.attempt + 1}/{ctx.max_attempts}, Elapsed: {ctx.elapsed:.2f}s")
    if ctx.stage == "failed":
        print(f"  Error: {ctx.error!r}")

async def on_error(ctx):
    await notify_admin(f"Failed to send to {ctx.target_id}: {ctx.error!r}")

await (adapter.Send.To("user", "123")
       .Retry(3).Timeout(10)
       .OnProgress(on_progress)
       .OnError(on_error)
       .Text("Monitored"))

SendContext includes the following fields: task_id, platform, method, target_type, target_id, bot_id, stage, attempt, max_attempts, started_at, finished_at, elapsed, error, result, extra.

Possible values for stage: pending, sending, retrying, success, failed, timeout, cancelled, dropped.

Delayed Sending (Defer)

# Send after a 5-second delay
await adapter.Send.To("user", "123").Defer(5).Text("Delayed message")

Note: The delay is an in-process timer; it is not persisted and will be lost if the process restarts.

Priority and Congestion Dropping (Priority)

# Low-priority message, automatically dropped during queue congestion
result = await (adapter.Send.To("user", "123")
               .Priority(-1, drop_if_busy=True)
               .Text("Droppable notification"))
# If dropped, result["status"] == "failed"

When drop_if_busy is enabled, if the number of in-flight send tasks exceeds the threshold (default 64), the current send is immediately abandoned. The global threshold can be adjusted using .PriorityThreshold(n).

Rule Composition and Background Execution

# Execute without blocking the main flow; rules still apply
task = (adapter.Send.To("user", "123")
        .Hook(lambda r: print("Send successful!"))
        .Retry(3)
        .Timeout(10)
        .OnProgress(on_progress)
        .Text("Hello"))

# Continue executing other operations
await handle_next_action()

Rule Propagation

Rules propagate with new instances created by To/Using/Account, preventing loss of rules during chained calls:

# Rules set before To are also propagated to the instance created by To
builder = adapter.Send.Retry(3).Timeout(10)
send = builder.To("user", "123")  # send still carries Retry(3) and Timeout(10)
await send.Text("hi")

Rule sets for multiple instances are independent (the hooks list is deeply copied).

Batch Build Mode (Build)

In addition to the single-send mode, SendDSL also supports batch build mode: multiple send methods are written in a single chain, and executed at once. This is suitable for scenarios where you want to send multiple messages in one go.

Entering Build Mode

Before calling a send method, call .Build(), which returns a SendBuilder. After this, send methods (such as Text/Image) will not be executed immediately, but will accumulate as send intents:

results = await (adapter.Send.To("user", "123")
                 .Build()                    # Enter build mode
                 .Text("First sentence")
                 .Image("pic.jpg")
                 .Text("Second sentence")
                 .send_all())                 # Execute all at once
# results = [Text result, Image result, Text result]

.send_all() returns an asyncio.Task, and awaiting it yields a list of results (in the order of the intents).

Parallel vs Sequential

By default, execution is parallel (concurrent sending, total time is approximately equal to the slowest message). To ensure the order of message arrival, call .Sequential():

# Sequential: Send in order
await (adapter.Send.To("group", "456")
       .Build()
       .Sequential()
       .Text("Send this first").Text("Then send this")
       .send_all())

# Parallel (default, can be explicitly called)
await (adapter.Send.To("group", "456")
       .Build()
       .Parallel()
       .Text("Parallel 1").Text("Parallel 2")
       .send_all())

Continue on Failure and Retry

Batch execution uses a continue on failure strategy: if one message fails, it does not interrupt the sending of other messages. When combined with .Retry(), failed messages will automatically retry (retry applies to individual messages, not the entire batch):

await (adapter.Send.To("user", "123")
       .Build()
       .Retry(2)                       # Each message retries 2 times
       .Text("May fail").Image("May also fail")
       .send_all())

Batch-wide Rules and Callbacks

Rules apply uniformly to the entire batch:

Method Description
.Timeout(seconds) Timeout for each individual send
.Retry(times) Each send retries individually (continue on failure)
.Defer(seconds) Delay the entire batch
.Hook(callback) Triggered after the entire batch succeeds, receives results list
.OnError(callback) Triggered when the batch has failures, receives BatchContext
.OnProgress(callback) Triggered when each message completes, receives BatchContext
def on_progress(ctx):
    print(f"Progress: {ctx.completed}/{ctx.total}, succeeded {ctx.succeeded}, failed {ctx.failed}")

async def on_error(ctx):
    print(f"There are {ctx.failed} failed messages in the batch")

results = await (adapter.Send.To("user", "123")
               .Build()
               .Retry(2).Timeout(10)
               .OnProgress(on_progress)
               .OnError(on_error)
               .Hook(lambda rs: print("Batch completed"))
               .Text("a").Text("b").Text("c")
               .send_all())

BatchContext contains: task_id, total, completed, succeeded, failed, stage, results, errors, elapsed, extra.

stage possible values: pending, sending, success (all succeeded), partial (partially succeeded), failed (all failed).

Decorators and Rule Inheritance

Decorators and rules before .Build() are inherited by the entire batch and apply to each message:

await (adapter.Send.To("group", "456")
       .At("789")                        # Inherited: each message @789
       .Build()
       .Retry(2)                         # Inherited + appended: each message retries individually
       .Text("@Your notification")
       .Image("Announcement image")
       .send_all())

After entering Build mode, you can still append decorators (applying to the entire batch):

await (adapter.Send.To("group", "456")
       .Build()
       .At("111").At("222")             # Appended @, applies to entire batch
       .Text("@Multiple people")
       .send_all())

Background Execution

As with single-send, .send_all() returns a Task, which can be executed in the background without awaiting:

task = (adapter.Send.To("user", "123")
        .Build()
        .Hook(lambda rs: print("Batch send completed"))
        .Text("a").Text("b")
        .send_all())

# Non-blocking main flow
await do_something_else()

Naming Convention

PascalCase Naming

All send methods should use the PascalCase naming convention:

# ✅ Correct
def Text(self, text: str):
    pass

def Image(self, file: bytes):
    pass

# ❌ Incorrect
def text(self, text: str):
    pass

def send_image(self, file: bytes):
    pass

Platform-specific Methods

Avoid adding platform prefixes to methods:

# ✅ Recommended
def Sticker(self, sticker_id: str):
    pass

# ❌ Not recommended
def TelegramSticker(self, sticker_id: str):
    pass

Use Raw methods instead:

# ✅ Recommended
await adapter.Send.Raw_ob12([{"type": "sticker", ...}])

# ❌ Not recommended
def TelegramSticker(self, ...):
    pass

Internal Decomposition of the Send Chain

Behind a single await adapter.Send.To("group", "123").Text("x"), the framework performs the following sequence of operations for you:

flowchart TD
    A["adapter.Send.To(...).Text(...)"] --> B["Chain methods To/Using<br/>Each returns a new immutable instance (order doesn't matter)"]
    B --> C["__getattribute__ intercepts send methods<br/>Wraps with a rule wrapper"]
    C --> D["Calls the original method (e.g. Text)<br/>Internally delegates to Raw_ob12"]
    D --> E["Raw_ob12 returns asyncio.create_task(...)"]
    E --> F["Write [Send] log"]
    F --> G["emit message.sending (fire-and-forget)"]
    G --> H{"Is a send rule declared?"}
    H -->|"No"| I["Task done_callback → emit message.sent"]
    H -->|"Yes"| J["apply_send_rules wraps into an outer Task<br/>Retry/timeout/delay/priority"]
    J --> I
    I --> K["await receives a standard response dict"]

What the framework does at each step:

Phase What the framework does
Chain merging To/Using/Account each call creates a new immutable instance and inherits previously set fields, so To(...).Using(...) and Using(...).To(...) are equivalent and order doesn't matter
Method wrapping Send methods (Text, etc.) are intercepted and wrapped by __getattribute__; modifier methods (To/Using/At/Retry, etc.) are not wrapped. Nested Raw_ob12 calls are prevented from repeated wrapping using the _in_rule_wrap marker
Task creation Raw_ob12 internally uses asyncio.create_task() to create the Task; Text() only synchronously returns this Task, without blocking
Send logging Writes [Send] platform/method -> target event log (can be suppressed with exclude_levels=["EVENT"])
message.sending Triggered immediately in a fire-and-forget manner when the send method is called (only if listeners exist, short-circuited by has_handlers)
message.sent Bound to the Task's done_callback — when rules are present, it covers the final result of the entire retry process; without rules, it is simply the completion of the original Task

Account Resolution Fallback Chain

When the adapter internally calls _resolve_account(account_id), it resolves to a specific account in the following order:

  1. Single-account adapter (no AccountConfigClass) → directly returns
  2. Exact match of account name account_id
  3. Match of each account's bot_id field
  4. Match of any str field value in each account (excluding enabled/name)
  5. Fallback to the first enabled account
  6. If all fail → raises ValueError

The account_id you provide comes from: Using() explicitly specified > event self field (where account_id takes precedence over user_id, automatically injected by event.reply()) > unspecified (adapter falls back to the first enabled account).

Send Rule Engine (Retry/Timeout/Delay)

Rules are wrapped into a new outer Task after Raw_ob12 returns the Task, without affecting the main flow. Key facts:

Rule Description
Retry(n) Total attempts: n+1; immediate re-send after failure, no exponential backoff
Timeout(s) Single send times out and is cancelled (asyncio.wait_for), retries if not exhausted
Defer(s) Delays sleep before sending
Priority(level, drop_if_busy) If backlog exceeds threshold, directly returns {status:"failed", retcode:10002, message:"dropped_low_priority"}
Hook(fn) Only executed in order when the final send is successful
on_progress / on_error Callbacks at each stage / final failure

Note: Retries are "immediate re-sends" without any backoff interval; if platform rate limiting requires backoff, please manually sleep and re-send in the on_error callback. Rule success is determined by status == "ok" in the returned dict (where retcode == 0).

For the complete semantics of the standard response format and retcode, see API Response Specification.

Return Values

Task Object

All send methods return an asyncio.Task. The adapter only needs to implement Raw_ob12, and the standard methods (Text/Image, etc.) are delegated by default:

import asyncio

def Raw_ob12(self, message, **kwargs):
    async def _do_send():
        segments = self._apply_modifiers(message)
        return await self._adapter.call_api(
            endpoint="/send_message",
            message=segments,
            **self.send_context,
            **kwargs,
        )
    return asyncio.create_task(_do_send())

# Text/Image/Voice/Video/File are inherited from the base class and automatically delegated to Raw_ob12
# If you need to override standard methods, just return an asyncio.Task:
# def Text(self, text: str):
#     return self.Raw_ob12([{"type": "text", "data": {"text": text}}])

Standardized Response

call_api should return a standardized response. It is recommended to use the make_response() / make_error() methods:

async def call_api(self, endpoint: str, **params):
    try:
        result = await self._do_api_call(endpoint, **params)
        return self.make_response(
            data=result.get("data"),
            message_id=result.get("message_id", ""),
            raw=result,
        )
    except Exception as e:
        return self.make_error(message=str(e))

Manual construction is also supported (the old way is still compatible):

async def call_api(self, endpoint: str, **params):
    return {
        "status": "ok" or "failed",
        "retcode": 0 or error_code,
        "data": {...},
        "message_id": "msg_id" or "",
        "message": "",
        "{platform}_raw": raw_response
    }

Complete Examples

Basic Usage

from ErisPulse.Core import adapter

my_adapter = adapter.get("myplatform")

# Send text message
await my_adapter.Send.To("user", "123").Text("Hello World!")

# Send image
await my_adapter.Send.To("group", "456").Image("https://example.com/image.jpg")

# Send file
with open("document.pdf", "rb") as f:
    await my_adapter.Send.To("user", "123").File(f.read())

Chained Calls

# @user + reply
await my_adapter.Send.To("group", "456").At("789").Reply("msg123").Text("Reply to @ message")

# @all + multiple modifiers
await my_adapter.Send.Using("bot1").To("group", "456").AtAll().Text("Announcement message")

Raw Messages and Message Building

Raw_ob12 is the core entry point for reverse conversion (OneBot 12 message segments → platform API calls), and MessageBuilder is a chainable message segment builder designed to work with it.

For the complete Raw_ob12 implementation specification, MessageBuilder usage, and code examples, please refer to: