Events System¶
The Cascade Chat events system is a centralized, event-driven layer for communication between components. All IRC activities, system events, and UI interactions flow through the EventBus.
Overview¶
The events system uses a publisher-subscriber pattern: - Publishers emit events to the EventBus. - Subscribers register interest in specific event types. - The EventBus routes events to all relevant subscribers asynchronously.
Delivery model¶
The EventBus has two delivery lanes:
Emitqueues durable and control events on a bounded, ordered lane. A single dispatcher delivers those events to subscribers in emission order.EmitLatest(key, event)publishes replaceable snapshot state without blocking its caller. While the dispatcher is busy, repeated events with the same key are coalesced and only the latest value is retained. Ordered events take priority over this snapshot lane.
Use EmitLatest only when an event contains a complete current value and
intermediate transitions are not meaningful. For example, user.meta is a
full per-network, per-nickname roster snapshot. Messages, connection events,
and completion markers must use Emit because dropping or reordering one would
change their meaning.
Incoming IRC lines have a separate isolation boundary before the EventBus.
irc-go handles protocol-critical work on its socket read goroutine, then
Cascade enqueues application callbacks onto an elastic single-consumer worker.
Database writes, event publication, plugin handoff, and frontend notifications
therefore remain ordered without preventing the socket from reading the next
server line. Disconnect cleanup drains that worker before releasing the client.
Plugins consequently observe IRC-derived events in wire order, but they do not
run on (and cannot apply backpressure to) the IRC socket reader.
Connection completion is also an ordering boundary. The backend does not report
Connect success until the registration transcript and
connection.established callback have passed through the application worker.
Events emitted immediately after a successful connect therefore cannot overtake
CAP, welcome, MOTD, or other registration events seen by plugins and the UI.
Protocol follow-ups generated by callbacks use a second queue. Potentially
amplified requests such as NAMES, WHO, and CHATHISTORY are serialized and
paced independently of both application callbacks and user messages. Optional
roster enrichment starts only after NAMES completes, so reconnecting many
channels cannot collapse into a burst of large server replies.
Architecture¶
flowchart LR
irc[IRC Client]
sys[System]
ui[UI]
irc --> bus
sys --> bus
ui --> bus
bus([EventBus])
bus --> pm[Plugin Manager]
bus --> storage["Storage<br/><small>future</small>"]
bus --> frontend["Frontend<br/><small>future</small>"]
subgraph Publishers
irc
sys
ui
end
subgraph Subscribers
pm
storage
frontend
end
Event Structure¶
All events follow this structure:
type Event struct {
Type string // Event type identifier
Data map[string]interface{} // Event-specific data
Timestamp time.Time // When the event occurred
Source EventSource // Source of the event
}
Event Sources¶
irc: Events from IRC protocol interactionsui: Events from user interface (future)system: Events from system components
Event Types¶
IRC Events¶
All IRC events are defined in internal/irc/events.go:
Connection Events¶
connection.established: Emitted when successfully connected to an IRC servernetworkId(int64): Network IDaddress(string): Server address-
port(int): Server port -
connection.lost: Emitted when connection to server is lost networkId(int64): Network IDerror(string): Error message if available
Message Events¶
message.received: Emitted when a message is receivednetworkId(int64): Network IDchannel(string): Channel name (empty for PMs)user(string): Sender nicknamemessage(string): Message content-
timestamp(int64): Unix timestamp -
message.sent: Emitted when a message is sent networkId(int64): Network IDchannel(string): Channel name (empty for PMs)message(string): Message content
User Events¶
user.joined: Emitted when a user joins a channelnetworkId(int64): Network IDchannel(string): Channel name-
userornickname(string): User nickname -
user.parted: Emitted when a user leaves a channel networkId(int64): Network IDchannel(string): Channel nameuserornickname(string): User nickname-
reason(string): Part reason (optional) -
user.quit: Emitted when a user quits the server networkId(int64): Network IDuserornickname(string): User nickname-
reason(string): Quit reason -
user.kicked: Emitted when a user is kicked from a channel networkId(int64): Network IDchannel(string): Channel nameuserornickname(string): Kicked userkicker(string): User who performed the kick-
reason(string): Kick reason -
user.nick: Emitted when a user changes nickname networkId(int64): Network IDold_nickname(string): Previous nickname-
nickname(string): New nickname -
user.meta: Replaceable snapshot of a user's live roster attributes networkId(int64): Network IDnickname(string): User nicknameaway(bool): Whether the user is awayaway_message(string): Away message, when knownaccount(string): Authenticated account, when knownhost(string): Current user and host, when knownrealname(string): Current real name, when known- Delivery is last-write-wins per network and nickname during a burst. Treat this as current state, not as an audit stream of every transition.
Channel Events¶
channel.topic: Emitted when channel topic changesnetworkId(int64): Network IDchannel(string): Channel nametopic(string): New topic-
setter(string): User who set the topic (optional) -
channel.mode: Emitted when channel modes change networkId(int64): Network IDchannel(string): Channel namemodes(string): Mode string (e.g., "+o user")-
setter(string): User who set the mode (optional) -
channels.changed: Emitted when channel list changes networkId(int64): Network ID-
channels([]string): List of channel names -
channel.names.complete: Emitted after a channel's NAMES reply completes networkId(int64): Network IDchannel(string): Channel nameusers([]string): Completed member snapshot
SASL Authentication Events¶
sasl.started: Emitted when SASL authentication beginsnetworkId(int64): Network ID-
mechanism(string): SASL mechanism (e.g., "PLAIN", "SCRAM-SHA-256") -
sasl.success: Emitted when SASL authentication succeeds -
networkId(int64): Network ID -
sasl.failed: Emitted when SASL authentication fails networkId(int64): Network ID-
error(string): Error message -
sasl.aborted: Emitted when SASL authentication is aborted networkId(int64): Network ID
Other Events¶
whois.received: Emitted when WHOIS information is receivednetworkId(int64): Network ID-
whois(WhoisInfo): Parsed WHOIS information (seeinternal/irc/events.go) -
error: Emitted when an error occurs networkId(int64): Network ID (optional)error(string): Error messagetype(string): Error type (optional)
System Events¶
metadata.updated: Emitted when plugin metadata is updatedtype(string): Metadata type (e.g., "nickname_color")key(string): Metadata keyvalue(interface{}): Metadata valuenetwork_id(int64): Network ID (optional)channel(string): Channel name (optional)- Bursts are trailing-edge coalesced by network, channel, type, and key. A
multi-value event contains an
updatesarray of objects with these fields.
UI Events (Future)¶
ui.pane.focused: Emitted when a pane gains focusui.pane.blurred: Emitted when a pane loses focus
Using the EventBus¶
Subscribing to Events¶
// Subscribe to a specific event type
eventBus.Subscribe("message.received", mySubscriber)
// Subscribe to all events (wildcard)
eventBus.Subscribe("*", mySubscriber)
Implementing a Subscriber¶
Any type implementing the Subscriber interface can receive events:
type MySubscriber struct{}
func (s *MySubscriber) OnEvent(event events.Event) {
// Handle the event
switch event.Type {
case "message.received":
// Process message
}
}
Emitting Events¶
eventBus.Emit(events.Event{
Type: "message.received",
Data: map[string]interface{}{
"networkId": 1,
"channel": "#general",
"user": "Alice",
"message": "Hello!",
},
Timestamp: time.Now(),
Source: events.EventSourceIRC,
})
Synchronous Events¶
For tests or the rare case that delivery must finish before the caller
continues, use EmitSync:
Note: Synchronous emission blocks until all subscribers process the event. Use sparingly.
Event Flow Example¶
- IRC Client receives a PRIVMSG from the server
- IRC Client emits
message.receivedevent to EventBus - EventBus routes event to all subscribers:
- Plugin Manager forwards to plugins subscribed to
message.received - Storage (future) saves message to database
- Frontend (future) updates UI
Best Practices¶
- Event Naming: Use dot-separated hierarchical names (e.g.,
user.joined,channel.mode) - Event Data: Include all relevant context (networkId, channel, etc.)
- Async Processing:
Emitis asynchronous to its caller, but subscribers run serially on the dispatcher. KeepOnEventbounded and non-blocking. - Error Handling: Handle errors gracefully in subscribers
- Wildcard Subscriptions: Use sparingly; prefer specific event types for performance
- Snapshot Semantics: Use
EmitLatestonly for complete, replaceable state
Thread Safety¶
The EventBus is fully thread-safe: - Subscriptions/unsubscriptions are protected by mutex - Subscriber lists are copied before callbacks run, so subscriptions can change safely while an event is being delivered - One dispatcher calls subscribers synchronously; a slow subscriber delays later delivery, so subscriber work must remain bounded - Snapshot publication is non-blocking and coalesces under load; ordered publication applies bounded backpressure when its queue is saturated
Future Enhancements¶
- Event filtering by data fields
- Event replay for debugging
- Event metrics and monitoring
Frontend events¶
The Go EventBus described above is internal to the backend process. A separate
set of events travels the other direction: from the Go backend to the React
webview via Wails' Events.Emit API. The frontend listens with
Events.On(name, handler).
These are distinct from the Go bus events. They carry serialized JSON payloads
rather than events.Event structs, and they are consumed by the UI layer, not
by Go subscribers.
Deep-link events¶
Emitted when Cascade handles an irc:// or ircs:// URI (see
Opening irc:// links).
deeplink:join¶
The link matched exactly one saved network. The frontend should connect (if not already connected) and join or open each target.
{
"networkId": 42,
"targets": [
{ "name": "#cascade", "isNick": false, "key": "" },
{ "name": "alice", "isNick": true, "key": "" }
]
}
| Field | Type | Description |
|---|---|---|
networkId |
number |
ID of the saved network to use. |
targets |
Target[] |
One entry per channel or nick from the URI. |
targets[].name |
string |
Channel name (with #) or nickname. |
targets[].isNick |
boolean |
true when the target is a nick (opens a PM). |
targets[].key |
string |
Channel key (?key=… query param), or empty. |
deeplink:add-network¶
The server in the link has no saved network. The frontend should open the Add Network form prefilled with these values.
| Field | Type | Description |
|---|---|---|
host |
string |
Server hostname from the URI. |
port |
number |
Port, or the scheme default (6667 / 6697). |
tls |
boolean |
true when the scheme is ircs://. |
channel |
string |
First channel from the URI, for display only. Not auto-joined. |
deeplink:disambiguate¶
The server matches more than one saved network. The frontend should present a
picker; once the user chooses, proceed as with deeplink:join.
{
"candidates": [
{ "networkId": 1, "name": "Libera (work)" },
{ "networkId": 7, "name": "Libera (personal)" }
],
"targets": [
{ "name": "#cascade", "isNick": false, "key": "" }
]
}
| Field | Type | Description |
|---|---|---|
candidates |
Candidate[] |
Networks that match the server in the URI. |
candidates[].networkId |
number |
Saved network ID. |
candidates[].name |
string |
Display name of the network. |
targets |
Target[] |
Same shape as deeplink:join; pass through after the user picks. |