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 }