Skip to content

Routing, dependency injection, and state

Routers and filters

Create feature routers, then include each detached router exactly once:

from chattice import Dispatcher, F, Router
from chattice.events import MessageEvent

support = Router(name="support")


@support.message(F.text == "help")
async def help_message(message: MessageEvent) -> str:
    return "How can I help?"


dispatcher = Dispatcher()
dispatcher.include_router(support)

Common observer filters

Apply one scope to every handler registered on an observer:

reports = Router(name="reports")

reports.message.filter(F.space.name.in_({"spaces/AAA", "spaces/BBB"}))


@reports.message(F.text == "report")
async def report(message: MessageEvent) -> str:
    return "report"


@reports.message(F.text == "status")
async def status(message: MessageEvent) -> str:
    return "ready"

Repeated observer.filter(...) calls append filters and combine them as AND. Common filters run once before handler-specific filters. A mapping returned by a common filter becomes dependency context for handler filters and the handler itself; the existing context-conflict rules still apply.

Observer filters are local to that observer and Router. They do not leak to another observer or inherit into child Routers. If no specific message handler matches, the existing generic event fallback remains available.

Direct-message scoping uses the same magic-filter API:

assistant.message.filter(F.space.space_type == "DIRECT_MESSAGE")

Specific observers run before the generic event observer. Filters are ordered and can inject mappings into handler dependency context. Middleware runs outer-to-inner through the router tree. Error observers receive an ErrorEvent and can translate application failures.

Native regex routing

F.text.regexp(...) matches a string field with a Python regular expression, re.match semantics (the pattern must match from the START of the value). This is Python regex syntax — do not wrap patterns in /.../.

import re


@support.message(F.text.regexp(r"^[Tt]est$"))
async def test_command(message: MessageEvent) -> str:
    return "ok"


@support.message(F.text.regexp(r"^test$", flags=re.IGNORECASE))
async def test_command_ci(message: MessageEvent) -> str:
    return "ok"

A pattern string is compiled once at filter construction; invalid patterns raise ValueError immediately, never at evaluation time. Pre-compiled re.Pattern values are accepted as-is (and cannot be combined with flags). Missing fields and non-string values never match. String equality stays literal: F.text == "^test$" compares the exact characters, regex semantics never leak into ==.

F.text.regexp(...) composes with &, |, ~ like any other Magic Filter, e.g. F.text.regexp(r"^ping") & ~F.text.regexp(r"pong$").

Dependency injection

Handlers declare only the values they need. Event parameters are resolved by type; application dependencies are resolved by name from dispatcher context:

@router.message()
async def handle(message: MessageEvent, inventory: InventoryService) -> str:
    return await inventory.lookup(message.text)


result = await dispatcher.feed_update(event, inventory=inventory_service)

Define annotated handlers and injected classes at module scope so runtime type hint resolution remains reliable.

FSM scope

StorageKey(user, space, thread) is the complete key shape. There is no THREAD_USER symbol. Strategies select dimensions from that key:

Strategy Scope
USER_IN_SPACE user + Space, including Thread when present (default)
USER user across Spaces
SPACE shared Space state
from chattice.fsm import FSMContext, MemoryStorage, State, StateFilter, StatesGroup


class Flow(StatesGroup):
    waiting = State()


@router.message(StateFilter(Flow.waiting))
async def waiting(message: MessageEvent, state: FSMContext) -> str:
    await state.update_data(answer=message.text)
    await state.finish()
    return "Saved"


dispatcher = Dispatcher(fsm_storage=MemoryStorage())

Use MemoryStorage only within one development/test process. Use the Redis backend or the revisioned FSMRecordStorage contract when state must survive processes; compare-and-set is the concurrency boundary.

Next: Workspace Events.

Per-user FSM vs shared-resource state

FSM records scope per-user workflow state (USER_IN_SPACE / USER / SPACE keys). For state that is SHARED across participants — a workflow board, a shared card's votes, a team approval record — use your application database keyed by the resource, not FSM. Per-user FSM never changes how a shared Message renders; it only scopes each actor's backend state.