vojo/apps/ai-bot/matrix.go

201 lines
6.5 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"sync/atomic"
"time"
)
// MatrixError is a parsed Matrix CS-API error body ({errcode, error}).
type MatrixError struct {
StatusCode int
ErrCode string `json:"errcode"`
Err string `json:"error"`
RetryAfterMs int `json:"retry_after_ms"`
}
func (e *MatrixError) Error() string {
return fmt.Sprintf("matrix %d %s: %s", e.StatusCode, e.ErrCode, e.Err)
}
// MatrixClient is a thin plaintext CS-API client that authenticates as an
// appservice: every request carries the non-expiring `as_token` plus a
// `?user_id=` identity assertion so the homeserver treats it as the bot user.
// No crypto store — Vojo rooms are unencrypted by default.
type MatrixClient struct {
base string
asToken string
asUserID string
http *http.Client
txnSeq atomic.Uint64
}
func NewMatrixClient(base, asToken, asUserID string) *MatrixClient {
return &MatrixClient{
base: base,
asToken: asToken,
asUserID: asUserID,
http: &http.Client{Timeout: 60 * time.Second},
}
}
func (c *MatrixClient) nextTxnID() string {
return "aibot-" + strconv.FormatInt(time.Now().UnixNano(), 36) + "-" +
strconv.FormatUint(c.txnSeq.Add(1), 36)
}
// do issues a CS-API request as the appservice user and decodes JSON into out.
// Non-2xx responses are returned as *MatrixError.
func (c *MatrixClient) do(ctx context.Context, method, path string, query url.Values, body, out any) error {
var reader io.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
reader = bytes.NewReader(buf)
}
if query == nil {
query = url.Values{}
}
// Appservice identity assertion: act as the bot user (spec §Identity
// assertion). The as_token authenticates the appservice; user_id selects
// which namespaced user we are acting as.
query.Set("user_id", c.asUserID)
u := c.base + path + "?" + query.Encode()
req, err := http.NewRequestWithContext(ctx, method, u, reader)
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Authorization", "Bearer "+c.asToken)
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
mErr := &MatrixError{StatusCode: resp.StatusCode}
_ = json.Unmarshal(data, mErr) // best-effort; body may not be JSON
return mErr
}
if out != nil && len(data) > 0 {
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("decode response from %s: %w", path, err)
}
}
return nil
}
// Whoami confirms the as_token + user_id resolves to BOT_MXID (startup check).
func (c *MatrixClient) Whoami(ctx context.Context) (string, error) {
var out struct {
UserID string `json:"user_id"`
}
if err := c.do(ctx, http.MethodGet, "/_matrix/client/v3/account/whoami", nil, nil, &out); err != nil {
return "", err
}
return out.UserID, nil
}
func (c *MatrixClient) JoinRoom(ctx context.Context, roomID string) error {
return c.do(ctx, http.MethodPost, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/join", nil, struct{}{}, nil)
}
func (c *MatrixClient) LeaveRoom(ctx context.Context, roomID string) error {
return c.do(ctx, http.MethodPost, "/_matrix/client/v3/rooms/"+url.PathEscape(roomID)+"/leave", nil, struct{}{}, nil)
}
// SendEvent PUTs a message event with a unique txn id and returns its event id.
func (c *MatrixClient) SendEvent(ctx context.Context, roomID, evType string, content any) (string, error) {
path := fmt.Sprintf("/_matrix/client/v3/rooms/%s/send/%s/%s",
url.PathEscape(roomID), url.PathEscape(evType), url.PathEscape(c.nextTxnID()))
var out struct {
EventID string `json:"event_id"`
}
if err := c.do(ctx, http.MethodPut, path, nil, content, &out); err != nil {
return "", err
}
return out.EventID, nil
}
// SetDisplayName sets the bot user's profile display name (F23). Idempotent.
func (c *MatrixClient) SetDisplayName(ctx context.Context, name string) error {
path := "/_matrix/client/v3/profile/" + url.PathEscape(c.asUserID) + "/displayname"
return c.do(ctx, http.MethodPut, path, nil, map[string]any{"displayname": name}, nil)
}
// SendTyping sets or clears the bot user's typing indicator in a room. The
// homeserver broadcasts m.typing, which clients render as "… is typing"; the
// timeout (ms) applies only when starting and is omitted when clearing.
func (c *MatrixClient) SendTyping(ctx context.Context, roomID string, typing bool, timeoutMs int) error {
path := "/_matrix/client/v3/rooms/" + url.PathEscape(roomID) + "/typing/" + url.PathEscape(c.asUserID)
body := map[string]any{"typing": typing}
if typing {
body["timeout"] = timeoutMs
}
return c.do(ctx, http.MethodPut, path, nil, body, nil)
}
// RoomEncrypted checks live encryption state (F15 — never a join-time snapshot).
// A 404/M_NOT_FOUND means no m.room.encryption state → unencrypted.
func (c *MatrixClient) RoomEncrypted(ctx context.Context, roomID string) (bool, error) {
path := "/_matrix/client/v3/rooms/" + url.PathEscape(roomID) + "/state/m.room.encryption/"
err := c.do(ctx, http.MethodGet, path, nil, nil, &struct{}{})
if err == nil {
return true, nil
}
if mErr, ok := err.(*MatrixError); ok && (mErr.StatusCode == http.StatusNotFound || mErr.ErrCode == "M_NOT_FOUND") {
return false, nil
}
return false, err
}
// RoomMembership returns joined+invited counts and the set of homeservers that
// have a member present (joined or invited). Used both to classify a room as a
// 1:1 (F3) and to enforce that the bot only stays in rooms hosted entirely on
// allowed servers — appservice transactions carry no room summary, so this reads
// /members. The member is identified by the event's state_key (the sender is
// whoever *set* the membership, which may differ).
func (c *MatrixClient) RoomMembership(ctx context.Context, roomID string) (joined, invited int, servers map[string]bool, err error) {
path := "/_matrix/client/v3/rooms/" + url.PathEscape(roomID) + "/members"
var out struct {
Chunk []Event `json:"chunk"`
}
if err = c.do(ctx, http.MethodGet, path, nil, nil, &out); err != nil {
return 0, 0, nil, err
}
servers = make(map[string]bool)
for i := range out.Chunk {
e := &out.Chunk[i]
if e.StateKey == nil {
continue
}
switch e.membershipOf() {
case "join":
joined++
servers[serverOf(*e.StateKey)] = true
case "invite":
invited++
servers[serverOf(*e.StateKey)] = true
}
}
return joined, invited, servers, nil
}