feat: parse encrypted isphere packet logs
This commit is contained in:
@@ -81,7 +81,7 @@ Stop and ask the user only when:
|
|||||||
- Produces: `packetlog.ParseMessageLog(plaintext string) (packetlog.Message, error)`.
|
- Produces: `packetlog.ParseMessageLog(plaintext string) (packetlog.Message, error)`.
|
||||||
- Produces: `packetlog.Message` with fields `ID`, `From`, `To`, `Type`, `Body`, `Subject`, `SendTime`, `ReceiptID`, `ReceiptType`, `Consumed`, and `Read`.
|
- Produces: `packetlog.Message` with fields `ID`, `From`, `To`, `Type`, `Body`, `Subject`, `SendTime`, `ReceiptID`, `ReceiptType`, `Consumed`, and `Read`.
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing decrypt test**
|
- [x] **Step 1: Write failing decrypt test**
|
||||||
|
|
||||||
Create `internal/isphere/logcodec/codec_test.go`:
|
Create `internal/isphere/logcodec/codec_test.go`:
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ go test ./internal/isphere/logcodec -run TestDecryptLineWithKnownPolicyRoundTrip
|
|||||||
|
|
||||||
Expected: FAIL because the package/function does not exist.
|
Expected: FAIL because the package/function does not exist.
|
||||||
|
|
||||||
- [ ] **Step 2: Implement decryptor and test helper**
|
- [x] **Step 2: Implement decryptor and test helper**
|
||||||
|
|
||||||
Create `internal/isphere/logcodec/codec.go`:
|
Create `internal/isphere/logcodec/codec.go`:
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ import (
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: Verify decryptor test passes**
|
- [x] **Step 3: Verify decryptor test passes**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -217,7 +217,7 @@ go test ./internal/isphere/logcodec -run TestDecryptLineWithKnownPolicyRoundTrip
|
|||||||
|
|
||||||
Expected: PASS.
|
Expected: PASS.
|
||||||
|
|
||||||
- [ ] **Step 4: Write failing PacketReader parser test**
|
- [x] **Step 4: Write failing PacketReader parser test**
|
||||||
|
|
||||||
Create `internal/isphere/packetlog/message_test.go`:
|
Create `internal/isphere/packetlog/message_test.go`:
|
||||||
|
|
||||||
@@ -259,7 +259,7 @@ go test ./internal/isphere/packetlog -run TestParseMessageLogExtractsXMPPMessage
|
|||||||
|
|
||||||
Expected: FAIL because the package/function does not exist.
|
Expected: FAIL because the package/function does not exist.
|
||||||
|
|
||||||
- [ ] **Step 5: Implement PacketReader parser**
|
- [x] **Step 5: Implement PacketReader parser**
|
||||||
|
|
||||||
Create `internal/isphere/packetlog/message.go`:
|
Create `internal/isphere/packetlog/message.go`:
|
||||||
|
|
||||||
@@ -339,7 +339,7 @@ func ParseMessageLog(plaintext string) (Message, error) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 6: Verify PacketReader parser test passes**
|
- [x] **Step 6: Verify PacketReader parser test passes**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -349,7 +349,7 @@ go test ./internal/isphere/packetlog -run TestParseMessageLogExtractsXMPPMessage
|
|||||||
|
|
||||||
Expected: PASS.
|
Expected: PASS.
|
||||||
|
|
||||||
- [ ] **Step 7: Run full verification**
|
- [x] **Step 7: Run full verification**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -360,7 +360,7 @@ go build ./cmd/isphere-mcp
|
|||||||
|
|
||||||
Expected: both exit 0.
|
Expected: both exit 0.
|
||||||
|
|
||||||
- [ ] **Step 8: Commit Loop C1**
|
- [x] **Step 8: Commit Loop C1**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -372,34 +372,143 @@ git commit -m "feat: parse encrypted isphere packet logs"
|
|||||||
|
|
||||||
Expected: commit succeeds.
|
Expected: commit succeeds.
|
||||||
|
|
||||||
- [ ] **Step 9: Rewrite next loop before continuing**
|
- [x] **Step 9: Rewrite next loop before continuing**
|
||||||
|
|
||||||
After Loop C1 passes, update the `Loop C2` section below with actual package names and parser behavior from the implementation. If Loop C1 exposes a parser limitation, write that limitation into Loop C2 before coding.
|
After Loop C1 passes, update the `Loop C2` section below with actual package names and parser behavior from the implementation. If Loop C1 exposes a parser limitation, write that limitation into Loop C2 before coding.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Loop C1 Result
|
||||||
|
|
||||||
|
Implemented and verified in this loop:
|
||||||
|
|
||||||
|
- `internal/isphere/logcodec.DecryptLine(ciphertextBase64 string) (string, error)` decrypts DES/CBC/PKCS7 Base64 log lines using the iSphere log policy key `hyhccdtm` and IV `12 34 56 78 90 AB CD EF`.
|
||||||
|
- `internal/isphere/packetlog.ParseMessageLog(plaintext string) (packetlog.Message, error)` extracts an XMPP `<message>` stanza from decrypted `PacketReader.ProcessPacket` plaintext and maps id, from, to, type, body, subject, `isphere sendtime`, receipt id/type, consumed, and read flags.
|
||||||
|
- Tests use synthetic/redacted fixtures only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Loop C2: Log-backed source abstraction
|
## Loop C2: Log-backed source abstraction
|
||||||
|
|
||||||
**Current draft goal:** Wrap `logcodec.DecryptLine` and `packetlog.ParseMessageLog` into a source abstraction that returns normalized messages from a list of encrypted log lines.
|
**Goal:** Wrap `logcodec.DecryptLine` and `packetlog.ParseMessageLog` into a source abstraction that returns normalized messages from encrypted PacketReader log lines.
|
||||||
|
|
||||||
**Planned files:**
|
**Files:**
|
||||||
- Create: `internal/isphere/source.go`
|
- Create: `internal/isphere/source.go`
|
||||||
- Create: `internal/isphere/source_test.go`
|
- Create: `internal/isphere/source_test.go`
|
||||||
|
- Modify: `docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md`
|
||||||
|
|
||||||
**Draft interface after Loop C1:**
|
**Interfaces:**
|
||||||
|
- Consumes: `logcodec.DecryptLine(ciphertextBase64 string) (string, error)`.
|
||||||
|
- 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**
|
||||||
|
|
||||||
|
Create `internal/isphere/source_test.go`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go test ./internal/isphere -run TestEncryptedPacketLogSourceReturnsNormalizedMessages -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because `EncryptedPacketLogSource`, `ReceiveMessagesQuery`, and related types do not exist.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Implement source abstraction**
|
||||||
|
|
||||||
|
Create `internal/isphere/source.go`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
package isphere
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"isphere-ai-bridge/internal/isphere/logcodec"
|
||||||
|
"isphere-ai-bridge/internal/isphere/packetlog"
|
||||||
|
)
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
ID string
|
ID string
|
||||||
ConversationID string
|
ConversationID string
|
||||||
ConversationType string
|
ConversationType string
|
||||||
SenderID string
|
SenderID string
|
||||||
ReceiverID string
|
ReceiverID string
|
||||||
Text string
|
Text string
|
||||||
Timestamp string
|
Timestamp string
|
||||||
Subject string
|
Subject string
|
||||||
ReceiptID string
|
ReceiptID string
|
||||||
Read bool
|
Read bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReceiveMessagesQuery struct {
|
type ReceiveMessagesQuery struct {
|
||||||
@@ -414,10 +523,103 @@ type EncryptedPacketLogSource struct {
|
|||||||
Lines []string
|
Lines []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s EncryptedPacketLogSource) ReceiveMessages(ctx context.Context, query ReceiveMessagesQuery) (ReceiveMessagesResult, error)
|
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
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Rewrite rule before implementation:** Replace this draft with exact code after Loop C1 is committed.
|
- [ ] **Step 3: Verify source abstraction test passes**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go test ./internal/isphere -run TestEncryptedPacketLogSourceReturnsNormalizedMessages -v
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run full verification**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go test ./...
|
||||||
|
go build ./cmd/isphere-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both exit 0.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit Loop C2**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git add -- internal/isphere/source.go internal/isphere/source_test.go docs/superpowers/plans/2026-07-09-stage-c-log-backed-receive-messages-loop.md
|
||||||
|
git diff --cached --check
|
||||||
|
git commit -m "feat: add log-backed receive message source"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: commit succeeds.
|
||||||
|
|
||||||
|
- [ ] **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
52
internal/isphere/logcodec/codec.go
Normal file
52
internal/isphere/logcodec/codec.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package logcodec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/des"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
policyKey = []byte("hyhccdtm")
|
||||||
|
policyIV = []byte{0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF}
|
||||||
|
)
|
||||||
|
|
||||||
|
func DecryptLine(ciphertextBase64 string) (string, error) {
|
||||||
|
ciphertext, err := base64.StdEncoding.DecodeString(ciphertextBase64)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("decode base64 log line: %w", err)
|
||||||
|
}
|
||||||
|
if len(ciphertext) == 0 || len(ciphertext)%des.BlockSize != 0 {
|
||||||
|
return "", fmt.Errorf("ciphertext length %d is not a positive DES block multiple", len(ciphertext))
|
||||||
|
}
|
||||||
|
|
||||||
|
block, err := des.NewCipher(policyKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("create DES cipher: %w", err)
|
||||||
|
}
|
||||||
|
plain := make([]byte, len(ciphertext))
|
||||||
|
cipher.NewCBCDecrypter(block, policyIV).CryptBlocks(plain, ciphertext)
|
||||||
|
plain, err = unpadPKCS7(plain, des.BlockSize)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(plain), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func unpadPKCS7(data []byte, blockSize int) ([]byte, error) {
|
||||||
|
if len(data) == 0 || len(data)%blockSize != 0 {
|
||||||
|
return nil, errors.New("pkcs7 data is empty or not block aligned")
|
||||||
|
}
|
||||||
|
pad := int(data[len(data)-1])
|
||||||
|
if pad == 0 || pad > blockSize || pad > len(data) {
|
||||||
|
return nil, fmt.Errorf("invalid pkcs7 padding length %d", pad)
|
||||||
|
}
|
||||||
|
for i := len(data) - pad; i < len(data); i++ {
|
||||||
|
if int(data[i]) != pad {
|
||||||
|
return nil, errors.New("invalid pkcs7 padding bytes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data[:len(data)-pad], nil
|
||||||
|
}
|
||||||
43
internal/isphere/logcodec/codec_test.go
Normal file
43
internal/isphere/logcodec/codec_test.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package logcodec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/des"
|
||||||
|
"encoding/base64"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDecryptLineWithKnownPolicyRoundTrip(t *testing.T) {
|
||||||
|
plaintext := "--------------------------------------------------------------------------------------------------------------------------------------------\r\n2026/7/7 15:30:07\r\n<message id=\"msg-1\" from=\"sender@imopenfire1-lanzhou/imp_pc_4.1.2.6842\" to=\"receiver@imopenfire1-lanzhou\" type=\"chat\"><body>redacted</body></message>"
|
||||||
|
ciphertext := encryptLineForTest(t, plaintext)
|
||||||
|
|
||||||
|
got, err := DecryptLine(ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecryptLine returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got != plaintext {
|
||||||
|
t.Fatalf("DecryptLine() = %q, want %q", got, plaintext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptLineForTest(t *testing.T, plaintext string) string {
|
||||||
|
t.Helper()
|
||||||
|
block, err := des.NewCipher([]byte("hyhccdtm"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCipher: %v", err)
|
||||||
|
}
|
||||||
|
plain := padPKCS7ForTest([]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 padPKCS7ForTest(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
|
||||||
|
}
|
||||||
73
internal/isphere/packetlog/message.go
Normal file
73
internal/isphere/packetlog/message.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package packetlog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
ID string
|
||||||
|
From string
|
||||||
|
To string
|
||||||
|
Type string
|
||||||
|
Body string
|
||||||
|
Subject string
|
||||||
|
SendTime string
|
||||||
|
ReceiptID string
|
||||||
|
ReceiptType string
|
||||||
|
Consumed bool
|
||||||
|
Read bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type xmppMessage struct {
|
||||||
|
XMLName xml.Name `xml:"message"`
|
||||||
|
ID string `xml:"id,attr"`
|
||||||
|
From string `xml:"from,attr"`
|
||||||
|
To string `xml:"to,attr"`
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
Body string `xml:"body"`
|
||||||
|
Subject string `xml:"subject"`
|
||||||
|
Receipt xmppReceipt `xml:"received"`
|
||||||
|
ISphere xmppISphere `xml:"isphere"`
|
||||||
|
Consumed *struct{} `xml:"consumed"`
|
||||||
|
Readed *struct{} `xml:"readed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xmppReceipt struct {
|
||||||
|
ID string `xml:"id,attr"`
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type xmppISphere struct {
|
||||||
|
SendTime string `xml:"sendtime,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseMessageLog(plaintext string) (Message, error) {
|
||||||
|
start := strings.Index(plaintext, "<message")
|
||||||
|
if start < 0 {
|
||||||
|
return Message{}, errors.New("message stanza not found")
|
||||||
|
}
|
||||||
|
payload := strings.TrimSpace(plaintext[start:])
|
||||||
|
var parsed xmppMessage
|
||||||
|
if err := xml.Unmarshal([]byte(payload), &parsed); err != nil {
|
||||||
|
return Message{}, fmt.Errorf("parse xmpp message: %w", err)
|
||||||
|
}
|
||||||
|
if parsed.ID == "" {
|
||||||
|
return Message{}, errors.New("message id is empty")
|
||||||
|
}
|
||||||
|
return Message{
|
||||||
|
ID: parsed.ID,
|
||||||
|
From: parsed.From,
|
||||||
|
To: parsed.To,
|
||||||
|
Type: parsed.Type,
|
||||||
|
Body: strings.TrimSpace(parsed.Body),
|
||||||
|
Subject: strings.TrimSpace(parsed.Subject),
|
||||||
|
SendTime: parsed.ISphere.SendTime,
|
||||||
|
ReceiptID: parsed.Receipt.ID,
|
||||||
|
ReceiptType: parsed.Receipt.Type,
|
||||||
|
Consumed: parsed.Consumed != nil,
|
||||||
|
Read: parsed.Readed != nil,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
27
internal/isphere/packetlog/message_test.go
Normal file
27
internal/isphere/packetlog/message_test.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package packetlog
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseMessageLogExtractsXMPPMessage(t *testing.T) {
|
||||||
|
plaintext := "--------------------------------------------------------------------------------------------------------------------------------------------\r\n2026/7/7 15:30:07\r\n<message id=\"2de080bf-f5cc-45e2-b229-2df6bcd4afe3\" from=\"sender@imopenfire1-lanzhou/imp_pc_4.1.2.6842\" to=\"receiver@imopenfire1-lanzhou\" type=\"chat\">\r\n <body>redacted.docx</body>\r\n <received xmlns=\"urn:xmpp:receipts\" id=\"167c08c3d710489f812c13dc23d4e404\" type=\"1\" stamp=\"\" />\r\n <subject>FILE_TRANSFER_CANCEL</subject>\r\n <isphere xmlns=\"isphere.im\" type=\"1001\" sendtime=\"1783423807000\" version=\"1\">\r\n <alert type=\"0\" />\r\n </isphere>\r\n <consumed />\r\n <readed />\r\n</message>"
|
||||||
|
|
||||||
|
got, err := ParseMessageLog(plaintext)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseMessageLog returned error: %v", err)
|
||||||
|
}
|
||||||
|
if got.ID != "2de080bf-f5cc-45e2-b229-2df6bcd4afe3" {
|
||||||
|
t.Fatalf("ID = %q", got.ID)
|
||||||
|
}
|
||||||
|
if got.From != "sender@imopenfire1-lanzhou/imp_pc_4.1.2.6842" || got.To != "receiver@imopenfire1-lanzhou" {
|
||||||
|
t.Fatalf("from/to = %q/%q", got.From, got.To)
|
||||||
|
}
|
||||||
|
if got.Body != "redacted.docx" || got.Subject != "FILE_TRANSFER_CANCEL" {
|
||||||
|
t.Fatalf("body/subject = %q/%q", got.Body, got.Subject)
|
||||||
|
}
|
||||||
|
if got.SendTime != "1783423807000" || got.ReceiptID != "167c08c3d710489f812c13dc23d4e404" || got.ReceiptType != "1" {
|
||||||
|
t.Fatalf("receipt fields = %+v", got)
|
||||||
|
}
|
||||||
|
if !got.Consumed || !got.Read {
|
||||||
|
t.Fatalf("consumed/read = %v/%v", got.Consumed, got.Read)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user