# Development

So you want to extend ANetBBS — write a door game, add a web feature,
ship a theme, hook into the message system. This page walks through
each major extension point with working examples.

## Project layout

```
anetbbs/
├── anetbbs/              ← Python package (importable)
│   ├── web/              ← Flask blueprints (one file per feature area)
│   ├── core/             ← terminal session, menu engine
│   ├── echomail/         ← FidoNet binkp / QWK / TIC
│   ├── games/            ← door runner + Synchronet JS shim
│   │   ├── sbbs_stubs/   ← Synchronet API surface (pure Node)
│   │   ├── sbbs_doors/   ← bundled doors (LORD lives here)
│   │   └── web_games.py  ← in-browser mini-game registry
│   ├── mrc/              ← MRC chat client + namespace
│   ├── msp/              ← Inter-BBS IM (RFC 1312)
│   ├── rss/              ← RSS poller + reader
│   ├── wiki/             ← collaborative wiki engine
│   ├── features/         ← cross-cutting helpers
│   ├── templates/        ← Jinja2 HTML templates (mirrors blueprint names)
│   ├── static/           ← CSS, JS, images
│   ├── models.py         ← SQLAlchemy models (single source of truth)
│   └── web_app.py        ← create_app() factory + extension wiring
├── deploy/               ← systemd units, nginx template, wsgi wrapper
├── docs/                 ← these markdown files
├── vendor/               ← shipped third-party binaries (Mystic, BotWars…)
└── mrc/                  ← standalone MRC bridge daemon (aiohttp-based)
```

Everything in `anetbbs/` is one Python package. Blueprints register
themselves via `create_app()` in `web_app.py` — that's where to wire
in a new feature.

---

## Doors — 10 ways in

ANetBBS supports ten distinct **door types** out of the box. Each
maps to a `Game` model row (sysop creates via `/admin/games/`) with a
`game_type` discriminator.

| `game_type` | Run path | Drop file | Use when |
|---|---|---|---|
| `door_dos` | DOSBox-staging bridge with TCP nullmodem on 127.0.0.1:5000-5100 | `DOOR.SYS` / `DORINFO1.DEF` | Original DOS doors (TradeWars, LORD-DOS, Usurper…) |
| `door_dosemu` | dosemu2, virtual COM1 bridged to caller's PTY (no FOSSIL) | `DOOR.SYS` | DOS doors that need dosemu2 specifically instead of DOSBox |
| `door_native` | Direct exec, stdio piped to caller | configurable | Linux-native doors (Java, Go, …) |
| `door_synchronet` | Real `jsexec` if present, otherwise Node + our compat shim | `DOOR32.SYS` | Synchronet `.js` doors (LORD-JS, MajorMUD-JS, dorkit-based doors) |
| `door_mystic` | Runs a Python door script via a `mystic_bbs` compat shim | Mystic-style | Mystic-API Python doors (`.py`) |
| `door_mystic_mps` | Auto-compiles `.mps` → `.mpx` via `mplc`, then runs the real `mystic` binary | Mystic-style | Mystic Pascal source/bytecode doors (`.mps`/`.mpx`) |
| `door_rlogin` | Outbound rlogin to remote BBS game-server | none — passes user identity | DoorParty, A-Net Online, Synchronet xtrn servers |
| `door_telnet` | Outbound telnet to remote game server, no pre-auth handshake | none | TWGS (Trade Wars Game Server) and similar telnet-only remotes |
| `builtin_web` | Flask-routed browser game (xterm.js not needed) | none | Pure-web mini-games (Snake, 2048, Hangman…) |
| `door_dos_browser` | EmulatorJS + dosbox_pure, runs entirely client-side in the browser | none | DOS shareware bundles playable with no telnet/SSH client at all |

### Door entrypoints (config fields)

Every door row has these knobs (sysop sees them at `/admin/games/`):

| Field | Tokens supported | Example |
|---|---|---|
| `executable_path` | `%U` `%n` `%P` (per-node dir) … | `/home/anetbbs/doors/lord/lord.sh` |
| `working_directory` | same | `/home/anetbbs/doors/lord/` |
| `command_line_args` | same + `%f` (drop file path) | `-l %f -node %n` |
| `drop_file_path` | `%P` … | `%P/door.sys` |
| `drop_file_type` | — | `DOOR.SYS`, `DORINFO1.DEF`, `DOOR32.SYS` |
| `max_nodes` | — | `4` |
| `min_time_required` | minutes | `5` |
| `enabled` | bool | — |
| `sort_order` | int | for menu ordering |

