Skip to content

Commit ec5d1d3

Browse files
committed
feat: extend plugin SDK Message with channel, encryption, and DM context
1 parent 99ad8a7 commit ec5d1d3

8 files changed

Lines changed: 203 additions & 14 deletions

File tree

PLUGIN_ECOSYSTEM.md

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,11 @@ The plugin ecosystem consists of several interconnected components:
3737

3838
**Features**:
3939
- Plugin interface with lifecycle methods
40-
- Message processing and response system
40+
- Message processing and response system with extended context (channel, encryption status, message ID, recipient, edited flag)
4141
- Command registration and execution
4242
- Configuration management
4343
- Manifest validation
44+
- Backwards-compatible JSON wire format (new `omitempty` fields are silently ignored by older plugins)
4445

4546
### 2. Plugin Host (`plugin/host/`)
4647

@@ -145,7 +146,27 @@ The plugin ecosystem consists of several interconnected components:
145146
}
146147
```
147148

148-
### Message Types
149+
### Message Data (type "message")
150+
151+
When the hub sends a `"message"` request, the `data` payload is an `sdk.Message` object:
152+
153+
```json
154+
{
155+
"sender": "alice",
156+
"content": "hello world",
157+
"created_at": "2025-07-24T15:04:00Z",
158+
"type": "text",
159+
"channel": "general",
160+
"encrypted": false,
161+
"message_id": 42,
162+
"recipient": "",
163+
"edited": false
164+
}
165+
```
166+
167+
Zero-value fields (`channel` empty, `encrypted` false, `message_id` 0, etc.) are omitted from JSON via `omitempty`. Plugins compiled against older SDK versions silently ignore new keys.
168+
169+
### Request Types
149170

150171
1. **init**: Plugin initialization with configuration
151172
2. **message**: Incoming chat message processing
@@ -169,11 +190,15 @@ type MyPlugin struct {
169190
}
170191

171192
func (p *MyPlugin) OnMessage(msg sdk.Message) ([]sdk.Message, error) {
193+
if msg.Encrypted {
194+
return nil, nil // content is opaque ciphertext
195+
}
172196
if strings.HasPrefix(msg.Content, "hello") {
173197
return []sdk.Message{{
174198
Sender: "MyBot",
175199
Content: "Hello back!",
176200
CreatedAt: time.Now(),
201+
Channel: msg.Channel, // reply in the same channel
177202
}}, nil
178203
}
179204
return nil, nil

PROTOCOL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ Sensitive values (like `admin_key`) are passed only during handshake and are not
241241

242242
## Notes on Extensibility
243243

244-
The protocol is intentionally JSON-based. **Plugins** extend the server through a managed plugin host (separate processes, JSON IPC); chat messages with `type` `"text"` can be forwarded to plugins for automation, while `:`-prefixed lines and `admin_command` messages participate in the command pipeline (including built-in admin commands where authorized). Clients and tools should use the `type` field and optional structured fields (`message_id`, `channel`, `reaction`, etc.) to interpret each payload.
244+
The protocol is intentionally JSON-based. **Plugins** extend the server through a managed plugin host (separate processes, JSON IPC); chat messages with `type` `"text"` can be forwarded to plugins for automation, while `:`-prefixed lines and `admin_command` messages participate in the command pipeline (including built-in admin commands where authorized). The plugin SDK's `Message` struct mirrors the core wire fields (`sender`, `content`, `created_at`, `type`, `channel`, `encrypted`, `message_id`, `recipient`, `edited`) so plugins receive full message context. All extended fields use `omitempty`, making the wire format backwards-compatible with older compiled plugins. Clients and tools should use the `type` field and optional structured fields (`message_id`, `channel`, `reaction`, etc.) to interpret each payload.
245245

246246
---
247247

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -766,12 +766,12 @@ Percentages are **statement coverage** from a merged profile (`go test -coverpro
766766
| `plugin/license` | 87.1% | 203 LOC | High |
767767
| `client/crypto` | 80.3% | 320 LOC | High |
768768
| `config` | 73.2% | 285 LOC | High |
769-
| `plugin/host` | 63.0% | 533 LOC | Medium |
769+
| `plugin/host` | 62.5% | 536 LOC | Medium |
770770
| `client/config` | 57.3% | 1683 LOC | Medium |
771771
| `internal/doctor` | 49.8% | 661 LOC | Medium |
772772
| `plugin/store` | 47.0% | 490 LOC | Medium |
773773
| `cmd/license` | 42.2% | 140 LOC | Medium |
774-
| `server` | 36.1% | 6298 LOC | Low |
774+
| `server` | 36.1% | 6310 LOC | Low |
775775
| `plugin/manager` | 32.1% | 626 LOC | Low |
776776
| `client` | 23.0% | 4966 LOC | Low |
777777
| `cmd/server` | 13.7% | 424 LOC | Low |

