agentirc-cli 9.4.0__tar.gz → 9.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/CHANGELOG.md +87 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/CLAUDE.md +8 -8
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/PKG-INFO +3 -3
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/_internal/bots/http_listener.py +9 -1
- agentirc_cli-9.5.0/agentirc/_internal/event_subscriptions.py +282 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/_internal/telemetry/audit.py +7 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/_internal/virtual_client.py +36 -16
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/channel.py +31 -4
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/cli.py +4 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/client.py +177 -16
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/config.py +10 -2
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/ircd.py +80 -44
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/protocol.py +132 -2
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/server_link.py +59 -9
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/skill.py +10 -37
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/docs/api-stability.md +75 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/docs/cli.md +1 -1
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/docs/deployment.md +9 -1
- agentirc_cli-9.5.0/docs/extension-api.md +243 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/docs/superpowers/specs/2026-04-30-bootstrap-design.md +4 -3
- agentirc_cli-9.5.0/docs/superpowers/specs/2026-05-01-bot-extension-api-design.md +458 -0
- agentirc_cli-9.5.0/docs/superpowers/specs/2026-05-01-task14-audit.md +65 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/pyproject.toml +18 -18
- agentirc_cli-9.5.0/tests/test_bot_capability.py +204 -0
- agentirc_cli-9.5.0/tests/test_event_subscriptions.py +335 -0
- agentirc_cli-9.5.0/tests/test_eventpub.py +194 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_events_basic.py +9 -2
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_events_lifecycle.py +16 -9
- agentirc_cli-9.5.0/tests/test_protocol_bot_exports.py +214 -0
- agentirc_cli-9.5.0/tests/test_wire_format_envelope.py +325 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/uv.lock +1 -1
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/.claude/skills/pr-review/SKILL.md +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/.claude/skills/pr-review/scripts/portability-lint.sh +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/.claude/skills/pr-review/scripts/pr-batch.sh +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/.claude/skills/pr-review/scripts/pr-comments.sh +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/.claude/skills/pr-review/scripts/pr-reply.sh +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/.claude/skills/pr-review/scripts/pr-sonar.sh +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/.claude/skills/pr-review/scripts/pr-status.sh +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/.claude/skills/pr-review/scripts/workflow.sh +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/.claude/skills.local.yaml.example +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/.github/workflows/publish.yml +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/.github/workflows/tests.yml +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/.gitignore +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/LICENSE +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/README.md +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/__init__.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/__main__.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/_internal/__init__.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/_internal/aio.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/_internal/bots/__init__.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/_internal/bots/bot_manager.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/_internal/cli_shared/__init__.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/_internal/cli_shared/constants.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/_internal/cli_shared/mesh.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/_internal/constants.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/_internal/pidfile.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/_internal/protocol/__init__.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/_internal/protocol/message.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/_internal/protocol/replies.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/_internal/telemetry/__init__.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/_internal/telemetry/context.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/_internal/telemetry/metrics.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/_internal/telemetry/tracing.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/events.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/history_store.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/remote_client.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/room_store.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/rooms_util.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/skills/__init__.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/skills/history.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/skills/icon.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/skills/rooms.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/skills/threads.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/agentirc/thread_store.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/docs/steward/onboarding.md +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/__init__.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/_helpers.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/conftest.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/telemetry/__init__.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/telemetry/_fakes.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/telemetry/_metrics_helpers.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/telemetry/test_audit_emit.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/telemetry/test_audit_lifecycle.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/telemetry/test_audit_module.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/telemetry/test_audit_parse_error.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/telemetry/test_config.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/telemetry/test_dispatch_span.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/telemetry/test_emit_event_span.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/telemetry/test_metrics_init.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/telemetry/test_metrics_s2s.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/telemetry/test_outbound_inject.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/telemetry/test_parse_error.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/telemetry/test_s2s_relay_span.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/telemetry/test_server_init.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/telemetry/test_server_link_inject.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/telemetry/test_tracing.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_channel.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_cli.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_config_loader.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_connection.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_discovery.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_events_catalog.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_events_federation.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_events_history.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_events_reserved_nick.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_federation.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_history.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_link_reconnect.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_mentions.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_messaging.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_modes.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_room_persistence.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_rooms_federation.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_rooms_integration.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_server_icon_skill.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_skills.py +0 -0
- {agentirc_cli-9.4.0 → agentirc_cli-9.5.0}/tests/test_threads.py +0 -0
|
@@ -4,6 +4,93 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
Format follows [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [9.5.0] - 2026-05-02
|
|
8
|
+
|
|
9
|
+
Closes [agentculture/agentirc#15](https://github.com/agentculture/agentirc/issues/15) — out-of-process bot extension API. Unblocks [agentculture/culture#308](https://github.com/agentculture/culture/issues/308) Phase A2 (bot rewrite against the public API).
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **IRCv3 `agentirc.io/bot` capability.** Advertised in `CAP LS` output. When negotiated via `CAP REQ`, gates four behaviours: silent JOIN/PART/QUIT broadcasts (other channel members see nothing), no auto-op on a fresh-channel first-joiner, `+` prefix in NAMES output, `B` flag in WHO output. Channel membership is added normally; events still fire (subscribers see them via `EVENTSUB`).
|
|
14
|
+
- **`EVENTSUB` / `EVENTUNSUB` / `EVENT` / `EVENTERR` IRC verbs** for streaming events to bots out-of-process. `EVENTSUB <sub-id> [type=<glob>] [channel=<name>] [nick=<glob>]`: filters AND-ed; `type` and `nick` accept `fnmatch`-style globs; `channel` accepts an exact name, `*`, or empty (nick-scoped only). Multiple concurrent subscriptions per client are allowed. Wire format: `:server EVENT <sub-id> <type> <channel-or-*> <nick> :<base64-json-envelope>` carrying the canonical 5-field envelope. Per-subscription bounded queue (default 1024); on overflow the server emits `EVENTERR <sub-id> :backpressure-overflow` and drops the subscription (connection stays open). Subscriptions die on client disconnect.
|
|
15
|
+
- **`EVENTPUB` IRC verb** for bots to emit custom-typed events back into the stream. `EVENTPUB <type> <channel-or-*> :<base64-json-data>`. Type validated against `EVENT_TYPE_RE` (dotted lowercase, ≥1 dot — single-segment names like `message` and `topic` are reserved for built-in vocabulary). Server fills `nick` from the bot's connection nick (not spoofable) and `timestamp` from `time.time()` so federation peers see consistent clocks. `_`-prefixed keys are stripped from the payload before emit.
|
|
16
|
+
- New internal module `agentirc._internal.event_subscriptions` with the `Subscription` dataclass and `SubscriptionRegistry`. `IRCd.subscription_registry` exposes the registry; `IRCd.emit_event` dispatches every event through it.
|
|
17
|
+
- `agentirc.protocol.SEVENT` verb constant (added in 9.5.0a2; reaffirmed here).
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **`webhook_port` is no longer bound by `IRCd.start()`.** The field stays in `ServerConfig` so culture's `~/.culture/server.yaml` keeps loading unchanged, but `agentirc` no longer instantiates the HTTP listener. Consumers that need webhook→bot dispatch host their own listener (see `docs/deployment.md`). No deprecation warning at runtime — the docs change is sufficient.
|
|
22
|
+
- `Channel.add()` no longer auto-ops bot-CAP clients (real or `VirtualClient`). A bot joining an empty channel stays unprivileged; the next human becomes op.
|
|
23
|
+
- `Channel.get_prefix()` returns `+` for bot-CAP members in NAMES output.
|
|
24
|
+
- `Client._build_who_flags()` adds the `B` flag for bot-CAP members in WHO output (composes with `H` and `@`/`+`).
|
|
25
|
+
- `VirtualClient` gains a class-level `caps = frozenset({"agentirc.io/bot", "message-tags"})` so the in-process system bot is treated identically to a real CAP-bot.
|
|
26
|
+
- `Client._handle_cap` `CAP LS` reply now lists supported caps from a class-level `_SUPPORTED_CAPS` frozenset (centralised; removing a cap is a major bump).
|
|
27
|
+
- `agentirc/_internal/bots/http_listener.py` module docstring notes the no-op stub is scheduled for removal in 9.6.0.
|
|
28
|
+
|
|
29
|
+
### Notes
|
|
30
|
+
|
|
31
|
+
- This is the **final slice** of the bot extension API. `9.5.0a1` shipped the public `agentirc.protocol` declarations; `9.5.0a2` switched the federation wire format to the 5-field envelope; this release wires the actual behaviour.
|
|
32
|
+
- **Federation interop unchanged from 9.5.0a2.** 9.4→9.5 federation works (sniff tolerance); 9.5→9.4 emit breaks until peers upgrade.
|
|
33
|
+
- The `agentirc.skill.{Event, EventType}` re-export shim from 9.5.0a1 stays through the 9.x line; removal is scheduled for 10.0.0.
|
|
34
|
+
- The `agentirc/_internal/bots/` synthesize stubs (`bot_manager.py`, `http_listener.py`) stay through the 9.5.x cycle; removal is scheduled for 9.6.0 once Phase A2 confirms no consumer imports them.
|
|
35
|
+
|
|
36
|
+
## [9.5.0a2] - 2026-05-02
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- **Wire format: SEVENT payload and IRCv3 `event-data` tag now carry the 5-field envelope** `{type, channel, nick, data, timestamp}`. Both the federation seam (`SEVENT` between linked servers) and the human-visible `#system` PRIVMSG `event-data` tag emit the new shape via `IRCd._build_event_envelope` + `IRCd._encode_event_data`. Type-specific keys (e.g. `text`, `room_id`, `purpose`) now nest under `data`; `nick` and `channel` remain at the top level. See `docs/superpowers/specs/2026-05-01-bot-extension-api-design.md` § Decision A for the rationale.
|
|
41
|
+
- `IRCd._build_event_payload` is replaced by `IRCd._build_event_envelope` (internal — no public API impact). Old name removed; the only two callsites (`_surface_event_privmsg` and `ServerLink.relay_event`'s SEVENT fallback) updated in the same commit.
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- `ServerLink._is_envelope(decoded)` — sniff helper that recognises the new envelope shape vs. the legacy data-only dict at decode time. `_handle_sevent` uses it for asymmetric tolerance (see Notes).
|
|
46
|
+
- `agentirc.protocol.SEVENT` — verb-name constant for the federation event-relay verb. Used by the new test suite and the outbound relay path. Other agentirc-side string-literal callsites are tracked separately in [#11](https://github.com/agentculture/agentirc/issues/11).
|
|
47
|
+
- `tests/test_wire_format_envelope.py` — 12 tests including a golden-file byte-equality lock-in for the canonical JSON encoding (any future change to the encoder that breaks this is a wire-format break and requires a major bump), plus regression guards for the trust-bypass and underscore-injection fixes below.
|
|
48
|
+
|
|
49
|
+
### Security
|
|
50
|
+
|
|
51
|
+
- **`ServerLink._handle_sevent` now treats the SEVENT verb-arg channel as authoritative.** The previous draft of this PR (pre-review) used the envelope's `channel` field on the receive side, which would have allowed a malformed/malicious peer to bypass `_check_incoming_trust` by sending `target="*"` (no trust check) while putting a restricted channel name in the envelope. The verb-arg channel is what the trust gate already protects, so the receiver now ignores any envelope channel claim and uses `verb_channel` for both the trust check and the resulting `Event.channel`.
|
|
52
|
+
- **Peer-supplied `_`-prefixed keys are stripped from incoming SEVENT data before emit.** `_render` and similar server-internal hints would otherwise let a peer dictate human-readable surfacing on this server. The receiver strips every key starting with `_` from the decoded data before adding its own `_origin` marker.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- **`IRCd._encode_event_data` fallback now produces a valid 5-field envelope instead of `b"{}"`.** A serialization failure used to emit a bare empty dict, which fails the receiver's envelope sniff and is misclassified as legacy data-only — worse than emitting an event with no type-specific payload. The fallback now emits `{type, channel, nick, data: {}, timestamp: time.time()}` so consumers can still parse the shape.
|
|
57
|
+
|
|
58
|
+
### Notes
|
|
59
|
+
|
|
60
|
+
- **Federation interop story.** A 9.5 daemon's `_handle_sevent` sniffs the decoded payload: if it has a top-level `type` (string) and `data` (dict), treat as 9.5+ envelope; otherwise treat as ≤9.4 legacy data-only and reconstruct an `Event` from the SEVENT verb args + the bare data dict. This gives 9.5 receivers asymmetric tolerance — they read traffic from both upgraded and pre-9.5 peers — so federations can roll forward one peer at a time. The reverse direction (9.5 emit → 9.4 receive) does **not** work; 9.4 daemons have no sniff and will fail to find expected `data` fields. Operators must either upgrade all peers in lockstep, or roll one at a time and accept that 9.5-emitted events drop on still-9.4 peers until those peers also upgrade. This matches the migration cost the spec calls out.
|
|
61
|
+
- **Audit JSONL is unchanged.** `agentirc/_internal/telemetry/audit.py:build_audit_record` continues to record the legacy data-only payload (data dict with `nick`/`channel` merged in via `setdefault`, `_`-prefixed keys stripped). Ops dashboards and downstream log processors that read the audit JSONL keep their existing field shape; the 5-field envelope is reserved for the public network surface (SEVENT payload + IRCv3 `event-data` tag).
|
|
62
|
+
- **Audit timestamp on legacy peers.** When `_handle_sevent` decodes a legacy payload from a ≤9.4 peer, no originating timestamp is available; the receiver assigns `time.time()` to the reconstructed `Event.timestamp`. 9.5+ peers preserve the originating timestamp.
|
|
63
|
+
- This is the **wire-format slice** of the bot extension API. The next slice (9.5.0a3) wires the bot CAP behavior (`agentirc.io/bot`), the `EVENTSUB`/`EVENTUNSUB`/`EVENTPUB` handlers, and the `SubscriptionRegistry`. 9.5.0 final adds `webhook_port` unbinding and flips `docs/api-stability.md` to "current".
|
|
64
|
+
- Tracks [agentculture/agentirc#15](https://github.com/agentculture/agentirc/issues/15).
|
|
65
|
+
|
|
66
|
+
## [9.5.0a1] - 2026-05-02
|
|
67
|
+
|
|
68
|
+
### Added
|
|
69
|
+
|
|
70
|
+
- Public `agentirc.protocol` exports for the bot extension API: the `Event` dataclass, the `EventType` enum, 20 per-type `EVENT_TYPE_*` string constants, the `EVENTSUB` / `EVENTUNSUB` / `EVENT` / `EVENTERR` / `EVENTPUB` verb constants, and the `BOT_CAP = "agentirc.io/bot"` capability identifier. `Event.type` is widened to `EventType | str` so federation peers can deliver event types this version doesn't recognise. See `docs/superpowers/specs/2026-05-01-bot-extension-api-design.md` for the full design and `docs/extension-api.md` for the bot-author quick reference.
|
|
71
|
+
- `ServerConfig.event_subscription_queue_max: int = 1024` — per-subscription queue bound. Recognised by `ServerConfig.from_yaml` and `cli._resolve_config()` as a top-level YAML key. Consumed by the subscription registry that lands in 9.5.0a3.
|
|
72
|
+
- `agentirc.skill` keeps re-exporting `Event` and `EventType` for backward compat; the re-export shim is removed in 9.6.0 once Phase A2 confirms no consumer relies on the path.
|
|
73
|
+
|
|
74
|
+
### Changed
|
|
75
|
+
|
|
76
|
+
- `EventType` upgraded from `enum.Enum` to `enum.StrEnum`. Members keep their `.value` strings unchanged, but Python consumers now observe new identity semantics: `isinstance(EventType.JOIN, str)` is `True`, `EventType.JOIN == "user.join"` is `True`, JSON serialization emits the bare string. Internal call sites verified — none compare `EventType.X` against a bare string today, so the truthier equality is pure improvement; downstream consumers that did string-equality round-trips against `EventType` see strictly more matches, never fewer.
|
|
77
|
+
|
|
78
|
+
### Notes
|
|
79
|
+
|
|
80
|
+
- This is the **declarations slice** of the bot extension API. No daemon-level behavior changes: `EVENTSUB` etc. are reserved verb constants but the daemon does not yet handle them; `BOT_CAP` is exported but not yet advertised in `CAP LS` output. The wire-format envelope refactor lands in 9.5.0a2; the bot CAP behavior, subscription verbs, `EVENTPUB`, and `webhook_port` unbinding land in 9.5.0a3 / 9.5.0 final.
|
|
81
|
+
- Tracks [agentculture/agentirc#15](https://github.com/agentculture/agentirc/issues/15).
|
|
82
|
+
|
|
83
|
+
## [9.4.1] - 2026-05-01
|
|
84
|
+
|
|
85
|
+
### Documentation
|
|
86
|
+
|
|
87
|
+
- Marked the bootstrap closed in `docs/superpowers/specs/2026-04-30-bootstrap-design.md` and `CLAUDE.md`. Tasks 16–18 (tag `v9.4.0`, verify PyPI publish, report-back to culture) are done; the spec status note now reads as a closed timeline ("Released ✅"), and `CLAUDE.md`'s "Current state" reads "bootstrap complete (9.4.0 released)" with a non-blocking follow-ups list linking to issues #7–#12 (Track A wire-format fixes, steward backport, callsite sweep, A2 test migration).
|
|
88
|
+
- Fixed 14 stale `OriNachum/*` GitHub URLs in `pyproject.toml` and the bootstrap spec to canonical `agentculture/*` paths. Investigation showed these weren't merely stylistic — `https://github.com/OriNachum/culture` returns 404 and `https://github.com/OriNachum/agentirc` 301-redirects to the wrong path. URL fixes only; sha256s are content-hashed and unaffected. `cite check` still passes.
|
|
89
|
+
|
|
90
|
+
### Notes
|
|
91
|
+
|
|
92
|
+
- Functionally identical to `9.4.0`. Published as a fresh PyPI release because `publish.yml` triggers on push-to-`main` and PyPI rejects re-publishing the same version with different sha256.
|
|
93
|
+
|
|
7
94
|
## [9.4.0] - 2026-05-01
|
|
8
95
|
|
|
9
96
|
### Added
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
4
4
|
|
|
5
|
-
## Current state: bootstrap
|
|
5
|
+
## Current state: bootstrap complete (9.4.0 released)
|
|
6
6
|
|
|
7
|
-
This repo is the agentirc server-core extraction out of the sibling project [`culture`](https://github.com/
|
|
7
|
+
This repo is the agentirc server-core extraction out of the sibling project [`culture`](https://github.com/agentculture/culture). As of 9.4.0 (tagged at `5590256` and live on PyPI):
|
|
8
8
|
|
|
9
9
|
- **Server-core** (`agentirc/{ircd,server_link,channel,events,skill,remote_client,…}.py`, `agentirc/skills/{rooms,threads,history,icon}.py`) — vendored from `culture@df50942` via the `cite-don't-copy` pattern (see `[tool.citation]` in `pyproject.toml`).
|
|
10
10
|
- **Client transport** (`agentirc/client.py`) — vendored from `culture/agentirc/client.py` in PR-B2.
|
|
@@ -15,13 +15,13 @@ This repo is the agentirc server-core extraction out of the sibling project [`cu
|
|
|
15
15
|
- **Internal support** (`agentirc/_internal/`) — `aio`, `constants`, `protocol/`, `telemetry/`, `virtual_client`, `pidfile`, `cli_shared/`, `bots/` stubs.
|
|
16
16
|
- **Bootstrap docs** (PR-B4, 9.4.0) — `docs/api-stability.md` (3 public modules + semver contract), `docs/cli.md` (verb table, flag reference, exit codes, YAML/CLI precedence, agentirc-vs-culture diff table), `docs/deployment.md` (on-disk footprint, systemd `Type=simple` example, container deployment, multi-host federation, log rotation, coexistence with culture, backup).
|
|
17
17
|
|
|
18
|
-
End-to-end verified: `agentirc start --port <p>` boots a real IRCd, TCP NICK/USER handshake returns `001 RPL_WELCOME`, `agentirc stop` shuts cleanly. `agentirc serve --config server.yaml --port 9999` correctly overlays CLI flag on YAML.
|
|
18
|
+
End-to-end verified: `agentirc start --port <p>` boots a real IRCd, TCP NICK/USER handshake returns `001 RPL_WELCOME`, `agentirc stop` shuts cleanly. `agentirc serve --config server.yaml --port 9999` correctly overlays CLI flag on YAML. `pip install agentirc-cli==9.4.0` from real PyPI in a clean venv produces both `agentirc` and `agentirc-cli` binaries; both reach the same `agentirc.cli:main` entry point. Acceptance audit recorded at [`docs/superpowers/specs/2026-05-01-task14-audit.md`](docs/superpowers/specs/2026-05-01-task14-audit.md). Culture-side cutover unblocked via [agentculture/culture#308](https://github.com/agentculture/culture/issues/308).
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
- **
|
|
22
|
-
- **
|
|
23
|
-
- **
|
|
24
|
-
- **
|
|
20
|
+
**Outstanding follow-ups (non-blocking; the bootstrap itself is closed):**
|
|
21
|
+
- **Cross-repo wire-format fixes (Track A)** — [#7](https://github.com/agentculture/agentirc/issues/7) (`ROOMETAEND`/`ROOMETASET` typos), [#8](https://github.com/agentculture/agentirc/issues/8) (`ERR_NOSUCHCHANNEL` overload), [#9](https://github.com/agentculture/agentirc/issues/9) (`STHREAD` verb collapse). Each requires culture-side change first then agentirc bump.
|
|
22
|
+
- **Steward backport** — [#10](https://github.com/agentculture/agentirc/issues/10). Port the 9.3.0 `pr-sonar.sh` + `workflow.sh sonar` wiring upstream so other workspace projects pick it up via re-vendoring.
|
|
23
|
+
- **Optional callsite sweep** — [#11](https://github.com/agentculture/agentirc/issues/11). Replace inline IRC verb / numeric-reply string literals in `ircd.py`/`server_link.py`/`skills/*.py` with `agentirc.protocol.<NAME>` imports. Pure refactor.
|
|
24
|
+
- **A2 bot-fixtured tests (low-priority)** — [#12](https://github.com/agentculture/agentirc/issues/12). Currently in culture; could be migrated to agentirc via subprocess-fixture rewrite if culture's coverage drifts.
|
|
25
25
|
|
|
26
26
|
Read the bootstrap spec at `docs/superpowers/specs/2026-04-30-bootstrap-design.md` for the full plan; it is the operative source of truth and is intentionally self-contained. The culture-side counterpart spec is at `../culture/docs/superpowers/specs/2026-04-30-agentirc-extraction-design.md` — not normally needed, but explains *why* if a decision looks arbitrary.
|
|
27
27
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agentirc-cli
|
|
3
|
-
Version: 9.
|
|
3
|
+
Version: 9.5.0
|
|
4
4
|
Summary: Agent-friendly IRCd: server core for AI agent meshes
|
|
5
|
-
Project-URL: Homepage, https://github.com/
|
|
6
|
-
Project-URL: Issues, https://github.com/
|
|
5
|
+
Project-URL: Homepage, https://github.com/agentculture/agentirc
|
|
6
|
+
Project-URL: Issues, https://github.com/agentculture/agentirc/issues
|
|
7
7
|
Author: Ori Nachum
|
|
8
8
|
License: MIT License
|
|
9
9
|
|
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
"""No-op ``HttpListener`` stub.
|
|
1
|
+
"""No-op ``HttpListener`` stub (scheduled for removal in 9.6.0).
|
|
2
2
|
|
|
3
3
|
Pairs with the no-op :class:`agentirc._internal.bots.bot_manager.BotManager`.
|
|
4
4
|
The real implementation lives in ``culture.bots.http_listener`` and exposes
|
|
5
5
|
a webhook surface for triggering bot events. In a standalone agentirc
|
|
6
6
|
deployment there is nothing to listen for, so ``start()`` and ``stop()``
|
|
7
7
|
are no-ops.
|
|
8
|
+
|
|
9
|
+
As of 9.5.0, :class:`agentirc.ircd.IRCd` no longer instantiates this stub —
|
|
10
|
+
the webhook listener is the consumer's responsibility (see
|
|
11
|
+
``docs/api-stability.md`` and ``docs/deployment.md``). The class itself
|
|
12
|
+
stays in the codebase for one cycle so any vendored test or culture-runtime
|
|
13
|
+
override that imports it keeps working; it is scheduled for deletion in
|
|
14
|
+
9.6.0 once Phase A2 of agentculture/culture#308 confirms no consumer
|
|
15
|
+
imports it.
|
|
8
16
|
"""
|
|
9
17
|
|
|
10
18
|
from __future__ import annotations
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Per-client event subscription registry for the bot extension API (9.5.0).
|
|
2
|
+
|
|
3
|
+
Public-facing wire format and verb syntax are described in the design spec at
|
|
4
|
+
``docs/superpowers/specs/2026-05-01-bot-extension-api-design.md`` § Decision B.
|
|
5
|
+
This module is internal — consumers interact via the ``EVENTSUB``/``EVENTUNSUB``
|
|
6
|
+
IRC verbs handled by :class:`agentirc.client.Client`.
|
|
7
|
+
|
|
8
|
+
Design points:
|
|
9
|
+
|
|
10
|
+
- One :class:`Subscription` per ``sub-id`` per client. Multiple concurrent
|
|
11
|
+
subscriptions per client are allowed; each gets its own bounded queue and
|
|
12
|
+
its own drain task.
|
|
13
|
+
- Filter fields are AND-ed; ``type`` and ``nick`` accept ``fnmatch``-style
|
|
14
|
+
globs (``*``/``?``/``[]``); ``channel`` is exact match (or ``"*"`` for any
|
|
15
|
+
channel, or ``""`` for nick-scoped events only).
|
|
16
|
+
- Backpressure: a per-subscription ``asyncio.Queue`` bounded by
|
|
17
|
+
``ServerConfig.event_subscription_queue_max``. On overflow, the registry
|
|
18
|
+
sends ``EVENTERR <sub-id> :backpressure-overflow`` and removes the
|
|
19
|
+
subscription. The connection itself stays open — the bot can re-subscribe
|
|
20
|
+
with the same or a fresh ``sub-id`` and use ``BACKFILL`` to recover.
|
|
21
|
+
- The drain task encodes events via :func:`agentirc.ircd.IRCd._build_event_envelope`
|
|
22
|
+
+ :func:`agentirc.ircd.IRCd._encode_event_data`, so the ``EVENT`` line on the
|
|
23
|
+
wire carries the same canonical 5-field envelope as the federated ``SEVENT``
|
|
24
|
+
payload and the IRCv3 ``event-data`` tag.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import fnmatch
|
|
31
|
+
import logging
|
|
32
|
+
import re
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from agentirc.client import Client
|
|
38
|
+
from agentirc.protocol import Event
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# A per-subscription channel filter that matches every channel, including the
|
|
44
|
+
# nick-scoped ``None`` case.
|
|
45
|
+
CHANNEL_ANY = "*"
|
|
46
|
+
# A per-subscription channel filter that matches only nick-scoped events
|
|
47
|
+
# (events with ``event.channel is None``).
|
|
48
|
+
CHANNEL_NICK_SCOPED_ONLY = ""
|
|
49
|
+
|
|
50
|
+
# Channel-filter format for ``EVENTSUB``: an exact channel name (``#``-prefixed),
|
|
51
|
+
# the literal ``*`` (any channel including nick-scoped), or the empty string
|
|
52
|
+
# (nick-scoped events only). Anything else is rejected so subscribers don't
|
|
53
|
+
# silently bind a filter that never matches.
|
|
54
|
+
_CHANNEL_FILTER_RE = re.compile(r"^(#[^\s,]+|\*|)$")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Filter-parameter dispatch table for EVENTSUB
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
# Each handler receives the partially-built ``filters`` dict and the value
|
|
61
|
+
# from one ``key=value`` token; it mutates ``filters`` and returns either
|
|
62
|
+
# ``None`` on success or an error-reason string suitable for
|
|
63
|
+
# ``EVENTERR <sub-id> :<reason>``. ``Client._parse_eventsub_filters`` walks
|
|
64
|
+
# the tokens and dispatches via :data:`FILTER_HANDLERS`.
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _set_type_glob(filters: dict, value: str) -> str | None:
|
|
68
|
+
filters["type_glob"] = value or "*"
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _set_channel(filters: dict, value: str) -> str | None:
|
|
73
|
+
if not _CHANNEL_FILTER_RE.match(value):
|
|
74
|
+
return f"invalid-channel-filter {value}"
|
|
75
|
+
filters["channel"] = CHANNEL_NICK_SCOPED_ONLY if value == "" else value
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _set_nick_glob(filters: dict, value: str) -> str | None:
|
|
80
|
+
filters["nick_glob"] = value or "*"
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
FILTER_HANDLERS: dict[str, Callable[[dict, str], "str | None"]] = {
|
|
85
|
+
"type": _set_type_glob,
|
|
86
|
+
"channel": _set_channel,
|
|
87
|
+
"nick": _set_nick_glob,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class Subscription:
|
|
93
|
+
"""A single subscription owned by a client.
|
|
94
|
+
|
|
95
|
+
Constructed by :meth:`SubscriptionRegistry.add`; do not instantiate
|
|
96
|
+
directly. ``queue`` and ``drain_task`` are populated by the registry.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
sub_id: str
|
|
100
|
+
type_glob: str = "*"
|
|
101
|
+
channel: str = CHANNEL_ANY
|
|
102
|
+
nick_glob: str = "*"
|
|
103
|
+
queue: asyncio.Queue = field(default_factory=asyncio.Queue)
|
|
104
|
+
drain_task: asyncio.Task | None = None
|
|
105
|
+
dropped: bool = False
|
|
106
|
+
|
|
107
|
+
def matches(self, event: Event) -> bool:
|
|
108
|
+
"""Return True if *event* satisfies the AND-ed filter fields."""
|
|
109
|
+
type_str = str(event.type)
|
|
110
|
+
if self.type_glob != "*" and not fnmatch.fnmatchcase(type_str, self.type_glob):
|
|
111
|
+
return False
|
|
112
|
+
if self.channel != CHANNEL_ANY:
|
|
113
|
+
if self.channel == CHANNEL_NICK_SCOPED_ONLY:
|
|
114
|
+
if event.channel is not None:
|
|
115
|
+
return False
|
|
116
|
+
else:
|
|
117
|
+
if event.channel != self.channel:
|
|
118
|
+
return False
|
|
119
|
+
if self.nick_glob != "*" and not fnmatch.fnmatchcase(event.nick, self.nick_glob):
|
|
120
|
+
return False
|
|
121
|
+
return True
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class SubscriptionRegistry:
|
|
125
|
+
"""Routes events to per-client subscription queues.
|
|
126
|
+
|
|
127
|
+
Owned by :class:`agentirc.ircd.IRCd`. ``IRCd.emit_event`` calls
|
|
128
|
+
:meth:`dispatch` on every event; subscriber clients drain their per-sub
|
|
129
|
+
queues via background tasks.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(self, *, queue_max: int = 1024) -> None:
|
|
133
|
+
self._subs: dict[Client, dict[str, Subscription]] = {}
|
|
134
|
+
self._queue_max = queue_max
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def queue_max(self) -> int:
|
|
138
|
+
return self._queue_max
|
|
139
|
+
|
|
140
|
+
def get(self, client: Client, sub_id: str) -> Subscription | None:
|
|
141
|
+
return self._subs.get(client, {}).get(sub_id)
|
|
142
|
+
|
|
143
|
+
def list_for_client(self, client: Client) -> list[Subscription]:
|
|
144
|
+
return list(self._subs.get(client, {}).values())
|
|
145
|
+
|
|
146
|
+
def add(
|
|
147
|
+
self,
|
|
148
|
+
client: Client,
|
|
149
|
+
sub_id: str,
|
|
150
|
+
*,
|
|
151
|
+
type_glob: str = "*",
|
|
152
|
+
channel: str = CHANNEL_ANY,
|
|
153
|
+
nick_glob: str = "*",
|
|
154
|
+
) -> Subscription | None:
|
|
155
|
+
"""Register a new subscription. Returns None on sub-id collision.
|
|
156
|
+
|
|
157
|
+
The caller is responsible for sending the appropriate ``EVENTERR``
|
|
158
|
+
when the result is ``None``.
|
|
159
|
+
"""
|
|
160
|
+
per_client = self._subs.setdefault(client, {})
|
|
161
|
+
if sub_id in per_client:
|
|
162
|
+
return None
|
|
163
|
+
sub = Subscription(
|
|
164
|
+
sub_id=sub_id,
|
|
165
|
+
type_glob=type_glob,
|
|
166
|
+
channel=channel,
|
|
167
|
+
nick_glob=nick_glob,
|
|
168
|
+
queue=asyncio.Queue(maxsize=self._queue_max),
|
|
169
|
+
)
|
|
170
|
+
per_client[sub_id] = sub
|
|
171
|
+
sub.drain_task = asyncio.create_task(
|
|
172
|
+
self._drain(client, sub),
|
|
173
|
+
name=f"event-sub-drain[{sub_id}]",
|
|
174
|
+
)
|
|
175
|
+
return sub
|
|
176
|
+
|
|
177
|
+
def remove(self, client: Client, sub_id: str) -> bool:
|
|
178
|
+
"""Cancel and forget *sub_id* on *client*. Returns True on hit."""
|
|
179
|
+
per_client = self._subs.get(client)
|
|
180
|
+
if not per_client or sub_id not in per_client:
|
|
181
|
+
return False
|
|
182
|
+
sub = per_client.pop(sub_id)
|
|
183
|
+
sub.dropped = True
|
|
184
|
+
if sub.drain_task is not None and not sub.drain_task.done():
|
|
185
|
+
sub.drain_task.cancel()
|
|
186
|
+
if not per_client:
|
|
187
|
+
self._subs.pop(client, None)
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
def remove_client(self, client: Client) -> None:
|
|
191
|
+
"""Cancel every subscription owned by *client*. Called on disconnect."""
|
|
192
|
+
per_client = self._subs.pop(client, {})
|
|
193
|
+
for sub in per_client.values():
|
|
194
|
+
sub.dropped = True
|
|
195
|
+
if sub.drain_task is not None and not sub.drain_task.done():
|
|
196
|
+
sub.drain_task.cancel()
|
|
197
|
+
|
|
198
|
+
async def dispatch(self, event: Event) -> None:
|
|
199
|
+
"""Enqueue *event* on every matching subscription.
|
|
200
|
+
|
|
201
|
+
On queue overflow, mark the subscription dropped, send the bot an
|
|
202
|
+
``EVENTERR <sub-id> :backpressure-overflow`` line, and remove the
|
|
203
|
+
subscription from the registry. The bot's connection stays open.
|
|
204
|
+
"""
|
|
205
|
+
# ``dispatch`` may call ``_handle_overflow`` which awaits
|
|
206
|
+
# ``client.send_raw``; while that yields, another coroutine can call
|
|
207
|
+
# ``add`` or ``remove`` and mutate the dicts. The ``list()`` snapshots
|
|
208
|
+
# avoid ``RuntimeError: dictionary changed size during iteration``.
|
|
209
|
+
for client, per_client in list(self._subs.items()): # NOSONAR python:S7504: defensive snapshot vs. concurrent mutation
|
|
210
|
+
for sub in list(per_client.values()): # NOSONAR python:S7504: defensive snapshot vs. concurrent mutation
|
|
211
|
+
if sub.dropped:
|
|
212
|
+
continue
|
|
213
|
+
if not sub.matches(event):
|
|
214
|
+
continue
|
|
215
|
+
try:
|
|
216
|
+
sub.queue.put_nowait(event)
|
|
217
|
+
except asyncio.QueueFull:
|
|
218
|
+
await self._handle_overflow(client, sub)
|
|
219
|
+
|
|
220
|
+
async def _handle_overflow(self, client: Client, sub: Subscription) -> None:
|
|
221
|
+
sub.dropped = True
|
|
222
|
+
try:
|
|
223
|
+
await client.send_raw(f"EVENTERR {sub.sub_id} :backpressure-overflow")
|
|
224
|
+
except Exception:
|
|
225
|
+
logger.exception(
|
|
226
|
+
"Failed to send backpressure-overflow EVENTERR for sub %s",
|
|
227
|
+
sub.sub_id,
|
|
228
|
+
)
|
|
229
|
+
self.remove(client, sub.sub_id)
|
|
230
|
+
|
|
231
|
+
async def _drain(self, client: Client, sub: Subscription) -> None:
|
|
232
|
+
"""Pull events off *sub.queue* and send them as ``EVENT`` lines.
|
|
233
|
+
|
|
234
|
+
Imports :mod:`agentirc.ircd` and :mod:`agentirc.protocol` lazily to
|
|
235
|
+
avoid an import cycle (``ircd.py`` instantiates this registry).
|
|
236
|
+
"""
|
|
237
|
+
from agentirc.ircd import IRCd
|
|
238
|
+
from agentirc.protocol import EVENT
|
|
239
|
+
|
|
240
|
+
server_name = client.server.config.name
|
|
241
|
+
|
|
242
|
+
# ``CancelledError`` (raised when ``remove``/``remove_client`` cancels
|
|
243
|
+
# the drain task) propagates out of this coroutine naturally — no
|
|
244
|
+
# cleanup is needed because the registry already removed the
|
|
245
|
+
# subscription before cancelling the task.
|
|
246
|
+
while True:
|
|
247
|
+
event = await sub.queue.get()
|
|
248
|
+
if sub.dropped:
|
|
249
|
+
return
|
|
250
|
+
|
|
251
|
+
type_wire = str(event.type)
|
|
252
|
+
target = event.channel if event.channel is not None else "*"
|
|
253
|
+
envelope = IRCd._build_event_envelope(event)
|
|
254
|
+
encoded = IRCd._encode_event_data(envelope, type_wire)
|
|
255
|
+
line = (
|
|
256
|
+
f":{server_name} {EVENT} {sub.sub_id} {type_wire} "
|
|
257
|
+
f"{target} {event.nick} :{encoded}"
|
|
258
|
+
)
|
|
259
|
+
try:
|
|
260
|
+
await client.send_raw(line)
|
|
261
|
+
except Exception:
|
|
262
|
+
# Connection is gone or send failed; let the disconnect
|
|
263
|
+
# cleanup path remove this subscription.
|
|
264
|
+
logger.debug(
|
|
265
|
+
"EVENT send to %s sub %s failed; awaiting disconnect cleanup",
|
|
266
|
+
getattr(client, "nick", "<?>"),
|
|
267
|
+
sub.sub_id,
|
|
268
|
+
)
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
__all__ = [
|
|
273
|
+
"CHANNEL_ANY",
|
|
274
|
+
"CHANNEL_NICK_SCOPED_ONLY",
|
|
275
|
+
"Subscription",
|
|
276
|
+
"SubscriptionRegistry",
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# Re-export ``Any`` to satisfy strict-import linters that flag the typing import
|
|
281
|
+
# above. (Subscription.queue is parameterized in docstrings only.)
|
|
282
|
+
_ = Any
|
|
@@ -313,6 +313,13 @@ def build_audit_record(
|
|
|
313
313
|
|
|
314
314
|
See culture/protocol/extensions/audit.md for the field set.
|
|
315
315
|
"""
|
|
316
|
+
# Audit JSONL records the legacy data-only payload (data dict with
|
|
317
|
+
# nick/channel merged in via setdefault, _-prefixed keys stripped).
|
|
318
|
+
# This intentionally diverges from the 9.5+ public wire envelope built
|
|
319
|
+
# by IRCd._build_event_envelope: ops dashboards and downstream log
|
|
320
|
+
# processors that read the audit JSONL keep their existing field shape;
|
|
321
|
+
# the 5-field envelope is reserved for the public network surface
|
|
322
|
+
# (SEVENT payload + IRCv3 event-data tag).
|
|
316
323
|
payload = {k: v for k, v in (event.data or {}).items() if not k.startswith("_")}
|
|
317
324
|
if event.nick:
|
|
318
325
|
payload.setdefault("nick", event.nick)
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import logging
|
|
6
6
|
from typing import TYPE_CHECKING
|
|
7
7
|
|
|
8
|
+
from agentirc.protocol import BOT_CAP
|
|
8
9
|
from agentirc.skill import Event, EventType
|
|
9
10
|
from agentirc._internal.protocol.message import Message
|
|
10
11
|
|
|
@@ -27,6 +28,17 @@ class VirtualClient:
|
|
|
27
28
|
in channel.members, NAMES, WHO, and WHOIS transparently.
|
|
28
29
|
"""
|
|
29
30
|
|
|
31
|
+
# The in-process system bot is treated identically to a real CAP-bot
|
|
32
|
+
# everywhere: external code paths (``Channel._local_members``,
|
|
33
|
+
# ``Channel.get_prefix``, ``Client._build_who_flags``) read this attr
|
|
34
|
+
# via ``getattr(member, "caps", ...)`` and apply the no-auto-op,
|
|
35
|
+
# ``+``-prefix, and ``B``-flag rules; the methods on this class
|
|
36
|
+
# (``join_channel``/``part_channel``) check it themselves to suppress
|
|
37
|
+
# their own JOIN/PART broadcasts to other channel members. Class-level
|
|
38
|
+
# so every instance gets the same caps; VirtualClients don't negotiate
|
|
39
|
+
# caps the way real Clients do.
|
|
40
|
+
caps: frozenset[str] = frozenset({BOT_CAP, "message-tags"})
|
|
41
|
+
|
|
30
42
|
def __init__(self, nick: str, user: str, server: IRCd):
|
|
31
43
|
self.nick = nick
|
|
32
44
|
self.user = user
|
|
@@ -63,14 +75,19 @@ class VirtualClient:
|
|
|
63
75
|
# Ensure bot is never auto-promoted to operator
|
|
64
76
|
channel.operators.discard(self)
|
|
65
77
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
78
|
+
# Bot-CAP clients (the entire VirtualClient class) skip the
|
|
79
|
+
# per-member JOIN broadcast — silent presence to other members.
|
|
80
|
+
# Channel membership above is already added; the user.join event
|
|
81
|
+
# below still fires so EVENTSUB subscribers see it.
|
|
82
|
+
if BOT_CAP not in self.caps:
|
|
83
|
+
join_msg = Message(
|
|
84
|
+
prefix=self.prefix,
|
|
85
|
+
command="JOIN",
|
|
86
|
+
params=[channel_name],
|
|
87
|
+
)
|
|
88
|
+
for member in [*channel.members]:
|
|
89
|
+
if member is not self:
|
|
90
|
+
await member.send(join_msg)
|
|
74
91
|
|
|
75
92
|
if emit_event:
|
|
76
93
|
await self.server.emit_event(
|
|
@@ -83,14 +100,17 @@ class VirtualClient:
|
|
|
83
100
|
if not channel or self not in channel.members:
|
|
84
101
|
return
|
|
85
102
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
103
|
+
# Symmetric with join_channel — bot-CAP clients skip the per-member
|
|
104
|
+
# PART broadcast. The user.part event below still fires.
|
|
105
|
+
if BOT_CAP not in self.caps:
|
|
106
|
+
part_msg = Message(
|
|
107
|
+
prefix=self.prefix,
|
|
108
|
+
command="PART",
|
|
109
|
+
params=[channel_name],
|
|
110
|
+
)
|
|
111
|
+
for member in [*channel.members]:
|
|
112
|
+
if member is not self:
|
|
113
|
+
await member.send(part_msg)
|
|
94
114
|
|
|
95
115
|
await self.server.emit_event(
|
|
96
116
|
Event(
|
|
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from typing import TYPE_CHECKING, Union
|
|
4
4
|
|
|
5
|
+
from agentirc.protocol import BOT_CAP
|
|
6
|
+
|
|
5
7
|
if TYPE_CHECKING:
|
|
6
8
|
from agentirc.client import Client
|
|
7
9
|
from agentirc.remote_client import RemoteClient
|
|
@@ -40,18 +42,36 @@ class Channel:
|
|
|
40
42
|
return self.room_id is not None
|
|
41
43
|
|
|
42
44
|
def _local_members(self) -> set[Client]:
|
|
43
|
-
"""Return only local (non-remote, non-virtual) members.
|
|
45
|
+
"""Return only local (non-remote, non-virtual, non-bot-CAP) members.
|
|
46
|
+
|
|
47
|
+
Used as the auto-op eligibility predicate by :meth:`add`. Bot-CAP
|
|
48
|
+
clients are excluded so a bot joining an empty channel never becomes
|
|
49
|
+
op — a human joining later does.
|
|
50
|
+
"""
|
|
44
51
|
from agentirc.remote_client import RemoteClient
|
|
45
52
|
from agentirc._internal.virtual_client import VirtualClient
|
|
46
53
|
|
|
47
|
-
return {
|
|
54
|
+
return {
|
|
55
|
+
m
|
|
56
|
+
for m in self.members
|
|
57
|
+
if not isinstance(m, (RemoteClient, VirtualClient))
|
|
58
|
+
and BOT_CAP not in getattr(m, "caps", frozenset())
|
|
59
|
+
}
|
|
48
60
|
|
|
49
61
|
def add(self, client: Client) -> None:
|
|
50
|
-
# Only grant op to the first LOCAL joiner
|
|
62
|
+
# Only grant op to the first LOCAL joiner. Bot-CAP clients
|
|
63
|
+
# (real or VirtualClient) are excluded — they never auto-op,
|
|
64
|
+
# so a bot joining an empty channel stays unprivileged and
|
|
65
|
+
# the next human becomes op.
|
|
51
66
|
if not self._local_members():
|
|
52
67
|
from agentirc.remote_client import RemoteClient
|
|
68
|
+
from agentirc._internal.virtual_client import VirtualClient
|
|
53
69
|
|
|
54
|
-
|
|
70
|
+
is_op_eligible = (
|
|
71
|
+
not isinstance(client, (RemoteClient, VirtualClient))
|
|
72
|
+
and BOT_CAP not in getattr(client, "caps", frozenset())
|
|
73
|
+
)
|
|
74
|
+
if is_op_eligible:
|
|
55
75
|
self.operators.add(client)
|
|
56
76
|
self.members.add(client)
|
|
57
77
|
|
|
@@ -77,4 +97,11 @@ class Channel:
|
|
|
77
97
|
return "@"
|
|
78
98
|
if client in self.voiced:
|
|
79
99
|
return "+"
|
|
100
|
+
# Bot-CAP clients render with the voice prefix in NAMES output —
|
|
101
|
+
# the closest standard IRC mode for "non-disruptive participant",
|
|
102
|
+
# so vanilla IRC clients filter bots from presence panels by
|
|
103
|
+
# checking the ``+`` prefix. Op wins on conflict (above), so an
|
|
104
|
+
# explicitly-opped bot still renders as ``@``.
|
|
105
|
+
if BOT_CAP in getattr(client, "caps", frozenset()):
|
|
106
|
+
return "+"
|
|
80
107
|
return ""
|
|
@@ -185,6 +185,9 @@ def _resolve_config(args: argparse.Namespace) -> "ServerConfig": # noqa: F821 (
|
|
|
185
185
|
args.data_dir, raw.get("data_dir"), os.path.expanduser("~/.culture/data")
|
|
186
186
|
)
|
|
187
187
|
)
|
|
188
|
+
event_subscription_queue_max = _pick(
|
|
189
|
+
None, raw.get("event_subscription_queue_max"), 1024
|
|
190
|
+
)
|
|
188
191
|
|
|
189
192
|
cfg = ServerConfig(
|
|
190
193
|
name=name or "agentirc",
|
|
@@ -195,6 +198,7 @@ def _resolve_config(args: argparse.Namespace) -> "ServerConfig": # noqa: F821 (
|
|
|
195
198
|
links=_resolve_links(getattr(args, "link", None), raw.get("links") or []),
|
|
196
199
|
system_bots=raw.get("system_bots") or {},
|
|
197
200
|
telemetry=_build_telemetry(raw.get("telemetry") or {}),
|
|
201
|
+
event_subscription_queue_max=event_subscription_queue_max,
|
|
198
202
|
)
|
|
199
203
|
|
|
200
204
|
args.name = name # may be None — handler resolves via default-server file
|