Full token table is in [`14-door-games.md`](14-door-games.md).

### Per-node scratch dirs

The node manager allocates one of `GAMES_MAX_NODES` slots (default 10)
on each door launch and creates `data/temp/nodeN/` for that session.
Use `%P` in any path field to point at it. Multiple concurrent players
each get isolated dirs — never coordinate by writing to
`<door>/NODE1.DAT` and hoping; use `%P`.

### Writing a Synchronet JS door

The compat shim is at `anetbbs/games/sbbs_stubs/`. ~270 functions/objects
covering `bbs.*`, `console.*`, `system.*`, `user.*`, `File`, `load()`,
`require()`, `dd_lightbar_menu`, `mouse_getkey`. If you write a door
that uses only those APIs, it'll run on our shim **without needing
Synchronet installed**.

Example minimal door (`hello.js`):

```javascript
load('sbbsdefs.js');
console.clear(ANSI_NORMAL);
console.putmsg('\x01n\x01c\x01hHello from a JS door!\r\n');
console.putmsg('Your handle: \x01y' + user.alias + '\r\n');
console.putmsg('\r\n\x01wPress any key...\x01n');
console.getkey();
```

Drop this in `~/doors/hello/hello.js`, create a Game row with:
- `game_type`: `door_synchronet` (label "Synchronet JS Game" in the
  admin dropdown)
- **Synchronet Script Path (.js)**: `~/doors/hello/hello.js`
- **Synchronet Exec Dir**: leave blank unless the script expects to
  run from a specific working directory
- `drop_file_type`: `door32.sys`
- `drop_file_path`: `%Pdoor32.sys`

There's no separate wrapper shell script to point at — `executable_path`
and `command_line_args` aren't used for this door type at all. The
launcher auto-detects a real `jsexec` if present, otherwise falls back
to the Node.js compat shim, using the two Synchronet-specific fields
above to find your script. Drop-file generation, terminal I/O,
ANSI/CP437 passthrough, and user info injection are all handled the
same generic way every other door type gets them.

**When the shim isn't enough:** drop a real Synchronet install in
`/usr/local/sbbs/` and set `SBBS_PATH` in `.env`. The launcher detects
`jsexec` and prefers it over the shim.

See [`15-synchronet-compat.md`](15-synchronet-compat.md) for the
complete shim API surface and known gaps.

### Writing a Mystic-style door

ANetBBS ships the full Mystic 1.12 A48 runtime at
`<install>/vendor/mystic/` — sysop doesn't need to install Mystic
separately. The runtime includes `mplc` (compiler), `mystic` (script
interpreter), `mide` (script editor), and the full data/menus/themes.

For a `.mps` Pascal source door (or a pre-compiled `.mpx`):
- `game_type`: `door_mystic_mps`
- `mystic_script_path`: path to the `.mps`/`.mpx` file
- Runs the real `mystic` binary via `MYSTIC_BBS_PATH` (default
  `/usr/local/bin/mystic`), auto-compiling `.mps` → `.mpx` with `mplc`
  on launch if the bytecode is missing or older than the source. If
  `mplc` isn't installed, it falls back to assuming the script is
  already compiled.
