presence_handler.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703
  1. package webapi
  2. import (
  3. "context"
  4. "fmt"
  5. "log/slog"
  6. "net/http"
  7. "slices"
  8. "strings"
  9. "time"
  10. "github.com/mk6i/open-oscar-server/state"
  11. "github.com/mk6i/open-oscar-server/wire"
  12. )
  13. // PresenceHandler handles Web AIM API presence-related endpoints.
  14. type PresenceHandler struct {
  15. SessionManager *SessionManager
  16. FeedbagService FeedbagService
  17. BuddyBroadcaster BuddyBroadcaster
  18. LocateService LocateService
  19. IconSource BuddyIconSource
  20. Logger *slog.Logger
  21. }
  22. const maxPresenceTargets = 32
  23. // ProfileData is the getProfile payload.
  24. type ProfileData struct {
  25. ScreenName string `json:"screenName" xml:"screenName"`
  26. Profile string `json:"profile" xml:"profile"`
  27. LastUpdated int64 `json:"lastUpdated" xml:"lastUpdated"`
  28. }
  29. // SetStateData echoes the identity fields a setState changed.
  30. type SetStateData struct {
  31. AimID string `json:"aimId" xml:"aimId"`
  32. DisplayID string `json:"displayId" xml:"displayId"`
  33. State string `json:"state" xml:"state"`
  34. AwayMsg string `json:"awayMsg" xml:"awayMsg"`
  35. StatusMsg string `json:"statusMsg" xml:"statusMsg"`
  36. UserType string `json:"userType" xml:"userType"` // "aim", "icq"
  37. OnlineTime int64 `json:"onlineTime" xml:"onlineTime"`
  38. }
  39. // PresenceData contains presence information. Each query fills in one field and
  40. // leaves the other nil.
  41. //
  42. // omitzero, not omitempty: a query matching nothing must still render its key as an
  43. // empty array, since clients read data.groups and data.users strictly.
  44. type PresenceData struct {
  45. Groups []BuddyGroupInfo `json:"groups,omitzero" xml:"groups>group,omitempty"`
  46. Users []BuddyPresenceInfo `json:"users,omitzero" xml:"users>user,omitempty"`
  47. }
  48. // BuddyGroupInfo represents a buddy group with its members.
  49. type BuddyGroupInfo struct {
  50. Name string `json:"name" xml:"name"`
  51. Buddies []BuddyPresenceInfo `json:"buddies" xml:"buddies>buddy"`
  52. }
  53. // BuddyPresenceInfo represents presence information for a buddy.
  54. //
  55. // AimID is the normalized screen name the web client keys users by; DisplayID
  56. // preserves the casing and spacing the user signed on with. The client renders
  57. // DisplayID and falls back to AimID when it is absent.
  58. type BuddyPresenceInfo struct {
  59. AimID string `json:"aimId" xml:"aimId"`
  60. DisplayID string `json:"displayId,omitempty" xml:"displayId,omitempty"`
  61. Friendly string `json:"friendly,omitempty" xml:"friendly,omitempty"` // Viewer's private alias, rendered in preference to DisplayID
  62. State string `json:"state" xml:"state"` // "online", "offline", "away", "idle"
  63. StatusMsg string `json:"statusMsg,omitempty" xml:"statusMsg,omitempty"`
  64. AwayMsg string `json:"awayMsg,omitempty" xml:"awayMsg,omitempty"`
  65. ProfileMsg string `json:"profileMsg,omitempty" xml:"profileMsg,omitempty"`
  66. IdleTime int `json:"idleTime,omitempty" xml:"idleTime,omitempty"`
  67. OnlineTime int64 `json:"onlineTime,omitempty" xml:"onlineTime,omitempty"`
  68. UserType string `json:"userType" xml:"userType"` // "aim", "icq"
  69. Service string `json:"service,omitempty" xml:"service,omitempty"` // Non-native network; omitted for AIM
  70. BuddyIcon string `json:"buddyIcon,omitempty" xml:"buddyIcon,omitempty"`
  71. MoodIcon string `json:"moodIcon,omitempty" xml:"moodIcon,omitempty"`
  72. // Profile carries member-directory fields, present only under mdir=1. It must be
  73. // non-nil even when empty: clients treat a missing profile as "not a user".
  74. Profile *BuddyProfileInfo `json:"profile,omitempty" xml:"profile,omitempty"`
  75. }
  76. // BuddyProfileInfo is the nested "profile" object carried under mdir=1. Gender and
  77. // birth date are absent because the directory record has nowhere to store them.
  78. type BuddyProfileInfo struct {
  79. FriendlyName string `json:"friendlyName,omitempty" xml:"friendlyName,omitempty"`
  80. FirstName string `json:"firstName,omitempty" xml:"firstName,omitempty"`
  81. LastName string `json:"lastName,omitempty" xml:"lastName,omitempty"`
  82. HomeAddress []BuddyAddressInfo `json:"homeAddress,omitempty" xml:"homeAddress,omitempty"`
  83. }
  84. // BuddyAddressInfo is one entry of a profile's homeAddress array.
  85. type BuddyAddressInfo struct {
  86. City string `json:"city,omitempty" xml:"city,omitempty"`
  87. State string `json:"state,omitempty" xml:"state,omitempty"`
  88. Country string `json:"country,omitempty" xml:"country,omitempty"`
  89. }
  90. // GetPresence handles GET /presence/get requests.
  91. func (h *PresenceHandler) GetPresence(w http.ResponseWriter, r *http.Request, session *Session) {
  92. ctx := r.Context()
  93. aimsid := session.AimSID
  94. // Check if buddy list is requested
  95. getBuddyList := r.URL.Query().Get("bl") == "1"
  96. wantProfileMsg := r.URL.Query().Get("profileMsg") == "1"
  97. // mdir asks for member-directory fields alongside presence.
  98. wantDirInfo := isTrueParam(r.URL.Query().Get("mdir"))
  99. targetUsers := targetNames(r)
  100. // Create PresenceData struct to hold the response data
  101. presenceData := PresenceData{}
  102. if getBuddyList {
  103. // Retrieve buddy list from feedbag
  104. groups, err := h.getBuddyListGroups(ctx, session, wantProfileMsg)
  105. if err != nil {
  106. h.Logger.ErrorContext(ctx, "failed to get buddy list", "err", err.Error())
  107. // Return empty buddy list on error instead of failing
  108. groups = []BuddyGroupInfo{}
  109. }
  110. presenceData.Groups = groups
  111. } else if len(targetUsers) > 0 {
  112. // Get presence for specific users
  113. if len(targetUsers) > maxPresenceTargets {
  114. // truncate rather than reject
  115. h.Logger.WarnContext(ctx, "presence get: truncating oversized target list",
  116. "aimsid", session.AimSID,
  117. "requested", len(targetUsers),
  118. "cap", maxPresenceTargets,
  119. )
  120. targetUsers = targetUsers[:maxPresenceTargets]
  121. }
  122. presenceList := make([]BuddyPresenceInfo, 0, len(targetUsers))
  123. // The client's user-object merge deletes any alias it holds, so every
  124. // presence payload has to carry friendly for aliased buddies.
  125. aliases := session.Aliases(ctx)
  126. for _, user := range targetUsers {
  127. info := h.getUserPresence(ctx, session.OSCARSession, session.BaseURL, state.DisplayScreenName(user), wantProfileMsg)
  128. info.Friendly = aliases[info.AimID]
  129. if wantDirInfo {
  130. info.Profile = h.directoryProfile(ctx, user)
  131. }
  132. presenceList = append(presenceList, info)
  133. }
  134. presenceData.Users = presenceList
  135. } else {
  136. presenceData.Groups = []BuddyGroupInfo{}
  137. presenceData.Users = []BuddyPresenceInfo{}
  138. }
  139. // Send response in requested format
  140. SendOK(w, r, presenceData, h.Logger)
  141. h.Logger.DebugContext(ctx, "presence retrieved",
  142. "aimsid", aimsid,
  143. "buddy_list", getBuddyList,
  144. "targets", targetUsers,
  145. )
  146. }
  147. // directoryProfile reads a user's member-directory record for the mdir=1 profile
  148. // object. It never returns nil: a user with no directory record must still appear
  149. // as an empty profile rather than be dropped.
  150. func (h *PresenceHandler) directoryProfile(ctx context.Context, screenName string) *BuddyProfileInfo {
  151. profile := &BuddyProfileInfo{}
  152. reply, err := h.LocateService.DirInfo(ctx, wire.SNACFrame{}, wire.SNAC_0x02_0x0B_LocateGetDirInfo{ScreenName: screenName})
  153. if err != nil {
  154. h.Logger.ErrorContext(ctx, "presence: directory lookup failed",
  155. "screenName", screenName, "err", err.Error())
  156. return profile
  157. }
  158. body, ok := reply.Body.(wire.SNAC_0x02_0x0C_LocateGetDirReply)
  159. if !ok {
  160. return profile
  161. }
  162. profile.FirstName, _ = body.String(wire.ODirTLVFirstName)
  163. profile.LastName, _ = body.String(wire.ODirTLVLastName)
  164. profile.FriendlyName, _ = body.String(wire.ODirTLVNickName)
  165. city, _ := body.String(wire.ODirTLVCity)
  166. stateName, _ := body.String(wire.ODirTLVState)
  167. country, _ := body.String(wire.ODirTLVCountry)
  168. if city != "" || stateName != "" || country != "" {
  169. profile.HomeAddress = []BuddyAddressInfo{{City: city, State: stateName, Country: country}}
  170. }
  171. return profile
  172. }
  173. // getBuddyListGroups retrieves the buddy list organized by groups.
  174. func (h *PresenceHandler) getBuddyListGroups(ctx context.Context, session *Session, wantProfileMsg bool) ([]BuddyGroupInfo, error) {
  175. // Get feedbag items via the feedbag service
  176. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  177. reply, err := h.FeedbagService.Query(ctx, session.OSCARSession, frame)
  178. if err != nil {
  179. return nil, err
  180. }
  181. body, ok := reply.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  182. if !ok {
  183. return nil, fmt.Errorf("unexpected feedbag reply body type %T", reply.Body)
  184. }
  185. items := body.Items
  186. // Organize items into groups, keyed by GroupID. Group rows store their
  187. // identity in GroupID (ItemID is 0 for every group), so a GroupID-keyed map
  188. // is the only way to associate buddies — which reference their group via
  189. // GroupID — with the right group.
  190. groupMap := make(map[uint16]*BuddyGroupInfo)
  191. // First pass: identify groups. Skip the root group (GroupID 0), which holds
  192. // the master group order rather than buddies.
  193. for _, item := range items {
  194. if item.ClassID != wire.FeedbagClassIdGroup || item.GroupID == 0 {
  195. continue
  196. }
  197. name := item.Name
  198. if name == "" {
  199. name = "Buddies" // Default group name
  200. }
  201. groupMap[item.GroupID] = &BuddyGroupInfo{
  202. Name: name,
  203. Buddies: []BuddyPresenceInfo{},
  204. }
  205. }
  206. // Second pass: add buddies to their group with presence info.
  207. for _, item := range items {
  208. if item.ClassID != wire.FeedbagClassIdBuddy || item.Name == "" {
  209. continue
  210. }
  211. group, exists := groupMap[item.GroupID]
  212. if !exists {
  213. // Orphan buddy whose group row is missing: synthesize a default
  214. // group for its GroupID so the buddy is not dropped.
  215. group = &BuddyGroupInfo{Name: "Buddies", Buddies: []BuddyPresenceInfo{}}
  216. groupMap[item.GroupID] = group
  217. }
  218. // UserInfoQuery performs the blocking check and online lookup; blocked or
  219. // offline buddies come back as "offline", preserving the list structure.
  220. presence := h.getUserPresence(ctx, session.OSCARSession, session.BaseURL, state.DisplayScreenName(item.Name), wantProfileMsg)
  221. group.Buddies = append(group.Buddies, presence)
  222. }
  223. // If no groups exist at all, return a single default group.
  224. if len(groupMap) == 0 {
  225. groupMap[0] = &BuddyGroupInfo{
  226. Name: "Buddies",
  227. Buddies: []BuddyPresenceInfo{},
  228. }
  229. }
  230. // Convert map to slice
  231. groups := make([]BuddyGroupInfo, 0, len(groupMap))
  232. for _, group := range groupMap {
  233. groups = append(groups, *group)
  234. }
  235. return groups, nil
  236. }
  237. // getUserPresence resolves a user's presence by issuing a locate UserInfoQuery
  238. // on behalf of the requesting OSCAR session (instance). UserInfoQuery performs
  239. // the OSCAR blocking check and online lookup internally: blocked and offline
  240. // users both come back as a locate error, which we surface as "offline".
  241. func (h *PresenceHandler) getUserPresence(ctx context.Context, instance *state.SessionInstance, baseURL string, target state.DisplayScreenName, wantProfileMsg bool) BuddyPresenceInfo {
  242. ident := target.IdentScreenName()
  243. // Default offline presence
  244. presence := BuddyPresenceInfo{
  245. AimID: ident.String(),
  246. DisplayID: target.String(),
  247. State: "offline",
  248. UserType: userTypeFor(ident),
  249. Service: serviceFor(ident),
  250. }
  251. // The unauthenticated icon endpoint resolves presence without a session, so
  252. // there may be no OSCAR instance to query on behalf of.
  253. if instance == nil {
  254. return presence
  255. }
  256. reqType := wire.LocateTypeUnavailable // away message
  257. if wantProfileMsg {
  258. reqType |= wire.LocateTypeSig // profile text
  259. }
  260. reply, err := h.LocateService.UserInfoQuery(ctx, instance, wire.SNACFrame{},
  261. wire.SNAC_0x02_0x05_LocateUserInfoQuery{Type: uint16(reqType), ScreenName: ident.String()})
  262. if err != nil {
  263. h.Logger.WarnContext(ctx, "failed to query user info", "screenName", ident.String(), "error", err)
  264. return presence
  265. }
  266. info, ok := reply.Body.(wire.SNAC_0x02_0x06_LocateUserInfoReply)
  267. if !ok {
  268. // Locate error => user is blocked or offline.
  269. return presence
  270. }
  271. presence.State = "online"
  272. // Publish the icon only now that locate has confirmed the user is online and
  273. // has not blocked the caller. Offline and blocking users return above without
  274. // an icon, so neither their icon nor its activity-revealing hash leaks to a
  275. // caller they are otherwise invisible to.
  276. presence.BuddyIcon = h.IconSource.PublishedURL(ctx, baseURL, ident)
  277. // The locate reply carries the screen name as the user formatted it, which
  278. // beats whatever casing the caller happened to pass in.
  279. if info.ScreenName != "" {
  280. presence.DisplayID = info.ScreenName
  281. }
  282. if tod, ok := info.Uint32BE(wire.OServiceUserInfoSignonTOD); ok {
  283. presence.OnlineTime = int64(tod)
  284. }
  285. if st := statusBitState(info.TLVUserInfo); st != "" {
  286. presence.State = st
  287. } else if info.IsAway() {
  288. presence.State = "away"
  289. }
  290. if idle, ok := info.Uint16BE(wire.OServiceUserInfoIdleTime); ok && idle > 0 {
  291. presence.State = "idle"
  292. presence.IdleTime = int(idle)
  293. }
  294. if msg, ok := info.LocateInfo.String(wire.LocateTLVTagsInfoUnavailableData); ok {
  295. presence.AwayMsg = msg
  296. }
  297. if wantProfileMsg {
  298. if prof, ok := info.LocateInfo.String(wire.LocateTLVTagsInfoSigData); ok {
  299. presence.ProfileMsg = prof
  300. }
  301. }
  302. presence.MoodIcon = moodIconURL(baseURL, presence.State, userInfoCaps(info.TLVUserInfo))
  303. return presence
  304. }
  305. // SetState handles GET /presence/setState requests to update user's presence state.
  306. func (h *PresenceHandler) SetState(w http.ResponseWriter, r *http.Request, session *Session) {
  307. ctx := r.Context()
  308. stateParam := r.URL.Query().Get("state")
  309. if stateParam == "" {
  310. stateParam = r.URL.Query().Get("view")
  311. }
  312. awayMsg := r.URL.Query().Get("awayMsg")
  313. if awayMsg == "" {
  314. awayMsg = r.URL.Query().Get("away")
  315. }
  316. oscarSession := session.OSCARSession
  317. // Map web state to OSCAR status bits
  318. var statusBitmask uint32
  319. switch stateParam {
  320. case "online":
  321. statusBitmask = 0x0000 // Clear all status bits
  322. oscarSession.SetAwayMessage("")
  323. oscarSession.ClearUserInfoFlag(wire.OServiceUserFlagUnavailable)
  324. case "away":
  325. statusBitmask = wire.OServiceUserStatusAway
  326. oscarSession.SetUserInfoFlag(wire.OServiceUserFlagUnavailable)
  327. if awayMsg != "" {
  328. oscarSession.SetAwayMessage(awayMsg)
  329. }
  330. case "invisible":
  331. statusBitmask = wire.OServiceUserStatusInvisible
  332. case "dnd":
  333. statusBitmask = wire.OServiceUserStatusDND
  334. case "occupied":
  335. // ICQ's Busy, a distinct status bit from DND.
  336. statusBitmask = wire.OServiceUserStatusBusy
  337. oscarSession.SetUserInfoFlag(wire.OServiceUserFlagUnavailable)
  338. default:
  339. SendError(w, r, http.StatusBadRequest, "invalid state parameter")
  340. return
  341. }
  342. // Update OSCAR session status
  343. oscarSession.SetUserStatusBitmask(statusBitmask)
  344. // Broadcast presence update
  345. if statusBitmask&wire.OServiceUserStatusInvisible != 0 {
  346. // User going invisible - broadcast departure
  347. if err := h.BuddyBroadcaster.BroadcastBuddyDeparted(ctx, oscarSession.IdentScreenName()); err != nil {
  348. h.Logger.ErrorContext(ctx, "failed to broadcast buddy departed", "err", err.Error())
  349. }
  350. } else {
  351. // User visible - broadcast arrival/update
  352. if err := h.BuddyBroadcaster.BroadcastBuddyArrived(ctx, oscarSession.IdentScreenName(), oscarSession.Session().TLVUserInfo()); err != nil {
  353. h.Logger.ErrorContext(ctx, "failed to broadcast buddy arrived", "err", err.Error())
  354. }
  355. }
  356. // Notify the user's own client so its status indicator re-renders. The AIM
  357. // client updates its self-presence badge only from "myInfo" events; the
  358. // "presence" broadcast above drives buddy dots, not the user's own state.
  359. // Without this, changing to Busy/Away leaves the user still showing as
  360. // available in their own UI.
  361. h.pushMyInfo(session, stateParam, awayMsg, "")
  362. h.Logger.InfoContext(ctx, "presence state updated",
  363. "screenName", session.ScreenName.String(),
  364. "state", stateParam,
  365. "hasAwayMsg", awayMsg != "",
  366. )
  367. // Send success response
  368. SendOK(w, r, &SetStateData{
  369. AimID: session.ScreenName.IdentScreenName().String(),
  370. DisplayID: session.ScreenName.String(),
  371. State: stateParam,
  372. AwayMsg: awayMsg,
  373. StatusMsg: "",
  374. UserType: userTypeFor(session.ScreenName.IdentScreenName()),
  375. OnlineTime: time.Now().Unix(),
  376. }, h.Logger)
  377. }
  378. // SetStatus handles GET /presence/setStatus requests to update user's status message.
  379. func (h *PresenceHandler) SetStatus(w http.ResponseWriter, r *http.Request, session *Session) {
  380. ctx := r.Context()
  381. // Get the status message
  382. statusMsg := r.URL.Query().Get("statusMsg")
  383. statusCode := r.URL.Query().Get("statusCode")
  384. if r.URL.Query().Has("mood") {
  385. moodID := r.URL.Query().Get("mood")
  386. if moodID == "" {
  387. session.OSCARSession.ClearMood()
  388. } else {
  389. m, hasMood := wire.MoodByID(moodID)
  390. if !hasMood {
  391. SendError(w, r, http.StatusBadRequest, "invalid mood ID")
  392. return
  393. }
  394. session.OSCARSession.SetMood(m.Cap)
  395. }
  396. setInfo := wire.SNAC_0x02_0x04_LocateSetInfo{
  397. TLVRestBlock: wire.TLVRestBlock{
  398. TLVList: wire.TLVList{
  399. wire.NewTLVBE(wire.LocateTLVTagsInfoCapabilities, session.OSCARSession.Caps()),
  400. },
  401. },
  402. }
  403. if err := h.LocateService.SetInfo(ctx, session.OSCARSession, setInfo); err != nil {
  404. h.Logger.ErrorContext(ctx, "failed to set mood capability", "err", err.Error())
  405. SendError(w, r, http.StatusInternalServerError, "failed to save status")
  406. return
  407. }
  408. }
  409. // Notify the user's own client so its status message re-renders. Preserve the
  410. // current presence state so a status-only change does not flip the self badge.
  411. h.pushMyInfo(session, currentWebState(session.OSCARSession), session.OSCARSession.Session().AwayMessage(), statusMsg)
  412. h.Logger.InfoContext(ctx, "status message updated",
  413. "screenName", session.ScreenName.String(),
  414. "statusMsg", statusMsg,
  415. "statusCode", statusCode,
  416. "mood", r.URL.Query().Get("mood"),
  417. )
  418. SendOK(w, r, nil, h.Logger)
  419. }
  420. // SetProfile handles GET /presence/setProfile requests to update user's profile.
  421. func (h *PresenceHandler) SetProfile(w http.ResponseWriter, r *http.Request, session *Session) {
  422. ctx := r.Context()
  423. // Get the profile content
  424. profileText := r.URL.Query().Get("profile")
  425. // Limit profile size (4KB max)
  426. if len(profileText) > 4096 {
  427. SendError(w, r, http.StatusBadRequest, "profile too large (max 4KB)")
  428. return
  429. }
  430. instance := session.OSCARSession
  431. // Save profile via OSCAR LocateService.
  432. setInfo := wire.SNAC_0x02_0x04_LocateSetInfo{
  433. TLVRestBlock: wire.TLVRestBlock{
  434. TLVList: wire.TLVList{
  435. wire.NewTLVBE(wire.LocateTLVTagsInfoSigData, profileText),
  436. },
  437. },
  438. }
  439. if err := h.LocateService.SetInfo(ctx, instance, setInfo); err != nil {
  440. h.Logger.ErrorContext(ctx, "failed to set profile", "err", err.Error())
  441. SendError(w, r, http.StatusInternalServerError, "failed to save profile")
  442. return
  443. }
  444. h.Logger.InfoContext(ctx, "profile updated",
  445. "screenName", session.ScreenName.String(),
  446. "profileSize", len(profileText),
  447. )
  448. // Send success response
  449. SendOK(w, r, nil, h.Logger)
  450. }
  451. // GetProfile handles GET /presence/getProfile requests to retrieve user's profile.
  452. func (h *PresenceHandler) GetProfile(w http.ResponseWriter, r *http.Request, session *Session) {
  453. ctx := r.Context()
  454. // Get target screen name (optional - defaults to self)
  455. targetSN := r.URL.Query().Get("sn")
  456. if targetSN == "" {
  457. targetSN = session.ScreenName.String()
  458. }
  459. // Retrieve profile via OSCAR LocateService.
  460. var profileText string
  461. instance := session.OSCARSession
  462. reply, err := h.LocateService.UserInfoQuery(ctx, instance, wire.SNACFrame{},
  463. wire.SNAC_0x02_0x05_LocateUserInfoQuery{Type: uint16(wire.LocateTypeSig), ScreenName: targetSN})
  464. if err != nil {
  465. h.Logger.WarnContext(ctx, "failed to get profile", "err", err.Error())
  466. } else if info, ok := reply.Body.(wire.SNAC_0x02_0x06_LocateUserInfoReply); ok {
  467. if prof, ok := info.LocateInfo.String(wire.LocateTLVTagsInfoSigData); ok {
  468. profileText = prof
  469. }
  470. }
  471. // Send response
  472. responseData := &ProfileData{ScreenName: targetSN, Profile: profileText}
  473. SendOK(w, r, responseData, h.Logger)
  474. }
  475. // Icon handles GET /presence/icon requests for presence icons.
  476. func (h *PresenceHandler) Icon(w http.ResponseWriter, r *http.Request) {
  477. // Get parameters
  478. name := r.URL.Query().Get("name")
  479. size := r.URL.Query().Get("size")
  480. iconType := r.URL.Query().Get("type")
  481. if name == "" {
  482. SendError(w, r, http.StatusBadRequest, "missing name parameter")
  483. return
  484. }
  485. // Default values
  486. if size == "" {
  487. size = "32"
  488. }
  489. if iconType == "" {
  490. iconType = "aim"
  491. }
  492. // For now, redirect to a placeholder icon
  493. // In production, this would redirect to actual icon storage/CDN
  494. var iconURL string
  495. // If it's an email lookup, extract username
  496. if strings.Contains(name, "@") {
  497. parts := strings.Split(name, "@")
  498. if len(parts) > 0 {
  499. name = parts[0]
  500. }
  501. }
  502. // Resolve the target's presence via OSCAR LocateService, querying on behalf
  503. // of the caller's session. This endpoint is unauthenticated, so fall back to
  504. // the offline icon when no valid session is supplied.
  505. var instance *state.SessionInstance
  506. if aimsid := r.URL.Query().Get("aimsid"); aimsid != "" {
  507. if session, err := h.SessionManager.GetSession(r.Context(), aimsid); err == nil {
  508. instance = session.OSCARSession
  509. }
  510. }
  511. // This endpoint serves a presence state badge, not the user's buddy icon, so
  512. // it has no use for a buddy icon URL.
  513. switch h.getUserPresence(r.Context(), instance, "", state.DisplayScreenName(name), false).State {
  514. case "away":
  515. iconURL = "/static/icons/away_" + iconType + "_" + size + ".png"
  516. case "idle":
  517. iconURL = "/static/icons/idle_" + iconType + "_" + size + ".png"
  518. case "offline":
  519. iconURL = "/static/icons/offline_" + iconType + "_" + size + ".png"
  520. default:
  521. iconURL = "/static/icons/online_" + iconType + "_" + size + ".png"
  522. }
  523. // Redirect to icon URL
  524. http.Redirect(w, r, iconURL, http.StatusFound)
  525. }
  526. // statusBitState reports the web state named by a user's ICQ status bits, or ""
  527. // when neither Busy nor DND is set. Callers must consult it before IsAway(): Busy
  528. // and DND also raise the unavailable flag, so an away-first test reports every busy
  529. // user as away.
  530. func statusBitState(info wire.TLVUserInfo) string {
  531. status, ok := info.Uint32BE(wire.OServiceUserInfoStatus)
  532. if !ok {
  533. return ""
  534. }
  535. switch {
  536. case status&wire.OServiceUserStatusBusy != 0:
  537. return "occupied"
  538. case status&wire.OServiceUserStatusDND != 0:
  539. return "dnd"
  540. }
  541. return ""
  542. }
  543. // currentWebState maps an OSCAR session's presence flags to the web state string
  544. // the clients expect ("online", "away", "idle", "invisible", "occupied", "dnd").
  545. func currentWebState(instance *state.SessionInstance) string {
  546. sess := instance.Session()
  547. bitmask := instance.UserStatusBitmask()
  548. switch {
  549. case sess.Invisible():
  550. return "invisible"
  551. // Checked before Away: both set the unavailable flag, so an occupied user would
  552. // otherwise report back as away on the next myInfo.
  553. case bitmask&wire.OServiceUserStatusBusy != 0:
  554. return "occupied"
  555. case bitmask&wire.OServiceUserStatusDND != 0:
  556. return "dnd"
  557. case sess.Away():
  558. return "away"
  559. case instance.Idle():
  560. return "idle"
  561. default:
  562. return "online"
  563. }
  564. }
  565. // pushMyInfo queues a "myInfo" event on the user's own session so the AIM client
  566. // re-renders its self-presence badge. The client binds its identity-badge render
  567. // to "myInfo" events only, so state changes made via setState/setStatus are
  568. // invisible in the user's own UI unless a myInfo event is delivered.
  569. func (h *PresenceHandler) pushMyInfo(session *Session, webState, awayMsg, statusMsg string) {
  570. if !session.IsSubscribedTo("myInfo") && !session.IsSubscribedTo("presence") {
  571. return
  572. }
  573. // buddyIcon is omitted here (empty) so the client's merge preserves the icon it
  574. // already holds; a setState/setStatus does not change the icon. Icon changes
  575. // arrive on their own myInfo via the pump's MyInfoRefresher.
  576. moodIcon := moodIconURL(session.BaseURL, webState, session.OSCARSession.Session().Caps())
  577. myInfo := buildMyInfo(session.ScreenName, webState, "", moodIcon)
  578. myInfo.AwayMsg = awayMsg
  579. myInfo.StatusMsg = statusMsg
  580. session.EventQueue.Push(EventType("myInfo"), myInfo)
  581. }
  582. // userInfoCaps returns the capability UUIDs a user info block advertises.
  583. func userInfoCaps(info wire.TLVUserInfo) [][16]byte {
  584. b, ok := info.Bytes(wire.OServiceUserInfoOscarCaps)
  585. if !ok {
  586. return nil
  587. }
  588. caps := make([][16]byte, 0, len(b)/16)
  589. for chunk := range slices.Chunk(b, 16) {
  590. if len(chunk) < 16 {
  591. break
  592. }
  593. caps = append(caps, [16]byte(chunk))
  594. }
  595. return caps
  596. }
  597. // moodIconURL returns the mood icon URL for the mood advertised in caps, or ""
  598. // when there is none. A mood supersedes webState on the client, so a user who is
  599. // not visibly online never gets one.
  600. func moodIconURL(baseURL, webState string, caps [][16]byte) string {
  601. if webState == "offline" || webState == "invisible" {
  602. return ""
  603. }
  604. for _, c := range caps {
  605. if m, ok := wire.MoodByCap(c); ok {
  606. return baseURL + "/mood?id=" + wire.MoodIconID(m.ID)
  607. }
  608. }
  609. return ""
  610. }