TESTING.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ The Marchat test suite provides foundational coverage of the application's core
5252
| `server/config_test.go` | Server configuration | Server configuration logic and validation |
5353
| `server/client_test.go` | Server client management | WebSocket client initialization, message handling, admin operations |
5454
| `server/health_test.go` | Server health monitoring | Health checks, system metrics, HTTP endpoints, concurrent access |
55-
| `plugin/sdk/plugin_test.go` | Plugin SDK | Message types, JSON serialization, validation |
55+
| `plugin/sdk/plugin_test.go` | Plugin SDK | Message types, extended fields (channel, encrypted, message_id, recipient, edited), JSON serialization, omitempty validation, backwards-compat unknown-field handling |
5656
| `plugin/host/host_test.go` | Plugin Host | Plugin lifecycle, communication, enable/disable |
5757
| `plugin/host/plugin_lifecycle_test.go` | Plugin Host subprocess IPC | Minimal JSON plugin built with `go build`, `StartPlugin` / `StopPlugin`, `ExecuteCommand`, double-start guard |
5858
| `plugin/store/store_test.go` | Plugin Store | Registry management, platform resolution, filtering |
@@ -174,12 +174,12 @@ go test -cover ./...
174174
| `plugin/license` | 87.1% | High | 203 | Small |
175175
| `client/crypto` | 80.3% | High | 320 | Small |
176176
| `config` | 73.2% | High | 285 | Small |
177-
| `plugin/host` | 63.0% | Medium | 533 | Medium |
177+
| `plugin/host` | 62.5% | Medium | 536 | Medium |
178178
| `client/config` | 57.3% | Medium | 1683 | Medium |
179179
| `internal/doctor` | 49.8% | Medium | 661 | Medium |
180180
| `plugin/store` | 47.0% | Medium | 490 | Medium |
181181
| `cmd/license` | 42.2% | Medium | 140 | Small |
182-
| `server` | 36.1% | Low | 6298 | Large |
182+
| `server` | 36.1% | Low | 6310 | Large |
183183
| `plugin/manager` | 32.1% | Low | 626 | Medium |
184184
| `client` | 23.0% | Low | 4966 | Large |
185185
| `cmd/server` | 13.7% | Low | 424 | Medium |
@@ -195,7 +195,7 @@ go test -cover ./...
195195
- **Config Package**: Configuration loading, validation, environment variables (73.2%)
196196

