buddy_list_manager.go 22 KB

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