buddylist_handler.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. package webapi
  2. import (
  3. "context"
  4. "log/slog"
  5. "math/rand"
  6. "net/http"
  7. "strings"
  8. "github.com/mk6i/open-oscar-server/state"
  9. "github.com/mk6i/open-oscar-server/wire"
  10. )
  11. // BuddyListHandler handles Web AIM API buddy list management endpoints.
  12. type BuddyListHandler struct {
  13. BuddyListManager *BuddyListManager
  14. Logger *slog.Logger
  15. FeedbagService FeedbagService
  16. }
  17. // AddBuddy handles GET /buddylist/addBuddy requests.
  18. func (h *BuddyListHandler) AddBuddy(w http.ResponseWriter, r *http.Request, session *Session) {
  19. ctx := r.Context()
  20. aimsid := r.URL.Query().Get("aimsid")
  21. buddyName := strings.TrimSpace(r.URL.Query().Get("buddy"))
  22. groupName := strings.TrimSpace(r.URL.Query().Get("group"))
  23. if buddyName == "" {
  24. SendError(w, r, http.StatusBadRequest, "missing buddy parameter")
  25. return
  26. }
  27. if groupName == "" {
  28. groupName = "Buddies" // Default group
  29. }
  30. // Add buddy to feedbag
  31. resultCode, buddyInfo := h.addBuddyToFeedbag(ctx, session, buddyName, groupName)
  32. // Prepare response
  33. responseData := &ResultCodeData{ResultCode: resultCode}
  34. if resultCode == "success" {
  35. responseData.BuddyInfo = buddyInfo
  36. }
  37. SendOK(w, r, responseData, h.Logger)
  38. if resultCode == "success" {
  39. groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
  40. if err != nil {
  41. h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
  42. } else {
  43. blPayload := &BuddyListData{Groups: groups}
  44. session.EventQueue.Push(EventTypeBuddyList, blPayload)
  45. }
  46. }
  47. h.Logger.InfoContext(ctx, "buddy added",
  48. "aimsid", aimsid,
  49. "buddy", buddyName,
  50. "group", groupName,
  51. "result", resultCode,
  52. )
  53. }
  54. // AddGroup handles GET /buddylist/addGroup requests.
  55. func (h *BuddyListHandler) AddGroup(w http.ResponseWriter, r *http.Request, session *Session) {
  56. ctx := r.Context()
  57. aimsid := r.URL.Query().Get("aimsid")
  58. groupName := strings.TrimSpace(r.URL.Query().Get("group"))
  59. if groupName == "" {
  60. SendError(w, r, http.StatusBadRequest, "missing group parameter")
  61. return
  62. }
  63. resultCode := h.addGroupToFeedbag(ctx, session, groupName)
  64. SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
  65. if resultCode == "success" {
  66. groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
  67. if err != nil {
  68. h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
  69. } else {
  70. blPayload := &BuddyListData{Groups: groups}
  71. session.EventQueue.Push(EventTypeBuddyList, blPayload)
  72. }
  73. }
  74. h.Logger.InfoContext(ctx, "buddy list group added",
  75. "aimsid", aimsid,
  76. "group", groupName,
  77. "result", resultCode,
  78. )
  79. }
  80. func (h *BuddyListHandler) addGroupToFeedbag(ctx context.Context, sess *Session, groupName string) string {
  81. // A session sees no SNAC for its own feedbag writes, so it drops the alias
  82. // cache itself. See WebAPISession.InvalidateAliases.
  83. defer sess.InvalidateAliases()
  84. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  85. snac, err := h.FeedbagService.Query(ctx, sess.OSCARSession, frame)
  86. if err != nil {
  87. h.Logger.ErrorContext(ctx, "failed to retrieve feedbag", "err", err.Error())
  88. return "error"
  89. }
  90. reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  91. if !ok {
  92. return "error"
  93. }
  94. fl := state.NewFeedbagList(reply.Items, rand.Intn)
  95. fl.AddGroup(groupName)
  96. pending := fl.PendingUpdates()
  97. if len(pending) == 0 {
  98. return "alreadyExists"
  99. }
  100. insertFrame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
  101. if _, err := h.FeedbagService.UpsertItem(ctx, sess.OSCARSession, insertFrame, pending); err != nil {
  102. h.Logger.ErrorContext(ctx, "failed to add group", "err", err.Error())
  103. return "error"
  104. }
  105. return "success"
  106. }
  107. // RemoveBuddy handles GET /buddylist/removeBuddy requests.
  108. func (h *BuddyListHandler) RemoveBuddy(w http.ResponseWriter, r *http.Request, session *Session) {
  109. ctx := r.Context()
  110. aimsid := r.URL.Query().Get("aimsid")
  111. buddyName := strings.TrimSpace(r.URL.Query().Get("buddy"))
  112. groupName := strings.TrimSpace(r.URL.Query().Get("group"))
  113. allGroupsParam := r.URL.Query().Get("allGroups")
  114. allGroups := allGroupsParam == "true" || allGroupsParam == "1"
  115. if buddyName == "" {
  116. SendError(w, r, http.StatusBadRequest, "missing buddy parameter")
  117. return
  118. }
  119. resultCode, rmErr := h.BuddyListManager.RemoveBuddyFromFeedbag(ctx, session, buddyName, groupName, allGroups)
  120. if rmErr != nil {
  121. h.Logger.ErrorContext(ctx, "remove buddy failed", "err", rmErr.Error())
  122. }
  123. SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
  124. if resultCode == "success" {
  125. groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
  126. if err != nil {
  127. h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
  128. } else {
  129. blPayload := &BuddyListData{Groups: groups}
  130. session.EventQueue.Push(EventTypeBuddyList, blPayload)
  131. }
  132. }
  133. h.Logger.InfoContext(ctx, "buddy removed",
  134. "aimsid", aimsid,
  135. "buddy", buddyName,
  136. "group", groupName,
  137. "result", resultCode,
  138. )
  139. }
  140. // todo don't remove empty group?
  141. // RemoveGroup handles GET /buddylist/removeGroup requests.
  142. func (h *BuddyListHandler) RemoveGroup(w http.ResponseWriter, r *http.Request, session *Session) {
  143. ctx := r.Context()
  144. aimsid := r.URL.Query().Get("aimsid")
  145. groupName := strings.TrimSpace(r.URL.Query().Get("group"))
  146. if groupName == "" {
  147. SendError(w, r, http.StatusBadRequest, "missing group parameter")
  148. return
  149. }
  150. resultCode, rmErr := h.BuddyListManager.RemoveGroupFromFeedbag(ctx, session, groupName)
  151. if rmErr != nil {
  152. h.Logger.ErrorContext(ctx, "remove group failed", "err", rmErr.Error())
  153. }
  154. SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
  155. if resultCode == "success" {
  156. groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
  157. if err != nil {
  158. h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
  159. } else {
  160. blPayload := &BuddyListData{Groups: groups}
  161. session.EventQueue.Push(EventTypeBuddyList, blPayload)
  162. }
  163. }
  164. h.Logger.InfoContext(ctx, "buddy list group removed",
  165. "aimsid", aimsid,
  166. "group", groupName,
  167. "result", resultCode,
  168. )
  169. }
  170. // addBuddyToFeedbag adds a buddy to the user's feedbag.
  171. func (h *BuddyListHandler) addBuddyToFeedbag(ctx context.Context, sess *Session, buddyName, groupName string) (string, *BuddyPresenceInfo) {
  172. defer sess.InvalidateAliases()
  173. // Retrieve current feedbag
  174. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagQuery}
  175. snac, err := h.FeedbagService.Query(ctx, sess.OSCARSession, frame)
  176. if err != nil {
  177. h.Logger.ErrorContext(ctx, "failed to retrieve feedbag", "err", err.Error())
  178. return "error", nil
  179. }
  180. reply, ok := snac.Body.(wire.SNAC_0x13_0x06_FeedbagReply)
  181. if !ok {
  182. // todo what
  183. return "error", nil
  184. }
  185. fl := state.NewFeedbagList(reply.Items, rand.Intn)
  186. fl.AddGroup(groupName)
  187. if pending := fl.PendingUpdates(); len(pending) > 0 {
  188. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
  189. if _, err := h.FeedbagService.UpsertItem(ctx, sess.OSCARSession, frame, pending); err != nil {
  190. h.Logger.ErrorContext(ctx, "failed to add buddy", "err", err.Error())
  191. return "error", nil
  192. }
  193. }
  194. added, err := fl.AddBuddy(groupName, buddyName, "", "")
  195. if err != nil {
  196. h.Logger.ErrorContext(ctx, "failed to add buddy to feedbag", "err", err.Error())
  197. return "error", nil
  198. }
  199. if !added {
  200. return "alreadyExists", nil
  201. }
  202. if pending := fl.PendingUpdates(); len(pending) > 0 {
  203. buddyItems := make(map[uint16][]wire.FeedbagItem)
  204. for _, item := range pending {
  205. if item.ClassID == wire.FeedbagClassIdBuddy {
  206. if _, ok := buddyItems[item.GroupID]; !ok {
  207. buddyItems[item.GroupID] = nil
  208. }
  209. buddyItems[item.GroupID] = append(buddyItems[item.GroupID], item)
  210. }
  211. }
  212. for _, buddies := range buddyItems {
  213. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagInsertItem}
  214. if _, err := h.FeedbagService.UpsertItem(ctx, sess.OSCARSession, frame, buddies); err != nil {
  215. h.Logger.ErrorContext(ctx, "failed to add buddy", "err", err.Error())
  216. return "error", nil
  217. }
  218. }
  219. for _, item := range pending { // todo why not filter buddies out of pending?
  220. if item.ClassID == wire.FeedbagClassIdGroup {
  221. frame := wire.SNACFrame{FoodGroup: wire.Feedbag, SubGroup: wire.FeedbagUpdateItem}
  222. if _, err := h.FeedbagService.UpsertItem(ctx, sess.OSCARSession, frame, []wire.FeedbagItem{item}); err != nil {
  223. h.Logger.ErrorContext(ctx, "failed to add buddy", "err", err.Error())
  224. return "error", nil
  225. }
  226. }
  227. }
  228. }
  229. // Get current presence for the buddy
  230. buddyInfo := &BuddyPresenceInfo{
  231. AimID: state.NewIdentScreenName(buddyName).String(),
  232. DisplayID: buddyName,
  233. State: "offline", // Default to offline
  234. UserType: "aim",
  235. }
  236. // TODO: Check actual presence status and update buddyInfo accordingly
  237. return "success", buddyInfo
  238. }
  239. // RenameGroup handles GET /buddylist/renameGroup requests.
  240. //
  241. // The Web AIM client calls this with oldGroup (current group name) and newGroup
  242. // (the requested new name). This is a stub; it does not yet rename the group in
  243. // the feedbag.
  244. func (h *BuddyListHandler) RenameGroup(w http.ResponseWriter, r *http.Request, session *Session) {
  245. ctx := r.Context()
  246. aimsid := r.URL.Query().Get("aimsid")
  247. oldGroup := strings.TrimSpace(r.URL.Query().Get("oldGroup"))
  248. newGroup := strings.TrimSpace(r.URL.Query().Get("newGroup"))
  249. if oldGroup == "" || newGroup == "" {
  250. SendError(w, r, http.StatusBadRequest, "missing oldGroup or newGroup parameter")
  251. return
  252. }
  253. resultCode, rnErr := h.BuddyListManager.RenameGroupInFeedbag(ctx, session, oldGroup, newGroup)
  254. if rnErr != nil {
  255. h.Logger.ErrorContext(ctx, "rename group failed", "err", rnErr.Error())
  256. }
  257. SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
  258. if resultCode == "success" {
  259. h.pushBuddyListEvent(ctx, session)
  260. }
  261. h.Logger.InfoContext(ctx, "buddy list group renamed",
  262. "aimsid", aimsid,
  263. "oldGroup", oldGroup,
  264. "newGroup", newGroup,
  265. "result", resultCode,
  266. )
  267. }
  268. // MoveBuddy handles GET /buddylist/moveBuddy requests.
  269. //
  270. // The Web AIM client calls this with buddy (the buddy to move), group (the
  271. // current group), and optionally newGroup (destination group) and beforeBuddy
  272. // (buddy to position it before). This is a stub; it does not yet move the buddy
  273. // in the feedbag.
  274. func (h *BuddyListHandler) MoveBuddy(w http.ResponseWriter, r *http.Request, session *Session) {
  275. ctx := r.Context()
  276. aimsid := r.URL.Query().Get("aimsid")
  277. buddyName := strings.TrimSpace(r.URL.Query().Get("buddy"))
  278. groupName := strings.TrimSpace(r.URL.Query().Get("group"))
  279. newGroup := strings.TrimSpace(r.URL.Query().Get("newGroup"))
  280. beforeBuddy := strings.TrimSpace(r.URL.Query().Get("beforeBuddy"))
  281. if buddyName == "" {
  282. SendError(w, r, http.StatusBadRequest, "missing buddy parameter")
  283. return
  284. }
  285. if groupName == "" {
  286. SendError(w, r, http.StatusBadRequest, "missing group parameter")
  287. return
  288. }
  289. resultCode, mvErr := h.BuddyListManager.MoveBuddyInFeedbag(ctx, session, buddyName, groupName, newGroup, beforeBuddy)
  290. if mvErr != nil {
  291. h.Logger.ErrorContext(ctx, "move buddy failed", "err", mvErr.Error())
  292. }
  293. SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
  294. if resultCode == "success" {
  295. h.pushBuddyListEvent(ctx, session)
  296. }
  297. h.Logger.InfoContext(ctx, "buddy moved",
  298. "aimsid", aimsid,
  299. "buddy", buddyName,
  300. "group", groupName,
  301. "newGroup", newGroup,
  302. "beforeBuddy", beforeBuddy,
  303. "result", resultCode,
  304. )
  305. }
  306. // SetBuddyAttribute handles GET /buddylist/setBuddyAttribute requests.
  307. //
  308. // The Web AIM client calls this with t (the buddy) and friendly (the display
  309. // name / alias). This is a stub; it does not yet persist the attribute to the
  310. // feedbag.
  311. func (h *BuddyListHandler) SetBuddyAttribute(w http.ResponseWriter, r *http.Request, session *Session) {
  312. ctx := r.Context()
  313. aimsid := r.URL.Query().Get("aimsid")
  314. buddyName := strings.TrimSpace(r.URL.Query().Get("t"))
  315. friendly := strings.TrimSpace(r.URL.Query().Get("friendly"))
  316. if buddyName == "" {
  317. SendError(w, r, http.StatusBadRequest, "missing t parameter")
  318. return
  319. }
  320. resultCode, saErr := h.BuddyListManager.SetBuddyAttributeInFeedbag(ctx, session, buddyName, friendly)
  321. if saErr != nil {
  322. h.Logger.ErrorContext(ctx, "set buddy attribute failed", "err", saErr.Error())
  323. }
  324. SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
  325. if resultCode == "success" {
  326. h.pushBuddyListEvent(ctx, session)
  327. }
  328. h.Logger.InfoContext(ctx, "buddy attribute set",
  329. "aimsid", aimsid,
  330. "buddy", buddyName,
  331. "friendly", friendly,
  332. "result", resultCode,
  333. )
  334. }
  335. // SetGroupAttribute handles GET /buddylist/setGroupAttribute requests.
  336. //
  337. // The Web AIM client calls this with collapsed (the group's collapsed state)
  338. // and, for named groups, group. The unnamed default group omits group. This is
  339. // a stub; it does not yet persist the attribute to the feedbag.
  340. func (h *BuddyListHandler) SetGroupAttribute(w http.ResponseWriter, r *http.Request, session *Session) {
  341. ctx := r.Context()
  342. aimsid := r.URL.Query().Get("aimsid")
  343. query := r.URL.Query()
  344. groupName := strings.TrimSpace(query.Get("group"))
  345. collapsedParam := strings.TrimSpace(query.Get("collapsed"))
  346. if collapsedParam == "" {
  347. SendError(w, r, http.StatusBadRequest, "missing collapsed parameter")
  348. return
  349. }
  350. collapsed := collapsedParam == "true" || collapsedParam == "1"
  351. resultCode, saErr := h.BuddyListManager.SetGroupAttributeInFeedbag(ctx, session, groupName, collapsed)
  352. if saErr != nil {
  353. h.Logger.ErrorContext(ctx, "set group attribute failed", "err", saErr.Error())
  354. }
  355. SendOK(w, r, &ResultCodeData{ResultCode: resultCode}, h.Logger)
  356. if resultCode == "success" {
  357. h.pushBuddyListEvent(ctx, session)
  358. }
  359. h.Logger.InfoContext(ctx, "buddy list group attribute set",
  360. "aimsid", aimsid,
  361. "group", groupName,
  362. "collapsed", collapsed,
  363. "result", resultCode,
  364. )
  365. }
  366. // pushBuddyListEvent refreshes the buddy list and pushes it to the session's
  367. // event queue so the Web client re-renders after a mutation.
  368. func (h *BuddyListHandler) pushBuddyListEvent(ctx context.Context, session *Session) {
  369. groups, err := h.BuddyListManager.GetBuddyListForUser(ctx, session)
  370. if err != nil {
  371. h.Logger.ErrorContext(ctx, "failed to get buddy list for event", "err", err.Error())
  372. return
  373. }
  374. session.EventQueue.Push(EventTypeBuddyList, &BuddyListData{Groups: groups})
  375. }
  376. // ResultCodeData is the payload the buddy list editing methods answer with.
  377. //
  378. // The spec shows these methods returning an empty data; the Web AIM client
  379. // reads resultCode from it, so the server sends one.
  380. type ResultCodeData struct {
  381. ResultCode string `json:"resultCode" xml:"resultCode"`
  382. // BuddyInfo accompanies a successful addBuddy only.
  383. BuddyInfo *BuddyPresenceInfo `json:"buddyInfo,omitempty" xml:"buddyInfo,omitempty"`
  384. // BuddyNames accompanies the temp-buddy methods only.
  385. BuddyNames []string `json:"buddyNames,omitempty" xml:"buddyNames>buddyName,omitempty"`
  386. }