Initial Commit
This commit is contained in:
380
matrix/bridge.go
Normal file
380
matrix/bridge.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package matrix
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"local/sneedchatbridge/config"
|
||||
"local/sneedchatbridge/sneed"
|
||||
"local/sneedchatbridge/utils"
|
||||
)
|
||||
|
||||
//
|
||||
// RoomEvent — internal Matrix event representation
|
||||
//
|
||||
|
||||
type RoomEvent struct {
|
||||
RoomID string
|
||||
EventID string
|
||||
Sender string
|
||||
Type string
|
||||
Content map[string]interface{}
|
||||
Unsigned map[string]interface{}
|
||||
Redacts string
|
||||
}
|
||||
|
||||
//
|
||||
// Bridge structure
|
||||
//
|
||||
|
||||
type Bridge struct {
|
||||
cfg *config.Config
|
||||
sneedc *sneed.Client
|
||||
httpClient *http.Client
|
||||
|
||||
roomID string
|
||||
|
||||
muOutbound sync.Mutex
|
||||
outboundSent []map[string]interface{} // recent outbound messages for echo suppression
|
||||
|
||||
muMap sync.Mutex
|
||||
idMap map[int]int // Matrix synthetic ID → Sneed message ID
|
||||
sneedTo map[int]int // Sneed message ID → Matrix synthetic ID
|
||||
|
||||
muGhost sync.Mutex
|
||||
remoteUserToGhost map[int]string // user_id → MXID
|
||||
}
|
||||
|
||||
//
|
||||
// Constructor
|
||||
//
|
||||
|
||||
func NewBridge(cfg *config.Config, sneedc *sneed.Client) *Bridge {
|
||||
b := &Bridge{
|
||||
cfg: cfg,
|
||||
sneedc: sneedc,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
|
||||
// roomID must come from Synapse: invite your appservice bot into the room
|
||||
roomID: cfg.MatrixRoomID,
|
||||
|
||||
idMap: make(map[int]int),
|
||||
sneedTo: make(map[int]int),
|
||||
|
||||
remoteUserToGhost: make(map[int]string),
|
||||
outboundSent: make([]map[string]interface{}, 0, 64),
|
||||
}
|
||||
|
||||
// Wire Sneedchat callbacks
|
||||
sneedc.OnMessage = b.onSneedMessage
|
||||
sneedc.OnEdit = b.onSneedEdit
|
||||
sneedc.OnDelete = b.onSneedDelete
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
//
|
||||
// MATRIX → SNEEDCHAT ENTRYPOINT
|
||||
//
|
||||
|
||||
func (b *Bridge) HandleMatrixEvent(ev RoomEvent) {
|
||||
// ignore our own appservice ghost users
|
||||
if strings.HasSuffix(ev.Sender, ":"+b.cfg.MatrixGhostUserDomain) {
|
||||
return
|
||||
}
|
||||
|
||||
switch ev.Type {
|
||||
case "m.room.message":
|
||||
b.handleMatrixMessage(ev)
|
||||
|
||||
case "m.room.redaction":
|
||||
b.handleMatrixRedaction(ev)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// MATRIX → SNEEDCHAT Message
|
||||
//
|
||||
|
||||
func (b *Bridge) handleMatrixMessage(ev RoomEvent) {
|
||||
body, _ := ev.Content["body"].(string)
|
||||
body = utils.CleanSpaces(body)
|
||||
if body == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Track outbound for echo suppression
|
||||
b.trackOutbound(ev.EventID, body, time.Now())
|
||||
|
||||
// If only image link
|
||||
if utils.IsImageURL(body) {
|
||||
wrapped := utils.WrapImageForSneed(body)
|
||||
b.sneedc.Say(wrapped)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise text
|
||||
b.sneedc.Say(body)
|
||||
}
|
||||
|
||||
//
|
||||
// MATRIX → SNEEDCHAT Redaction
|
||||
//
|
||||
|
||||
func (b *Bridge) handleMatrixRedaction(ev RoomEvent) {
|
||||
raw := ev.Redacts
|
||||
if raw == "" {
|
||||
return
|
||||
}
|
||||
|
||||
synthID, ok := b.eventIDToInt(raw)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
b.muMap.Lock()
|
||||
sneedID, ok := b.idMap[synthID]
|
||||
b.muMap.Unlock()
|
||||
|
||||
if ok {
|
||||
b.sneedc.Say(fmt.Sprintf("/delete %d", sneedID))
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Synthetic ID converter
|
||||
//
|
||||
|
||||
func (b *Bridge) eventIDToInt(evID string) (int, bool) {
|
||||
evID = strings.TrimPrefix(evID, "$")
|
||||
if len(evID) < 8 {
|
||||
return 0, false
|
||||
}
|
||||
i, err := strconv.Atoi(evID[:8])
|
||||
return i, err == nil
|
||||
}
|
||||
|
||||
//
|
||||
// Outbound tracking to avoid echoing Matrix messages back
|
||||
//
|
||||
|
||||
func (b *Bridge) trackOutbound(eventID string, content string, ts time.Time) {
|
||||
sid, ok := b.eventIDToInt(eventID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
b.muOutbound.Lock()
|
||||
defer b.muOutbound.Unlock()
|
||||
|
||||
b.outboundSent = append(b.outboundSent, map[string]interface{}{
|
||||
"synthetic_id": sid,
|
||||
"content": content,
|
||||
"ts": ts,
|
||||
})
|
||||
|
||||
// prune entries older than 60 seconds
|
||||
cut := time.Now().Add(-60 * time.Second)
|
||||
pruned := b.outboundSent[:0]
|
||||
for _, e := range b.outboundSent {
|
||||
if e["ts"].(time.Time).After(cut) {
|
||||
pruned = append(pruned, e)
|
||||
}
|
||||
}
|
||||
b.outboundSent = pruned
|
||||
}
|
||||
|
||||
//
|
||||
// SNEEDCHAT → MATRIX Message
|
||||
//
|
||||
|
||||
func (b *Bridge) onSneedMessage(msgID int, userID int, username string, content string) {
|
||||
mxid := b.makeGhostMXID(username, userID)
|
||||
body := utils.BBCodeToMarkdown(content)
|
||||
|
||||
if err := b.sendMatrixMessage(mxid, b.roomID, body, msgID); err != nil {
|
||||
log.Printf("❌ Error sending Matrix message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// SNEEDCHAT → MATRIX Edit
|
||||
//
|
||||
|
||||
func (b *Bridge) onSneedEdit(msgID int, userID int, newText string) {
|
||||
b.muMap.Lock()
|
||||
synthID, ok := b.sneedTo[msgID]
|
||||
b.muMap.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
mxEventID := fmt.Sprintf("$%08d:sneed", synthID)
|
||||
body := utils.BBCodeToMarkdown(newText)
|
||||
|
||||
_ = b.sendMatrixEdit(b.roomID, mxEventID, body)
|
||||
}
|
||||
|
||||
//
|
||||
// SNEEDCHAT → MATRIX Delete
|
||||
//
|
||||
|
||||
func (b *Bridge) onSneedDelete(msgID int, userID int) {
|
||||
b.muMap.Lock()
|
||||
synthID, ok := b.sneedTo[msgID]
|
||||
b.muMap.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
mxEventID := fmt.Sprintf("$%08d:sneed", synthID)
|
||||
_ = b.sendMatrixRedaction(b.roomID, mxEventID)
|
||||
}
|
||||
|
||||
//
|
||||
// GHOST USER GENERATION (Collision-Safe)
|
||||
//
|
||||
|
||||
func (b *Bridge) makeGhostMXID(username string, userID int) string {
|
||||
b.muGhost.Lock()
|
||||
defer b.muGhost.Unlock()
|
||||
|
||||
// Already assigned?
|
||||
if mx, ok := b.remoteUserToGhost[userID]; ok {
|
||||
return mx
|
||||
}
|
||||
|
||||
base := utils.NormalizeUsername(username)
|
||||
domain := b.cfg.MatrixGhostUserDomain
|
||||
prefix := b.cfg.MatrixGhostUserPrefix
|
||||
|
||||
// Try @base
|
||||
mxid := fmt.Sprintf("@%s%s:%s", prefix, base, domain)
|
||||
if !b.mxidInUse(mxid) {
|
||||
b.remoteUserToGhost[userID] = mxid
|
||||
return mxid
|
||||
}
|
||||
|
||||
// Try suffixes
|
||||
for i := 2; i < 10000; i++ {
|
||||
candidate := fmt.Sprintf("@%s%s_%d:%s", prefix, base, i, domain)
|
||||
if !b.mxidInUse(candidate) {
|
||||
b.remoteUserToGhost[userID] = candidate
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
fallback := fmt.Sprintf("@%suid_%d:%s", prefix, userID, domain)
|
||||
b.remoteUserToGhost[userID] = fallback
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (b *Bridge) mxidInUse(mxid string) bool {
|
||||
for _, x := range b.remoteUserToGhost {
|
||||
if x == mxid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
//
|
||||
// MATRIX SEND HELPERS
|
||||
//
|
||||
|
||||
func (b *Bridge) sendMatrixMessage(mxid, roomID, body string, sneedID int) error {
|
||||
url := fmt.Sprintf(
|
||||
"%s/_matrix/client/r0/rooms/%s/send/m.room.message/%d?access_token=%s",
|
||||
b.getHS(),
|
||||
roomID,
|
||||
time.Now().UnixNano(),
|
||||
b.cfg.MatrixAppserviceToken,
|
||||
)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"msgtype": "m.text",
|
||||
"body": body,
|
||||
"sender": mxid,
|
||||
}
|
||||
|
||||
return b.httpPutJSON(url, payload)
|
||||
}
|
||||
|
||||
func (b *Bridge) sendMatrixEdit(roomID, targetEventID, newBody string) error {
|
||||
url := fmt.Sprintf(
|
||||
"%s/_matrix/client/r0/rooms/%s/send/m.room.message/%d?access_token=%s",
|
||||
b.getHS(),
|
||||
roomID,
|
||||
time.Now().UnixNano(),
|
||||
b.cfg.MatrixAppserviceToken,
|
||||
)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"msgtype": "m.text",
|
||||
"body": newBody,
|
||||
"m.new_content": map[string]interface{}{
|
||||
"msgtype": "m.text",
|
||||
"body": newBody,
|
||||
},
|
||||
"m.relates_to": map[string]interface{}{
|
||||
"rel_type": "m.replace",
|
||||
"event_id": targetEventID,
|
||||
},
|
||||
}
|
||||
|
||||
return b.httpPutJSON(url, payload)
|
||||
}
|
||||
|
||||
func (b *Bridge) sendMatrixRedaction(roomID, targetEventID string) error {
|
||||
url := fmt.Sprintf(
|
||||
"%s/_matrix/client/r0/rooms/%s/redact/%s/%d?access_token=%s",
|
||||
b.getHS(),
|
||||
roomID,
|
||||
targetEventID,
|
||||
time.Now().UnixNano(),
|
||||
b.cfg.MatrixAppserviceToken,
|
||||
)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"reason": "Deleted on Sneedchat",
|
||||
}
|
||||
|
||||
return b.httpPutJSON(url, payload)
|
||||
}
|
||||
|
||||
//
|
||||
// HTTP PUT helper
|
||||
//
|
||||
|
||||
func (b *Bridge) httpPutJSON(url string, payload map[string]interface{}) error {
|
||||
data, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest(http.MethodPut, url, bytes.NewReader(data))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := b.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("Matrix returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
//
|
||||
// Homeserver URL (static unless you add config)
|
||||
//
|
||||
|
||||
func (b *Bridge) getHS() string {
|
||||
return "http://localhost:8008"
|
||||
}
|
||||
Reference in New Issue
Block a user