feat: add log-backed receive message source
This commit is contained in:
@@ -402,7 +402,7 @@ Implemented and verified in this loop:
|
||||
- Consumes: `packetlog.ParseMessageLog(plaintext string) (packetlog.Message, error)`.
|
||||
- Produces: `EncryptedPacketLogSource.ReceiveMessages(ctx context.Context, query ReceiveMessagesQuery) (ReceiveMessagesResult, error)`.
|
||||
|
||||
- [ ] **Step 1: Write failing source abstraction test**
|
||||
- [x] **Step 1: Write failing source abstraction test**
|
||||
|
||||
Create `internal/isphere/source_test.go`:
|
||||
|
||||
@@ -482,7 +482,7 @@ go test ./internal/isphere -run TestEncryptedPacketLogSourceReturnsNormalizedMes
|
||||
|
||||
Expected: FAIL because `EncryptedPacketLogSource`, `ReceiveMessagesQuery`, and related types do not exist.
|
||||
|
||||
- [ ] **Step 2: Implement source abstraction**
|
||||
- [x] **Step 2: Implement source abstraction**
|
||||
|
||||
Create `internal/isphere/source.go`:
|
||||
|
||||
@@ -584,7 +584,7 @@ func conversationID(sender string, receiver string) string {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify source abstraction test passes**
|
||||
- [x] **Step 3: Verify source abstraction test passes**
|
||||
|
||||
Run:
|
||||
|
||||
@@ -594,7 +594,7 @@ go test ./internal/isphere -run TestEncryptedPacketLogSourceReturnsNormalizedMes
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 4: Run full verification**
|
||||
- [x] **Step 4: Run full verification**
|
||||
|
||||
Run:
|
||||
|
||||
@@ -605,7 +605,7 @@ go build ./cmd/isphere-mcp
|
||||
|
||||
Expected: both exit 0.
|
||||
|
||||
- [ ] **Step 5: Commit Loop C2**
|
||||
- [x] **Step 5: Commit Loop C2**
|
||||
|
||||
Run:
|
||||
|
||||
@@ -617,23 +617,82 @@ git commit -m "feat: add log-backed receive message source"
|
||||
|
||||
Expected: commit succeeds.
|
||||
|
||||
- [ ] **Step 6: Rewrite Loop C3 before continuing**
|
||||
- [x] **Step 6: Rewrite Loop C3 before continuing**
|
||||
|
||||
After Loop C2 passes, update `Loop C3` with the exact source interface and MCP response shape from the committed implementation.
|
||||
|
||||
---
|
||||
|
||||
## Loop C2 Result
|
||||
|
||||
Implemented and verified in this loop:
|
||||
|
||||
- `internal/isphere.EncryptedPacketLogSource.ReceiveMessages(ctx, query)` decrypts encrypted PacketReader log lines, parses XMPP `<message>` stanzas, normalizes bare sender/receiver JIDs, and returns C2 `isphere.Message` records.
|
||||
- `ReceiveMessagesQuery{Limit: n}` limits returned messages when `n > 0`; zero or negative limit returns all available lines.
|
||||
- The source checks context cancellation before each decrypt/parse operation and returns `ctx.Err()` when canceled.
|
||||
- Tests use synthetic/redacted encrypted PacketReader fixture data only; no raw N12-pre evidence was committed.
|
||||
- Verification completed for C2 with `git diff --check`, `go test ./...`, and `go build ./cmd/isphere-mcp`; the generated `isphere-mcp.exe` build artifact was removed.
|
||||
- Loop C3 was rewritten around the actual C2 interface and the planned MCP response shape before continuing.
|
||||
|
||||
---
|
||||
|
||||
## Loop C3: Register `isphere_receive_messages` MCP tool
|
||||
|
||||
**Current draft goal:** Add the first business MCP tool after the source abstraction passes tests.
|
||||
**Goal:** Register the first business read MCP tool on top of the C2 source abstraction, while keeping helper tools read-only and leaving send/search/file operations out of scope.
|
||||
|
||||
**Planned files:**
|
||||
- Create: `internal/tools/isphere_read.go`
|
||||
- Create: `internal/tools/isphere_read_test.go`
|
||||
- Modify: `internal/mcpserver/server.go`
|
||||
- Modify: `internal/mcpserver/server_test.go`
|
||||
- Modify: `internal/tools/winhelper_test.go`
|
||||
- Modify: `docs/source-discovery/capability-source-matrix.md`
|
||||
- Modify: `docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md`
|
||||
|
||||
**Rewrite rule before implementation:** Update existing tests that currently assert only four helper tools. The new expected surface should be four helper tools plus `isphere_receive_messages`.
|
||||
**Interfaces from Loop C2:**
|
||||
- Tool name: `isphere_receive_messages`.
|
||||
- Tool input: `limit` only.
|
||||
- Tool source: `isphere.EncryptedPacketLogSource.ReceiveMessages(ctx, isphere.ReceiveMessagesQuery{Limit: input.Limit})`.
|
||||
- Tool output shape: a map/object with `messages`, where each message carries `id`, `conversation_id`, `conversation_type`, `sender_id`, `receiver_id`, `text`, `timestamp`, `subject`, `receipt_id`, and `read`.
|
||||
|
||||
- [ ] **Step 1: Write failing tool registration tests**
|
||||
|
||||
Add or update tests so they fail before implementation:
|
||||
|
||||
```powershell
|
||||
go test ./internal/tools -run TestISphereReceiveMessagesToolReturnsSourceMessages -v
|
||||
go test ./internal/mcpserver -run TestNewServerRegistersN8WinHelperToolsWithoutCallingHelper -v
|
||||
```
|
||||
|
||||
Expected initial failures: `isphere_receive_messages` is not registered and the server/tool lists still expose only the four helper tools.
|
||||
|
||||
- [ ] **Step 2: Implement `internal/tools/isphere_read.go`**
|
||||
|
||||
Register `isphere_receive_messages` with `mcp.AddTool`, convert the C2 `isphere.Message` slice into the explicit JSON/map output shape, and keep the only public input parameter as `limit`. Use a source interface so tests can inject a synthetic C2 source without raw evidence files.
|
||||
|
||||
- [ ] **Step 3: Register business read tool in `mcpserver.NewServer`**
|
||||
|
||||
Register the helper tools plus `isphere_receive_messages`. If no live log-line loader exists yet, wire an empty `isphere.EncryptedPacketLogSource{}` as the default so registration is testable without committing raw logs.
|
||||
|
||||
- [ ] **Step 4: Update existing helper-tool guard tests**
|
||||
|
||||
Rename or adjust the old “exactly four” helper test so it still blocks forbidden write/sending tool names but explicitly allows `isphere_receive_messages`. Update `internal/mcpserver/server_test.go` to expect five tools: four helper tools plus `isphere_receive_messages`.
|
||||
|
||||
- [ ] **Step 5: Run full verification**
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
git diff --check
|
||||
go test ./...
|
||||
go build ./cmd/isphere-mcp
|
||||
```
|
||||
|
||||
Expected: all exit 0.
|
||||
|
||||
- [ ] **Step 6: Commit Loop C3 and rewrite Loop C4**
|
||||
|
||||
Commit exact C3 paths only. Rewrite Loop C4 around the actual MCP tool output shape and the fact that default server wiring has no raw evidence loader yet.
|
||||
|
||||
---
|
||||
|
||||
|
||||
95
internal/isphere/source.go
Normal file
95
internal/isphere/source.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package isphere
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"isphere-ai-bridge/internal/isphere/logcodec"
|
||||
"isphere-ai-bridge/internal/isphere/packetlog"
|
||||
)
|
||||
|
||||
type Message struct {
|
||||
ID string
|
||||
ConversationID string
|
||||
ConversationType string
|
||||
SenderID string
|
||||
ReceiverID string
|
||||
Text string
|
||||
Timestamp string
|
||||
Subject string
|
||||
ReceiptID string
|
||||
Read bool
|
||||
}
|
||||
|
||||
type ReceiveMessagesQuery struct {
|
||||
Limit int
|
||||
}
|
||||
|
||||
type ReceiveMessagesResult struct {
|
||||
Messages []Message
|
||||
}
|
||||
|
||||
type EncryptedPacketLogSource struct {
|
||||
Lines []string
|
||||
}
|
||||
|
||||
func (s EncryptedPacketLogSource) ReceiveMessages(ctx context.Context, query ReceiveMessagesQuery) (ReceiveMessagesResult, error) {
|
||||
limit := query.Limit
|
||||
if limit <= 0 || limit > len(s.Lines) {
|
||||
limit = len(s.Lines)
|
||||
}
|
||||
messages := make([]Message, 0, limit)
|
||||
for index, line := range s.Lines {
|
||||
if len(messages) == limit {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ReceiveMessagesResult{}, ctx.Err()
|
||||
default:
|
||||
}
|
||||
plaintext, err := logcodec.DecryptLine(line)
|
||||
if err != nil {
|
||||
return ReceiveMessagesResult{}, fmt.Errorf("decrypt packet log line %d: %w", index, err)
|
||||
}
|
||||
parsed, err := packetlog.ParseMessageLog(plaintext)
|
||||
if err != nil {
|
||||
return ReceiveMessagesResult{}, fmt.Errorf("parse packet log line %d: %w", index, err)
|
||||
}
|
||||
messages = append(messages, normalizePacketMessage(parsed))
|
||||
}
|
||||
return ReceiveMessagesResult{Messages: messages}, nil
|
||||
}
|
||||
|
||||
func normalizePacketMessage(msg packetlog.Message) Message {
|
||||
sender := bareJID(msg.From)
|
||||
receiver := bareJID(msg.To)
|
||||
return Message{
|
||||
ID: msg.ID,
|
||||
ConversationID: conversationID(sender, receiver),
|
||||
ConversationType: msg.Type,
|
||||
SenderID: sender,
|
||||
ReceiverID: receiver,
|
||||
Text: msg.Body,
|
||||
Timestamp: msg.SendTime,
|
||||
Subject: msg.Subject,
|
||||
ReceiptID: msg.ReceiptID,
|
||||
Read: msg.Read,
|
||||
}
|
||||
}
|
||||
|
||||
func bareJID(value string) string {
|
||||
beforeResource, _, _ := strings.Cut(value, "/")
|
||||
return beforeResource
|
||||
}
|
||||
|
||||
func conversationID(sender string, receiver string) string {
|
||||
if sender == "" {
|
||||
return receiver
|
||||
}
|
||||
if receiver == "" {
|
||||
return sender
|
||||
}
|
||||
return sender + "|" + receiver
|
||||
}
|
||||
65
internal/isphere/source_test.go
Normal file
65
internal/isphere/source_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package isphere
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/cipher"
|
||||
"crypto/des"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncryptedPacketLogSourceReturnsNormalizedMessages(t *testing.T) {
|
||||
plaintext := `--------------------------------------------------------------------------------------------------------------------------------------------
|
||||
2026/7/7 15:30:07
|
||||
<message id="msg-1" from="sender@imopenfire1-lanzhou/imp_pc_4.1.2.6842" to="receiver@imopenfire1-lanzhou" type="chat">
|
||||
<body>redacted</body>
|
||||
<received xmlns="urn:xmpp:receipts" id="receipt-1" type="1" stamp="" />
|
||||
<subject>FILE_TRANSFER_CANCEL</subject>
|
||||
<isphere xmlns="isphere.im" type="1001" sendtime="1783423807000" version="1" />
|
||||
<readed />
|
||||
</message>`
|
||||
source := EncryptedPacketLogSource{Lines: []string{encryptPacketLineForTest(t, plaintext)}}
|
||||
|
||||
got, err := source.ReceiveMessages(context.Background(), ReceiveMessagesQuery{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ReceiveMessages returned error: %v", err)
|
||||
}
|
||||
if len(got.Messages) != 1 {
|
||||
t.Fatalf("message count = %d, want 1", len(got.Messages))
|
||||
}
|
||||
msg := got.Messages[0]
|
||||
if msg.ID != "msg-1" || msg.Text != "redacted" || msg.Subject != "FILE_TRANSFER_CANCEL" {
|
||||
t.Fatalf("unexpected message content: %+v", msg)
|
||||
}
|
||||
if msg.ConversationID != "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou" {
|
||||
t.Fatalf("conversation id = %q", msg.ConversationID)
|
||||
}
|
||||
if msg.SenderID != "sender@imopenfire1-lanzhou" || msg.ReceiverID != "receiver@imopenfire1-lanzhou" {
|
||||
t.Fatalf("sender/receiver = %q/%q", msg.SenderID, msg.ReceiverID)
|
||||
}
|
||||
if msg.Timestamp != "1783423807000" || msg.ReceiptID != "receipt-1" || !msg.Read {
|
||||
t.Fatalf("metadata = %+v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func encryptPacketLineForTest(t *testing.T, plaintext string) string {
|
||||
t.Helper()
|
||||
block, err := des.NewCipher([]byte("hyhccdtm"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewCipher: %v", err)
|
||||
}
|
||||
plain := padPacketLineForTest([]byte(plaintext), des.BlockSize)
|
||||
ciphertext := make([]byte, len(plain))
|
||||
iv := []byte{0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF}
|
||||
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, plain)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext)
|
||||
}
|
||||
|
||||
func padPacketLineForTest(data []byte, blockSize int) []byte {
|
||||
pad := blockSize - len(data)%blockSize
|
||||
out := append([]byte(nil), data...)
|
||||
for i := 0; i < pad; i++ {
|
||||
out = append(out, byte(pad))
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user