buddy_list_manager.go 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. package webapi
  2. import (
  3. "context"
  4. "errors"
  5. "fmt"
  6. "log/slog"
  7. "math/rand"
  8. "strings"
  9. "github.com/mk6i/open-oscar-server/state"
  10. "github.com/mk6i/open-oscar-server/wire"
  11. )
  12. // BuddyListManager handles the conversion of OSCAR feedbag data
  13. // to WebAPI buddy list format for web clients.
  14. type BuddyListManager struct {
  15. feedbagService FeedbagService
  16. locateService LocateService
  17. iconSource BuddyIconSource
  18. logger *slog.Logger
  19. }
  20. // NewBuddyListManager creates a new instance of the buddy list manager.
  21. func NewBuddyListManager(feedbagService FeedbagService, locateService LocateService, iconSource BuddyIconSource, logger *slog.Logger) *BuddyListManager {
  22. return &BuddyListManager{
  23. feedbagService: feedbagService,
  24. locateService: locateService,
  25. iconSource: iconSource,
  26. logger: logger,
  27. }
  28. }
  29. // BuddyGroup represents a group in the WebAPI buddy list format.
  30. type BuddyGroup struct {
  31. Name string `json:"name" xml:"name"`
  32. // ID is the feedbag group id, read strictly by clients. It follows Name to keep
  33. // the rendered element order unchanged.
  34. ID int `json:"id" xml:"id"`
  35. Buddies []BuddyInfo `json:"buddies" xml:"buddies>buddy"`
  36. Recent bool `json:"recent,omitempty" xml:"recent,omitempty"`
  37. // Smart is null or a number. It is a pointer rather than an interface so
  38. // encoding/xml can render it: a non-nil interface holding a map cannot be
  39. // marshalled, while a nil pointer is simply omitted.
  40. Smart *int `json:"smart,omitempty" xml:"smart,omitempty"`
  41. }
  42. // BuddyInfo represents a buddy in the WebAPI format.
  43. type BuddyInfo struct {
  44. AimID string `json:"aimId" xml:"aimId"`
  45. DisplayID string `json:"displayId" xml:"displayId"`
  46. Friendly string `json:"friendly,omitempty" xml:"friendly,omitempty"` // Viewer's private alias, rendered in preference to DisplayID
  47. State string `json:"state" xml:"state"` // "online", "offline", "away", "idle"
  48. StatusMsg string `json:"statusMsg,omitempty" xml:"statusMsg,omitempty"`
  49. AwayMsg string `json:"awayMsg,omitempty" xml:"awayMsg,omitempty"`
  50. OnlineTime int64 `json:"onlineTime,omitempty" xml:"onlineTime,omitempty"`
  51. IdleTime int `json:"idleTime,omitempty" xml:"idleTime,omitempty"` // Minutes idle
  52. UserType string `json:"userType" xml:"userType"` // "aim", "icq"
  53. Bot bool `json:"bot" xml:"bot"`
  54. Service string `json:"service,omitempty" xml:"service,omitempty"` // Non-native network; omitted for AIM
  55. PresenceIcon string `json:"presenceIcon,omitempty" xml:"presenceIcon,omitempty"`
  56. BuddyIcon string `json:"buddyIcon,omitempty" xml:"buddyIcon,omitempty"`
  57. MoodIcon string `json:"moodIcon,omitempty" xml:"moodIcon,omitempty"`
  58. Capabilities []string `json:"capabilities,omitempty" xml:"capabilities>capability,omitempty"`
  59. MemberSince int64 `json:"memberSince,omitempty" xml:"memberSince,omitempty"`
  60. }
  61. // GetBuddyListForUser retrieves and converts the buddy list for a user.
  62. func (m *BuddyListManager) GetBuddyListForUser(ctx context.Context, sess *Session) ([]BuddyGroup, error) {
  63. items, err := sess.Feedbag(ctx)
  64. if err != nil {
  65. return nil, fmt.Errorf("failed to retrieve feedbag: %w", err)
  66. }
  67. fl := state.NewFeedbagList(items, nil)
  68. out := []BuddyGroup{}
  69. for _, group := range fl.Groups() {
  70. groupName := group.Name
  71. if groupName == "" {
  72. groupName = "Buddies"
  73. }
  74. wg := BuddyGroup{ID: int(group.GroupID), Name: groupName, Buddies: []BuddyInfo{}}
  75. for _, buddy := range fl.Buddies(group) {
  76. if buddy.Name == "" {
  77. continue
  78. }
  79. info := m.getBuddyInfo(ctx, sess, buddy.Name)
  80. // The alias belongs in friendly, not displayId: the client renders
  81. // friendly in preference to displayId but still shows displayId as
  82. // the buddy's actual screen name.
  83. info.Friendly, _ = buddy.String(wire.FeedbagAttributesAlias)
  84. wg.Buddies = append(wg.Buddies, info)
  85. }
  86. out = append(out, wg)
  87. }
  88. return out, nil
  89. }
  90. // getBuddyInfo renders a buddy's presence from the session's presence view. A
  91. // buddy the view has never seen renders offline, which at sign-on includes one
  92. // whose arrival has not been drained yet; the presence events correct that.
  93. //
  94. // AwayMsg costs a locate query per unavailable buddy, since an away message is
  95. // absent from presence broadcasts.
  96. func (m *BuddyListManager) getBuddyInfo(ctx context.Context, sess *Session, buddyName string) BuddyInfo {
  97. // Default to offline. The web client keys users by the normalized aimId and
  98. // shallow-merges each buddy map onto the shared user object, so a display-form
  99. // aimId here overwrites the id every other event is keyed by.
  100. //
  101. // Feedbag buddy names are stored normalized, so they are not a source of
  102. // display names. DisplayID is filled in from the presence view below when the
  103. // buddy is online, or overridden by the caller's alias when one is set.
  104. ident := state.NewIdentScreenName(buddyName)
  105. info := BuddyInfo{
  106. AimID: ident.String(),
  107. DisplayID: buddyName,
  108. State: "offline",
  109. UserType: userTypeFor(ident),
  110. Service: serviceFor(ident),
  111. Bot: false,
  112. }
  113. presence, ok := sess.BuddyPresence(ident)
  114. if !ok {
  115. return info
  116. }
  117. // A departure keeps the last display name seen, which beats the normalized
  118. // feedbag spelling.
  119. if presence.DisplayID != "" {
  120. info.DisplayID = presence.DisplayID
  121. }
  122. info.State = presence.State
  123. info.StatusMsg = presence.StatusMsg
  124. info.OnlineTime = presence.OnlineTime
  125. info.IdleTime = presence.IdleTime
  126. if !presence.Online() {
  127. return info
  128. }
  129. if hasAwayMsg(presence.State) {
  130. info.AwayMsg = awayMessage(ctx, m.locateService, sess.OSCARSession, ident, m.logger)
  131. }
  132. info.Capabilities = []string{}
  133. info.BuddyIcon = m.iconSource.URLForHash(sess.BaseURL, ident, presence.IconHash)
  134. info.MoodIcon = moodIconURL(sess.BaseURL, presence.State, presence.Caps)
  135. return info
  136. }
  137. // RemoveBuddyFromFeedbag removes a buddy from a group (or all groups if allGroups is true) using feedbag delete/update SNACs.
  138. func (m *BuddyListManager) RemoveBuddyFromFeedbag(ctx context.Context, sess *Session, buddyName, groupName string, allGroups bool) (resultCode string, err error) {
  139. defer sess.InvalidateFeedbag()
  140. buddyName = strings.TrimSpace(buddyName)
  141. if buddyName == "" {
  142. return "error", fmt.Errorf("empty buddy")
  143. }
  144. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  145. snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
  146. if err != nil {
  147. m.logger.ErrorContext(ctx, "remove buddy: feedbag query failed", "err", err.Error())
  148. return "error", err
  149. }
  150. reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  151. if !ok {
  152. return "error", fmt.Errorf("unexpected feedbag reply type")
  153. }
  154. fl := state.NewFeedbagList(reply.Items, rand.Intn)
  155. target := groupName
  156. if allGroups {
  157. target = "*"
  158. }
  159. if err := fl.DeleteBuddy(target, buddyName); err != nil {
  160. if errors.Is(err, state.ErrGroupNotFound) {
  161. return "notFound", nil
  162. }
  163. m.logger.ErrorContext(ctx, "remove buddy: DeleteBuddy failed", "err", err.Error())
  164. return "error", err
  165. }
  166. if pending := fl.PendingDeletes(); len(pending) > 0 {
  167. delFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem}
  168. delBody := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{Items: pending}
  169. if _, err := m.feedbagService.DeleteItem(ctx, sess.OSCARSession, delFrame, delBody); err != nil {
  170. m.logger.ErrorContext(ctx, "remove buddy: Feedbag DeleteItem failed", "err", err.Error())
  171. return "error", err
  172. }
  173. if !stillListsBuddy(fl.Items(), buddyName) {
  174. sess.forgetBuddyPresence(state.NewIdentScreenName(buddyName))
  175. }
  176. } else {
  177. return "notFound", nil
  178. }
  179. if pending := fl.PendingUpdates(); len(pending) > 0 {
  180. upFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
  181. if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, upFrame, pending); err != nil {
  182. m.logger.ErrorContext(ctx, "remove buddy: Feedbag UpsertItem failed", "err", err.Error())
  183. return "error", err
  184. }
  185. }
  186. return "success", nil
  187. }
  188. // RemoveGroupFromFeedbag deletes a buddy group and updates the root order (TOC DelGroup).
  189. func (m *BuddyListManager) RemoveGroupFromFeedbag(ctx context.Context, sess *Session, requestedGroup string) (resultCode string, err error) {
  190. defer sess.InvalidateFeedbag()
  191. req := strings.TrimSpace(requestedGroup)
  192. if req == "" {
  193. return "error", fmt.Errorf("empty group")
  194. }
  195. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  196. snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
  197. if err != nil {
  198. m.logger.ErrorContext(ctx, "remove group: feedbag query failed", "err", err.Error())
  199. return "error", err
  200. }
  201. reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  202. if !ok {
  203. return "error", fmt.Errorf("unexpected feedbag reply type")
  204. }
  205. storedName, found := storedGroupNameForRequest(reply.Items, req)
  206. if !found {
  207. return "notFound", nil
  208. }
  209. fl := state.NewFeedbagList(reply.Items, rand.Intn)
  210. fl.DeleteGroup(storedName)
  211. if pending := fl.PendingDeletes(); len(pending) > 0 {
  212. delFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem}
  213. delBody := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{Items: pending}
  214. if _, err := m.feedbagService.DeleteItem(ctx, sess.OSCARSession, delFrame, delBody); err != nil {
  215. m.logger.ErrorContext(ctx, "remove group: Feedbag DeleteItem failed", "err", err.Error())
  216. return "error", err
  217. }
  218. remaining := fl.Items()
  219. for _, item := range pending {
  220. if item.ClassID != wire.FeedbagClassIdBuddy || item.Name == "" {
  221. continue
  222. }
  223. if !stillListsBuddy(remaining, item.Name) {
  224. sess.forgetBuddyPresence(state.NewIdentScreenName(item.Name))
  225. }
  226. }
  227. } else {
  228. return "notFound", nil
  229. }
  230. if pending := fl.PendingUpdates(); len(pending) > 0 {
  231. upFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
  232. if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, upFrame, pending); err != nil {
  233. m.logger.ErrorContext(ctx, "remove group: Feedbag UpsertItem failed", "err", err.Error())
  234. return "error", err
  235. }
  236. }
  237. return "success", nil
  238. }
  239. // RenameGroupInFeedbag renames a buddy group, updating the group item in place.
  240. func (m *BuddyListManager) RenameGroupInFeedbag(ctx context.Context, sess *Session, oldGroup, newGroup string) (resultCode string, err error) {
  241. defer sess.InvalidateFeedbag()
  242. oldGroup = strings.TrimSpace(oldGroup)
  243. newGroup = strings.TrimSpace(newGroup)
  244. if oldGroup == "" || newGroup == "" {
  245. return "error", fmt.Errorf("empty group name")
  246. }
  247. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  248. snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
  249. if err != nil {
  250. m.logger.ErrorContext(ctx, "rename group: feedbag query failed", "err", err.Error())
  251. return "error", err
  252. }
  253. reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  254. if !ok {
  255. return "error", fmt.Errorf("unexpected feedbag reply type")
  256. }
  257. storedName, found := storedGroupNameForRequest(reply.Items, oldGroup)
  258. if !found {
  259. return "notFound", nil
  260. }
  261. fl := state.NewFeedbagList(reply.Items, rand.Intn)
  262. if err := fl.RenameGroup(storedName, newGroup); err != nil {
  263. switch {
  264. case errors.Is(err, state.ErrGroupNotFound):
  265. return "notFound", nil
  266. case errors.Is(err, state.ErrGroupExists):
  267. return "alreadyExists", nil
  268. default:
  269. m.logger.ErrorContext(ctx, "rename group: RenameGroup failed", "err", err.Error())
  270. return "error", err
  271. }
  272. }
  273. if pending := fl.PendingUpdates(); len(pending) > 0 {
  274. upFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
  275. if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, upFrame, pending); err != nil {
  276. m.logger.ErrorContext(ctx, "rename group: Feedbag UpsertItem failed", "err", err.Error())
  277. return "error", err
  278. }
  279. }
  280. return "success", nil
  281. }
  282. // MoveBuddyInFeedbag moves a buddy to a different group and/or repositions it
  283. // within a group's order.
  284. func (m *BuddyListManager) MoveBuddyInFeedbag(ctx context.Context, sess *Session, buddyName, fromGroup, toGroup, beforeBuddy string) (resultCode string, err error) {
  285. defer sess.InvalidateFeedbag()
  286. buddyName = strings.TrimSpace(buddyName)
  287. fromGroup = strings.TrimSpace(fromGroup)
  288. toGroup = strings.TrimSpace(toGroup)
  289. beforeBuddy = strings.TrimSpace(beforeBuddy)
  290. if buddyName == "" || fromGroup == "" {
  291. return "error", fmt.Errorf("empty buddy or group")
  292. }
  293. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  294. snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
  295. if err != nil {
  296. m.logger.ErrorContext(ctx, "move buddy: feedbag query failed", "err", err.Error())
  297. return "error", err
  298. }
  299. reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  300. if !ok {
  301. return "error", fmt.Errorf("unexpected feedbag reply type")
  302. }
  303. storedFrom, found := storedGroupNameForRequest(reply.Items, fromGroup)
  304. if !found {
  305. return "notFound", nil
  306. }
  307. storedTo := ""
  308. if toGroup != "" {
  309. storedTo, found = storedGroupNameForRequest(reply.Items, toGroup)
  310. if !found {
  311. return "notFound", nil
  312. }
  313. }
  314. fl := state.NewFeedbagList(reply.Items, rand.Intn)
  315. if err := fl.MoveBuddy(storedFrom, storedTo, buddyName, beforeBuddy); err != nil {
  316. if errors.Is(err, state.ErrGroupNotFound) || errors.Is(err, state.ErrBuddyNotFound) {
  317. return "notFound", nil
  318. }
  319. m.logger.ErrorContext(ctx, "move buddy: MoveBuddy failed", "err", err.Error())
  320. return "error", err
  321. }
  322. if pending := fl.PendingDeletes(); len(pending) > 0 {
  323. delFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagDeleteItem}
  324. delBody := wire.SNAC_0x13_0x0A_FeedbagDeleteItem{Items: pending}
  325. if _, err := m.feedbagService.DeleteItem(ctx, sess.OSCARSession, delFrame, delBody); err != nil {
  326. m.logger.ErrorContext(ctx, "move buddy: Feedbag DeleteItem failed", "err", err.Error())
  327. return "error", err
  328. }
  329. }
  330. if pending := fl.PendingUpdates(); len(pending) > 0 {
  331. var inserts, updates []wire.FeedbagItem
  332. for _, item := range pending {
  333. if item.ClassID == wire.FeedbagClassIdBuddy {
  334. inserts = append(inserts, item)
  335. } else {
  336. updates = append(updates, item)
  337. }
  338. }
  339. if len(inserts) > 0 {
  340. insFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
  341. if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, insFrame, inserts); err != nil {
  342. m.logger.ErrorContext(ctx, "move buddy: Feedbag InsertItem failed", "err", err.Error())
  343. return "error", err
  344. }
  345. }
  346. if len(updates) > 0 {
  347. upFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
  348. if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, upFrame, updates); err != nil {
  349. m.logger.ErrorContext(ctx, "move buddy: Feedbag UpdateItem failed", "err", err.Error())
  350. return "error", err
  351. }
  352. }
  353. }
  354. return "success", nil
  355. }
  356. // SetBuddyAttributeInFeedbag sets a buddy's friendly (alias) name across all
  357. // groups it belongs to. An empty friendly clears the alias.
  358. func (m *BuddyListManager) SetBuddyAttributeInFeedbag(ctx context.Context, sess *Session, buddyName, friendly string) (resultCode string, err error) {
  359. defer sess.InvalidateFeedbag()
  360. buddyName = strings.TrimSpace(buddyName)
  361. if buddyName == "" {
  362. return "error", fmt.Errorf("empty buddy")
  363. }
  364. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  365. snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
  366. if err != nil {
  367. m.logger.ErrorContext(ctx, "set buddy attribute: feedbag query failed", "err", err.Error())
  368. return "error", err
  369. }
  370. reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  371. if !ok {
  372. return "error", fmt.Errorf("unexpected feedbag reply type")
  373. }
  374. fl := state.NewFeedbagList(reply.Items, rand.Intn)
  375. found, err := fl.SetBuddyAlias(buddyName, friendly)
  376. if err != nil {
  377. m.logger.ErrorContext(ctx, "set buddy attribute: SetBuddyAlias failed", "err", err.Error())
  378. return "error", err
  379. }
  380. if !found {
  381. return "notFound", nil
  382. }
  383. if pending := fl.PendingUpdates(); len(pending) > 0 {
  384. upFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
  385. if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, upFrame, pending); err != nil {
  386. m.logger.ErrorContext(ctx, "set buddy attribute: Feedbag UpsertItem failed", "err", err.Error())
  387. return "error", err
  388. }
  389. }
  390. return "success", nil
  391. }
  392. // SetGroupAttributeInFeedbag sets a group's collapsed state. An empty group
  393. // targets the unnamed default group.
  394. func (m *BuddyListManager) SetGroupAttributeInFeedbag(ctx context.Context, sess *Session, groupName string, collapsed bool) (resultCode string, err error) {
  395. defer sess.InvalidateFeedbag()
  396. groupName = strings.TrimSpace(groupName)
  397. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  398. snac, err := m.feedbagService.Query(ctx, sess.OSCARSession, frame)
  399. if err != nil {
  400. m.logger.ErrorContext(ctx, "set group attribute: feedbag query failed", "err", err.Error())
  401. return "error", err
  402. }
  403. reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  404. if !ok {
  405. return "error", fmt.Errorf("unexpected feedbag reply type")
  406. }
  407. // An empty group targets the unnamed default group (stored name ""); a named
  408. // group is resolved through the "Buddies" default-label mapping.
  409. storedName := ""
  410. if groupName != "" {
  411. var found bool
  412. storedName, found = storedGroupNameForRequest(reply.Items, groupName)
  413. if !found {
  414. return "notFound", nil
  415. }
  416. }
  417. fl := state.NewFeedbagList(reply.Items, rand.Intn)
  418. if err := fl.SetGroupCollapsed(storedName, collapsed); err != nil {
  419. if errors.Is(err, state.ErrGroupNotFound) {
  420. return "notFound", nil
  421. }
  422. m.logger.ErrorContext(ctx, "set group attribute: SetGroupCollapsed failed", "err", err.Error())
  423. return "error", err
  424. }
  425. if pending := fl.PendingUpdates(); len(pending) > 0 {
  426. upFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
  427. if _, err := m.feedbagService.UpsertItem(ctx, sess.OSCARSession, upFrame, pending); err != nil {
  428. m.logger.ErrorContext(ctx, "set group attribute: Feedbag UpsertItem failed", "err", err.Error())
  429. return "error", err
  430. }
  431. }
  432. return "success", nil
  433. }
  434. // stillListsBuddy reports whether buddyName appears among items. Callers deleting
  435. // a buddy from one group use it to tell a partial removal from a full one: a buddy
  436. // listed in several groups is still watched after leaving one of them, so their
  437. // cached presence is still maintained.
  438. func stillListsBuddy(items []wire.FeedbagItem, buddyName string) bool {
  439. want := state.NewIdentScreenName(buddyName)
  440. for _, item := range items {
  441. if item.ClassID != wire.FeedbagClassIdBuddy {
  442. continue
  443. }
  444. if state.NewIdentScreenName(item.Name) == want {
  445. return true
  446. }
  447. }
  448. return false
  449. }
  450. // feedbagGroupMatchesRequested returns true if a feedbag group row matches the
  451. // group the Web client asked for. OSCAR often stores the default group with an
  452. // empty name; GetBuddyListForUser labels that as "Buddies", so addBuddy must
  453. // treat "" and "Buddies" as the same bucket when the client sends group=Buddies.
  454. func feedbagGroupMatchesRequested(storedName, requested string) bool {
  455. req := strings.TrimSpace(requested)
  456. st := strings.TrimSpace(storedName)
  457. if strings.EqualFold(st, req) {
  458. return true
  459. }
  460. if strings.EqualFold(strings.TrimSpace(req), "Buddies") && st == "" {
  461. return true
  462. }
  463. return false
  464. }
  465. // storedGroupNameForRequest returns the feedbag group row Name for a Web client group label.
  466. // Rows with GroupID 0 are the root order record, not a named buddy group.
  467. func storedGroupNameForRequest(items []wire.FeedbagItem, requested string) (string, bool) {
  468. for _, item := range items {
  469. if item.ClassID != wire.FeedbagClassIdGroup {
  470. continue
  471. }
  472. if item.GroupID == 0 {
  473. continue
  474. }
  475. if feedbagGroupMatchesRequested(item.Name, requested) {
  476. return item.Name, true
  477. }
  478. }
  479. return "", false
  480. }