- `command_line_args`: `USERNAME_OR_@USER@ PASSWORD` — required.
  Mystic has no anonymous/no-login mode for scripts; this becomes the
  real `-u`/`-p` account the door runs under (`@USER@` substitutes the
  calling ANetBBS user's own username).

For a Mystic-API Python door:
- `game_type`: `door_mystic`
- `mystic_script_path`: path to the `.py` script
- Runs via a fake `mystic_bbs` module that maps Mystic's BBS API onto
  our compat helpers. Drop a `.py` script that does
  `from mystic_bbs import *` and you're a few function calls from a
  working door.

### rlogin out-dial doors

Connect to a remote BBS's game server without re-authenticating:
- `game_type`: `door_rlogin`
- `executable_path` field repurposed: `host:port`
- `command_line_args` repurposed: rlogin server-key (some servers want
  a shared secret)

**Important quirk:** Synchronet's rlogin uses INVERTED field order
vs RFC 1282 — sends `password\0username\0` (not `client\0server\0`).
Our connection class handles this automatically; if you're writing
against a non-Synchronet target, see `anetbbs/games/door_rlogin.py`.

### Built-in web games

Add a new web-only mini-game in `anetbbs/games/web_games.py`:

```python
WEB_GAMES.append({
    'name': 'My Cool Game',
    'slug': 'mycoolgame',
    'description': 'It is very cool.',
    'category': 'puzzle',          # puzzle | action | strategy | rpg | cards | casino | other
    'icon': 'bi-controller',        # Bootstrap icon class
    'web_game_module': 'mycoolgame',
    'sort_order': 110,
})
```

Then create `anetbbs/templates/games/web_games/mycoolgame.html` with
your game's HTML/JS. The game lobby auto-picks it up; the play route
is `/games/play/<slug>`.

---

## Web extensions

### Adding a new blueprint

A "blueprint" is Flask's term for a self-contained feature. To add one:

1. Create `anetbbs/web/myfeature.py`:

```python
from flask import Blueprint, render_template
from flask_login import login_required, current_user

myfeature_bp = Blueprint('myfeature', __name__, url_prefix='/myfeature')

@myfeature_bp.route('/')
@login_required
def index():
    return render_template('myfeature/index.html')
```

2. Register it in `anetbbs/web_app.py` next to the other `register_blueprint(...)` calls:

```python
from anetbbs.web.myfeature import myfeature_bp
app.register_blueprint(myfeature_bp)
```

3. Add `anetbbs/templates/myfeature/index.html` extending `base.html`:

```jinja
{% extends "base.html" %}
{% block title %}My Feature{% endblock %}
{% block content %}
  <h1>Hello, {{ current_user.username }}!</h1>
{% endblock %}
```

4. (Optional) Add a nav-bar link by editing the appropriate dropdown
   in `templates/base.html`.

That's the whole flow. ~50 lines of code for a working feature.

### Database / models

`anetbbs/models.py` is the single source of truth for SQLAlchemy
models. Add a new table by appending a class:

```python
class MyThing(db.Model):
    __tablename__ = 'my_things'
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
    note = db.Column(db.Text)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

    user = db.relationship('User', backref='my_things')
```

There's no `migrations/` directory and no `flask db migrate`/`upgrade`
step in this project, despite Flask-Migrate being wired up in
`web_app.py` — schema changes are picked up automatically instead: an
auto-sweep in `_lightweight_migrate()` (`web_app.py`) walks
`db.metadata.sorted_tables` on every app start and `ALTER TABLE ADD
COLUMN`s anything the model declares that the live DB is missing (new
tables come from the regular `db.create_all()` call right before it).
For a brand new table like `MyThing` above, that's all you need —
just restart the app (or re-run `update.sh` on a real install) and the
table appears. Only reach for a hand-written `_ensure_column()` call in
`_lightweight_migrate()` if you need a specific default/NOT NULL
constraint the generic permissive sweep doesn't give you (see that
function's own comments for examples).

### Adding a sysop admin page

Admin pages live behind `admin.dashboard` and require `current_user.is_admin`.
Two patterns:

1. **Drop a route into `anetbbs/web/admin.py`** for simple pages.
2. **Make a sub-blueprint** for larger features (see `echomail_admin.py`
   or `gallery_admin.py`).

Either way, add the page to the `ADMIN_HUB_SECTIONS` dict at the top
of `anetbbs/web/admin.py` (each category page's tool list is built
from this, not a separate nav template) and make sure the route has
both `@login_required` and your `is_admin` gate.

### Real-time / WebSocket features

Flask-SocketIO is set up in `web_app.py`. Pattern:

```python
from anetbbs.web_app import socketio
from flask_socketio import emit

@socketio.on('connect', namespace='/mything')
def on_connect():
    if not current_user.is_authenticated:
        return False  # reject
    emit('hello', {'user': current_user.username})

@socketio.on('do_thing', namespace='/mything')
def on_do_thing(data):
    ...
    emit('thing_done', {'result': 'ok'}, to=request.sid)
```

Client side: `const sock = io('/mything')` then bind handlers.

The Web Terminal (`anetbbs/web/web_terminal.py`) and MRC client
(`anetbbs/web/mrc.py`) are good reference implementations — both
spawn background eventlet greenthreads, pump bytes between a backend
socket and the browser, and handle reconnect.

---

## Themes

Themes are rows in the `themes` table with CSS variable values. The
sysop edits them visually at `/admin/theme-builder/`.

To ship a new built-in theme:

1. Open the theme builder, tweak colors, save with a name.
2. Export the resulting `Theme` row's values as SQL (or note them).
3. Add it to the default-theme seed block in `_create_default_data()`
   in `anetbbs/web_app.py` (there's no separate `seed_data.py` — it's
   all in `web_app.py`) so fresh installs get your theme.

CSS variables driving the theme include `--theme-bg-dark`,
`--theme-primary`, `--theme-accent`, etc. — see `templates/base.html`
for the full list.

---

## MRC integration

The MRC bridge runs as a **separate aiohttp daemon** (`mrc/bridge/`)
on its own port (`WEB_PORT + 1` — 5001 on the standard `WEB_PORT=5000`,
derived and written to `mrc/bridge/config.json` by `install.sh`; see
`docs/PORTS.md`) and is reverse-proxied at `/mrcws` and `/mrcweb/`. The
web BBS talks to it via socket.io at `/mrc/`.

If you want to add a slash-command, capability, or output format:

- The terminal client lives in `anetbbs/features/mrc_chat.py`
- The web client is `anetbbs/static/mrc/client.js`
- The bridge daemon's command surface is `mrc/bridge/main.py`

For an IRC↔MRC mirror, see `anetbbs/features/mrc_irc_bridge.py` — this
one is CLI/systemd-only, no web admin UI exists for it yet. A sysop
creates `MrcIrcBridge` rows directly and runs one instance per bridge
via `python -m anetbbs.features.mrc_irc_bridge --bridge-id N`, wrapped
by the `anetbbs-mrc-irc-bridge@.service` systemd template.

---

## Echomail / FidoNet plugins

The pollers live in `anetbbs/echomail/`. Each network type has its
own module (`binkp.py`, `qwk.py`, `tic.py`). If you're adding a new
network protocol, the cleanest pattern is to write a new module
exposing `poll_outbound(network)` and `poll_inbound(network)`
functions, then add a row to the `echomail_networks` table with the
matching `protocol` discriminator.

Echomail message bodies go through `anetbbs/echomail/kludges.py` for
FTS-0001-compliant CR-terminated kludge handling. Don't reinvent —
the spec is hostile to bytes.

On the hub side, `anetbbs/web/hub_admin.py` (the `/admin/echomail/hub/`
blueprint), the `HatchQueue`/TIC hatch pipeline, and the
`ScheduledEvent` handler registry (`anetbbs/events/handlers.py`) are
the extension points if you're adding hub-side infrastructure —
distributing a new file type over TIC, adding another handler to the
scheduler, etc. — rather than a new peer-side protocol. See
[`20-federation.md`](20-federation.md) and
[`21-scheduled-events.md`](21-scheduled-events.md) for how those fit
together.

---

## Outbound notifications / webhooks

Two outbound channels are built in:
- **Webhooks** — generic POST to a URL, configured at `/admin/webhooks/`
- **Email** — outgoing SMTP is a DB-backed `SmtpConfig` row, edited at
  Admin → SMTP / Email — not `.env` variables

To fire one from your feature code:

```python
from anetbbs.features.webhooks import fire
fire('post', {'board': 'general', 'title': 'Hello'})
```

`fire()` spins up a plain thread and makes one POST attempt with an
8-second timeout — it won't block your request, but there's no queue
and no retry on failure. See [`23-webhooks.md`](23-webhooks.md) for
the full list of the 8 real event types and the exact payload shape
each one sends.

---

## Testing

```bash
cd <install>
venv/bin/pip install -r requirements-dev.txt   # one-time, pulls in pytest
venv/bin/pytest tests/
# or:
venv/bin/python -m unittest discover -s tests -p "test_*.py"
```

`requirements-dev.txt` is needed either way: `tests/test_mrc_integration.py`
uses real `@pytest.fixture` decorators, so pytest has to be installed
for that one file to even be *importable* — `unittest discover` will
fail to load it (not just skip it) without pytest present, same as
running it under `pytest` directly.

The suite is a mix of `unittest.TestCase`-based files (most of them)
and a single pytest-fixture-based file — there's no shared
`conftest.py`; each test file that needs a Flask app/test client
builds its own (see any `test_*.py` file's `setUpClass`/fixture
functions for the pattern most commonly used — create a fresh
`create_app('testing')`, point `TestingConfig.SQLALCHEMY_DATABASE_URI`
at a scratch file or `tempfile.TemporaryDirectory()`, and restore any
shared state in `tearDownClass`). Add tests for new features under
`tests/`.

---

## Where to ask questions

- **Wiki: [[Development]]** — community-editable companion to this doc
- **Email** — `a-net-online@proton.me`
- **GitHub issues** — bug reports + feature requests on the
  `anetonline/anetbbs` repo
- **IRC** — `irc.a-net.online`, port `6667` (plain) or `6697` (SSL),
  room `#ANetBBS`

If you build something cool, send a PR and we'll bundle it.
