| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393 |
- package webapi
- import (
- "bytes"
- "context"
- "crypto/rand"
- "encoding/hex"
- "errors"
- "log/slog"
- mrand "math/rand/v2"
- "slices"
- "sort"
- "strconv"
- "strings"
- "sync"
- "time"
- "github.com/google/uuid"
- "github.com/mk6i/open-oscar-server/state"
- "github.com/mk6i/open-oscar-server/wire"
- )
- var (
- // ErrNoWebAPISession is returned when a WebAPI session is not found.
- ErrNoWebAPISession = errors.New("WebAPI session not found")
- // ErrWebAPISessionExpired is returned when a WebAPI session has expired.
- ErrWebAPISessionExpired = errors.New("WebAPI session expired")
- // ErrWebAPISessionManagerClosed is returned when a session is requested from
- // a manager that has been shut down.
- ErrWebAPISessionManagerClosed = errors.New("WebAPI session manager is shut down")
- )
- // Web API session lifecycle timeline.
- //
- // A web client keeps its session alive by long-polling GET /aim/fetchEvents.
- // Every authenticated request touches the session (middleware.RequireSession
- // calls TouchSession at request arrival), sliding expiry to now + the TTL. A
- // single poll blocks for up to 60s (the fetchEvents long-poll cap) and the
- // client waits ~500ms (TimeToNextFetch) before re-polling, so in steady state a
- // healthy client touches the session at worst every ~60-65s once jitter is
- // included. That worst-case touch interval is the floor the TTL must clear.
- //
- // If a client hangs up without calling endSession, its last touch was at its
- // last poll: the session then expires webAPISessionTTL later and the reaper
- // sweeps it within one webAPISessionReapInterval tick. So a silent client is
- // removed (and its OSCAR session closed) within TTL + tick of going quiet.
- const (
- // webAPISessionTTL bounds how long a session survives without a poll. It is
- // sized to absorb one missed poll cycle: ~60s for the normal cycle, ~60s for
- // the absorbed miss, plus ~20s of jitter margin. Two consecutive misses mean
- // the client is genuinely gone and the session is reaped.
- webAPISessionTTL = 150 * time.Second
- // webAPISessionReapInterval is how often the cleanup goroutine sweeps for
- // expired sessions (~TTL/5). A dead session lingers at most
- // webAPISessionTTL + webAPISessionReapInterval before removal.
- webAPISessionReapInterval = 30 * time.Second
- )
- // webAPICaps are the capabilities the Web API advertises on behalf of its
- // clients. It seeds every session's capability list and is sent as-is at
- // sign-on, so the two never drift.
- var webAPICaps = [][16]byte{wire.CapICQCh2Extended}
- // Session represents an active Web AIM API session.
- type Session struct {
- AimSID string // Unique session ID for web client
- ScreenName state.DisplayScreenName // User identity
- OSCARSession *state.SessionInstance // Bridge to existing OSCAR session
- BaseURL string // Web API base URL advertised to the web client, used to build absolute asset URLs
- Events []string // Subscribed event types
- EventQueue *EventQueue // Per-session event queue
- ClientName string // Client application name
- ClientVersion string // Client application version
- CreatedAt time.Time // SessionInstance creation time
- LastAccessed time.Time // Last activity time
- ExpiresAt time.Time // SessionInstance expiration time
- FetchTimeout int // Long-polling timeout in milliseconds
- TimeToNextFetch int // Suggested delay before next fetch
- RemoteAddr string // Client IP address
- BuddyListRefresher func(ctx context.Context) (any, error) // Called on feedbag changes to push buddylist event
- PermitDenyRefresher func(ctx context.Context) (any, error) // Called on feedbag changes to push permitDeny event
- FeedbagLoader func(ctx context.Context) ([]wire.FeedbagItem, error) // Reads the owner's feedbag; every read-only view of the roster derives from it
- // BuddyIconURL formats the absolute buddyIcon URL for a buddy from the icon
- // hash carried in a presence SNAC. Returns "" when no URL can be published.
- BuddyIconURL func(screenName state.IdentScreenName, hash []byte) string
- feedbag []wire.FeedbagItem // cached FeedbagLoader result, nil when unloaded or invalidated
- aliases map[string]string // aliases derived from feedbag, nil when unloaded or invalidated
- feedbagMu sync.Mutex
- // presence is the session's view of its buddies' last-known presence, keyed by
- // normalized aimId and fed by the BuddyArrived/BuddyDeparted SNACs the listener
- // receives.
- presence map[string]BuddyPresence
- presenceMu sync.RWMutex
- imLog map[string][]WebAPIStoredIM
- imLogMu sync.Mutex
- sentIMs map[uint64]string // OSCAR message cookie -> the msgId given to the client
- sentIMOrder []uint64 // insertion order of sentIMs, oldest first
- sentIMMu sync.Mutex
- // IMRateClassID is the rate class that sending an IM spends. The web client
- // renders any rate limit event as the IM banner, so only this class's updates
- // may reach it. Zero disables the alert.
- IMRateClassID wire.RateLimitClassID
- logger *slog.Logger // Logger for debugging
- listeners sync.WaitGroup
- ctx context.Context
- cancel context.CancelFunc
- closeMu sync.Mutex
- closed bool
- capabilities [][16]byte
- capsMu sync.RWMutex
- }
- // IsExpired checks if the session has expired.
- func (s *Session) IsExpired() bool {
- return time.Now().After(s.ExpiresAt)
- }
- // Aliases returns this session owner's private buddy aliases, keyed by normalized
- // screen name, derived from the cached feedbag and memoized alongside it. Buddies
- // without an alias are absent. The map is owned by the session and must not be
- // mutated by callers.
- func (s *Session) Aliases(ctx context.Context) map[string]string {
- s.feedbagMu.Lock()
- defer s.feedbagMu.Unlock()
- if s.aliases == nil {
- items, err := s.feedbagLocked(ctx)
- if err != nil {
- return nil
- }
- aliases := make(map[string]string)
- for _, item := range items {
- if item.ClassID != wire.FeedbagClassIdBuddy || item.Name == "" {
- continue
- }
- alias, ok := item.String(wire.FeedbagAttributesAlias)
- if !ok || alias == "" {
- continue
- }
- aliases[state.NewIdentScreenName(item.Name).String()] = alias
- }
- s.aliases = aliases
- }
- return s.aliases
- }
- // Feedbag returns the owner's feedbag rows, reading them through FeedbagLoader on
- // the first call after a change and serving the cached copy afterwards. The slice
- // is owned by the session and must not be mutated by callers.
- //
- // Code that rewrites the feedbag must re-read it rather than use this: it computes
- // item ids and a pending diff from what it reads, and a stale snapshot would
- // overwrite another instance's change.
- func (s *Session) Feedbag(ctx context.Context) ([]wire.FeedbagItem, error) {
- s.feedbagMu.Lock()
- defer s.feedbagMu.Unlock()
- return s.feedbagLocked(ctx)
- }
- // feedbagLocked loads and caches the feedbag. Callers hold feedbagMu.
- //
- // The lock is deliberately held across the load. Another instance can change the
- // list mid-read, and its feedbag SNAC invalidates this cache; a load outside the
- // lock could store its pre-change result after that invalidation.
- func (s *Session) feedbagLocked(ctx context.Context) ([]wire.FeedbagItem, error) {
- if s.feedbag == nil {
- items, err := s.FeedbagLoader(ctx)
- if err != nil {
- s.logger.Error("failed to load feedbag", "err", err.Error())
- return nil, err
- }
- if items == nil {
- // An empty feedbag must still count as loaded.
- items = []wire.FeedbagItem{}
- }
- s.feedbag = items
- }
- return s.feedbag, nil
- }
- // InvalidateFeedbag drops the cached feedbag and the aliases derived from it.
- // Callers that change the owner's feedbag must call this: the feedbag service
- // relays its item SNACs only to the owner's *other* instances.
- func (s *Session) InvalidateFeedbag() {
- s.feedbagMu.Lock()
- defer s.feedbagMu.Unlock()
- s.feedbag = nil
- s.aliases = nil
- }
- // aliasFor returns this session owner's private alias for buddy, or "" when none is
- // set. The web client deletes the alias it holds whenever it merges a user map, so
- // every event naming a buddy has to repeat it.
- func (s *Session) aliasFor(buddy state.IdentScreenName) string {
- // Runs on the SNAC listener goroutine, which has no request context.
- return s.Aliases(s.ctx)[buddy.String()]
- }
- // BuddyPresence is a buddy's last-known presence, assembled from the
- // BuddyArrived and BuddyDeparted SNACs a session receives. It carries only what
- // those SNACs carry: an away message lives in the locate reply's LocateInfo and
- // is not part of a presence broadcast.
- type BuddyPresence struct {
- DisplayID string // screen name as the buddy formats it
- State string // "online", "away", "idle", "occupied", "dnd", "offline"
- StatusMsg string
- OnlineTime int64
- IdleTime int // minutes
- Caps [][16]byte
- IconHash []byte
- }
- // Online reports whether the buddy is visible to the viewer.
- func (p BuddyPresence) Online() bool {
- return p.State != "offline"
- }
- // BuddyPresence returns a buddy's last-known presence and whether the session
- // has one. Callers rendering the roster treat a miss as offline.
- func (s *Session) BuddyPresence(buddy state.IdentScreenName) (BuddyPresence, bool) {
- s.presenceMu.RLock()
- defer s.presenceMu.RUnlock()
- p, ok := s.presence[buddy.String()]
- return p, ok
- }
- // setBuddyPresence records a buddy's presence, replacing whatever was there.
- func (s *Session) setBuddyPresence(buddy state.IdentScreenName, p BuddyPresence) {
- s.presenceMu.Lock()
- defer s.presenceMu.Unlock()
- if s.presence == nil {
- s.presence = make(map[string]BuddyPresence)
- }
- s.presence[buddy.String()] = p
- }
- // setBuddyOffline marks a buddy offline, keeping the display name last seen for
- // them. A BuddyDeparted carries no TLV block, so nothing else survives: an
- // offline buddy publishes no icon, status message or mood.
- func (s *Session) setBuddyOffline(buddy state.IdentScreenName) {
- s.presenceMu.Lock()
- defer s.presenceMu.Unlock()
- if s.presence == nil {
- s.presence = make(map[string]BuddyPresence)
- }
- s.presence[buddy.String()] = BuddyPresence{
- DisplayID: s.presence[buddy.String()].DisplayID,
- State: "offline",
- }
- }
- // forgetBuddyPresence drops a buddy's cached presence. Callers do this when the
- // viewer stops watching them: OSCAR notifies only users who currently watch each
- // other, so no arrival or departure for that buddy is relayed here again.
- func (s *Session) forgetBuddyPresence(buddy state.IdentScreenName) {
- s.presenceMu.Lock()
- defer s.presenceMu.Unlock()
- delete(s.presence, buddy.String())
- }
- // forgetUnlistedBuddies drops the cached presence of the named buddies that are no
- // longer on the roster. One still listed in another group is still watched.
- func (s *Session) forgetUnlistedBuddies(names []string) {
- if len(names) == 0 {
- return
- }
- items, err := s.Feedbag(s.ctx)
- if err != nil {
- // Without the roster a partial removal cannot be told from a full one, and
- // a wrongly dropped entry reads offline until the buddy changes presence.
- return
- }
- for _, name := range names {
- if stillListsBuddy(items, name) {
- continue
- }
- s.forgetBuddyPresence(state.NewIdentScreenName(name))
- }
- }
- // isAIMViewer reports whether the session owner is an AIM account.
- func (s *Session) isAIMViewer() bool {
- return s.ScreenName.IdentScreenName().UIN() == 0
- }
- // Touch updates the last accessed time and extends expiration if needed.
- func (s *Session) Touch() {
- s.LastAccessed = time.Now()
- newExpiry := s.LastAccessed.Add(webAPISessionTTL)
- if newExpiry.After(s.ExpiresAt) {
- s.ExpiresAt = newExpiry
- }
- }
- // IsSubscribedTo checks if the session is subscribed to a specific event type.
- func (s *Session) IsSubscribedTo(eventType string) bool {
- return slices.Contains(s.Events, eventType)
- }
- // StartListeningToOSCARSession starts a goroutine that listens to the OSCAR session's
- // message channel and converts SNAC messages into WebAPI events.
- func (s *Session) StartListeningToOSCARSession() {
- s.closeMu.Lock()
- defer s.closeMu.Unlock()
- if s.closed {
- return
- }
- s.listeners.Go(func() {
- msgCh := s.OSCARSession.ReceiveMessage()
- for {
- select {
- case msg, ok := <-msgCh:
- if !ok {
- // Channel closed, OSCAR session ended
- return
- }
- s.handleSNACMessage(msg)
- case <-s.OSCARSession.Closed():
- // The OSCAR instance went away without this session asking — a
- // boot, a rate-limit disconnect. Tell the client rather than
- // leaving its parked fetcher to hang: a sessionEnded event
- // releases the poll at once and the client signs off on the
- // spot, instead of waiting out the reaper's next sweep.
- //
- // A teardown this session started needs no event, and gets
- // none: Close closes the queue before it closes the instance,
- // so this Push is a no-op on that path.
- s.EventQueue.Push(EventTypeSessionEnded, struct{}{})
- return
- }
- }
- })
- }
- // Close tears down the session: it releases any parked event fetchers, closes
- // the OSCAR instance, and waits for the listener goroutine to unwind. Safe to
- // call more than once.
- func (s *Session) Close() {
- s.closeMu.Lock()
- if s.closed {
- s.closeMu.Unlock()
- return
- }
- s.closed = true
- s.closeMu.Unlock()
- s.EventQueue.Close()
- s.OSCARSession.CloseInstance()
- s.cancel()
- s.listeners.Wait()
- }
- // handleSNACMessage converts a SNAC message into WebAPI events and pushes them to the event queue.
- func (s *Session) handleSNACMessage(msg wire.SNACMessage) {
- // Convert SNAC message to WebAPI events based on food group and subgroup
- switch msg.Frame.FoodGroup {
- case wire.ICBM:
- s.handleICBMMessage(msg)
- case wire.Buddy:
- s.handleBuddyMessage(msg)
- case wire.Feedbag:
- s.handleFeedbagMessage(msg)
- case wire.OService:
- s.handleOServiceMessage(msg)
- }
- }
- // handleOServiceMessage handles OService SNAC messages relayed to the session's
- // own OSCAR instance.
- func (s *Session) handleOServiceMessage(msg wire.SNACMessage) {
- switch msg.Frame.SubGroup {
- case wire.OServiceUserInfoUpdate:
- s.handleUserInfoUpdate(msg)
- case wire.OServiceRateParamChange:
- s.handleRateLimitUpdate(msg)
- }
- }
- // handleUserInfoUpdate surfaces OServiceUserInfoUpdate, which the server relays to
- // a user when their own user info changes (notably a buddy icon upload or clear).
- // The client re-renders its identity badge from myInfo events only, so we
- // translate this into a fresh myInfo. The away message is the one field read off
- // the session, since no user info block carries the text.
- func (s *Session) handleUserInfoUpdate(msg wire.SNACMessage) {
- if !s.IsSubscribedTo("myInfo") && !s.IsSubscribedTo("presence") {
- return
- }
- body, ok := msg.Body.(wire.SNAC_0x01_0x0F_OServiceUserInfoUpdate)
- if !ok || len(body.UserInfo) == 0 {
- return
- }
- info := body.UserInfo[0]
- screenName := state.DisplayScreenName(info.ScreenName)
- webState := selfWebState(info, screenName.IdentScreenName().UIN() == 0)
- // A missing icon TLV yields a nil hash, which publishes the placeholder URL
- // and so clears an icon the client still holds.
- hash := buddyIconHash(info)
- myInfo := buildMyInfo(
- screenName,
- webState,
- s.BuddyIconURL(screenName.IdentScreenName(), hash),
- moodIconURL(s.BaseURL, webState, userInfoCaps(info)),
- )
- myInfo.AwayMsg = s.OSCARSession.Session().AwayMessage()
- myInfo.StatusMsg = userStatusMsg(info)
- s.EventQueue.Push(EventTypeMyInfo, myInfo)
- }
- // handleRateLimitUpdate translates a rate limit status change — broadcast by the
- // account's rate limit monitor — into a rateLimit event. Only the IM class is
- // surfaced, since the client feeds any rateLimit event into the
- // conversation-window alert. Code 1 is a class-params change, not a status
- // transition, and is ignored.
- func (s *Session) handleRateLimitUpdate(msg wire.SNACMessage) {
- if s.IMRateClassID == 0 {
- return
- }
- body, ok := msg.Body.(wire.SNAC_0x01_0x0A_OServiceRateParamsChange)
- if !ok {
- return
- }
- if wire.RateLimitClassID(body.Rate.ID) != s.IMRateClassID {
- return
- }
- var status string
- switch body.Code {
- case 2:
- status = "warn"
- case 3:
- status = "limit"
- case 4:
- status = "clear"
- default:
- return
- }
- s.EventQueue.Push(EventTypeRateLimit, RateLimitEvent{
- Classes: []RateLimitClass{
- {ID: int(body.Rate.ID), Status: status},
- },
- })
- }
- // handleICBMMessage handles ICBM (instant messaging) SNAC messages.
- func (s *Session) handleICBMMessage(msg wire.SNACMessage) {
- switch msg.Frame.SubGroup {
- case wire.ICBMChannelMsgToClient:
- s.handleIncomingIM(msg)
- case wire.ICBMClientEvent:
- s.handleTypingNotification(msg)
- case wire.ICBMClientErr:
- s.handleClientError(msg)
- }
- }
- // handleIncomingIM handles incoming instant messages.
- func (s *Session) handleIncomingIM(msg wire.SNACMessage) {
- body, ok := msg.Body.(wire.SNAC_0x04_0x07_ICBMChannelMsgToClient)
- if !ok {
- return
- }
- // A send time is only stamped on a message replayed out of the offline store,
- // so its presence marks this as a delivery of something sent while the user was
- // signed off, and carries the moment the sender actually sent it.
- sentTime, isOffline := body.Uint32BE(wire.ICBMTLVSendTime)
- // Retrieval answers only the instance that asked, and StartSession asks only
- // when the client subscribed to offlineIM, so a stamped message here is one
- // this session requested. A live IM still needs the im subscription.
- if !isOffline && !s.IsSubscribedTo("im") {
- return
- }
- // Extract message text from TLV data
- var messageText string
- if msgData, hasMsg := body.Bytes(wire.ICBMTLVAOLIMData); hasMsg {
- if text, err := wire.UnmarshalICBMMessageText(msgData); err == nil {
- messageText = text
- }
- }
- if messageText == "" {
- return
- }
- // Check if it's an auto-response (channel 2)
- autoResponse := body.ChannelID == 0x0002
- // msgId must be unique per delivered event. The OSCAR cookie is not a
- // reliable unique id (some clients reuse it across messages), and the web
- // client dedupes its conversation list by msgId, silently dropping any
- // collisions. Mint a fresh random id instead of reusing body.Cookie.
- msgID := strconv.FormatUint(mrand.Uint64(), 16)
- // SNAC user info carries the sender's display screen name. The web client
- // keys conversations and users by the normalized aimId and only renders
- // displayId, so the two forms must not be interchanged.
- partnerDisplay := body.ScreenName
- partner := state.NewIdentScreenName(partnerDisplay)
- partnerAimID := partner.String()
- // An offline message is logged under the time it was sent, so the stored-IM
- // history it lands in stays in the order the conversation happened.
- timestamp := time.Now().Unix()
- if isOffline {
- timestamp = int64(sentTime)
- }
- s.AddStoredIM(partnerAimID, partnerAimID, messageText, msgID, timestamp)
- if isOffline {
- // The client resolves an offline sender from aimId and friendly alone,
- // so friendly falls back to the sender's own formatting when the viewer
- // has no alias for them.
- friendly := s.aliasFor(partner)
- if friendly == "" {
- friendly = partnerDisplay
- }
- s.EventQueue.Push(EventTypeOfflineIM, OfflineIMEvent{
- AimID: partnerAimID,
- Friendly: friendly,
- Message: messageText,
- MsgID: msgID,
- Timestamp: timestamp,
- Imf: imfPlainText,
- AutoResp: autoResponse,
- })
- s.logger.Debug("delivered offline instant message",
- "from", partnerDisplay,
- "to", s.ScreenName,
- "sent", timestamp)
- } else {
- source := UserInfo{
- AimID: partnerAimID,
- DisplayID: partnerDisplay,
- Friendly: s.aliasFor(partner),
- UserType: userTypeFor(partner),
- }
- if presence, ok := s.BuddyPresence(partner); ok {
- source.State = presence.State
- source.OnlineTime = presence.OnlineTime
- }
- s.EventQueue.Push(EventTypeIM, IMEvent{
- Source: source,
- Message: messageText,
- MsgID: msgID,
- Timestamp: timestamp,
- Imf: imfPlainText,
- AutoResp: autoResponse,
- })
- s.logger.Debug("delivered instant message",
- "from", partnerDisplay,
- "to", s.ScreenName)
- }
- if s.IsSubscribedTo("conversation") {
- // unread is 0 here, not 1, because the "im"/"offlineIM" event pushed above
- // already causes the client to increment its own persisted per-buddy unread
- // tally. The "Recent chats" badge is the sum of that persisted tally and
- // this conversation's unreadCount, so sending 1 here would double-count
- // the message (badge shows 2 for the first IM). Mirrors the sent-IM path,
- // which also passes 0.
- s.EventQueue.Push(EventTypeConversation, ConversationEventData("update", []ConversationEntryData{
- ConversationEntry(
- partnerAimID,
- partnerDisplay,
- messageText,
- msgID,
- partnerAimID,
- false,
- 0,
- ),
- }))
- }
- }
- // handleClientError translates ICBMClientErr — the recipient reporting that it
- // could not handle a message already delivered to it — into a clientError event
- // for the sender. Only OSCAR clients raise this SNAC.
- func (s *Session) handleClientError(msg wire.SNACMessage) {
- if !s.IsSubscribedTo("im") {
- return
- }
- body, ok := msg.Body.(wire.SNAC_0x04_0x0B_ICBMClientErr)
- if !ok {
- return
- }
- // The SNAC names the erroring party by their own formatting, so both the
- // normalized aimId and displayId are sent, along with the viewer's alias.
- sender := state.NewIdentScreenName(body.ScreenName)
- channel := "im"
- if body.ChannelID == wire.ICBMChannelRendezvous {
- channel = "data"
- }
- s.EventQueue.Push(EventTypeClientError, ClientErrorEvent{
- Source: UserInfo{
- AimID: sender.String(),
- DisplayID: body.ScreenName,
- Friendly: s.aliasFor(sender),
- UserType: userTypeFor(sender),
- },
- Cookie: s.msgIDForCookie(body.Cookie),
- Channel: channel,
- })
- }
- // handleTypingNotification handles typing notifications.
- func (s *Session) handleTypingNotification(msg wire.SNACMessage) {
- if !s.IsSubscribedTo("typing") {
- return
- }
- body, ok := msg.Body.(wire.SNAC_0x04_0x14_ICBMClientEvent)
- if !ok {
- return
- }
- // Event types: 0x0000=none, 0x0001=typed (paused), 0x0002=typing
- var typingStatus string
- switch body.Event {
- case 0x0002:
- typingStatus = "typing"
- case 0x0001:
- typingStatus = "typed"
- default:
- typingStatus = "none"
- }
- typingEvent := TypingEvent{
- AimID: state.NewIdentScreenName(body.ScreenName).String(),
- TypingStatus: typingStatus,
- }
- s.EventQueue.Push(EventTypeTyping, typingEvent)
- }
- // handleBuddyMessage handles buddy/presence SNAC messages.
- func (s *Session) handleBuddyMessage(msg wire.SNACMessage) {
- switch msg.Frame.SubGroup {
- case wire.BuddyArrived:
- s.handleBuddyArrived(msg)
- case wire.BuddyDeparted:
- s.handleBuddyDeparted(msg)
- }
- }
- // handleBuddyArrived handles when a buddy comes online.
- //
- // For BuddyArrived updates, presence state is inferred from the TLVUserInfo.
- // Away and invisible transitions are typically broadcast using BuddyArrived
- // with updated user flags/status bits, not BuddyDeparted.
- func (s *Session) handleBuddyArrived(msg wire.SNACMessage) {
- body, ok := msg.Body.(wire.SNAC_0x03_0x0B_BuddyArrived)
- if !ok {
- return
- }
- buddy := state.NewIdentScreenName(body.ScreenName)
- presence := buddyPresenceFrom(body.TLVUserInfo, s.isAIMViewer())
- s.setBuddyPresence(buddy, presence)
- if !s.IsSubscribedTo("presence") {
- return
- }
- presenceEvent := PresenceEvent{
- AimID: buddy.String(),
- Friendly: s.aliasFor(buddy),
- State: presence.State,
- UserType: userTypeFor(buddy),
- StatusMsg: presence.StatusMsg,
- IdleTime: presence.IdleTime,
- OnlineTime: presence.OnlineTime,
- MoodIcon: moodIconURL(s.BaseURL, presence.State, presence.Caps),
- }
- // A BuddyArrived carries the buddy's current icon in TLV 0x1D whenever they
- // have one, so an icon change (or clear, which arrives as the sentinel hash)
- // rides along on the presence broadcast. Publish the matching URL: with an
- // icon it is content-addressed; without one it is the placeholder URL, which
- // differs from any prior icon URL and so clears a removed icon under the
- // client's shallow merge. An empty result (no origin known) is omitted, which
- // preserves whatever icon the client already holds.
- if s.BuddyIconURL != nil {
- presenceEvent.BuddyIcon = s.BuddyIconURL(buddy, presence.IconHash)
- }
- s.EventQueue.Push(EventTypePresence, presenceEvent)
- }
- // bartIDs returns the BART items a user info block carries. They travel as a list
- // in one TLV: a user's buddy icon and status message ride in it together.
- func bartIDs(info wire.TLVUserInfo) []wire.BARTID {
- b, ok := info.Bytes(wire.OServiceUserInfoBARTInfo)
- if !ok {
- return nil
- }
- var ids []wire.BARTID
- if err := wire.UnmarshalBE(&ids, bytes.NewReader(b)); err != nil {
- return nil
- }
- return ids
- }
- // buddyIconHash returns the buddy icon hash carried in a user info block, or nil
- // when it carries none.
- func buddyIconHash(info wire.TLVUserInfo) []byte {
- for _, id := range bartIDs(info) {
- if id.Type == wire.BARTTypesBuddyIcon || id.Type == wire.BARTTypesBuddyIconSmall {
- return id.Hash
- }
- }
- return nil
- }
- // userStatusMsg returns the status message carried in a user info block, or ""
- // when it carries none or one that cannot be decoded.
- func userStatusMsg(info wire.TLVUserInfo) string {
- for _, id := range bartIDs(info) {
- msg, err := id.StatusText()
- if err != nil {
- continue
- }
- if msg != "" {
- return msg
- }
- }
- return ""
- }
- // sessionStatusMsg returns the status message a session currently advertises.
- func sessionStatusMsg(instance *state.SessionInstance) string {
- status, _ := instance.Session().Status()
- msg, err := status.StatusText()
- if err != nil {
- return ""
- }
- return msg
- }
- // buddyWebState reports the web state a buddy's user info describes, along with
- // the minutes they have been idle. Invisibility reads as offline. Idle is always
- // reported, but only upgrades an otherwise online user to "idle".
- func buddyWebState(info wire.TLVUserInfo, isAIMViewer bool) (string, int) {
- idle := 0
- if mins, ok := info.Uint16BE(wire.OServiceUserInfoIdleTime); ok {
- idle = int(mins)
- }
- if info.IsInvisible() {
- return "offline", idle
- }
- // statusBitState must be consulted before IsAway: Busy and DND also raise
- // the unavailable flag, so an away-first test reports every busy user as
- // away.
- if st := statusBitState(info, isAIMViewer); st != "" {
- return st, idle
- }
- if info.IsAway() {
- return "away", idle
- }
- if mask, ok := info.Uint32BE(wire.OServiceUserInfoStatus); ok && mask&wire.OServiceUserStatusAway != 0 {
- return "away", idle
- }
- if idle > 0 {
- return "idle", idle
- }
- return "online", idle
- }
- // buddyPresenceFrom builds a presence record from the user info block a
- // BuddyArrived carries. isAIMViewer decides whether Busy and DND collapse to
- // "away".
- func buddyPresenceFrom(info wire.TLVUserInfo, isAIMViewer bool) BuddyPresence {
- st, idle := buddyWebState(info, isAIMViewer)
- p := BuddyPresence{
- DisplayID: info.ScreenName,
- State: st,
- IdleTime: idle,
- StatusMsg: userStatusMsg(info),
- Caps: userInfoCaps(info),
- IconHash: buddyIconHash(info),
- }
- if tod, ok := info.Uint32BE(wire.OServiceUserInfoSignonTOD); ok {
- p.OnlineTime = int64(tod)
- }
- return p
- }
- // hasAwayMsg reports whether a state is one whose user may be advertising an
- // away message.
- func hasAwayMsg(webState string) bool {
- switch webState {
- case "away", "occupied", "dnd":
- return true
- }
- return false
- }
- // awayMessage reads a buddy's away message, which lives in the locate reply's
- // LocateInfo and so is absent from presence broadcasts. Callers ask only for a
- // buddy the view already reports as unavailable.
- func awayMessage(
- ctx context.Context,
- locateService LocateService,
- instance *state.SessionInstance,
- buddy state.IdentScreenName,
- logger *slog.Logger,
- ) string {
- reply, err := locateService.UserInfoQuery(ctx, instance, wire.SNACFrame{},
- wire.SNAC_0x02_0x05_LocateUserInfoQuery{
- Type: uint16(wire.LocateTypeUnavailable),
- ScreenName: buddy.String(),
- })
- if err != nil {
- logger.WarnContext(ctx, "failed to query away message",
- "screenName", buddy.String(), "error", err)
- return ""
- }
- info, ok := reply.Body.(wire.SNAC_0x02_0x06_LocateUserInfoReply)
- if !ok {
- // Locate error => the buddy went offline or blocked the caller between
- // their arrival and this query.
- return ""
- }
- msg, _ := info.LocateInfo.String(wire.LocateTLVTagsInfoUnavailableData)
- return msg
- }
- // handleBuddyDeparted handles when a buddy goes offline.
- func (s *Session) handleBuddyDeparted(msg wire.SNACMessage) {
- body, ok := msg.Body.(wire.SNAC_0x03_0x0C_BuddyDeparted)
- if !ok {
- return
- }
- buddy := state.NewIdentScreenName(body.ScreenName)
- // Recorded before the subscription check, for the same reason as arrivals.
- s.setBuddyOffline(buddy)
- if !s.IsSubscribedTo("presence") {
- return
- }
- // BuddyIcon is deliberately omitted: an offline buddy keeps their icon, and
- // omitting it lets the client's merge preserve the icon it already holds.
- presenceEvent := PresenceEvent{
- AimID: buddy.String(),
- Friendly: s.aliasFor(buddy),
- State: "offline",
- UserType: userTypeFor(buddy),
- }
- s.EventQueue.Push(EventTypePresence, presenceEvent)
- }
- // feedbagResultAuthRequired is the per-item feedbag result meaning the target's ICQ
- // settings require authorization, so the item was not stored.
- const feedbagResultAuthRequired = uint16(0x000E)
- // refreshBuddyList re-reads the roster and pushes it to the client. Runs on the SNAC
- // listener goroutine, so it uses the session context rather than a request context.
- func (s *Session) refreshBuddyList() {
- if s.BuddyListRefresher == nil {
- return
- }
- payload, err := s.BuddyListRefresher(s.ctx)
- if err != nil {
- s.logger.Error("failed to refresh buddy list after feedbag change", "err", err)
- return
- }
- s.EventQueue.Push(EventTypeBuddyList, payload)
- }
- func (s *Session) handleFeedbagMessage(msg wire.SNACMessage) {
- s.InvalidateFeedbag()
- switch msg.Frame.SubGroup {
- case wire.FeedbagStatus:
- // Insert/update/delete below reach only a user's *other* instances, so this
- // is the one notification a session gets for its own feedbag write.
- if !s.IsSubscribedTo(string(EventTypeBuddyList)) {
- return
- }
- if body, ok := msg.Body.(wire.SNAC_0x13_0x0E_FeedbagStatus); ok {
- // A buddy declined for authorization is not stored, and is simply
- // absent from the refreshed roster.
- if slices.Contains(body.Results, feedbagResultAuthRequired) {
- s.logger.Info("feedbag item declined pending authorization")
- }
- }
- s.refreshBuddyList()
- case wire.FeedbagInsertItem, wire.FeedbagUpdateItem, wire.FeedbagDeleteItem:
- // An insert and an update both relay an UpdateItem body; only a delete
- // carries a DeleteItem body.
- var items []wire.FeedbagItem
- isDelete := false
- switch body := msg.Body.(type) {
- case wire.SNAC_0x13_0x09_FeedbagUpdateItem:
- items = body.Items
- case wire.SNAC_0x13_0x0A_FeedbagDeleteItem:
- items = body.Items
- isDelete = true
- }
- if isDelete {
- var removed []string
- for _, item := range items {
- if item.ClassID == wire.FeedbagClassIdBuddy && item.Name != "" {
- removed = append(removed, item.Name)
- }
- }
- s.forgetUnlistedBuddies(removed)
- }
- s.refreshBuddyList()
- if s.PermitDenyRefresher != nil {
- for _, item := range items {
- if item.ClassID == wire.FeedbagClassIDPermit ||
- item.ClassID == wire.FeedbagClassIDDeny ||
- item.ClassID == wire.FeedbagClassIdPdinfo {
- pdd, err := s.PermitDenyRefresher(s.ctx)
- if err != nil {
- s.logger.Error("failed to refresh permit/deny after feedbag change", "err", err)
- } else {
- s.EventQueue.Push(EventTypePermitDeny, pdd)
- }
- break
- }
- }
- }
- }
- }
- // SessionManager manages Web API sessions with thread-safe operations.
- // Construct it with NewSessionManager and drive its reaper with Run.
- type SessionManager struct {
- sessions map[string]*Session // Keyed by aimsid
- mu sync.RWMutex
- closed bool // set by Shutdown; rejects new sessions and makes drain idempotent
- stopCh chan struct{} // closed by Shutdown to stop the reaper
- reaperWG sync.WaitGroup // tracks a running reaper so Shutdown can join it
- }
- // NewSessionManager creates a new WebAPI session manager. It does not start
- // any goroutines; call Run to start reaping expired sessions.
- func NewSessionManager() *SessionManager {
- return &SessionManager{
- sessions: make(map[string]*Session),
- stopCh: make(chan struct{}),
- }
- }
- // CreateSession creates a new WebAPI session.
- //
- // The session does not begin listening to its OSCAR instance yet: the caller
- // must wire the session's refresher callbacks (BuddyListRefresher, BuddyIconURL,
- // ...) and then call StartListeningToOSCARSession. Wiring them after the
- // listener starts would race the goroutine, which reads them as it converts
- // SNACs into events.
- func (m *SessionManager) CreateSession(screenName state.DisplayScreenName, events []string, oscarSession *state.SessionInstance, baseURL string, logger *slog.Logger) (*Session, error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- // Refuse to create sessions once shut down: the reaper is stopped, so a
- // session added now would never be closed or reaped.
- if m.closed {
- return nil, ErrWebAPISessionManagerClosed
- }
- // Generate unique session ID
- aimsid, err := generateSessionID()
- if err != nil {
- return nil, err
- }
- now := time.Now()
- sessCtx, sessCancel := context.WithCancel(context.Background())
- session := &Session{
- ctx: sessCtx,
- cancel: sessCancel,
- AimSID: aimsid,
- ScreenName: screenName,
- OSCARSession: oscarSession,
- BaseURL: baseURL,
- Events: events,
- EventQueue: NewEventQueue(1000), // Max 1000 events per session
- CreatedAt: now,
- LastAccessed: now,
- ExpiresAt: now.Add(webAPISessionTTL),
- FetchTimeout: 60000, // 60 seconds default for better stability
- TimeToNextFetch: 500, // 500ms suggested delay
- logger: logger,
- capabilities: slices.Clone(webAPICaps),
- }
- m.sessions[aimsid] = session
- // The caller starts the OSCAR listener (StartListeningToOSCARSession) once it
- // has wired the session's refresher callbacks; starting it here would race
- // those assignments.
- return session, nil
- }
- // GetSession retrieves a session by aimsid.
- func (m *SessionManager) GetSession(ctx context.Context, aimsid string) (*Session, error) {
- m.mu.RLock()
- defer m.mu.RUnlock()
- session, exists := m.sessions[aimsid]
- if !exists {
- return nil, ErrNoWebAPISession
- }
- if session.IsExpired() {
- return nil, ErrWebAPISessionExpired
- }
- // A rate-limit disconnect (EvaluateRateLimit -> Session.CloseSession) closes
- // every instance for the account while this web session is still unexpired.
- // The aimsid must stop resolving at that point, otherwise a client told to
- // disconnect could keep issuing charged requests against a dead session (the
- // reaper only removes it on time expiry, up to a TTL later).
- if session.OSCARSession.IsClosed() {
- return nil, ErrWebAPISessionExpired
- }
- return session, nil
- }
- // RemoveSession removes a session by aimsid.
- func (m *SessionManager) RemoveSession(ctx context.Context, aimsid string) error {
- m.mu.Lock()
- session, exists := m.sessions[aimsid]
- if !exists {
- m.mu.Unlock()
- return ErrNoWebAPISession
- }
- delete(m.sessions, aimsid)
- m.mu.Unlock()
- // Tear down outside the lock: CloseInstance fans out to buddy-departed
- // broadcasts and signout, which we don't want to run under m.mu.
- session.Close()
- return nil
- }
- // TouchSession updates the last accessed time for a session.
- func (m *SessionManager) TouchSession(ctx context.Context, aimsid string) error {
- m.mu.Lock()
- defer m.mu.Unlock()
- session, exists := m.sessions[aimsid]
- if !exists {
- return ErrNoWebAPISession
- }
- session.Touch()
- return nil
- }
- // Run reaps expired sessions on a fixed interval until ctx is cancelled or
- // Shutdown is called. The caller owns the goroutine's lifecycle; typically
- // launch it under the server's errgroup:
- //
- // g.Go(func() error { mgr.Run(ctx); return nil })
- //
- // Run is a no-op once the manager is closed, so a reaper that loses the race
- // with Shutdown never starts reaping a drained manager.
- func (m *SessionManager) Run(ctx context.Context) {
- m.mu.Lock()
- if m.closed {
- m.mu.Unlock()
- return
- }
- // Registering under m.mu is what makes Shutdown's join sound: Shutdown flips
- // closed under the same lock, so a reaper either registers before Shutdown
- // waits or is turned away here.
- m.reaperWG.Add(1)
- m.mu.Unlock()
- defer m.reaperWG.Done()
- ticker := time.NewTicker(webAPISessionReapInterval)
- defer ticker.Stop()
- for {
- select {
- case <-ticker.C:
- m.reapExpired()
- case <-m.stopCh:
- return
- case <-ctx.Done():
- return
- }
- }
- }
- // reapExpired removes every dead session and tears it down. A session is dead
- // once it has passed its expiry, or once its underlying OSCAR session has been
- // closed out from under it (e.g. by a rate-limit disconnect) — the latter is
- // already rejected by GetSession, and reaping it here frees the entry promptly
- // rather than leaving it until time expiry.
- func (m *SessionManager) reapExpired() {
- m.mu.Lock()
- now := time.Now()
- var expired []*Session
- for aimsid, session := range m.sessions {
- if now.After(session.ExpiresAt) || session.OSCARSession.IsClosed() {
- delete(m.sessions, aimsid)
- expired = append(expired, session)
- }
- }
- m.mu.Unlock()
- // Tear down outside the lock: CloseInstance fans out to buddy-departed
- // broadcasts and signout, which we don't want to run under m.mu.
- for _, session := range expired {
- session.Close()
- }
- }
- // Shutdown drains and closes all sessions, stops the reaper started by Run, and
- // blocks further CreateSession calls. It does not depend on the caller
- // cancelling Run's context. Safe to call more than once, though only the first
- // call waits for the drain. The drain is bounded by ctx: Shutdown returns
- // ctx.Err() rather than block forever on a listener that ignores cancellation.
- func (m *SessionManager) Shutdown(ctx context.Context) error {
- m.mu.Lock()
- if m.closed {
- m.mu.Unlock()
- return nil
- }
- m.closed = true
- close(m.stopCh)
- sessions := make([]*Session, 0, len(m.sessions))
- for _, session := range m.sessions {
- sessions = append(sessions, session)
- }
- // Clear all sessions
- m.sessions = make(map[string]*Session)
- m.mu.Unlock()
- drained := make(chan struct{})
- go func() {
- defer close(drained)
- // Tear down outside the lock: CloseInstance fans out to buddy-departed
- // broadcasts and signout, which we don't want to run under m.mu.
- for _, session := range sessions {
- session.Close()
- }
- m.reaperWG.Wait()
- }()
- select {
- case <-drained:
- return nil
- case <-ctx.Done():
- return ctx.Err()
- }
- }
- // generateSessionID creates a cryptographically secure session ID.
- func generateSessionID() (string, error) {
- bytes := make([]byte, 32) // 256 bits
- if _, err := rand.Read(bytes); err != nil {
- return "", err
- }
- return hex.EncodeToString(bytes), nil
- }
- // sentIMCookieLimit bounds the cookie->msgId map. An error arrives within seconds
- // of the send, so a small window suffices; the oldest entry is evicted once full.
- const sentIMCookieLimit = 256
- // RecordSentIM remembers the msgId handed out for an outgoing message, keyed by the
- // OSCAR cookie that message carries on the wire. The two are unrelated by
- // construction, so a clientError — which names its message by cookie — could not
- // otherwise say which message it refers to.
- func (s *Session) RecordSentIM(cookie uint64, msgID string) {
- if s == nil || msgID == "" {
- return
- }
- s.sentIMMu.Lock()
- defer s.sentIMMu.Unlock()
- if s.sentIMs == nil {
- s.sentIMs = make(map[uint64]string)
- }
- if _, seen := s.sentIMs[cookie]; !seen {
- s.sentIMOrder = append(s.sentIMOrder, cookie)
- }
- s.sentIMs[cookie] = msgID
- if len(s.sentIMOrder) > sentIMCookieLimit {
- delete(s.sentIMs, s.sentIMOrder[0])
- s.sentIMOrder = s.sentIMOrder[1:]
- }
- }
- // msgIDForCookie resolves an OSCAR message cookie back to the msgId handed out for
- // it, or "" when the message is not one this session sent.
- func (s *Session) msgIDForCookie(cookie uint64) string {
- if s == nil {
- return ""
- }
- s.sentIMMu.Lock()
- defer s.sentIMMu.Unlock()
- return s.sentIMs[cookie]
- }
- // WebAPIStoredIM is one message in a Web AIM session's in-memory IM log.
- // The Web AIM client expects fetchStoredIMs entries with sender, message, msgId, and date.
- type WebAPIStoredIM struct {
- Sender string
- Message string
- MsgID string
- Date int64 // Unix seconds
- }
- // AddStoredIM appends a message to the per-partner log for this session.
- func (s *Session) AddStoredIM(partnerAimID, sender, message, msgID string, date int64) {
- if s == nil || partnerAimID == "" || message == "" {
- return
- }
- s.imLogMu.Lock()
- defer s.imLogMu.Unlock()
- if s.imLog == nil {
- s.imLog = make(map[string][]WebAPIStoredIM)
- }
- s.imLog[normalizeWebAPIAimID(partnerAimID)] = append(s.imLog[normalizeWebAPIAimID(partnerAimID)], WebAPIStoredIM{
- Sender: sender,
- Message: message,
- MsgID: msgID,
- Date: date,
- })
- }
- // StoredIM is one entry in a fetchStoredIMs reply.
- type StoredIM struct {
- Sender string `json:"sender" xml:"sender"`
- Message string `json:"message" xml:"message"`
- MsgID string `json:"msgId" xml:"msgId"`
- Date int64 `json:"date" xml:"date"`
- }
- // StoredIMQuery describes filters for fetchStoredIMs.
- type StoredIMQuery struct {
- PartnerAimID string
- StartTime int64
- EndTime int64
- NToGet int
- SortOrder string
- SkipMsgID string
- StopMsgID string
- }
- // GetStoredIMs returns stored messages for a conversation partner, filtered and sorted
- // per the Web AIM client's fetchStoredIMs parameters.
- func (s *Session) GetStoredIMs(q StoredIMQuery) []StoredIM {
- if s == nil || q.PartnerAimID == "" {
- return nil
- }
- s.imLogMu.Lock()
- msgs := append([]WebAPIStoredIM(nil), s.imLog[normalizeWebAPIAimID(q.PartnerAimID)]...)
- s.imLogMu.Unlock()
- if len(msgs) == 0 {
- return []StoredIM{}
- }
- filtered := make([]WebAPIStoredIM, 0, len(msgs))
- for _, msg := range msgs {
- if q.StartTime > 0 && msg.Date < q.StartTime {
- continue
- }
- if q.EndTime > 0 && msg.Date > q.EndTime {
- continue
- }
- filtered = append(filtered, msg)
- }
- descending := strings.EqualFold(q.SortOrder, "descendingDate")
- sort.Slice(filtered, func(i, j int) bool {
- if descending {
- return filtered[i].Date > filtered[j].Date
- }
- return filtered[i].Date < filtered[j].Date
- })
- if q.SkipMsgID != "" {
- for i, msg := range filtered {
- if msg.MsgID == q.SkipMsgID {
- filtered = filtered[i+1:]
- break
- }
- }
- }
- if q.StopMsgID != "" {
- for i, msg := range filtered {
- if msg.MsgID == q.StopMsgID {
- filtered = filtered[:i]
- break
- }
- }
- }
- n := q.NToGet
- if n <= 0 {
- n = 100
- }
- if len(filtered) > n {
- filtered = filtered[:n]
- }
- out := make([]StoredIM, len(filtered))
- for i, msg := range filtered {
- out[i] = StoredIM(msg)
- }
- return out
- }
- func (s *Session) Caps() [][16]byte {
- s.capsMu.RLock()
- defer s.capsMu.RUnlock()
- return slices.Clone(s.capabilities)
- }
- // ClearMood removes the mood capability the session advertises, if any. The
- // user then presents whatever presence state they are in.
- func (s *Session) ClearMood() {
- s.capsMu.Lock()
- defer s.capsMu.Unlock()
- s.clearMood()
- }
- // clearMood drops every mood capability the session advertises. The caller must
- // hold s.capsMu.
- func (s *Session) clearMood() {
- s.capabilities = slices.DeleteFunc(s.capabilities, func(cap [16]byte) bool {
- return wire.IsMoodCap(cap)
- })
- }
- // SetMood replaces the mood capability the session advertises. A client shows
- // one mood at a time, so whichever mood was set before is dropped.
- //
- // It panics when mood is not a mood capability: the caller resolves it from the
- // mood table, so anything else is a programming error rather than bad input.
- func (s *Session) SetMood(mood uuid.UUID) {
- s.capsMu.Lock()
- defer s.capsMu.Unlock()
- if !wire.IsMoodCap(mood) {
- panic("uuid is not a mood capability")
- }
- s.clearMood()
- s.capabilities = append(s.capabilities, mood)
- }
- // normalizeWebAPIAimID keys the IM log by the same normalization the web client
- // applies to aimIds, so a partner stored from a display screen name is still
- // found when the client queries by aimId.
- func normalizeWebAPIAimID(aimID string) string {
- return state.NewIdentScreenName(aimID).String()
- }
|