buddy_list_manager.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629
  1. package handlers
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "log/slog"
  7. "math/rand"
  8. "slices"
  9. "strings"
  10. "github.com/mk6i/open-oscar-server/state"
  11. "github.com/mk6i/open-oscar-server/wire"
  12. )
  13. // BuddyListManager handles the conversion of OSCAR feedbag data
  14. // to WebAPI buddy list format for web clients.
  15. type BuddyListManager struct {
  16. feedbagService FeedbagService
  17. locateService LocateService
  18. logger *slog.Logger
  19. }
  20. // NewBuddyListManager creates a new instance of the buddy list manager.
  21. func NewBuddyListManager(feedbagService FeedbagService, locateService LocateService, logger *slog.Logger) *BuddyListManager {
  22. return &BuddyListManager{
  23. feedbagService: feedbagService,
  24. locateService: locateService,
  25. logger: logger,
  26. }
  27. }
  28. // WebAPIBuddyGroup represents a group in the WebAPI buddy list format.
  29. type WebAPIBuddyGroup struct {
  30. Name string `json:"name"`
  31. Buddies []WebAPIBuddyInfo `json:"buddies"`
  32. Recent bool `json:"recent,omitempty"`
  33. Smart interface{} `json:"smart,omitempty"` // Can be null or number
  34. }
  35. // WebAPIBuddyInfo represents a buddy in the WebAPI format.
  36. type WebAPIBuddyInfo struct {
  37. AimID string `json:"aimId"`
  38. DisplayID string `json:"displayId"`
  39. Friendly string `json:"friendly,omitempty"` // Viewer's private alias, rendered in preference to DisplayID
  40. State string `json:"state"` // "online", "offline", "away", "idle"
  41. StatusMsg string `json:"statusMsg,omitempty"`
  42. AwayMsg string `json:"awayMsg,omitempty"`
  43. OnlineTime int64 `json:"onlineTime,omitempty"`
  44. IdleTime int `json:"idleTime,omitempty"` // Minutes idle
  45. UserType string `json:"userType"` // "aim", "icq", "admin"
  46. Bot bool `json:"bot"`
  47. Service string `json:"service,omitempty"` // "AIM", "ICQ" (Web AIM client compares case-sensitively)
  48. PresenceIcon string `json:"presenceIcon,omitempty"`
  49. BuddyIcon string `json:"buddyIcon,omitempty"`
  50. Capabilities []string `json:"capabilities,omitempty"`
  51. MemberSince int64 `json:"memberSince,omitempty"`
  52. }
  53. // GetBuddyListForUser retrieves and converts the buddy list for a user.
  54. func (m *BuddyListManager) GetBuddyListForUser(ctx context.Context, sess *state.WebAPISession) ([]WebAPIBuddyGroup, error) {
  55. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  56. snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
  57. if err != nil {
  58. return nil, fmt.Errorf("failed to retrieve feedbag: %w", err)
  59. }
  60. reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  61. if !ok {
  62. return nil, fmt.Errorf("failed to retrieve feedbag: unexpected reply type")
  63. }
  64. items := reply.Items
  65. type buddy struct {
  66. name string
  67. alias string
  68. }
  69. type group struct {
  70. name string
  71. buddies map[uint16]buddy
  72. order []uint16
  73. }
  74. type feedbagBL struct {
  75. order []uint16
  76. groups map[uint16]group
  77. }
  78. bl := feedbagBL{groups: make(map[uint16]group)}
  79. for _, item := range items {
  80. if item.ClassID != wire.FeedbagClassIdGroup {
  81. continue
  82. }
  83. if item.GroupID == 0 {
  84. val, hasVal := item.Uint16SliceBE(wire.FeedbagAttributesOrder)
  85. if hasVal {
  86. bl.order = val
  87. }
  88. continue
  89. }
  90. name := item.Name
  91. if name == "" {
  92. name = "Buddies"
  93. }
  94. g := group{
  95. name: name,
  96. buddies: make(map[uint16]buddy),
  97. }
  98. val, _ := item.Uint16SliceBE(wire.FeedbagAttributesOrder)
  99. g.order = val
  100. bl.groups[item.GroupID] = g
  101. }
  102. for _, item := range items {
  103. if item.ClassID != wire.FeedbagClassIdBuddy || item.Name == "" {
  104. continue
  105. }
  106. if _, exists := bl.groups[item.GroupID]; !exists {
  107. bl.groups[item.GroupID] = group{
  108. name: "Buddies",
  109. buddies: make(map[uint16]buddy),
  110. order: nil,
  111. }
  112. }
  113. b := buddy{name: item.Name}
  114. if val, hasVal := item.String(wire.FeedbagAttributesAlias); hasVal {
  115. b.alias = val
  116. }
  117. g := bl.groups[item.GroupID]
  118. g.buddies[item.ItemID] = b
  119. bl.groups[item.GroupID] = g
  120. }
  121. groupOrder := bl.order
  122. if len(groupOrder) == 0 && len(bl.groups) > 0 {
  123. groupOrder = make([]uint16, 0, len(bl.groups))
  124. for gid := range bl.groups {
  125. groupOrder = append(groupOrder, gid)
  126. }
  127. slices.Sort(groupOrder)
  128. }
  129. var out []WebAPIBuddyGroup
  130. for _, gid := range groupOrder {
  131. g, ok := bl.groups[gid]
  132. if !ok {
  133. continue
  134. }
  135. groupName := g.name
  136. if groupName == "" {
  137. groupName = "Buddies"
  138. }
  139. wg := WebAPIBuddyGroup{Name: groupName, Buddies: []WebAPIBuddyInfo{}}
  140. for _, bid := range g.order {
  141. b, ok := g.buddies[bid]
  142. if !ok {
  143. continue
  144. }
  145. info := m.getBuddyInfo(ctx, sess.OSCARSession, b.name)
  146. // The alias belongs in friendly, not displayId: the client renders
  147. // friendly in preference to displayId but still shows displayId as
  148. // the buddy's actual screen name.
  149. info.Friendly = b.alias
  150. wg.Buddies = append(wg.Buddies, info)
  151. }
  152. out = append(out, wg)
  153. }
  154. return out, nil
  155. }
  156. // getBuddyInfo retrieves a buddy's current presence by issuing a locate
  157. // UserInfoQuery on behalf of the requesting session's OSCAR instance.
  158. func (m *BuddyListManager) getBuddyInfo(ctx context.Context, instance *state.SessionInstance, buddyName string) WebAPIBuddyInfo {
  159. // Default to offline. The web client keys users by the normalized aimId and
  160. // shallow-merges each buddy map onto the shared user object, so a display-form
  161. // aimId here overwrites the id every other event is keyed by.
  162. //
  163. // Feedbag buddy names are stored normalized, so they are not a source of
  164. // display names. DisplayID is filled in from the locate reply below when the
  165. // buddy is online, or overridden by the caller's alias when one is set.
  166. ident := state.NewIdentScreenName(buddyName)
  167. info := WebAPIBuddyInfo{
  168. AimID: ident.String(),
  169. DisplayID: buddyName,
  170. State: "offline",
  171. UserType: "aim",
  172. Bot: false,
  173. Service: "AIM",
  174. }
  175. reply, err := m.locateService.UserInfoQuery(ctx, instance, wire.SNACFrame{},
  176. wire.SNAC_0x02_0x05_LocateUserInfoQuery{
  177. Type: uint16(wire.LocateTypeUnavailable), // away message
  178. ScreenName: ident.String(),
  179. })
  180. if err != nil {
  181. m.logger.WarnContext(ctx, "failed to query buddy info", "screenName", buddyName, "error", err)
  182. return info
  183. }
  184. userInfo, ok := reply.Body.(wire.SNAC_0x02_0x06_LocateUserInfoReply)
  185. if !ok {
  186. // Locate error => buddy is blocked or offline.
  187. return info
  188. }
  189. info.State = "online"
  190. info.Capabilities = []string{}
  191. // The locate reply carries the screen name as the buddy formatted it.
  192. if userInfo.ScreenName != "" {
  193. info.DisplayID = userInfo.ScreenName
  194. }
  195. if tod, ok := userInfo.Uint32BE(wire.OServiceUserInfoSignonTOD); ok {
  196. info.OnlineTime = int64(tod)
  197. }
  198. if userInfo.IsAway() {
  199. info.State = "away"
  200. if msg, ok := userInfo.LocateInfo.String(wire.LocateTLVTagsInfoUnavailableData); ok {
  201. info.AwayMsg = msg
  202. }
  203. }
  204. if idle, ok := userInfo.Uint16BE(wire.OServiceUserInfoIdleTime); ok && idle > 0 {
  205. info.IdleTime = int(idle)
  206. if info.State == "online" {
  207. info.State = "idle"
  208. }
  209. }
  210. return info
  211. }
  212. // RemoveBuddyFromFeedbag removes a buddy from a group (or all groups if allGroups is true) using feedbag delete/update SNACs.
  213. func (m *BuddyListManager) RemoveBuddyFromFeedbag(ctx context.Context, sess *state.WebAPISession, buddyName, groupName string, allGroups bool) (resultCode string, err error) {
  214. // Buddy items carry the owner's alias for the buddy, and the feedbag service
  215. // relays a session's own writes only to the owner's other instances, so every
  216. // method here that rewrites buddy items has to drop the alias cache itself.
  217. defer sess.InvalidateAliases()
  218. buddyName = strings.TrimSpace(buddyName)
  219. if buddyName == "" {
  220. return "error", fmt.Errorf("empty buddy")
  221. }
  222. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  223. snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
  224. if err != nil {
  225. m.logger.ErrorContext(ctx, "remove buddy: feedbag query failed", "err", err.Error())
  226. return "error", err
  227. }
  228. reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  229. if !ok {
  230. return "error", fmt.Errorf("unexpected feedbag reply type")
  231. }
  232. fl := state.NewFeedbagList(reply.Items, rand.Intn)
  233. target := groupName
  234. if allGroups {
  235. target = "*"
  236. }
  237. if err := fl.DeleteBuddy(target, buddyName); err != nil {
  238. if errors.Is(err, state.ErrGroupNotFound) {
  239. return "notFound", nil
  240. }
  241. m.logger.ErrorContext(ctx, "remove buddy: DeleteBuddy failed", "err", err.Error())
  242. return "error", err
  243. }
  244. if pending := fl.PendingDeletes(); len(pending) > 0 {
  245. delFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem}
  246. delBody := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{Items: pending}
  247. if _, err := m.feedbagService.DeleteItem(ctx, sess.OSCARSession, delFrame, delBody); err != nil {
  248. m.logger.ErrorContext(ctx, "remove buddy: Feedbag DeleteItem failed", "err", err.Error())
  249. return "error", err
  250. }
  251. } else {
  252. return "notFound", nil
  253. }
  254. if pending := fl.PendingUpdates(); len(pending) > 0 {
  255. upFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
  256. if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, upFrame, pending); err != nil {
  257. m.logger.ErrorContext(ctx, "remove buddy: Feedbag UpsertItem failed", "err", err.Error())
  258. return "error", err
  259. }
  260. }
  261. return "success", nil
  262. }
  263. // RemoveGroupFromFeedbag deletes a buddy group and updates the root order (TOC DelGroup).
  264. func (m *BuddyListManager) RemoveGroupFromFeedbag(ctx context.Context, sess *state.WebAPISession, requestedGroup string) (resultCode string, err error) {
  265. defer sess.InvalidateAliases()
  266. req := strings.TrimSpace(requestedGroup)
  267. if req == "" {
  268. return "error", fmt.Errorf("empty group")
  269. }
  270. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  271. snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
  272. if err != nil {
  273. m.logger.ErrorContext(ctx, "remove group: feedbag query failed", "err", err.Error())
  274. return "error", err
  275. }
  276. reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  277. if !ok {
  278. return "error", fmt.Errorf("unexpected feedbag reply type")
  279. }
  280. storedName, found := storedGroupNameForRequest(reply.Items, req)
  281. if !found {
  282. return "notFound", nil
  283. }
  284. fl := state.NewFeedbagList(reply.Items, rand.Intn)
  285. fl.DeleteGroup(storedName)
  286. if pending := fl.PendingDeletes(); len(pending) > 0 {
  287. delFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem}
  288. delBody := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{Items: pending}
  289. if _, err := m.feedbagService.DeleteItem(ctx, sess.OSCARSession, delFrame, delBody); err != nil {
  290. m.logger.ErrorContext(ctx, "remove group: Feedbag DeleteItem failed", "err", err.Error())
  291. return "error", err
  292. }
  293. } else {
  294. return "notFound", nil
  295. }
  296. if pending := fl.PendingUpdates(); len(pending) > 0 {
  297. upFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
  298. if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, upFrame, pending); err != nil {
  299. m.logger.ErrorContext(ctx, "remove group: Feedbag UpsertItem failed", "err", err.Error())
  300. return "error", err
  301. }
  302. }
  303. return "success", nil
  304. }
  305. // RenameGroupInFeedbag renames a buddy group, updating the group item in place.
  306. func (m *BuddyListManager) RenameGroupInFeedbag(ctx context.Context, sess *state.WebAPISession, oldGroup, newGroup string) (resultCode string, err error) {
  307. defer sess.InvalidateAliases()
  308. oldGroup = strings.TrimSpace(oldGroup)
  309. newGroup = strings.TrimSpace(newGroup)
  310. if oldGroup == "" || newGroup == "" {
  311. return "error", fmt.Errorf("empty group name")
  312. }
  313. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  314. snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
  315. if err != nil {
  316. m.logger.ErrorContext(ctx, "rename group: feedbag query failed", "err", err.Error())
  317. return "error", err
  318. }
  319. reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  320. if !ok {
  321. return "error", fmt.Errorf("unexpected feedbag reply type")
  322. }
  323. storedName, found := storedGroupNameForRequest(reply.Items, oldGroup)
  324. if !found {
  325. return "notFound", nil
  326. }
  327. fl := state.NewFeedbagList(reply.Items, rand.Intn)
  328. if err := fl.RenameGroup(storedName, newGroup); err != nil {
  329. switch {
  330. case errors.Is(err, state.ErrGroupNotFound):
  331. return "notFound", nil
  332. case errors.Is(err, state.ErrGroupExists):
  333. return "alreadyExists", nil
  334. default:
  335. m.logger.ErrorContext(ctx, "rename group: RenameGroup failed", "err", err.Error())
  336. return "error", err
  337. }
  338. }
  339. if pending := fl.PendingUpdates(); len(pending) > 0 {
  340. upFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
  341. if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, upFrame, pending); err != nil {
  342. m.logger.ErrorContext(ctx, "rename group: Feedbag UpsertItem failed", "err", err.Error())
  343. return "error", err
  344. }
  345. }
  346. return "success", nil
  347. }
  348. // MoveBuddyInFeedbag moves a buddy to a different group and/or repositions it
  349. // within a group's order.
  350. func (m *BuddyListManager) MoveBuddyInFeedbag(ctx context.Context, sess *state.WebAPISession, buddyName, fromGroup, toGroup, beforeBuddy string) (resultCode string, err error) {
  351. defer sess.InvalidateAliases()
  352. buddyName = strings.TrimSpace(buddyName)
  353. fromGroup = strings.TrimSpace(fromGroup)
  354. toGroup = strings.TrimSpace(toGroup)
  355. beforeBuddy = strings.TrimSpace(beforeBuddy)
  356. if buddyName == "" || fromGroup == "" {
  357. return "error", fmt.Errorf("empty buddy or group")
  358. }
  359. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  360. snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
  361. if err != nil {
  362. m.logger.ErrorContext(ctx, "move buddy: feedbag query failed", "err", err.Error())
  363. return "error", err
  364. }
  365. reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  366. if !ok {
  367. return "error", fmt.Errorf("unexpected feedbag reply type")
  368. }
  369. storedFrom, found := storedGroupNameForRequest(reply.Items, fromGroup)
  370. if !found {
  371. return "notFound", nil
  372. }
  373. storedTo := ""
  374. if toGroup != "" {
  375. storedTo, found = storedGroupNameForRequest(reply.Items, toGroup)
  376. if !found {
  377. return "notFound", nil
  378. }
  379. }
  380. fl := state.NewFeedbagList(reply.Items, rand.Intn)
  381. if err := fl.MoveBuddy(storedFrom, storedTo, buddyName, beforeBuddy); err != nil {
  382. if errors.Is(err, state.ErrGroupNotFound) || errors.Is(err, state.ErrBuddyNotFound) {
  383. return "notFound", nil
  384. }
  385. m.logger.ErrorContext(ctx, "move buddy: MoveBuddy failed", "err", err.Error())
  386. return "error", err
  387. }
  388. if pending := fl.PendingDeletes(); len(pending) > 0 {
  389. delFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem}
  390. delBody := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{Items: pending}
  391. if _, err := m.feedbagService.DeleteItem(ctx, sess.OSCARSession, delFrame, delBody); err != nil {
  392. m.logger.ErrorContext(ctx, "move buddy: Feedbag DeleteItem failed", "err", err.Error())
  393. return "error", err
  394. }
  395. }
  396. if pending := fl.PendingUpdates(); len(pending) > 0 {
  397. var inserts, updates []wire.FeedbagItem
  398. for _, item := range pending {
  399. if item.ClassID == wire.FeedbagClassIdBuddy {
  400. inserts = append(inserts, item)
  401. } else {
  402. updates = append(updates, item)
  403. }
  404. }
  405. if len(inserts) > 0 {
  406. insFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
  407. if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, insFrame, inserts); err != nil {
  408. m.logger.ErrorContext(ctx, "move buddy: Feedbag InsertItem failed", "err", err.Error())
  409. return "error", err
  410. }
  411. }
  412. if len(updates) > 0 {
  413. upFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
  414. if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, upFrame, updates); err != nil {
  415. m.logger.ErrorContext(ctx, "move buddy: Feedbag UpdateItem failed", "err", err.Error())
  416. return "error", err
  417. }
  418. }
  419. }
  420. return "success", nil
  421. }
  422. // SetBuddyAttributeInFeedbag sets a buddy's friendly (alias) name across all
  423. // groups it belongs to. An empty friendly clears the alias.
  424. func (m *BuddyListManager) SetBuddyAttributeInFeedbag(ctx context.Context, sess *state.WebAPISession, buddyName, friendly string) (resultCode string, err error) {
  425. defer sess.InvalidateAliases()
  426. buddyName = strings.TrimSpace(buddyName)
  427. if buddyName == "" {
  428. return "error", fmt.Errorf("empty buddy")
  429. }
  430. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  431. snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
  432. if err != nil {
  433. m.logger.ErrorContext(ctx, "set buddy attribute: feedbag query failed", "err", err.Error())
  434. return "error", err
  435. }
  436. reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  437. if !ok {
  438. return "error", fmt.Errorf("unexpected feedbag reply type")
  439. }
  440. fl := state.NewFeedbagList(reply.Items, rand.Intn)
  441. found, err := fl.SetBuddyAlias(buddyName, friendly)
  442. if err != nil {
  443. m.logger.ErrorContext(ctx, "set buddy attribute: SetBuddyAlias failed", "err", err.Error())
  444. return "error", err
  445. }
  446. if !found {
  447. return "notFound", nil
  448. }
  449. if pending := fl.PendingUpdates(); len(pending) > 0 {
  450. upFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
  451. if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, upFrame, pending); err != nil {
  452. m.logger.ErrorContext(ctx, "set buddy attribute: Feedbag UpsertItem failed", "err", err.Error())
  453. return "error", err
  454. }
  455. }
  456. return "success", nil
  457. }
  458. // SetGroupAttributeInFeedbag sets a group's collapsed state. An empty group
  459. // targets the unnamed default group.
  460. func (m *BuddyListManager) SetGroupAttributeInFeedbag(ctx context.Context, sess *state.WebAPISession, groupName string, collapsed bool) (resultCode string, err error) {
  461. defer sess.InvalidateAliases()
  462. groupName = strings.TrimSpace(groupName)
  463. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  464. snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
  465. if err != nil {
  466. m.logger.ErrorContext(ctx, "set group attribute: feedbag query failed", "err", err.Error())
  467. return "error", err
  468. }
  469. reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  470. if !ok {
  471. return "error", fmt.Errorf("unexpected feedbag reply type")
  472. }
  473. // An empty group targets the unnamed default group (stored name ""); a named
  474. // group is resolved through the "Buddies" default-label mapping.
  475. storedName := ""
  476. if groupName != "" {
  477. var found bool
  478. storedName, found = storedGroupNameForRequest(reply.Items, groupName)
  479. if !found {
  480. return "notFound", nil
  481. }
  482. }
  483. fl := state.NewFeedbagList(reply.Items, rand.Intn)
  484. if err := fl.SetGroupCollapsed(storedName, collapsed); err != nil {
  485. if errors.Is(err, state.ErrGroupNotFound) {
  486. return "notFound", nil
  487. }
  488. m.logger.ErrorContext(ctx, "set group attribute: SetGroupCollapsed failed", "err", err.Error())
  489. return "error", err
  490. }
  491. if pending := fl.PendingUpdates(); len(pending) > 0 {
  492. upFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
  493. if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, upFrame, pending); err != nil {
  494. m.logger.ErrorContext(ctx, "set group attribute: Feedbag UpsertItem failed", "err", err.Error())
  495. return "error", err
  496. }
  497. }
  498. return "success", nil
  499. }
  500. // FormatBuddyListEvent formats a buddy list for an event.
  501. func (m *BuddyListManager) FormatBuddyListEvent(groups []WebAPIBuddyGroup) map[string]interface{} {
  502. // Convert groups to a format that AMF3 can properly encode
  503. // AMF3 has trouble with complex struct slices, so convert to maps
  504. groupMaps := make([]interface{}, len(groups))
  505. for i, group := range groups {
  506. buddyMaps := make([]interface{}, len(group.Buddies))
  507. for j, buddy := range group.Buddies {
  508. // Convert each buddy to a map
  509. buddyMap := map[string]interface{}{
  510. "aimId": buddy.AimID,
  511. "displayId": buddy.DisplayID,
  512. "state": buddy.State,
  513. "userType": buddy.UserType,
  514. "bot": buddy.Bot,
  515. "service": buddy.Service,
  516. }
  517. // Add optional fields if present
  518. if buddy.StatusMsg != "" {
  519. buddyMap["statusMsg"] = buddy.StatusMsg
  520. }
  521. if buddy.AwayMsg != "" {
  522. buddyMap["awayMsg"] = buddy.AwayMsg
  523. }
  524. if buddy.OnlineTime > 0 {
  525. buddyMap["onlineTime"] = float64(buddy.OnlineTime)
  526. }
  527. if buddy.IdleTime > 0 {
  528. buddyMap["idleTime"] = buddy.IdleTime
  529. }
  530. if buddy.PresenceIcon != "" {
  531. buddyMap["presenceIcon"] = buddy.PresenceIcon
  532. }
  533. if buddy.BuddyIcon != "" {
  534. buddyMap["buddyIcon"] = buddy.BuddyIcon
  535. }
  536. if len(buddy.Capabilities) > 0 {
  537. buddyMap["capabilities"] = buddy.Capabilities
  538. }
  539. if buddy.MemberSince > 0 {
  540. buddyMap["memberSince"] = float64(buddy.MemberSince)
  541. }
  542. buddyMaps[j] = buddyMap
  543. }
  544. // Convert group to a map
  545. groupMap := map[string]interface{}{
  546. "name": group.Name,
  547. "buddies": buddyMaps,
  548. }
  549. // Add optional group fields
  550. if group.Recent {
  551. groupMap["recent"] = group.Recent
  552. }
  553. if group.Smart != nil {
  554. groupMap["smart"] = group.Smart
  555. }
  556. groupMaps[i] = groupMap
  557. }
  558. return map[string]interface{}{
  559. "groups": groupMaps,
  560. }
  561. }