Documentation
¶
Index ¶
- Constants
- Variables
- type Config
- type DisplaynameParams
- type GChatClient
- func (c *GChatClient) Connect(ctx context.Context)
- func (c *GChatClient) CreateChatWithGhost(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.CreateChatResponse, error)
- func (c *GChatClient) Disconnect()
- func (c *GChatClient) FetchMessages(ctx context.Context, params bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error)
- func (c *GChatClient) GetCapabilities(_ context.Context, portal *bridgev2.Portal) *event.RoomFeatures
- func (c *GChatClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error)
- func (c *GChatClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error)
- func (c *GChatClient) HandleMatrixEdit(ctx context.Context, edit *bridgev2.MatrixEdit) error
- func (c *GChatClient) HandleMatrixMembership(ctx context.Context, msg *bridgev2.MatrixMembershipChange) (*bridgev2.MatrixMembershipResult, error)
- func (c *GChatClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error)
- func (c *GChatClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error
- func (c *GChatClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (*database.Reaction, error)
- func (c *GChatClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error
- func (c *GChatClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error
- func (c *GChatClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev2.MatrixRoomName) (bool, error)
- func (c *GChatClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error
- func (c *GChatClient) IsLoggedIn() bool
- func (c *GChatClient) IsThisUser(_ context.Context, userID networkid.UserID) bool
- func (c *GChatClient) LogoutRemote(ctx context.Context)
- func (c *GChatClient) PreHandleMatrixReaction(_ context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error)
- func (c *GChatClient) ResolveIdentifier(ctx context.Context, identifier string, createChat bool) (*bridgev2.ResolveIdentifierResponse, error)
- type GChatConnector
- func (gc *GChatConnector) CreateLogin(_ context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error)
- func (gc *GChatConnector) GetBridgeInfoVersion() (info, capabilities int)
- func (gc *GChatConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities
- func (gc *GChatConnector) GetConfig() (string, any, configupgrade.Upgrader)
- func (gc *GChatConnector) GetDBMetaTypes() database.MetaTypes
- func (gc *GChatConnector) GetLoginFlows() []bridgev2.LoginFlow
- func (gc *GChatConnector) GetName() bridgev2.BridgeName
- func (gc *GChatConnector) Init(bridge *bridgev2.Bridge)
- func (gc *GChatConnector) LoadUserLogin(_ context.Context, login *bridgev2.UserLogin) error
- func (gc *GChatConnector) SetMaxFileSize(maxSize int64)
- func (gc *GChatConnector) Start(_ context.Context) error
- func (gc *GChatConnector) ValidateUserID(userID networkid.UserID) bool
- type GChatLogin
- type GhostMetadata
- type MessageMetadata
- type PortalMetadata
- type ReactionMetadata
- type UserLoginMetadata
Constants ¶
const ( // GChatBadCredentials is sent for gchatmeow.ConnStateBadCredentials: 401 / // invalid_grant / not-logged-in, i.e. Google actively rejected a cookie // set the bridge did have. GChatBadCredentials status.BridgeStateErrorCode = "gchat-bad-credentials" // GChatCookiesMissing is sent by Connect's pre-flight when // UserLoginMetadata holds no usable cookie set at all. Distinct from // GChatBadCredentials -- same advice to the user, different diagnosis for // whoever reads the log -- following the sibling connectors' pattern of // fine-grained codes with coarse-grained advice. GChatCookiesMissing status.BridgeStateErrorCode = "gchat-cookies-missing" // GChatTransientDisconnect is sent for gchatmeow.ConnStateTransient: a // recoverable disconnect that the client library is already backing off // and retrying internally. GChatTransientDisconnect status.BridgeStateErrorCode = "gchat-transient-disconnect" // GChatFatalError is sent for gchatmeow.ConnStateFatal (e.g. a // SID-invalid storm exceeding the resync cap) -- an unrecoverable // condition for the current connection attempt that most likely needs a // user-initiated relogin. GChatFatalError status.BridgeStateErrorCode = "gchat-fatal-error" )
const ( LoginStepIDCookies = "fi.mau.googlechat.cookies" LoginStepIDComplete = "fi.mau.googlechat.complete" )
const MaxTextLength = 4096
MaxTextLength is the outgoing text length bridgev2 will accept before truncating/rejecting a message. Google Chat's own server-side limit isn't documented in this project's protocol research, so this keeps the original stub value.
Variables ¶
var ErrCannotDMYourself = bridgev2.RespError{
ErrCode: "FI.MAU.GOOGLECHAT.CANNOT_DM_SELF",
Err: "googlechat: that is your own account, and Google Chat cannot open a direct message with yourself",
StatusCode: 400,
}
ErrCannotDMYourself is returned when the identifier names the acting account itself. Google Chat has no self-DM through create_dm; it answers with a bare HTTP 400 that says nothing about the cause, so the check is made locally where the reason is still known.
Detected two different ways. A gaia id is compared against the acting login's id before the request goes out, which is exact. An EMAIL cannot be resolved to a gaia id at all -- the private API has no such lookup -- so that case is recognised the other way round: the request goes out, and if Google rejects it with a 400, the address is compared against the acting login's own (userinfo.go stores it). After the fact rather than before it, so a stale or aliased address can never block a DM that would have worked.
var ErrCannotResolveEmailWithoutCreating = bridgev2.RespError{
ErrCode: "FI.MAU.GOOGLECHAT.EMAIL_REQUIRES_CREATE",
Err: "googlechat: an email address can only be resolved by starting the chat (Google Chat has no email lookup)",
StatusCode: 400,
}
ErrCannotResolveEmailWithoutCreating is returned when an email is offered for resolution only. See this file's doc comment: there is no email-to-gaia lookup, so the only way to learn the user behind an address is to open the DM, which a resolve-only call must not do as a side effect.
The deliberate rejections in this file are bridgev2.RespError VALUES (never pointers, and never type-annotated `error`), matching login.go's sentinels. The type is what makes the provisioning API answer 400 with the message below instead of 500 "Internal error resolving identifier": RespondWithError looks for a WritableError, which RespError satisfies by value. It stays invisible on the bot-command path, because bridgev2.RespError.Error() returns only the message, without the errcode.
Value, not pointer, is load-bearing twice over. RespError contains maps, so declaring these concrete turns an accidental `err == ErrX` into a compile error rather than a runtime panic -- and RespError.Is falls back to comparing ERRCODES when handed a pointer, which would silently conflate two sentinels that share one. errors.Is is safe throughout: it skips its `==` fast path for a non-comparable target and still consults RespError.Is. Status codes are literals so this package imports no HTTP client (see ErrLoginCookiesInvalid).
var ErrGhostUnidentified = bridgev2.RespError{
ErrCode: "FI.MAU.GOOGLECHAT.GHOST_UNIDENTIFIED",
Err: "googlechat: cannot start a chat with an unidentified user",
StatusCode: 400,
}
ErrGhostUnidentified is CreateChatWithGhost's own missing-input rejection. It gets its own errcode rather than sharing ErrIdentifierMissing's: they are different callers with different remedies, and two sentinels sharing an errcode is exactly the pair that would conflate first if these ever became pointers.
var ErrIdentifierMissing = bridgev2.RespError{
ErrCode: "FI.MAU.GOOGLECHAT.IDENTIFIER_MISSING",
Err: "googlechat: no identifier left to resolve -- on Google Chat a login ID is also a user id, so a bare id that is one of your own logins is taken as the login selector; pass the target after it, as `start-chat <your-login-id> <target-id>`",
StatusCode: 400,
}
ErrIdentifierMissing is returned when no identifier survives argument parsing.
On Google Chat this has one cause worth naming. A login ID *is* a gaia id -- gcid.MakeUserID and gcid.MakeUserLoginID are the same identity cast, and login.go fills UserLogin.ID from get_self_user_status -- so every one of the user's own login ids is also a syntactically valid identifier. bridgev2 consumes the first argument as a login selector whenever it names one of this user's logins, which means `start-chat <your-own-login-id>` has its ONLY argument eaten and arrives here empty.
var ErrIdentifierNotSingle = bridgev2.RespError{
ErrCode: "FI.MAU.GOOGLECHAT.IDENTIFIER_NOT_SINGLE",
Err: "googlechat: identifier contains whitespace -- pass exactly one identifier (start-chat's optional first argument is a login ID, not the sender)",
StatusCode: 400,
}
ErrIdentifierNotSingle is returned when an identifier contains internal whitespace.
This is almost always the same mistake: `start-chat`'s optional first argument is a LOGIN ID, not a sender, and bridgev2 folds it back into the identifier when it is not one it recognises (commands/startchat.go's getClientForStartingChat). So `start-chat me@x.com them@y.com` arrives here as one string with a space in it. Shipping that to Google produces a bare HTTP 500 that says nothing about the real problem, so it is caught here with an explanation instead.
var ErrLoginCookiesInvalid = bridgev2.RespError{
ErrCode: "FI.MAU.GOOGLECHAT.INVALID_COOKIES",
Err: "Those cookies don't seem to be valid. Please log into https://chat.google.com in a browser and extract fresh ones.",
StatusCode: 400,
}
ErrLoginCookiesInvalid is returned when Google rejects the submitted cookies (gchatmeow.ErrNotLoggedIn, from /mole/world's qwAQke == "AccountsSignInUi" check). The user-facing reply for this condition is "Those cookies don't seem to be valid".
var ErrLoginFailed = bridgev2.RespError{
ErrCode: "FI.MAU.GOOGLECHAT.LOGIN_FAILED",
Err: "Failed to log into Google Chat with the provided cookies",
StatusCode: 400,
}
ErrLoginFailed is returned for any other failure while validating the submitted cookies (network failure, malformed /mole/world response, a GetSelfUserStatus RPC error, ...) -- a generic, human-readable stand-in for the underlying error, which is logged (via the login's User.Log) for debugging but never surfaced to the API/Matrix client verbatim.
var ErrNotAGoogleChatIdentifier = bridgev2.RespError{
ErrCode: "FI.MAU.GOOGLECHAT.NOT_A_GOOGLECHAT_IDENTIFIER",
Err: "googlechat: that looks like a Matrix ID, not a Google Chat identifier -- pass an email address or a numeric Google Chat user id",
StatusCode: 400,
}
ErrNotAGoogleChatIdentifier is returned for something that is clearly a Matrix identifier rather than a Google Chat one. Both contain "@", which was previously the whole test for an email, so these used to be forwarded to create_dm and come back as an opaque server rejection.
var ExampleConfig string
Functions ¶
This section is empty.
Types ¶
type Config ¶
type Config struct {
DisplaynameTemplate string `yaml:"displayname_template"`
InitialChatSync int `yaml:"initial_chat_sync"`
// DisableOutboundMedia turns off sending Matrix media (m.image/m.file/
// m.video/m.audio) to Google Chat, even though the send path is fully
// implemented (handlematrix.go's HandleMatrixMessage media branch, M5
// Task 5): Google's /uploads endpoint has reportedly returned HTTP 500
// for every upload since ~Feb 2026 (upstream issue #114,
// https://github.com/mautrix/googlechat/issues/114). When true,
// HandleMatrixMessage rejects a media message immediately with
// errOutboundMediaDisabled (handlematrix.go) -- a clean, explicit
// "unsupported (upstream #114)" message-send-status failure -- instead
// of attempting a download+upload that this bridge's own operator
// already knows will fail against their Google Chat account. Left false
// by default: the upload code itself is exercised and correct (M5 Task
// 2's own gchatmeow.Client.UploadFile), and whether #114 actually
// affects a given account/session is a live-server fact this bridge
// cannot determine ahead of time.
DisableOutboundMedia bool `yaml:"disable_outbound_media"`
// DisableInlineURLMedia turns off downloading and inlining the media a
// url_metadata annotation points at (a shared GIF, a link preview image).
// It is worth an operator switch because it is the only outbound request
// this bridge makes to an address that is not one of its own endpoints.
// Only Google's supplied image_url is ever fetched, never the address the
// sender typed (see inlineableURLMedia), and the one live capture showed
// Google had rehosted the media itself -- but Google picks that address,
// so an operator may still prefer no such request at all. The fetch is hardened (https-only, no proxy, no
// cookies, internal addresses refused -- gchatmeow/external.go) and gated
// on a narrow predicate (media.go's inlineableURLMedia), but an operator
// who does not want that egress at all can stop it here.
//
// Left false by default: without it a shared GIF arrives as a bare link,
// which is the thing the feature exists to fix. Turning it on never loses
// a message -- the URL is always in the body regardless
// (gchatfmt.AppendLinkAnnotations), so this only chooses between "link"
// and "link plus inline media".
DisableInlineURLMedia bool `yaml:"disable_inline_url_media"`
// contains filtered or unexported fields
}
func (*Config) FormatDisplayname ¶
func (c *Config) FormatDisplayname(ctx context.Context, params DisplaynameParams) string
FormatDisplayname renders params through the configured displayname_template. A template execution error (e.g. an operator's custom template referencing a field that doesn't exist on DisplaynameParams -- text/template only catches that at Execute time, not at Parse/PostProcess time) previously discarded the error outright, leaving whatever partial output Execute had written to buf before failing with no trace anywhere. Now logged via zerolog.Ctx(ctx): FormatDisplayname still returns buf.String() (whatever was rendered before the failure, same as before -- there is no sensible alternative displayname to fall back to here, and ghost creation/update must not fail outright over a cosmetic template bug), but an operator debugging "why does this ghost have a broken/truncated name" now gets a warning log instead of silence.
func (*Config) PostProcess ¶
type DisplaynameParams ¶
type GChatClient ¶
type GChatClient struct {
// Main is this login's owning connector, giving access to Config
// (e.g. Config.InitialChatSync in sync.go, Config.FormatDisplayname in
// userinfo.go) without reaching through UserLogin.Bridge.Network's
// interface type on every call. Populated by GChatConnector.LoadUserLogin
// (connector.go); left nil in tests that construct a bare *GChatClient
// directly (matching UserLogin's own "often nil in tests" pattern in this
// file -- only the methods that need it (sync.go, userinfo.go) require a
// real one).
Main *GChatConnector
UserLogin *bridgev2.UserLogin
// contains filtered or unexported fields
}
func (*GChatClient) Connect ¶
func (c *GChatClient) Connect(ctx context.Context)
Connect builds a gchatmeow.Client from this login's persisted UserLoginMetadata (cookies + user agent) -- LoadUserLogin (connector.go) only allocates the *GChatClient shell (it runs under the global cache lock, so the client is built from login.Metadata only, with network I/O deferred to here) -- wires its callbacks, and starts its supervision loop in the background. Never returns an error (bridgev2.NetworkAPI.Connect's contract); failures are surfaced via BridgeState.Send, matching a missing/invalid cookie set to BAD_CREDENTIALS.
Any previously-attached client is torn down first (see wireAndStart), so calling Connect again on the same *GChatClient -- which bridgev2 itself does not do in normal operation, but which a defensive caller might -- can never orphan a running client goroutine.
Connect also clears the loggedOut latch (metaMu-guarded, see its doc comment) before doing anything else: a new Connect call means this login's session is being (re)activated, so a stale latch from an earlier LogoutRemote must not suppress cookie persistence once this connection reaches CONNECTED (persistCookies below).
func (*GChatClient) CreateChatWithGhost ¶ added in v0.2608.2
func (c *GChatClient) CreateChatWithGhost(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.CreateChatResponse, error)
CreateChatWithGhost opens the DM with an already-known user, skipping identifier parsing entirely: a ghost's id IS the gaia id (gcid.MakeUserID is an identity mapping, see gcid's frozen-format doc comment).
func (*GChatClient) Disconnect ¶
func (c *GChatClient) Disconnect()
Disconnect stops this login's active client, if any. Safe to call multiple times (including when no client was ever attached): gchatmeow.Client.Disconnect is itself documented as a safe no-op when not connected, and a nil conn is simply skipped here.
func (*GChatClient) FetchMessages ¶
func (c *GChatClient) FetchMessages(ctx context.Context, params bridgev2.FetchMessagesParams) (*bridgev2.FetchMessagesResponse, error)
FetchMessages implements bridgev2.BackfillingNetworkAPI for history backfill (flat rooms; threaded spaces). Dispatches:
- params.ThreadRoot != "": fetchThreadMessages, regardless of params.Forward -- checked FIRST, before the Forward branch below, because the framework's own thread-backfill callers (fetchThreadBackfill/doThreadBackfill, portalbackfill.go) ALWAYS set Forward=true on a ThreadRoot-scoped call (a thread grows forward in time from its head, so cutoffMessages' forward-trim semantics are what apply to it) even though this is conceptually still part of the backward/initial backfill. Checking Forward before ThreadRoot here would misroute every real thread-backfill call into the Forward stub below and silently never fetch any thread replies -- verified by reading portalbackfill.go, not assumed.
- params.Forward && params.AnchorMessage != nil (with ThreadRoot == ""): forward CATCH-UP on an EXISTING, NON-EMPTY room ("new messages since the last known one"). doForwardBackfill is invoked from TWO DISTINCT call sites: the room-creation seed (portal.go:5404) ALWAYS passes lastMessage==nil, and the chat-list RESYNC path (portal.go:3874's handleRemoteChatResync) passes whatever GetLastMessage returns for the portal. That resync path IS live for this bridge -- sync.go emits a simplevent.ChatResync with a LatestMessageTS on every chat-list sync, and simplevent.ChatResync itself implements the framework's RemoteChatResyncBackfill: its default CheckNeedsBackfill returns true when LatestMessageTS is newer than the last bridged message (simplevent/chat.go:43-49). When it fires on a portal that ALREADY has bridged messages, lastMessage is non-nil and the call lands HERE; when it fires on an EMPTY portal, lastMessage is nil and it lands in the seed (otherwise-branch below) instead. (fetchThreadBackfill's ThreadRoot-scoped call also sets Forward=true, but is already routed to fetchThreadMessages by the ThreadRoot check above, never reaching here.) For the non-empty-portal case here we return an empty, HasMore=false response -- documented GC limitation (see the Forward bullet in this file's region doc comment above); catch_up_user already owns this case for existing rooms.
- otherwise (Forward==false, OR Forward==true && AnchorMessage==nil): fetchTopicHeadMessages, for a flat portal's backward/queue path, a ThreadsOnly portal's backward/queue path, AND the Forward==true NEW-ROOM bootstrap seed on EITHER portal shape (doForwardBackfill(ctx, source, nil, bundle) on room creation, portal.go:5404). Stubbing every Forward==true call to empty would leave backfill entirely inert on non-Beeper homeservers like continuwuity: doBackwardsBackfill's queue (backfillqueue.go's RunBackfillQueue) never starts there (BatchSending gate), so the bootstrap seed above was the ONLY room-creation trigger that actually runs on such a deployment, and it was fetching nothing. Routing it here instead fixes that: it reuses the exact same list_topics + per-topic-head strategy as the backward path (fetchTopicHeadMessages already handles AnchorMessage==nil with no filtering, needing no change), the three call shapes differ only in whether ShouldBackfillThread is forced true for every topic (ThreadsOnly) or computed per-topic from ThreadCreatedUsec (flat) -- see fetchTopicHeadMessages' own doc comment. A batch-send/Beeper homeserver where BOTH the queue and the room-creation seed could fire for the same room is not this milestone's actual target: continuwuity has no BatchSending, so the queue never runs there at all and the seed is the ONLY trigger (see the region doc comment's "Operationally" paragraph above). Where both CAN fire, overlap is bounded by TIMING, not by any framework per-message dedup: this connector never sets FetchMessagesResponse.AggressiveDeduplication, so cutoffMessages' id- based GetFirstPartByID pass (portalbackfill.go:286-313) never runs for these responses, and the seed's own call passes AnchorMessage==nil, so cutoffMessages applies NO filtering to it either (its lastMessage==nil early return, portalbackfill.go:248-250). The actual guard is portal.go:5362-5371, which upserts the queue's BackfillTask with NextDispatchMinTS = now + BackfillMinBackoffAfterRoomCreate (1 minute, backfillqueue.go:21) BEFORE the seed call at line 5404 runs synchronously, so the seed's rows are already persisted by the time the queue's first dispatch reads a real anchor (GetFirstPortalMessage) and calls back into this same function with THAT anchor -- whose hasAnchor filter (fetchTopicHeadMessages below) then excludes anything at or newer than it. A topic sharing the anchor's exact microsecond is the one documented, deliberate exception to that filter (see fetchTopicHeadMessages' anchor-filter comment) and could slip past it on such a homeserver -- but even then it cannot produce a user-visible duplicate: the queue re-posts via sendBatch -> compileBatchMessage, which derives each event id from GenerateDeterministicEventID(portal .MXID, PortalKey, msg.ID, part.ID) (portalbackfill.go:396) -- keyed on msg.ID, NOT timestamp -- so a re-posted already-seeded message resolves to the SAME event id and its duplicate DB insert is logged-and-skipped. This seed+queue overlap is reachable, but the deterministic-event-id backstop makes it idempotent regardless of the anchor filter's same-microsecond exception.
func (*GChatClient) GetCapabilities ¶
func (c *GChatClient) GetCapabilities(_ context.Context, portal *bridgev2.Portal) *event.RoomFeatures
GetCapabilities implements bridgev2.NetworkAPI.GetCapabilities (formerly the client.go stub that only ever returned MaxTextLength). ctx is unused -- capability selection needs no I/O, only the already-loaded portal.
func (*GChatClient) GetChatInfo ¶
func (c *GChatClient) GetChatInfo(ctx context.Context, portal *bridgev2.Portal) (*bridgev2.ChatInfo, error)
GetChatInfo fetches a portal's live chat info via the get_group RPC. Fidelity note: this bridge does NOT cache the GetGroupResponse per conversation (keyed by group revision) the way an account-level cache could -- every GetChatInfo call is a fresh RPC. bridgev2 itself only calls NetworkAPI.GetChatInfo on resync/backfill-check paths, not on every message, so the cache's main purpose (avoiding redundant per-message lookups) does not apply the same way here.
func (*GChatClient) GetUserInfo ¶
func (c *GChatClient) GetUserInfo(ctx context.Context, ghost *bridgev2.Ghost) (*bridgev2.UserInfo, error)
GetUserInfo resolves ghost.ID's Google Chat user info via a single-member get_members RPC. Design note: batching and caching lookups across a whole sync pass is intentionally not done here, since NetworkAPI.GetUserInfo is always called for one ghost at a time -- sync.go does its own prefetch batching separately.
func (*GChatClient) HandleMatrixEdit ¶
func (c *GChatClient) HandleMatrixEdit(ctx context.Context, edit *bridgev2.MatrixEdit) error
HandleMatrixEdit issues edit_message for a previously-bridged message edited in a portal room, building the request field-by-field:
- message_id.parent_id.topic_id.{group_id,topic_id}: group_id is gcid.ParsePortalID(edit.Portal.ID), the same derivation every other outbound call uses (handlematrix.go); topic_id reuses threadRootTopicID(edit.EditTarget) (handlematrix.go, M3 Task 6) -- the target's own stored MessageMetadata.TopicID, falling back to the target's own message id when that's empty (a `thread_id or message_id` fallback, where the thread id is the target's own stored topic id -- the same value this bridge keeps in MessageMetadata.TopicID).
- message_id.message_id: gcid.ParseMessageID(edit.EditTarget.ID).
- text_body + annotations: c.msgConverter().FromMatrix(ctx, edit.Content, resolve), the SAME M3 outbound formatting path (matrixfmt.Parse via the real newOutboundMentionResolver) HandleMatrixMessage uses (handlematrix.go).
- message_info.accept_format_annotations=true, unconditionally (required for outgoing formatting to render); unlike a brand new send, edit_message never sets message_info.reply_to at all, so it stays nil here too -- an edit cannot change what a message is a reply to.
On success, edit.EditTarget.Metadata's LastEditTime is bumped to the server's own resp.message.last_edit_time, persisted on the message row (dbmeta.go's MessageMetadata) so a later inbound echo of this exact edit (queueMessageEdit, events.go) correctly dedups against it even across a bridge restart. A failed RPC leaves LastEditTime untouched (there is nothing to dedup against since the edit never reached the server).
func (*GChatClient) HandleMatrixMembership ¶ added in v0.2607.5
func (c *GChatClient) HandleMatrixMembership(ctx context.Context, msg *bridgev2.MatrixMembershipChange) (*bridgev2.MatrixMembershipResult, error)
func (*GChatClient) HandleMatrixMessage ¶
func (c *GChatClient) HandleMatrixMessage(ctx context.Context, msg *bridgev2.MatrixMessage) (*bridgev2.MatrixMessageResponse, error)
HandleMatrixMessage sends a Matrix message to Google Chat, routing it to create_topic (a brand-new top-level message) or create_message (a reply into an existing topic) depending on msg.ThreadRoot -- see the file doc comment -- with full HTML formatting/mention conversion and outbound media (m.image/m.file/m.video/m.audio).
Every other message type is rejected with bridgev2.ErrUnsupportedMessageType for every msgtype that is neither TEXT/NOTICE nor is_media. In practice, bridgev2's own checkMessageContentCaps (driven by GetCapabilities' File map, capabilities.go) already rejects anything outside image/video/audio/file before this method is ever reached for a media msgtype -- this check is what actually enforces the msgtype gate for the types that DO reach here (e.g. m.emote, m.location).
Media branch: isOutboundMediaMsgType gates on exactly the four msgtypes capabilities.go's gchatFile map advertises. Config.DisableOutboundMedia short-circuits with errOutboundMediaDisabled before any network I/O -- see its own doc comment for why (issue #114). Otherwise buildUploadAnnotation (media.go) downloads the Matrix file (decrypting it if encrypted), uploads it to Google Chat, and returns an UPLOAD_METADATA/RENDER annotation; a failure at either step (the #114 upload 500 included) returns a clean, wrapped error here with NO request ever issued -- never a silent drop, and never a text-only fallback that would lose the file. hasOutboundCaption then decides whether msg.Content also carries a genuine caption (as opposed to Body merely repeating the file's own name): only then is msg.Content run through the SAME FromMatrix call a text message uses, so a media message's caption gets the identical formatting/mention treatment as a plain text body. The resulting file and caption annotations are combined via mergeAnnotations below exactly like text-only messages are -- never by outright replacement -- so a formatted caption can never clobber the file annotation (the B4 fix this file has guarded against append-only from the start, before any real UPLOAD_METADATA annotation existed to lose).
func (*GChatClient) HandleMatrixMessageRemove ¶
func (c *GChatClient) HandleMatrixMessageRemove(ctx context.Context, msg *bridgev2.MatrixMessageRemove) error
HandleMatrixMessageRemove issues delete_message for a previously-bridged message redacted in a portal room, building the delete_message request's MessageId (see this file's top-of-file doc comment for the field mapping).
func (*GChatClient) HandleMatrixReaction ¶
func (c *GChatClient) HandleMatrixReaction(ctx context.Context, msg *bridgev2.MatrixReaction) (*database.Reaction, error)
HandleMatrixReaction issues update_reaction (type ADD) for a Matrix reaction on a previously-bridged message, building the request field-by-field:
- message_id.parent_id.topic_id.{group_id,topic_id}: group_id is gcid.ParsePortalID(msg.Portal.ID), the same derivation every other outbound call uses (handlematrix.go); topic_id reuses threadRootTopicID(msg.TargetMessage) (handlematrix.go, M3 Task 6) -- the target's own stored MessageMetadata.TopicID, falling back to the target's own message id when that's empty (a `thread_id or message_id` fallback, where the thread id is the target's own stored topic id).
- message_id.message_id: gcid.ParseMessageID(msg.TargetMessage.ID).
- emoji.unicode: msg.PreHandleResp.Emoji, the bare (variation-selector-stripped) form PreHandleMatrixReaction already computed.
- type: ADD, unconditionally -- this method is only ever reached for a brand new (non-duplicate) reaction; bridgev2's own handleMatrixReaction (portal.go:1651-1663) already filters out duplicates before calling this.
The returned *database.Reaction carries a *ReactionMetadata caching the resolved topic id (see ReactionMetadata's own doc comment, dbmeta.go, for why this is cached here rather than re-derived on removal); every other field is left at its zero value, matching HandleMatrixEdit-adjacent connectors (e.g. gmessages' own HandleMatrixReaction) that lean on bridgev2's own documented "the central bridge module already has all the required fields and will fill them automatically" behavior (ReactionHandlingNetworkAPI.HandleMatrixReaction's own doc comment, mautrix-go bridgev2/networkinterface.go).
func (*GChatClient) HandleMatrixReactionRemove ¶
func (c *GChatClient) HandleMatrixReactionRemove(ctx context.Context, msg *bridgev2.MatrixReactionRemove) error
HandleMatrixReactionRemove issues update_reaction (type REMOVE) for a Matrix redaction of a previously-bridged reaction, the same way HandleMatrixReaction above does, except:
- message_id.message_id: gcid.ParseMessageID(msg.TargetReaction.MessageID) -- the reacted-to message's own id (NOT the reaction's own Matrix event id).
- message_id.parent_id.topic_id.topic_id: c.reactionTopicID(ctx, msg.TargetReaction), which prefers the *ReactionMetadata a Matrix-initiated HandleMatrixReaction call already cached (the fast path -- no lookup needed, see ReactionMetadata's doc comment, dbmeta.go) and otherwise falls back to a fresh DB.Message lookup -- see reactionTopicID's own doc comment for why the fallback is required (a reaction added from the Google Chat side, queueMessageReaction in events.go, has nothing to cache a topic id from at add-time).
- emoji.unicode: string(msg.TargetReaction.EmojiID) -- the SAME bare emoji this reaction's own PreHandleMatrixReaction/HandleMatrixReaction pair stored as the per-emoji dedup key (EmojiID, not the DB row's separate Emoji field, which bridgev2 only populates when EmojiID is left blank -- see this file's top-of-file doc comment on why EmojiID is always set here).
- type: REMOVE, unconditionally.
func (*GChatClient) HandleMatrixReadReceipt ¶
func (c *GChatClient) HandleMatrixReadReceipt(ctx context.Context, msg *bridgev2.MatrixReadReceipt) error
HandleMatrixReadReceipt issues mark_group_readstate for a Matrix read receipt sent in a portal room, building its fields:
- id: gchatmeow.PartsToGroupID(group.ID, group.IsDM), where group is gcid.ParsePortalID(msg.Portal.ID) -- the same GroupId-oneof derivation every other outbound call in this package uses (handlematrix.go, handleedit.go, handleredact.go, handlereaction.go).
- last_read_time: readTimeMicros(msg) above.
request_header is deliberately NOT set here: every gchatmeow.Client RPC wrapper stamps it itself (pkg/gchatmeow/api.go's MarkGroupReadstate calls newRequestHeader), exactly like every other outbound RPC in this package (handleedit.go, handleredact.go, handlereaction.go all follow the same pattern -- see sendNewTopic's doc comment, handlematrix.go, for the "connector builds business fields only, gchatmeow owns the header" split).
func (*GChatClient) HandleMatrixRoomName ¶ added in v0.2607.5
func (c *GChatClient) HandleMatrixRoomName(ctx context.Context, msg *bridgev2.MatrixRoomName) (bool, error)
func (*GChatClient) HandleMatrixTyping ¶
func (c *GChatClient) HandleMatrixTyping(ctx context.Context, msg *bridgev2.MatrixTyping) error
HandleMatrixTyping issues set_typing_state for a Matrix typing start/stop in a portal room, building its request body:
- context: typingContext(group, ""), gcid.ParsePortalID(msg.Portal.ID) -- the same GroupId-oneof derivation every other outbound call in this package uses (handlematrix.go, handleedit.go, handleredact.go, handlereaction.go, handlereceipt.go).
- state: TypingState_TYPING when msg.IsTyping, TypingState_STOPPED otherwise. bridgev2's framework (mautrix-go bridgev2/portal.go:1006-1071) calls this method once per user that started typing (IsTyping: true) and once per user that stopped (IsTyping: false), so both states are genuinely reached here -- this is not a start-only notification.
request_header is deliberately NOT set here: gchatmeow.Client.SetTypingState (pkg/gchatmeow/api.go) stamps it itself, matching every other outbound RPC in this package (see sendNewTopic's doc comment, handlematrix.go, for the "connector builds business fields only, gchatmeow owns the header" split).
The set_typing_state response carries a start_timestamp_usec, but it is unused here -- typing is fire-and-forget. This method discards SetTypingStateResponse entirely once the RPC succeeds.
func (*GChatClient) IsLoggedIn ¶
func (c *GChatClient) IsLoggedIn() bool
IsLoggedIn is a cached-only check (no I/O, per bridgev2.NetworkAPI's contract): true only once the last connection-state transition we saw was CONNECTED.
func (*GChatClient) IsThisUser ¶
IsThisUser reports whether userID names the same Google account as this login: the login's UserLoginID IS the account's gaia ID (gcid.MakeUserLoginID in login.go), and UserID is the same gaia ID reinterpreted (gcid.MakeUserID) -- so the comparison is just a type conversion, no I/O.
func (*GChatClient) LogoutRemote ¶
func (c *GChatClient) LogoutRemote(ctx context.Context)
LogoutRemote disconnects and best-effort clears the stored cookies so a later Connect (e.g. after a restart) reports BAD_CREDENTIALS instead of replaying a session the user explicitly logged out of. Google Chat's cookie-based sessions have no known remote "revoke" endpoint, so there is no remote invalidation call to make -- "best-effort" here means "local cleanup, tolerate a failed Save".
Also sets the loggedOut latch (under the same metaMu-guarded update as the cookie clear) so a persistCookies call from a Connected callback already in flight when this runs -- e.g. conn was mid-handshake and reached CONNECTED just as the user hit "log out" -- skips instead of resurrecting the just-cleared cookies. Connect clears the latch again on the next real (re)activation.
func (*GChatClient) PreHandleMatrixReaction ¶
func (c *GChatClient) PreHandleMatrixReaction(_ context.Context, msg *bridgev2.MatrixReaction) (bridgev2.MatrixReactionPreResponse, error)
PreHandleMatrixReaction resolves the emoji this login's own reaction should be keyed and sent by, stripping the variation selector off the Matrix-supplied key before even looking up the target message. EmojiID and Emoji are always the SAME bare (variation-selector-stripped) string: EmojiID becomes the per-emoji dedup key bridgev2's own handleMatrixReaction uses (see this file's top-of-file doc comment), and Emoji is what HandleMatrixReaction below sends to Google Chat's update_reaction RPC -- GC's own wire form never carries the selector either, so no separate normalization is needed between the two.
func (*GChatClient) ResolveIdentifier ¶ added in v0.2608.2
func (c *GChatClient) ResolveIdentifier(ctx context.Context, identifier string, createChat bool) (*bridgev2.ResolveIdentifierResponse, error)
type GChatConnector ¶
type GChatConnector struct {
Bridge *bridgev2.Bridge
Config Config
// MsgConv converts Google Chat proto messages into bridgev2's Matrix
// event shape (events.go's inbound MESSAGE_POSTED handling). Populated
// here rather than per-GChatClient: it holds no per-login state
// (msgconv.go: "conversion configuration only"), so one shared instance
// is enough for every UserLogin this connector serves, same as
// mautrix-meta's MetaConnector.MsgConv (_reference/meta/pkg/connector/connector.go).
MsgConv *msgconv.MessageConverter
// MaxFileSize caps how large an inbound attachment download may be
// (media.go's GChatClient.maxFileSize, threaded into
// gchatmeow.Client.DownloadAttachment) -- the running homeserver's own
// configured max upload size. bridgev2
// calls SetMaxFileSize below "asynchronously soon after startup"
// (bridgev2/networkinterface.go's MaxFileSizeingNetwork doc comment);
// until that first call lands, MaxFileSize stays at its zero value,
// which gchatmeow.DownloadAttachment's own maxSize<=0 contract already
// treats as "no cap" (download.go) -- an intentionally permissive
// default for the narrow startup race, not a design requiring a
// separate fallback constant (mirrors mautrix-meta's MetaConnector,
// _reference/meta/pkg/connector/connector.go, which does the same).
MaxFileSize int64
}
func (*GChatConnector) CreateLogin ¶
func (gc *GChatConnector) CreateLogin(_ context.Context, user *bridgev2.User, flowID string) (bridgev2.LoginProcess, error)
func (*GChatConnector) GetBridgeInfoVersion ¶
func (gc *GChatConnector) GetBridgeInfoVersion() (info, capabilities int)
func (*GChatConnector) GetCapabilities ¶
func (gc *GChatConnector) GetCapabilities() *bridgev2.NetworkGeneralCapabilities
func (*GChatConnector) GetConfig ¶
func (gc *GChatConnector) GetConfig() (string, any, configupgrade.Upgrader)
func (*GChatConnector) GetDBMetaTypes ¶
func (gc *GChatConnector) GetDBMetaTypes() database.MetaTypes
func (*GChatConnector) GetLoginFlows ¶
func (gc *GChatConnector) GetLoginFlows() []bridgev2.LoginFlow
func (*GChatConnector) GetName ¶
func (gc *GChatConnector) GetName() bridgev2.BridgeName
func (*GChatConnector) Init ¶
func (gc *GChatConnector) Init(bridge *bridgev2.Bridge)
func (*GChatConnector) LoadUserLogin ¶
LoadUserLogin fills login.Client with a fresh *GChatClient shell -- no network I/O here (LoadUserLogin runs under the global cache lock, so the client is constructed from login.Metadata only, with network I/O deferred to Connect); GChatClient.Connect builds the actual gchatmeow.Client from login.Metadata lazily, whether this is the very first load after a restart or the login command resubmitting cookies for an existing row.
bridgev2 calls LoadUserLogin again on an ALREADY-RUNNING login in two cases: User.NewLogin reusing an existing UserLogin row (a resubmitted login.go SubmitCookies) and Bridge.ResetNetworkConnections's recreateClient. Either way, login.Client may already hold a *GChatClient whose gchatmeow.Client is mid-connection; disconnecting it before overwriting login.Client is required, or its Connect goroutine (and live webchannel session) leaks forever.
func (*GChatConnector) SetMaxFileSize ¶
func (gc *GChatConnector) SetMaxFileSize(maxSize int64)
SetMaxFileSize implements bridgev2.MaxFileSizeingNetwork.
func (*GChatConnector) ValidateUserID ¶ added in v0.2608.3
func (gc *GChatConnector) ValidateUserID(userID networkid.UserID) bool
ValidateUserID implements bridgev2.IdentifierValidatingNetwork: it reports whether a networkid.UserID has the SHAPE of a Google Chat user id. gcid.MakeUserID is an identity cast, so the test is isGaiaID on the raw string.
This matters because a ghost id LOOKS pre-validated but is user input on two paths. The appservice ghost-MXID pattern matches an arbitrary localpart, so anything a Matrix user types as @googlechat_<junk>:server becomes a networkid.UserID -- reaching CreateChatWithGhost from provisionutil (the start-chat command and the provisioning API) and from the ghost-DM invite handler. This hook is the only place that can stop it EARLY: the framework checks it before materialising the ghost row, and before the invite path registers that ghost as a real appservice user and joins it to the room. A guard inside CreateChatWithGhost fires after all of that has happened.
Shape only, per the framework's contract -- deliberately no existence check and, in particular, no rejection of the acting account's own id: self-DMs are refused PER-LOGIN with ErrResolveIdentifierTryNext so bridgev2 can try the user's other logins, and the connector has no login context to make that call with anyway.
Bots share the same UserId.id field as humans with no separate format, and every bot id seen so far is numeric; if Google ever emits a non-numeric one, this would refuse to START a chat with that bot from Matrix. Bridging an existing bot DM is unaffected -- ValidateUserID has no inbound call sites -- and ResolveIdentifier already rejects any non-digit, non-"@" identifier, so this makes the ghost-MXID path agree with the bare-id path rather than adding a new limit.
type GChatLogin ¶
type GChatLogin struct {
User *bridgev2.User
Main *GChatConnector
// contains filtered or unexported fields
}
GChatLogin implements bridgev2.LoginProcessCookies: the cookie-paste login flow (the only login flow this bridge supports -- there is no interactive login web page; a browser extension / manual cookie extraction feeds either the command or this API).
func (*GChatLogin) Cancel ¶
func (gl *GChatLogin) Cancel()
Cancel is a no-op: Start doesn't open any external session or allocate any resource that would need cleanup (unlike a QR/display-and-wait flow).
func (*GChatLogin) Start ¶
Start returns the cookies step describing what the login UI must collect.
func (*GChatLogin) SubmitCookies ¶
func (gl *GChatLogin) SubmitCookies(ctx context.Context, cookies map[string]string) (*bridgev2.LoginStep, error)
SubmitCookies validates the submitted cookies against Google Chat, resolves the caller's Gaia ID, creates (or reuses) the UserLogin row with the validated cookies persisted, and starts the connection. Follows a 5-step sequence (build client -> validate cookies via /mole/world -> get_self_user_status -> persist cookies -> spawn the long-poll loop), adjusted for bridgev2's login-step shape.
type GhostMetadata ¶
type GhostMetadata struct {
Email string `json:"email,omitempty"`
}
type MessageMetadata ¶
type MessageMetadata struct {
// Original Google Chat create_time in microseconds. Required to build
// SendReplyTarget for quote-replies.
TimestampMicro int64 `json:"ts_micro,omitempty"`
// last_edit_time of the newest applied edit, for edit dedup.
LastEditTime int64 `json:"last_edit_time,omitempty"`
// Google Chat topic id this message belongs to (M3 Task 6): the
// message's own id for the head/root message of a topic (message_id ==
// topic_id on the wire), or the head's message id for a reply posted
// into an existing topic. Stamped on every bridged message, both
// directions:
// - inbound (msgconv_adapter.go's convertMessageToMatrix): read
// straight off the wire, msg.id.parent_id.topic_id.topic_id.
// - outbound (handlematrix.go): the id of the NEW topic a create_topic
// call just created, or the existing topic id a create_message
// reply was routed into.
// Needed both directions: outbound reads the Matrix thread root's
// stored topic id to route a reply into create_message's
// parent_id.topic_id; inbound lets a later Matrix reply into this same
// topic resolve its own ThreadRoot correctly (ToMatrix's message_id !=
// topic_id check, pkg/msgconv/from-gchat.go).
TopicID string `json:"topic_id,omitempty"`
}
type PortalMetadata ¶
type PortalMetadata struct {
// Last fully-handled group revision (catch_up_group watermark).
Revision int64 `json:"revision,omitempty"`
// Threaded space (2023+ "threads only" model).
ThreadsOnly bool `json:"threads_only,omitempty"`
// Whether topic-based threading is enabled at all. Stored as
// flat_threads_enabled || threads_only (chatinfo.go), so it's a
// superset of ThreadsOnly -- read alongside ThreadsOnly by
// capabilities.go, not just for legacy spaces.
ThreadsEnabled bool `json:"threads_enabled,omitempty"`
}
type ReactionMetadata ¶
type ReactionMetadata struct {
// Google Chat topic id the reacted-to message belongs to -- the same
// value MessageMetadata.TopicID stores on the message row itself (M3
// Task 6). Populated ONLY when a reaction was itself added via Matrix
// (handlereaction.go's HandleMatrixReaction, which already has the
// target message's own resolved MessageMetadata.TopicID in hand at
// add-time, no lookup needed) -- a fast-path optimization for a later
// Matrix redaction of that same reaction (HandleMatrixReactionRemove)
// to build the UpdateReaction RPC's message_id.parent_id.topic_id
// without a DB.Message round trip. A reaction added from the Google
// Chat side instead (queueMessageReaction, events.go, mirroring an
// inbound MessageReactionEvent that carries no per-message payload to
// read a topic id off directly) never populates this field, so
// HandleMatrixReactionRemove's own reactionTopicID helper always falls
// back to a fresh DB.Message lookup when it's empty, unconditionally
// re-fetching the target message row on every removal regardless of
// which side created the reaction. See reactionTopicID's own doc comment
// (handlereaction.go) for the full two-source resolution order; a Google
// Chat message's topic membership is immutable once posted, so caching
// this value here for the reactions that CAN cache it is always safe
// (unlike last_edit_time, which genuinely changes over a message's
// lifetime and so must stay live on the message row instead).
TopicID string `json:"topic_id,omitempty"`
}
type UserLoginMetadata ¶
type UserLoginMetadata struct {
// The five auth cookies (COMPASS, SSID, SID, OSID, HSID), refreshed after every connect.
Cookies map[string]string `json:"cookies"`
UserAgent string `json:"user_agent,omitempty"`
// Last fully-handled user event stream revision (catch_up_user watermark).
Revision int64 `json:"revision,omitempty"`
}
Source Files
¶
- backfill.go
- bridgestate.go
- capabilities.go
- chatinfo.go
- client.go
- config.go
- connector.go
- createchat.go
- dbmeta.go
- events.go
- handleedit.go
- handlematrix.go
- handlemembership.go
- handlereaction.go
- handlereceipt.go
- handleredact.go
- handleroomname.go
- handletyping.go
- login.go
- media.go
- mentions.go
- msgconv_adapter.go
- relayauth.go
- sync.go
- systemmessage.go
- userinfo.go