Initial Commit
This commit is contained in:
171
matrix/appservice_server.go
Normal file
171
matrix/appservice_server.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package matrix
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"local/sneedchatbridge/config"
|
||||
)
|
||||
|
||||
// StartAppserviceServer starts the Appservice HTTP server on the configured
|
||||
// listen address. Synapse will POST room events here as transactions, and
|
||||
// GET user queries (ghost user checks).
|
||||
func (b *Bridge) StartAppserviceServer(cfg *config.Config) error {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// /transactions/<txn_id> - Synapse pushes events here
|
||||
// -----------------------------------------------------------
|
||||
mux.HandleFunc("/_matrix/appservice/v1/transactions/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !b.checkAppserviceAuth(r, cfg.MatrixAppserviceToken) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPut && r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
b.handleTransaction(w, r)
|
||||
})
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// /users/@user:domain - Synapse asks if ghost user is allowed
|
||||
// -----------------------------------------------------------
|
||||
mux.HandleFunc("/_matrix/appservice/v1/users/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !b.checkAppserviceAuth(r, cfg.MatrixAppserviceToken) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
b.handleUserQuery(w, r, cfg)
|
||||
})
|
||||
|
||||
addr := cfg.AppserviceListenAddr
|
||||
log.Printf("🟢 Matrix Appservice HTTP server listening on %s", addr)
|
||||
return http.ListenAndServe(addr, mux)
|
||||
}
|
||||
|
||||
//
|
||||
// AUTHENTICATION
|
||||
//
|
||||
|
||||
// checkAppserviceAuth enforces authorization using the appservice token
|
||||
// provided in registration.yaml as hs_token and in .env as MATRIX_APPSERVICE_TOKEN.
|
||||
func (b *Bridge) checkAppserviceAuth(r *http.Request, token string) bool {
|
||||
// Try ?access_token=...
|
||||
if q := r.URL.Query().Get("access_token"); q != "" {
|
||||
return q == token
|
||||
}
|
||||
// Try Authorization: Bearer <token>
|
||||
if h := r.Header.Get("Authorization"); h != "" {
|
||||
if strings.HasPrefix(h, "Bearer ") {
|
||||
return strings.TrimPrefix(h, "Bearer ") == token
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
//
|
||||
// TRANSACTIONS
|
||||
//
|
||||
|
||||
// handleTransaction receives a list of events from Synapse.
|
||||
// Synapse POSTs a JSON body like:
|
||||
// { "events": [ { ... }, { ... } ] }
|
||||
func (b *Bridge) handleTransaction(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
Events []map[string]interface{} `json:"events"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
log.Printf("❌ Appservice transaction decode error: %v", err)
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
for _, ev := range payload.Events {
|
||||
b.routeEvent(ev)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("{}"))
|
||||
}
|
||||
|
||||
// routeEvent takes a raw Matrix event (map[string]interface{})
|
||||
// and converts it into a strongly-typed RoomEvent struct,
|
||||
// then forwards it to the bridge core.
|
||||
func (b *Bridge) routeEvent(ev map[string]interface{}) {
|
||||
evType, _ := ev["type"].(string)
|
||||
roomID, _ := ev["room_id"].(string)
|
||||
sender, _ := ev["sender"].(string)
|
||||
eventID, _ := ev["event_id"].(string)
|
||||
|
||||
// Extract content if present
|
||||
content := map[string]interface{}{}
|
||||
if c, ok := ev["content"].(map[string]interface{}); ok {
|
||||
content = c
|
||||
}
|
||||
|
||||
// Extract unsigned if present
|
||||
unsigned := map[string]interface{}{}
|
||||
if u, ok := ev["unsigned"].(map[string]interface{}); ok {
|
||||
unsigned = u
|
||||
}
|
||||
|
||||
// Redaction ID if present
|
||||
redacts, _ := ev["redacts"].(string)
|
||||
|
||||
// Assemble our internal event struct
|
||||
re := RoomEvent{
|
||||
RoomID: roomID,
|
||||
EventID: eventID,
|
||||
Sender: sender,
|
||||
Type: evType,
|
||||
Content: content,
|
||||
Unsigned: unsigned,
|
||||
Redacts: redacts,
|
||||
}
|
||||
|
||||
b.HandleMatrixEvent(re)
|
||||
}
|
||||
|
||||
//
|
||||
// USER QUERY ENDPOINT
|
||||
//
|
||||
|
||||
// handleUserQuery answers the Appservice ghost-user existence query.
|
||||
// Synapse queries:
|
||||
//
|
||||
// GET /_matrix/appservice/v1/users/@username:sneedchat.kiwifarms.net
|
||||
//
|
||||
// Returning `{}` approves ghost user creation.
|
||||
// Returning 404 denies.
|
||||
func (b *Bridge) handleUserQuery(w http.ResponseWriter, r *http.Request, cfg *config.Config) {
|
||||
path := r.URL.Path
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) == 0 {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
mxid := parts[len(parts)-1]
|
||||
if mxid == "" {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Allow only the configured ghost-user domain
|
||||
if !strings.HasSuffix(mxid, ":"+cfg.MatrixGhostUserDomain) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Approve creation
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{}`))
|
||||
}
|
||||
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