Status: canonical. Phase 11 (2026-09-01). Replaces the deleted
bbsengine6.notify/bbsengine6.message_deliverypackages (2026-07-22). Seebbsengine6/TODO-message-migration.mdfor the full migration history.
The bbsengine6.message package is the single source of truth for
channel-based pub/sub with PostgreSQL persistence, rate limiting,
blocking, recipient groups, templating, and a per-member unread
counter. Consumers (bed, casino, empyre, the bottombar F2
notification, the message CLI) call into this package; the old
notify / message_delivery APIs are gone.
access()Phase 11 (2026-09-01) split the package into four layers that
mirror casino's four-layer architecture (see casino/SPEC.md §3):
| Layer | Module | Role |
|---|---|---|
| Service | bbsengine6.message.service |
Business orchestration. Enable/disable gate, rate-limit gating, blocking filter, recipient expansion, legacy send shim |
bbsengine6.message.lib |
Public re-export facade + Message dataclass, MessageUrgency enum, DB helpers |
|
| DAL | bbsengine6.message.dal.messages |
engine.__message, engine.__message_recipient I/O |
bbsengine6.message.dal.recipients |
engine.__message_group_member expansion (@group, @everyone) |
|
bbsengine6.message.dal.groups |
engine.__message_group[_member] I/O |
|
bbsengine6.message.dal.blocking |
engine.__message_block I/O |
|
bbsengine6.message.dal.ratelimit |
engine.__message_rate_limit, engine.__message_type reads |
|
bbsengine6.message.dal.types |
engine.__message_type writes |
|
bbsengine6.message.dal._pool |
CONN_POOL_PATTERN helper + information_schema.tables probe |
|
| State | bbsengine6.message.cache |
In-memory local unread counter (no DB) |
| Domain | bbsengine6.message.templates |
{var} / $var template rendering |
bbsengine6.message.access (in __init__.py) |
Per-op authorization (subscribe / unsubscribe / list_pending) |
The DAL never imports psycopg directly; all DB plumbing goes
through bbsengine6.database. Services call DAL methods; DAL
executes queries; the engine layer never makes direct database
calls. The local unread cache in cache.py is not a DAL module
because it has no DB I/O — it sits at the package root so the DAL
contract stays "talks to Postgres only".
The full public surface is re-exported from
bbsengine6.message.lib and wildcard-imported into
bbsengine6.message.__init__. from bbsengine6.message import <name>
resolves any name listed below.
| Name | Definition | Notes |
|---|---|---|
Message |
dataclass (id, channel, sender_moniker, content, data, urgency, template, template_vars, datestamp) |
timestamp and recipients are derived properties |
MessageUrgency |
Enum(ROUTINE, IMPORTANT, URGENT, CRITICAL) |
Coerced to str via _coerce_urgency at the service boundary |
| Function | Signature | Notes |
|---|---|---|
send |
send(notification_type, recipients, template, template_vars=None, sender_moniker=None, data=None, urgency=None, should_persist=True, args=None, **kwargs) -> int |
Legacy message_delivery.send shim. Returns engine.__message.id, or 0 if disabled / rate-limited / no recipient survived expansion |
store_message |
store_message(channel, sender_moniker, content, recipient_monikers=None, data=None, urgency="ROUTINE", template=None, template_vars=None, database=None) -> int |
Thin wrapper over store_message_with_checks returning the new message id |
store_message_with_checks |
returns dict{message_id, rate_limit_ok, recipients_stored, recipients_blocked, recipients_skipped} |
The full-diagnostics variant used by net/integration.py and the bed MessageRouter |
get_pending_messages |
get_pending_messages(moniker, limit=50, database=None) -> List[dict] |
Datestamp-DESC, status IN (pending, delivered) |
get_pending_messages_prioritized |
get_pending_messages_prioritized(moniker, limit=50, database=None) -> List[dict] |
CRITICAL/URGENT/IMPORTANT/ROUTINE order then datestamp-DESC |
deliver_pending_on_connect |
deliver_pending_on_connect(moniker, database=None) -> List[dict] |
Prioritized; calls mark_delivered on every row returned |
mark_delivered |
mark_delivered(message_id, moniker, database=None) |
UPDATE engine.__message_recipient SET status='delivered', datedelivered=now() |
mark_read |
mark_read(message_id, moniker, database=None) |
UPDATE engine.__message_recipient SET status='read', dateread=now() |
expunge |
expunge(message_id, sender_moniker, database=None) -> bool |
Sender-side hard delete; FK CASCADE removes recipient rows |
get_queue |
get_queue(moniker, database=None) -> List[dict] |
Legacy notify-era API; thin wrapper around get_pending_messages(..., limit=1000) |
get_urgent |
get_urgent(moniker, limit=50, database=None) -> List[dict] |
urgency IN ('URGENT', 'CRITICAL'), ordered by urgency bucket then datestamp |
get_unread_count |
get_unread_count(moniker, database=None, *, args=None, pool=None, conn=None) -> int |
DB-side COUNT; returns 0 and logs a warning when engine.__message_recipient is missing |
| Function | Signature | Notes |
|---|---|---|
resolve_recipients |
resolve_recipients(recipients, database=None) -> List[str] |
Expands @group_name and @everyone references; depth cap 10; dedups by first occurrence |
member.group_exists |
bbsengine6.member.group_exists(args, group_name, **kwargs) -> bool|None |
Survives from the deleted notify package; validates format and queries engine.__message_group |
member.get_group_members |
bbsengine6.member.get_group_members(args, group_name, **kwargs) -> List[str]|None |
Survives from the deleted notify package; recursive expansion with cycle detection |
| Function | Signature | Notes |
|---|---|---|
is_enabled |
() -> bool |
Module-level boolean; defaults True |
enable |
() -> None |
Set the module-level boolean to True |
disable |
() -> None |
Set the module-level boolean to False (every service returns early/empty) |
get_local_unread_count |
(moniker) -> int |
Process-local cache; -1 means "never read" |
set_local_unread_count |
(moniker, count) -> None |
Write to the cache (clamped >= 0) |
bump_local_unread_count |
(moniker, delta=1) -> None |
Atomic update |
clear_local_unread_cache |
() -> None |
Reset the cache (used by tests) |
| Function | Signature | Notes |
|---|---|---|
create_message_group |
create_message_group(name, createdby=None, description=None, database=None) -> int |
INSERT into engine.__message_group; returns new id |
add_to_message_group |
add_to_message_group(group_id, member_moniker, addedby=None, database=None) -> bool |
Idempotent INSERT (ON CONFLICT DO NOTHING) |
remove_from_group |
remove_from_group(group_id, member_moniker, database=None) -> bool |
DELETE; returns True if a row was removed |
get_message_group_members |
get_message_group_members(group_id, database=None) -> List[str] |
SELECT member_moniker rows |
get_user_groups |
get_user_groups(moniker, database=None) -> List[dict] |
Groups the user belongs to |
block_sender |
block_sender(blocker_moniker, blocked_moniker, database=None) -> bool |
Idempotent INSERT into engine.__message_block |
unblock_sender |
unblock_sender(blocker_moniker, blocked_moniker, database=None) -> bool |
DELETE |
is_blocked |
is_blocked(blocker_moniker, blocked_moniker, database=None) -> bool |
True iff a row exists for the pair |
get_blocked |
get_blocked(moniker, database=None) -> List[str] |
Inverse of is_blocked: blocker monikers who have blocked moniker |
| Function | Signature | Notes |
|---|---|---|
check_rate_limit |
check_rate_limit(sender_moniker, message_type, database=None) -> Tuple[bool, int] |
Returns (allowed, remaining); 0 per-hour limit means unlimited |
record_message_sent |
record_message_sent(sender_moniker, message_type, database=None) -> bool |
UPSERT into engine.__message_rate_limit for the current hour bucket |
set_rate_limit |
set_rate_limit(type_name, limit, database=None) -> bool |
Idempotent UPDATE-or-INSERT on engine.__message_type.rate_limit_per_hour |
get_message_type_rate_limit |
get_message_type_rate_limit(message_type, database=None) -> int |
0 if the type is not registered |
| Function | Signature | Notes |
|---|---|---|
register_type |
register_type(type_name, description="", rate_limit_per_hour=0, requires_approval=False, database=None) -> bool |
UPSERT into engine.__message_type |
register_type_compat |
register_type_compat(type_name, urgency=None, max_per_user_per_hour=0, persist_by_default=True, args=None, **kwargs) -> bool |
Adapter that accepts the legacy message_delivery.register_type positional signature |
get_types |
get_types(database=None) -> List[dict] |
Every registered type, sorted by type_name |
| Function | Signature | Notes |
|---|---|---|
render_template |
render_template(template, variables) -> str |
{var} and $var substitution |
render_message_content |
render_message_content(content, template, template_vars) -> str |
Picks template+template_vars when both are given |
parse_variables_from_content |
parse_variables_from_content(content) -> List[str] |
Extracts every {var} / $var name used |
get_builtin_variables |
() -> Dict[str, Any] |
year, month, day, hour, minute, timestamp, date, time |
validate_template |
validate_template(template) -> Tuple[bool, List[str]] |
Returns (is_valid, errors) for brace and $var mismatches |
bbsengine6.message.dal.* is pure Postgres I/O. Modules never
import psycopg; every connection goes through bbsengine6.database
(via _connect_ctx(args, pool) in dal/_pool.py). The DAL also
provides a generic table_exists(cur, schema, table) helper that
runs a single information_schema.tables SELECT and never raises
— it returns False on any exception so a probe failure cannot
mask the caller's intent. This is what get_unread_count uses to
distinguish "zero unread" from "missing schema" when the
bootstrap hasn't run yet.
| Module | Owns |
|---|---|
messages.py |
engine.__message, engine.__message_recipient I/O |
recipients.py |
engine.__member (for @everyone expansion) and engine.__message_group (for @group lookups) |
groups.py |
engine.__message_group, engine.__message_group_member I/O |
blocking.py |
engine.__message_block I/O |
ratelimit.py |
engine.__message_rate_limit reads/writes; engine.__message_type reads |
types.py |
engine.__message_type writes (upsert, set_rate_limit, list_all) |
_pool.py |
_connect_ctx(args, pool) + table_exists(cur, schema, table) |
Async DAL is not yet provided. The current implementation is fully
sync (via bbsengine6.database.getpool / pool.connection()); an
async counterpart under bbsengine6.message.dal.aio/ would mirror
casino/dal/aiosql/ once a consumer needs it.
resolve_recipients(recipients, database=None) is the canonical
recipient expansion path. It runs at the service boundary (in
store_message_with_checks) so callers can pass @group_name or
@everyone and get notify-style expansion transparently:
@everyone -> SELECT moniker FROM engine.__member WHERE approved = TRUE
@group_name -> SELECT id FROM engine.__message_group WHERE name = ...
-> SELECT member_moniker FROM engine.__message_group_member
WHERE group_id = ...
alice -> "alice" (literal)
Expansion is recursive (depth cap 10) so nested groups work; the
@everyone token is case-insensitive (@Everyone works the same
as @everyone). The returned list preserves the first-seen order
with duplicates removed.
The surviving member.group_exists and member.get_group_members
functions in py/src/bbsengine6/member/lib.py cover member
validation and recursive group expansion at the bbsengine6.member
package boundary. They are not a transport layer — they only
read engine.__message_group and engine.__message_group_member.
resolve_recipients is the message-system path; member.get_group_members
is the validation/lookup path. They share the same table family
but have different consumers.
Every store_message_with_checks call (and therefore every
store_message / send) runs through _check_blocking_and_ratelimit
in bbsengine6.message.service:
sender_moniker is not None,
check_rate_limit(sender_moniker, channel, database=...) is
called. A 0 rate_limit_per_hour (or an unregistered type)
means "unlimited" ((True, 999)). On deny the message is
rejected before any DB writes and the result dict's
message_id is 0.is_blocked(recipient, sender_moniker) is consulted. Blocked pairs are skipped
silently and accumulated into recipients_blocked /
recipients_skipped in the diagnostics dict.record_message_sent(sender_moniker, channel, ...) bumps the
current hour bucket's engine.__message_rate_limit.message_count.block_sender / unblock_sender / is_blocked / get_blocked
are direct UIs onto engine.__message_block and are exposed for
caller-supplied blocks UI / CLI work.
bbsengine6.message.templates is pure rendering (no I/O, no
policy). Two syntaxes are accepted in the same template:
{var_name} — curly-brace substitution. Looks up
var_name in the variables dict and substitutes str(var_value).$var_name — dollar substitution. Same lookup, no braces.render_template(template, variables) walks the variables dict
once and applies both substitutions to a single result string. The
dollar form is positional: $varname is replaced; $ varname
(with a space) is not. Unknown variables are not an error — they
simply do not match either pattern.
validate_template(template) returns (is_valid, errors) where
errors lists brace mismatches, dollar mismatches, and any
malformed { tokens (e.g. {1foo}).
get_builtin_variables() returns a fresh dict on every call with
the current wall-clock time:
| Key | Value |
|---|---|
year |
datetime.now().year |
month |
datetime.now().month |
day |
datetime.now().day |
hour |
datetime.now().hour |
minute |
datetime.now().minute |
timestamp |
datetime.now().isoformat() |
date |
datetime.now().strftime("%Y-%m-%d") |
time |
datetime.now().strftime("%H:%M:%S") |
python -m bbsengine6.message (or python -m bbsengine6.message.cli)
runs the operator CLI. The default program name is bbsengine6-msg.
| Subcommand | Args | Mutating? |
|---|---|---|
list-types |
— | no |
pending |
moniker [--limit N] |
no |
unread |
moniker |
no |
mark-read |
moniker --message-id N --yes |
yes |
mark-delivered |
moniker --message-id N --yes |
yes |
expunge |
--message-id N --sender MONIKER --yes |
yes |
register-type |
type_name [--description ...] [--rate-limit N] [--requires-approval] --yes |
yes |
resolve |
--to TOKEN [--to TOKEN ...] |
no |
send |
--to TOKEN [--to TOKEN ...] --type TYPE --body BODY [--sender MONIKER] [--urgency U] [--vars k=v] [--dry-run] --yes |
yes |
Mutating subcommands require --yes; send also accepts
--dry-run to preview the rendered output without writing.
send --to @everyone is a privilege gate, not a confirmation:
the CLI checks bbsengine6.backend.lib.issysop against the
connected DB role and refuses with a clear error if current_user
is not in the sysop pg role. --to accepts comma-separated
tokens (alice,bob) and may be repeated.
--database overrides $BBSENGINE6_DBNAME (default zoid6).
access()bbsengine6.message is a first-class module under the bbsengine6
module registry. bbsengine6.message.init(args) registers
bbsengine6.message with apis={"access": access} so the
loader can resolve the per-op authorization policy via
bbsengine6.get_module_api("bbsengine6.message", "access").
access(args, op, /, **kwargs) authorizes op for the given
session/message pair. Recognized op values:
op |
Required message keys |
Returns True when |
|---|---|---|
subscribe |
moniker |
session.is_sysop is True, or session.moniker == target moniker |
unsubscribe |
moniker |
(same as subscribe) |
list_pending |
moniker |
(same as subscribe) |
At module-load time (bbsengine6.module.check calls with
op="run" and no session kwarg), access returns True for
everyone; the per-op rules only fire when the caller passes a
session kwarg. The function does NOT perform input validation
(moniker present, non-empty); that's the caller's job, sitting
next to the wire-envelope shape checks.
The schema lives under py/src/bbsengine6/sql/:
| File | Purpose |
|---|---|
message.sql |
engine.__message, engine.__message_recipient, supporting indexes |
message_groups.sql |
engine.__message_group, engine.__message_group_member |
message_type.sql |
engine.__message_type |
message_rate_limit.sql |
engine.__message_rate_limit |
message_block.sql |
engine.__message_block |
message_enum.sql |
urgency enum (ROUTINE / IMPORTANT / URGENT / CRITICAL) |
messageview.sql |
engine.message, engine.message_unread, engine.message_urgent, engine.message_blocked |
migrate_notify_to_message.sql |
One-time migration of legacy notify tables (already-migrated clusters skip) |
Bootstrap verification is performed by
py/src/bbsengine6/backend/checkmessage.py, which is called by
backend.stage_one.main() during bbsengine6.startup.
The full Phases 1-11 checklist is in
bbsengine6/TODO-message-migration.md. The short version:
remove_from_group,
get_blocked, get_urgent, expunge, get_queue, recipient
expansion, set_rate_limit, register_type, get_types,
Message dataclass, MessageUrgency enum) plus the four
engine.message* views.checkmessage is now the canonical message-system check).migrate_notify_to_message.sql provided for opt-in data
migration.bbsengine6.notify and
bbsengine6.message_delivery packages deleted along with the
SQL files, daemons, examples, and test files. Only
member.moniker_exists, member.group_exists, and
member.get_group_members survived.store_message_with_checks with blocking + rate limit,
urgency-first delivery, real send_to_remote, etc.); router's
last notify.send() call site routed through
message.store_message.send(...) and
register_type_compat(...) shims landed so casino and other
downstream game packages stay importable after Phase 7.from bbsengine6.message import <name>) is
unchanged; the implementation moved to
bbsengine6.message.{service,dal.*,cache,templates,lib}.