From 2830ea537f51a930d93376ba91e306407fde81ef Mon Sep 17 00:00:00 2001 From: Haw Loeung Date: Mon, 12 Oct 2026 06:30:49 +1100 Subject: [PATCH 1/2] Add initial draft --- .github/workflows/development.yml | 31 +++ .golangci.yaml | 116 ++++++++++++ LICENSE | 201 ++++++++++++++++++++ client.go | 302 ++++++++++++++++++++++++++++++ go.mod | 25 +++ go.sum | 39 ++++ handlers.go | 150 +++++++++++++++ types.go | 64 +++++++ 8 files changed, 928 insertions(+) create mode 100644 .github/workflows/development.yml create mode 100644 .golangci.yaml create mode 100644 LICENSE create mode 100644 client.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handlers.go create mode 100644 types.go diff --git a/.github/workflows/development.yml b/.github/workflows/development.yml new file mode 100644 index 0000000..c671f7a --- /dev/null +++ b/.github/workflows/development.yml @@ -0,0 +1,31 @@ +name: Development +on: + push: + branches: + - 'master' + pull_request: +jobs: + lint: + name: golangci-lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + # Fetch full history so the linter can accurately diff the PR base + fetch-depth: 0 + + # Run on Pull Requests (diffs against the target branch) + - name: Run golangci-lint (PR) + if: github.event_name == 'pull_request' + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + version: v2.12.1 + args: "--new-from-rev=origin/${{ github.base_ref }}" + + # Run on Pushes (standard run) + - name: Run golangci-lint (Push) + if: github.event_name == 'push' + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + version: v2.12.1 + only-new-issues: true diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..ac8884c --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,116 @@ +version: "2" +run: + issues-exit-code: 1 + tests: true +linters: + default: all + disable: + - asasalint + - cyclop + - decorder + - depguard + - err113 + - errname + - errorlint + - exhaustive + - exhaustruct + - forbidigo + - funcorder + - gochecknoglobals + - godot + - godox + - goheader + - gomoddirectives + - gomodguard + - gosmopolitan + - grouper + - importas + - ireturn + - lll + - maintidx + - makezero + - mnd + - nlreturn + - noctx + - paralleltest + - perfsprint + - prealloc + - promlinter + - revive + - tagalign + - tagliatelle + - testpackage + - thelper + - tparallel + - varnamelen + - wrapcheck + - wsl + - zerologlint + settings: + dupl: + threshold: 150 + errcheck: + check-type-assertions: false + check-blank: false + goconst: + min-len: 3 + min-occurrences: 3 + gocritic: + enabled-checks: + - boolExprSimplify + - builtinShadow + - commentedOutImport + - emptyFallthrough + - importShadow + - indexAlloc + - methodExprCall + - nestingReduce + - ptrToRefParam + - typeUnparen + - unnecessaryBlock + - yodaStyleExpr + gocyclo: + min-complexity: 15 + lll: + line-length: 150 + tab-width: 1 + misspell: + locale: US + nakedret: + max-func-lines: 0 + prealloc: + simple: true + range-loops: true + for-loops: false + unparam: + check-exported: false + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +issues: + max-same-issues: 0 +formatters: + enable: + - gofmt + - gofumpt + - goimports + settings: + gofmt: + simplify: true + goimports: + local-prefixes: + - github.com + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/client.go b/client.go new file mode 100644 index 0000000..0d8cff6 --- /dev/null +++ b/client.go @@ -0,0 +1,302 @@ +// Package matrixclient is a small Matrix client library used by matterircd +// (and usable by matterbridge), the equivalent of matterclient for Mattermost. +package matrixclient + +import ( + "context" + "errors" + "slices" + "strings" + "sync" + "sync/atomic" + "time" + + logger "github.com/sirupsen/logrus" + "maunium.net/go/mautrix" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// Client is a logged in Matrix client keeping track of rooms and users. +type Client struct { + sync.RWMutex + + mc *mautrix.Client + handler Handler + + ctx context.Context //nolint:containedctx + cancel context.CancelFunc + + firstSync atomic.Bool + connected atomic.Bool + + channels map[id.RoomID]*Channel + dmChannels map[id.RoomID][]id.UserID + users map[id.UserID]*User +} + +// New logs in and starts syncing. Handler.Ready is called after the initial sync. +func New(ctx context.Context, cred Credentials, h Handler) (*Client, error) { + mc, err := mautrix.NewClient(cred.Server, "", "") + if err != nil { + return nil, err + } + + _, err = mc.Login(ctx, &mautrix.ReqLogin{ + Type: mautrix.AuthTypePassword, + Identifier: mautrix.UserIdentifier{Type: mautrix.IdentifierTypeUser, User: cred.Login}, + Password: cred.Pass, + StoreCredentials: true, + }) + if err != nil { + return nil, err + } + + c := &Client{ + mc: mc, + handler: h, + channels: make(map[id.RoomID]*Channel), + dmChannels: make(map[id.RoomID][]id.UserID), + users: make(map[id.UserID]*User), + } + c.ctx, c.cancel = context.WithCancel(ctx) + + syncer, ok := mc.Syncer.(*mautrix.DefaultSyncer) + if !ok { + return nil, errors.New("matrixclient: unexpected syncer type") + } + + c.register(syncer) + + go c.syncLoop() + go c.waitReady() + + c.connected.Store(true) + + return c, nil +} + +func (c *Client) register(s *mautrix.DefaultSyncer) { + s.OnEventType(event.EventMessage, c.handleMessage) + s.OnEventType(event.EventReaction, c.handleReaction) + s.OnEventType(event.StateMember, c.handleMember) + s.OnEventType(event.StateRoomName, c.handleRoomName) + s.OnEventType(event.AccountDataDirectChats, c.handleDM) + s.OnEventType(event.StateCanonicalAlias, c.handleCanonicalAlias) + s.OnSync(func(_ context.Context, _ *mautrix.RespSync, _ string) bool { + c.firstSync.Store(true) + + return true + }) +} + +func (c *Client) syncLoop() { + for c.ctx.Err() == nil { + err := c.mc.SyncWithContext(c.ctx) + if err == nil || c.ctx.Err() != nil { + continue + } + + logger.Errorf("matrixclient: sync: %s", err) + + select { + case <-c.ctx.Done(): + case <-time.After(5 * time.Second): + } + } +} + +// waitReady waits for the first sync response, and gives its events a moment +// to be processed before reporting ready. +func (c *Client) waitReady() { + for !c.firstSync.Load() { + select { + case <-c.ctx.Done(): + return + case <-time.After(250 * time.Millisecond): + } + } + + select { + case <-c.ctx.Done(): + case <-time.After(time.Second): + c.handler.Ready() + } +} + +// Logout stops syncing. +func (c *Client) Logout() { + c.connected.Store(false) + c.cancel() +} + +// Connected reports whether the client is logged in. +func (c *Client) Connected() bool { return c.connected.Load() } + +// UserID is our own user ID. +func (c *Client) UserID() id.UserID { return c.mc.UserID } + +// IsDirect reports whether roomID is a direct message room. +func (c *Client) IsDirect(roomID id.RoomID) bool { + c.RLock() + defer c.RUnlock() + + _, ok := c.dmChannels[roomID] + + return ok +} + +// DirectUsers returns the users of a direct message room. +func (c *Client) DirectUsers(roomID id.RoomID) []id.UserID { + c.RLock() + defer c.RUnlock() + + return slices.Clone(c.dmChannels[roomID]) +} + +// DirectRoom returns the direct message room with userID, or "". +func (c *Client) DirectRoom(userID id.UserID) id.RoomID { + c.RLock() + defer c.RUnlock() + + for roomID, users := range c.dmChannels { + if len(users) == 1 && users[0] == userID { + return roomID + } + } + + return "" +} + +// RoomInfo is a snapshot of a room. +type RoomInfo struct { + ID id.RoomID + Name string // alias with ':' replaced by '/' + IsDirect bool +} + +// Rooms returns a snapshot of the known rooms (excluding 1:1 direct rooms). +func (c *Client) Rooms() []RoomInfo { + c.RLock() + defer c.RUnlock() + + rooms := make([]RoomInfo, 0, len(c.channels)) + + for roomID, ch := range c.channels { + ch.RLock() + + alias, direct, members := ch.Alias, ch.IsDirect, len(ch.Members) + + ch.RUnlock() + + if direct && alias == "" { + alias = id.RoomAlias(roomID) + } + + if direct && members == 1 { + continue + } + + rooms = append(rooms, RoomInfo{ID: roomID, Name: strings.Replace(string(alias), ":", "/", 1), IsDirect: direct}) + } + + return rooms +} + +// DisplayName returns the known display name of a user, or "". +func (c *Client) DisplayName(userID id.UserID) string { + c.RLock() + defer c.RUnlock() + + if u, ok := c.users[userID]; ok && u.MemberEventContent != nil { + return u.Displayname + } + + return "" +} + +// KnownUsers returns the IDs of all known users. +func (c *Client) KnownUsers() []id.UserID { + c.RLock() + defer c.RUnlock() + + ids := make([]id.UserID, 0, len(c.users)) + for userID := range c.users { + ids = append(ids, userID) + } + + return ids +} + +// JoinedMembers returns the joined members of a room. +func (c *Client) JoinedMembers(ctx context.Context, roomID id.RoomID) ([]id.UserID, error) { + resp, err := c.mc.JoinedMembers(ctx, roomID) + if err != nil { + return nil, err + } + + ids := make([]id.UserID, 0, len(resp.Joined)) + for userID := range resp.Joined { + ids = append(ids, userID) + } + + return ids, nil +} + +// Join joins a room by ID or alias. +func (c *Client) Join(ctx context.Context, roomIDOrAlias string) (id.RoomID, error) { + resp, err := c.mc.JoinRoom(ctx, roomIDOrAlias, nil) + if err != nil { + return "", err + } + + return resp.RoomID, nil +} + +// Send sends a text message, as a thread reply when parentID is set. +func (c *Client) Send(ctx context.Context, roomID id.RoomID, parentID id.EventID, text string) (id.EventID, error) { + content := event.MessageEventContent{MsgType: event.MsgText, Body: text} + if parentID != "" { + content.RelatesTo = &event.RelatesTo{Type: event.RelThread, EventID: parentID} + } + + resp, err := c.mc.SendMessageEvent(ctx, roomID, event.EventMessage, &content) + if err != nil { + return "", err + } + + return resp.EventID, nil +} + +// SendDirect sends a message to userID, creating the direct room if needed. +func (c *Client) SendDirect(ctx context.Context, userID id.UserID, parentID id.EventID, text string) (id.EventID, error) { + roomID := c.DirectRoom(userID) + if roomID == "" { + resp, err := c.mc.CreateRoom(ctx, &mautrix.ReqCreateRoom{ + Preset: "trusted_private_chat", + Invite: []id.UserID{userID}, + IsDirect: true, + }) + if err != nil { + return "", err + } + + roomID = resp.RoomID + + c.Lock() + c.dmChannels[roomID] = []id.UserID{userID} + c.Unlock() + } + + return c.Send(ctx, roomID, parentID, text) +} + +// Event fetches a single event, retrying once. +func (c *Client) Event(ctx context.Context, roomID id.RoomID, eventID id.EventID) (*event.Event, error) { + ev, err := c.mc.GetEvent(ctx, roomID, eventID) + if err != nil { + ev, err = c.mc.GetEvent(ctx, roomID, eventID) + } + + return ev, err +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4bc16e2 --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module github.com/matterbridge/matrixclient + +go 1.26.0 + +require ( + github.com/sirupsen/logrus v1.9.4 + maunium.net/go/mautrix v0.31.0 +) + +require ( + filippo.io/edwards25519 v1.2.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/rs/zerolog v1.35.1 // indirect + github.com/tidwall/gjson v1.19.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + go.mau.fi/util v0.10.1 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect + golang.org/x/net v0.59.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..869f097 --- /dev/null +++ b/go.sum @@ -0,0 +1,39 @@ +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= +github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= +github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.mau.fi/util v0.10.1 h1:1oSqb4TwzLA0cUDY0aomyBPFKkZ3J5fqCmrXV3VH3GQ= +go.mau.fi/util v0.10.1/go.mod h1:40TDo7/ekSeOjgr8KAmX31Yf4zrOF94j83WQB+u5ZPc= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= +golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +maunium.net/go/mautrix v0.31.0 h1:x6XNBSa0kOaBgcleI+ZBLkGvBdHIE0qdeY6TZ4wWUeI= +maunium.net/go/mautrix v0.31.0/go.mod h1:vfPYtoGAjlTnIF+W8tl+rjX3yB4PEfqYR2sDXvQo4Co= diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..bb0e520 --- /dev/null +++ b/handlers.go @@ -0,0 +1,150 @@ +package matrixclient + +import ( + "context" + "slices" + "strings" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +func (c *Client) channelLocked(roomID id.RoomID) *Channel { + ch, ok := c.channels[roomID] + if !ok { + ch = &Channel{ID: roomID, Members: make(map[id.UserID]*User)} + c.channels[roomID] = ch + } + + return ch +} + +func (c *Client) addDirectLocked(roomID id.RoomID, userID id.UserID, member *event.MemberEventContent) { + u := &User{ID: userID, MemberEventContent: member} + c.users[userID] = u + + ch := c.channelLocked(roomID) + + ch.Lock() + ch.IsDirect = true + ch.Members[userID] = u + ch.Unlock() + + if !slices.Contains(c.dmChannels[roomID], userID) { + c.dmChannels[roomID] = append(c.dmChannels[roomID], userID) + } +} + +func (c *Client) handleDM(_ context.Context, ev *event.Event) { + chats := ev.Content.AsDirectChats() + if chats == nil { + return + } + + c.Lock() + defer c.Unlock() + + for userID, rooms := range *chats { + for _, roomID := range rooms { + c.addDirectLocked(roomID, userID, &event.MemberEventContent{}) + } + } +} + +func (c *Client) handleMember(_ context.Context, ev *event.Event) { + member := ev.Content.AsMember() + if member == nil || ev.StateKey == nil { + return + } + + c.Lock() + defer c.Unlock() + + if _, ok := c.users[ev.Sender]; !ok { + c.users[ev.Sender] = &User{ID: ev.Sender, MemberEventContent: member} + } + + if member.IsDirect { + c.addDirectLocked(ev.RoomID, id.UserID(*ev.StateKey), member) + } +} + +func (c *Client) handleRoomName(_ context.Context, ev *event.Event) { + name := ev.Content.AsRoomName() + if name == nil { + return + } + + c.Lock() + defer c.Unlock() + + ch := c.channelLocked(ev.RoomID) + + ch.Lock() + defer ch.Unlock() + + // A canonical alias is preferred over the room name. + if ch.Alias == "" { + ch.Alias = id.RoomAlias("#" + strings.ReplaceAll(name.Name, " ", "")) + } +} + +func (c *Client) handleCanonicalAlias(_ context.Context, ev *event.Event) { + alias := ev.Content.AsCanonicalAlias() + if alias == nil { + return + } + + c.Lock() + defer c.Unlock() + + ch := c.channelLocked(ev.RoomID) + + ch.Lock() + ch.Alias, ch.AltAliases = alias.Alias, alias.AltAliases + ch.Unlock() +} + +func (c *Client) handleMessage(_ context.Context, ev *event.Event) { + if ev.Sender == c.mc.UserID { + return + } + + content := ev.Content.AsMessage() + if content == nil { + return + } + + msg := &Message{ + RoomID: ev.RoomID, + EventID: ev.ID, + Sender: ev.Sender, + Text: content.Body, + Direct: c.IsDirect(ev.RoomID), + Timestamp: ev.Timestamp, + } + if content.RelatesTo != nil { + msg.ParentID = content.RelatesTo.EventID + } + + c.handler.OnMessage(msg) +} + +func (c *Client) handleReaction(_ context.Context, ev *event.Event) { + if ev.Sender == c.mc.UserID { + return + } + + content := ev.Content.AsReaction() + if content == nil { + return + } + + c.handler.OnReaction(&Reaction{ + RoomID: ev.RoomID, + ParentID: content.RelatesTo.EventID, + Sender: ev.Sender, + Key: content.RelatesTo.Key, + Direct: c.IsDirect(ev.RoomID), + }) +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..5c45de5 --- /dev/null +++ b/types.go @@ -0,0 +1,64 @@ +package matrixclient + +import ( + "sync" + + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" +) + +// Credentials used to log in to a homeserver. +type Credentials struct { + Server string + Login string + Pass string +} + +// User is a Matrix user as seen in room state. +type User struct { + *event.MemberEventContent + + ID id.UserID +} + +// Channel is a Matrix room. +type Channel struct { + sync.RWMutex + + ID id.RoomID + Alias id.RoomAlias + AltAliases []id.RoomAlias + Members map[id.UserID]*User + IsDirect bool + Encrypted bool + Topic string +} + +// Message is an incoming room or direct message. +type Message struct { + RoomID id.RoomID + EventID id.EventID + ParentID id.EventID + Sender id.UserID + Text string + Direct bool + Timestamp int64 +} + +// Reaction is an incoming reaction to an event. +type Reaction struct { + RoomID id.RoomID + ParentID id.EventID + Sender id.UserID + Key string + Direct bool +} + +// Handler receives events from the client. All methods are called from the +// sync goroutine and must not block for long. +type Handler interface { + // Ready is called once, after the initial sync completed. + Ready() + OnMessage(msg *Message) + OnReaction(r *Reaction) +} From 12dcd692627b5b4758b92d5a67a908ec24bbee6b Mon Sep 17 00:00:00 2001 From: Haw Loeung Date: Mon, 12 Oct 2026 06:43:24 +1100 Subject: [PATCH 2/2] Various fixes --- client.go | 11 ++++++++--- handlers.go | 14 ++++++++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/client.go b/client.go index 0d8cff6..378f986 100644 --- a/client.go +++ b/client.go @@ -11,10 +11,11 @@ import ( "sync/atomic" "time" - logger "github.com/sirupsen/logrus" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + + logger "github.com/sirupsen/logrus" ) // Client is a logged in Matrix client keeping track of rooms and users. @@ -189,7 +190,7 @@ func (c *Client) Rooms() []RoomInfo { ch.RUnlock() - if direct && alias == "" { + if alias == "" { alias = id.RoomAlias(roomID) } @@ -197,7 +198,11 @@ func (c *Client) Rooms() []RoomInfo { continue } - rooms = append(rooms, RoomInfo{ID: roomID, Name: strings.Replace(string(alias), ":", "/", 1), IsDirect: direct}) + rooms = append(rooms, RoomInfo{ + ID: roomID, + Name: strings.Replace(string(alias), ":", "/", 1), + IsDirect: direct, + }) } return rooms diff --git a/handlers.go b/handlers.go index bb0e520..3b42a97 100644 --- a/handlers.go +++ b/handlers.go @@ -57,15 +57,21 @@ func (c *Client) handleMember(_ context.Context, ev *event.Event) { return } + targetID := id.UserID(*ev.StateKey) + c.Lock() defer c.Unlock() - if _, ok := c.users[ev.Sender]; !ok { - c.users[ev.Sender] = &User{ID: ev.Sender, MemberEventContent: member} + u, ok := c.users[targetID] + if !ok { + u = &User{ID: targetID} + c.users[targetID] = u } + u.MemberEventContent = member + if member.IsDirect { - c.addDirectLocked(ev.RoomID, id.UserID(*ev.StateKey), member) + c.addDirectLocked(ev.RoomID, targetID, member) } } @@ -136,7 +142,7 @@ func (c *Client) handleReaction(_ context.Context, ev *event.Event) { } content := ev.Content.AsReaction() - if content == nil { + if content == nil || content.RelatesTo.EventID == "" { return }