197197
### Medium Coverage (40-70%)
198-
- **Plugin Host Package**: Load/start/stop lifecycle, JSON IPC with a minimal test plugin, `ExecuteCommand` (63.0%)
198+
- **Plugin Host Package**: Load/start/stop lifecycle, JSON IPC with a minimal test plugin, `ExecuteCommand` (62.5%)
199199
- **Client Config Package**: Configuration management, path utilities, keystore migration, interactive UI (57.3%)
200200
- **Doctor Package**: Server/client diagnostics, env checks, update metadata, DB probes (49.8%)
201201
- **Plugin Store**: Registry management, platform resolution, filtering, caching (47.0%)
@@ -224,7 +224,7 @@ Statement percentages below are from the merged profile (`go tool cover -func=co
224224
| `config/config.go` | 73.2% | config | Configuration management |
225225
| `client/notification_manager.go` | 67.5% | client | Desktop / notification integration |
226226
| `client/config/interactive_ui.go` | 66.9% | client/config | Interactive configuration UI |
227-
| `plugin/host/host.go` | 63.2% | plugin/host | Plugin subprocess lifecycle and IPC |
227+
| `plugin/host/host.go` | 62.5% | plugin/host | Plugin subprocess lifecycle and IPC |
228228
| `server/logger.go` | 61.4% | server | Logging functionality |
229229
| `server/message_state.go` | 59.2% | server | Reactions, read receipts, channel prefs |
230230
| `server/db_dialect.go` | 58.5% | server | SQL dialect helpers |
@@ -247,7 +247,7 @@ Statement percentages below are from the merged profile (`go tool cover -func=co
247247
### Areas for Future Testing
248248
- **Server Package**: Advanced WebSocket handling, complex message routing scenarios (current: 36.1%)
249249
- **Client Package**: WebSocket communication, full TUI integration (current: 23.0%)
250-
- **Plugin Host**: Broader command/response paths and failure modes beyond the minimal IPC test plugin (current: 63.2%)
250+
- **Plugin Host**: Broader command/response paths and failure modes beyond the minimal IPC test plugin (current: 62.5%)
251251
- **Plugin Manager**: Store download, checksum, and install edge cases (current: 32.1%)
252252
- **Server Main**: Full `main` execution, HTTP/TLS serving, admin panel integration (current: 13.7% statement coverage for `cmd/server/main.go`)
253253
- **File Transfer**: File upload/download functionality
@@ -385,10 +385,10 @@ When adding new functionality to Marchat:
385385

386386
## Test Metrics
387387

388-
- **Top-level tests**: 334 `Test*` entrypoints from `go test -list . ./...` on the main module; the nested **`plugin/sdk`** module adds 6 more (`cd plugin/sdk && go test -list . ./...`).
388+
- **Top-level tests**: 334 `Test*` entrypoints from `go test -list . ./...` on the main module; the nested **`plugin/sdk`** module adds 10 more (`cd plugin/sdk && go test -list . ./...`).
389389
- **Test files**: 38 `_test.go` files in the repo tree (including `plugin/sdk/plugin_test.go`).
390390
- **Packages (`go list ./...`)**: 14 in the main module; `plugin/sdk` is a nested module with its own `go.mod`.
391-
- **Coverage by Package** (statement %, merged profile): 88.1% (`shared`), 87.1% (`plugin/license`), 80.3% (`client/crypto`), 73.2% (`config`), 63.0% (`plugin/host`), 57.3% (`client/config`), 49.8% (`internal/doctor`), 47.0% (`plugin/store`), 42.2% (`cmd/license`), 36.1% (`server`), 32.1% (`plugin/manager`), 23.0% (`client`), 13.7% (`cmd/server`)
391+
- **Coverage by Package** (statement %, merged profile): 88.1% (`shared`), 87.1% (`plugin/license`), 80.3% (`client/crypto`), 73.2% (`config`), 62.5% (`plugin/host`), 57.3% (`client/config`), 49.8% (`internal/doctor`), 47.0% (`plugin/store`), 42.2% (`cmd/license`), 36.1% (`server`), 32.1% (`plugin/manager`), 23.0% (`client`), 13.7% (`cmd/server`)
392392
- **Overall Coverage**: **37.7%** across main-module packages (regenerate with `go test -coverprofile=mergedcoverage ./...` then `go tool cover -func=mergedcoverage`; on PowerShell avoid `-coverprofile=*.out`--see note above)
393393
- **Lines of code (approx.)**: non-test `.go` lines per package directory, same totals as the **Current Coverage Status** table (e.g. `server` 6298, `client` 4966); re-count with:
394394
`python -c "import os; ..."` walking the tree and skipping `*_test.go`, or equivalent `find` + `wc -l`.

plugin/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,18 +65,55 @@ type Plugin interface {
6565
}
6666
```
6767

68+
### Message Type
69+
70+
The `sdk.Message` struct carries chat context from the hub to plugins and back:
71+
72+
```go
73+
type Message struct {
74+
Sender string `json:"sender"`
75+
Content string `json:"content"`
76+
CreatedAt time.Time `json:"created_at"`
77+
Type string `json:"type,omitempty"`
78+
Channel string `json:"channel,omitempty"`
79+
Encrypted bool `json:"encrypted,omitempty"`
80+
MessageID int64 `json:"message_id,omitempty"`
81+
Recipient string `json:"recipient,omitempty"`
82+
Edited bool `json:"edited,omitempty"`
83+
}
84+
```
85+
86+
| Field | Description |
87+
|-------|-------------|
88+
| `Sender` | Username of the message author |
89+
| `Content` | Message text (opaque ciphertext when `Encrypted` is true) |
90+
| `CreatedAt` | Timestamp |
91+
| `Type` | `"text"`, `"file"`, `"dm"`, etc. (matches `shared.MessageType` values) |
92+
| `Channel` | Channel the message belongs to (empty = default `general`) |
93+
| `Encrypted` | `true` when content is E2E encrypted; plugins should skip parsing |
94+
| `MessageID` | Server-assigned ID (useful for reactions, edits) |
95+
| `Recipient` | Target user for DMs (empty = broadcast) |
96+
| `Edited` | `true` if the message was edited after send |
97+
98+
**Backwards compatibility**: All extended fields use `omitempty`. Plugins compiled against older SDK versions silently ignore unknown JSON keys and omit them on output — no recompile required.
99+
68100
### Message Processing
69101

70102
Plugins receive messages and can respond with additional messages:
71103

72104
```go
73105
func (p *MyPlugin) OnMessage(msg sdk.Message) ([]sdk.Message, error) {
106+
// Skip encrypted messages the plugin cannot read
107+
if msg.Encrypted {
108+
return nil, nil
109+
}
74110
// Process incoming message
75111
if strings.HasPrefix(msg.Content, "hello") {
76112
response := sdk.Message{
77113
Sender: "MyBot",
78114
Content: "Hello back!",
79115
CreatedAt: time.Now(),
116+
Channel: msg.Channel, // reply in the same channel
80117
}
81118
return []sdk.Message{response}, nil
82119
}

plugin/sdk/plugin.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,22 @@ type Config struct {
2929
Settings map[string]string `json:"settings"`
3030
}
3131

32-
// Message represents a chat message
32+
// Message represents a chat message.
33+
//
34+
// Fields added after the initial SDK release (Channel, Encrypted, MessageID,
35+
// Recipient, Edited) use omitempty and are backwards-compatible: older plugins
36+
// that were compiled against the 4-field struct silently ignore the extra JSON
37+
// keys on input and omit them on output.
3338
type Message struct {
3439
Sender string `json:"sender"`
3540
Content string `json:"content"`
3641
CreatedAt time.Time `json:"created_at"`
3742
Type string `json:"type,omitempty"`
43+
Channel string `json:"channel,omitempty"`
44+
Encrypted bool `json:"encrypted,omitempty"`
45+
MessageID int64 `json:"message_id,omitempty"`
46+
Recipient string `json:"recipient,omitempty"`
47+
Edited bool `json:"edited,omitempty"`
3848
}
3949

4050
// PluginCommand represents a command that a plugin can register

plugin/sdk/plugin_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package sdk
22

33
import (
44
"encoding/json"
5+
"strings"
56
"testing"
67
"time"
78
)
@@ -77,6 +78,112 @@ func TestMessageEmptyFields(t *testing.T) {
7778
}
7879
}
7980

81+
func TestMessageExtendedFields(t *testing.T) {
82+
msg := Message{
83+
Sender: "alice",
84+
Content: "hello",
85+
CreatedAt: time.Now(),
86+
Type: "text",
87+
Channel: "dev",
88+
Encrypted: true,
89+
MessageID: 42,
90+
Recipient: "bob",
91+
Edited: true,
92+
}
93+
94+
if msg.Channel != "dev" {
95+
t.Errorf("Expected channel 'dev', got %s", msg.Channel)
96+
}
97+
if !msg.Encrypted {
98+
t.Error("Expected Encrypted to be true")
99+
}
100+
if msg.MessageID != 42 {
101+
t.Errorf("Expected MessageID 42, got %d", msg.MessageID)
102+
}
103+
if msg.Recipient != "bob" {
104+
t.Errorf("Expected recipient 'bob', got %s", msg.Recipient)
105+
}
106+
if !msg.Edited {
107+
t.Error("Expected Edited to be true")
108+
}
109+
}
110+
111+
func TestMessageExtendedFieldsJSONRoundTrip(t *testing.T) {
112+
now := time.Now().Truncate(time.Second)
113+
msg := Message{
114+
Sender: "alice",
115+
Content: "encrypted payload",
116+
CreatedAt: now,
117+
Type: "text",
118+
Channel: "general",
119+
Encrypted: true,
120+
MessageID: 99,
121+
Recipient: "bob",
122+
Edited: true,
123+
}
124+
125+
data, err := json.Marshal(msg)
126+
if err != nil {
127+
t.Fatalf("Failed to marshal: %v", err)
128+
}
129+
130+
var got Message
131+
if err := json.Unmarshal(data, &got); err != nil {
132+
t.Fatalf("Failed to unmarshal: %v", err)
133+
}
134+
135+
if got.Channel != msg.Channel {
136+
t.Errorf("Channel: want %q, got %q", msg.Channel, got.Channel)
137+
}
138+
if got.Encrypted != msg.Encrypted {
139+
t.Errorf("Encrypted: want %v, got %v", msg.Encrypted, got.Encrypted)
140+
}
141+
if got.MessageID != msg.MessageID {
142+
t.Errorf("MessageID: want %d, got %d", msg.MessageID, got.MessageID)
143+
}
144+
if got.Recipient != msg.Recipient {
145+
t.Errorf("Recipient: want %q, got %q", msg.Recipient, got.Recipient)
146+
}
147+
if got.Edited != msg.Edited {
148+
t.Errorf("Edited: want %v, got %v", msg.Edited, got.Edited)
149+
}
150+
}
151+
152+
func TestMessageExtendedFieldsOmitEmpty(t *testing.T) {
153+
msg := Message{
154+
Sender: "bot",
155+
Content: "hi",
156+
CreatedAt: time.Now(),
157+
}
158+
159+
data, err := json.Marshal(msg)
160+
if err != nil {
161+
t.Fatalf("Failed to marshal: %v", err)
162+
}
163+
164+
raw := string(data)
165+
for _, key := range []string{`"channel"`, `"encrypted"`, `"message_id"`, `"recipient"`, `"edited"`} {
166+
if strings.Contains(raw, key) {
167+
t.Errorf("Zero-value field %s should be omitted from JSON, got: %s", key, raw)
168+
}
169+
}
170+
}
171+
172+
func TestMessageBackwardsCompatUnknownFieldsIgnored(t *testing.T) {
173+
jsonWithExtra := `{"sender":"hub","content":"test","created_at":"2025-01-01T00:00:00Z","channel":"dev","encrypted":true,"message_id":7,"recipient":"bob","edited":true,"some_future_field":"ignored"}`
174+
175+
var msg Message
176+
if err := json.Unmarshal([]byte(jsonWithExtra), &msg); err != nil {
177+
t.Fatalf("Unmarshal should ignore unknown fields: %v", err)
178+
}
179+
if msg.Channel != "dev" {
180+
t.Errorf("Channel: want dev, got %s", msg.Channel)
181+
}
182+
if msg.MessageID != 7 {
183+
t.Errorf("MessageID: want 7, got %d", msg.MessageID)
184+
}
185+
}
186+
80187
func TestMessageWithSpecialCharacters(t *testing.T) {
81188
specialContent := "Hello 世界! 🚀 Special chars: @#$%^&*()"
82189
msg := Message{

server/plugin_commands.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,11 @@ func (h *PluginCommandHandler) SendMessageToPlugins(msg shared.Message) {
293293
Content: msg.Content,
294294
CreatedAt: msg.CreatedAt,
295295
Type: string(msg.Type),
296+
Channel: msg.Channel,
297+
Encrypted: msg.Encrypted,
298+
MessageID: msg.MessageID,
299+
Recipient: msg.Recipient,
300+
Edited: msg.Edited,
296301
}
297302

298303
h.manager.SendMessage(pluginMsg)
@@ -315,5 +320,10 @@ func ConvertPluginMessage(pluginMsg sdk.Message) shared.Message {
315320
Content: pluginMsg.Content,
316321
CreatedAt: pluginMsg.CreatedAt,
317322
Type: shared.MessageType(pluginMsg.Type),
323+
Channel: pluginMsg.Channel,
324+
Encrypted: pluginMsg.Encrypted,
325+
MessageID: pluginMsg.MessageID,
326+
Recipient: pluginMsg.Recipient,
327+
Edited: pluginMsg.Edited,
318328
}
319329
}

0 commit comments

Comments
 (0)