messaging.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416
  1. package handlers
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/rand"
  6. "encoding/binary"
  7. "fmt"
  8. "log/slog"
  9. "net/http"
  10. "strconv"
  11. "time"
  12. "github.com/mk6i/retro-aim-server/server/webapi/types"
  13. "github.com/mk6i/retro-aim-server/state"
  14. "github.com/mk6i/retro-aim-server/wire"
  15. )
  16. // MessageRelayer defines methods for relaying messages between users
  17. type MessageRelayer interface {
  18. RelayToScreenName(ctx context.Context, recipient state.IdentScreenName, msg wire.SNACMessage)
  19. }
  20. // OfflineMessageManager defines methods for managing offline messages
  21. type OfflineMessageManager interface {
  22. SaveMessage(ctx context.Context, msg state.OfflineMessage) error
  23. }
  24. // RelationshipFetcher defines methods for fetching user relationships
  25. type RelationshipFetcher interface {
  26. Relationship(ctx context.Context, me state.IdentScreenName, them state.IdentScreenName) (state.Relationship, error)
  27. }
  28. // MessagingHandler handles Web AIM API messaging endpoints
  29. type MessagingHandler struct {
  30. SessionManager *state.WebAPISessionManager
  31. MessageRelayer MessageRelayer
  32. OfflineMessageManager OfflineMessageManager
  33. SessionRetriever SessionRetriever
  34. RelationshipFetcher RelationshipFetcher
  35. Logger *slog.Logger
  36. }
  37. // SendIM handles the /im/sendIM endpoint for sending instant messages
  38. func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request) {
  39. ctx := r.Context()
  40. // Get session from aimsid
  41. aimsid := r.URL.Query().Get("aimsid")
  42. if aimsid == "" {
  43. h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: aimsid")
  44. return
  45. }
  46. sess, err := h.SessionManager.GetSession(r.Context(), aimsid)
  47. if err != nil {
  48. if err == state.ErrNoWebAPISession || err == state.ErrWebAPISessionExpired {
  49. h.sendErrorResponse(w, http.StatusUnauthorized, "invalid or expired session")
  50. } else {
  51. h.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
  52. }
  53. return
  54. }
  55. // Update session activity
  56. if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
  57. h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
  58. }
  59. // Parse parameters
  60. recipient := r.URL.Query().Get("t")
  61. if recipient == "" {
  62. h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: t (recipient)")
  63. return
  64. }
  65. message := r.URL.Query().Get("message")
  66. if message == "" {
  67. h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: message")
  68. return
  69. }
  70. // Parse optional parameters
  71. autoResponse := r.URL.Query().Get("autoResponse") == "1"
  72. offlineIM := r.URL.Query().Get("offlineIM") != "0" // default to true
  73. // Create recipient identifier
  74. recipientIdent := state.NewIdentScreenName(recipient)
  75. // Check blocking relationship
  76. rel, err := h.RelationshipFetcher.Relationship(ctx, sess.ScreenName.IdentScreenName(), recipientIdent)
  77. if err != nil {
  78. h.Logger.ErrorContext(ctx, "failed to fetch relationship", "error", err)
  79. h.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
  80. return
  81. }
  82. // Check if sender blocks recipient or recipient blocks sender
  83. if rel.BlocksYou {
  84. // Recipient blocks sender - pretend recipient is offline
  85. h.sendErrorResponse(w, http.StatusNotFound, "recipient is not online")
  86. return
  87. }
  88. if rel.YouBlock {
  89. // Sender has blocked recipient - cannot send message
  90. h.sendErrorResponse(w, http.StatusForbidden, "cannot send message to blocked user")
  91. return
  92. }
  93. // Check if recipient is online
  94. recipientSession := h.SessionRetriever.RetrieveSession(recipientIdent)
  95. // Generate message cookie
  96. var cookie [8]byte
  97. if _, err := rand.Read(cookie[:]); err != nil {
  98. h.Logger.ErrorContext(ctx, "failed to generate message cookie", "error", err)
  99. h.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
  100. return
  101. }
  102. cookieUint64 := binary.BigEndian.Uint64(cookie[:])
  103. // Get sender's OSCAR session if available
  104. var senderInfo wire.TLVUserInfo
  105. if sess.OSCARSession != nil {
  106. senderInfo = sess.OSCARSession.TLVUserInfo()
  107. } else {
  108. // Create minimal user info for web-only sessions
  109. senderInfo = wire.TLVUserInfo{
  110. ScreenName: sess.ScreenName.String(),
  111. WarningLevel: 0,
  112. }
  113. senderInfo.Append(wire.NewTLVBE(wire.OServiceUserInfoSignonTOD, uint32(sess.CreatedAt.Unix())))
  114. senderInfo.Append(wire.NewTLVBE(wire.OServiceUserInfoStatus, uint32(0x0000))) // online status
  115. }
  116. // Create message ID for response (UUID format like working implementation)
  117. // Using the cookie bytes to generate a UUID-like string
  118. messageID := fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
  119. binary.BigEndian.Uint32(cookie[:4]),
  120. binary.BigEndian.Uint16(cookie[4:6]),
  121. binary.BigEndian.Uint16(cookie[6:8]),
  122. binary.BigEndian.Uint16([]byte{0x80, 0x00}), // Version bits
  123. time.Now().UnixNano()&0xffffffffffff)
  124. if recipientSession == nil {
  125. // Recipient is offline
  126. if offlineIM {
  127. // Save offline message
  128. offlineMsg := state.OfflineMessage{
  129. Message: wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
  130. Cookie: cookieUint64,
  131. ChannelID: wire.ICBMChannelIM,
  132. ScreenName: recipient,
  133. TLVRestBlock: wire.TLVRestBlock{
  134. TLVList: wire.TLVList{
  135. wire.NewTLVBE(wire.ICBMTLVAOLIMData, h.encodeIMMessage(message, autoResponse)),
  136. wire.NewTLVBE(wire.ICBMTLVStore, uint8(1)), // store offline
  137. },
  138. },
  139. },
  140. Recipient: recipientIdent,
  141. Sender: sess.ScreenName.IdentScreenName(),
  142. Sent: time.Now().UTC(),
  143. }
  144. if err := h.OfflineMessageManager.SaveMessage(ctx, offlineMsg); err != nil {
  145. h.Logger.ErrorContext(ctx, "failed to save offline message",
  146. "from", sess.ScreenName.String(),
  147. "to", recipient,
  148. "error", err)
  149. h.sendErrorResponse(w, http.StatusInternalServerError, "failed to save offline message")
  150. return
  151. }
  152. h.Logger.DebugContext(ctx, "saved offline message",
  153. "from", sess.ScreenName.String(),
  154. "to", recipient)
  155. } else {
  156. // Recipient is offline and offline delivery is disabled
  157. h.sendErrorResponse(w, http.StatusNotFound, "recipient is not online")
  158. return
  159. }
  160. } else {
  161. // Recipient is online, deliver message
  162. clientIM := wire.SNAC_0x04_0x07_ICBMChannelMsgToClient{
  163. Cookie: cookieUint64,
  164. ChannelID: wire.ICBMChannelIM,
  165. TLVUserInfo: senderInfo,
  166. TLVRestBlock: wire.TLVRestBlock{},
  167. }
  168. // Add message data
  169. clientIM.Append(wire.NewTLVBE(wire.ICBMTLVAOLIMData, h.encodeIMMessage(message, autoResponse)))
  170. // Add auto-response flag if applicable
  171. if autoResponse {
  172. clientIM.Append(wire.NewTLVBE(wire.ICBMTLVAutoResponse, []byte{}))
  173. }
  174. // Send message to recipient
  175. h.MessageRelayer.RelayToScreenName(ctx, recipientIdent, wire.SNACMessage{
  176. Frame: wire.SNACFrame{
  177. FoodGroup: wire.ICBM,
  178. SubGroup: wire.ICBMChannelMsgToClient,
  179. RequestID: wire.ReqIDFromServer,
  180. },
  181. Body: clientIM,
  182. })
  183. // Queue IM event for the recipient's WebAPI session if they have one
  184. if recipientWebSession, err := h.SessionManager.GetSessionByUser(r.Context(), recipientIdent); err == nil && recipientWebSession != nil {
  185. eventData := types.IMEvent{
  186. From: sess.ScreenName.String(),
  187. Message: message,
  188. Timestamp: float64(time.Now().Unix()),
  189. AutoResp: autoResponse,
  190. }
  191. recipientWebSession.EventQueue.Push(types.EventTypeIM, eventData)
  192. }
  193. // Also queue sentIM event for the sender's WebAPI session to show in their UI
  194. senderEventData := types.SentIMEvent{
  195. Sender: types.UserInfo{
  196. AimID: sess.ScreenName.String(),
  197. DisplayID: sess.ScreenName.String(),
  198. UserType: "aim",
  199. },
  200. Dest: types.UserInfo{
  201. AimID: recipient,
  202. DisplayID: recipient,
  203. UserType: "aim",
  204. },
  205. Message: message,
  206. Timestamp: float64(time.Now().Unix()),
  207. AutoResp: autoResponse,
  208. }
  209. sess.EventQueue.Push(types.EventTypeSentIM, senderEventData)
  210. h.Logger.DebugContext(ctx, "queued sentIM event for sender",
  211. "from", sess.ScreenName.String(),
  212. "to", recipient,
  213. "eventType", types.EventTypeSentIM,
  214. )
  215. h.Logger.DebugContext(ctx, "delivered instant message",
  216. "from", sess.ScreenName.String(),
  217. "to", recipient)
  218. }
  219. // Send success response
  220. responseData := map[string]interface{}{
  221. "msgId": messageID,
  222. "state": "delivered",
  223. }
  224. response := BaseResponse{}
  225. response.Response.StatusCode = 200
  226. response.Response.StatusText = "OK"
  227. response.Response.Data = responseData
  228. SendResponse(w, r, response, h.Logger)
  229. }
  230. // encodeIMMessage encodes a text message into the OSCAR IM format
  231. func (h *MessagingHandler) encodeIMMessage(text string, autoResponse bool) []byte {
  232. // Create ICBM fragment list for the message
  233. frags, err := wire.ICBMFragmentList(text)
  234. if err != nil {
  235. // If fragment creation fails, return simple text bytes
  236. return []byte(text)
  237. }
  238. // Marshal the fragments
  239. buf := &bytes.Buffer{}
  240. for _, frag := range frags {
  241. if err := wire.MarshalBE(frag, buf); err != nil {
  242. // If marshaling fails, return simple text bytes
  243. return []byte(text)
  244. }
  245. }
  246. return buf.Bytes()
  247. }
  248. // sendErrorResponse sends an error response in Web AIM API format
  249. func (h *MessagingHandler) sendErrorResponse(w http.ResponseWriter, statusCode int, errorText string) {
  250. SendError(w, statusCode, errorText)
  251. }
  252. // SetTyping handles the /im/setTyping endpoint for typing indicators
  253. func (h *MessagingHandler) SetTyping(w http.ResponseWriter, r *http.Request) {
  254. ctx := r.Context()
  255. // Get session from aimsid
  256. aimsid := r.URL.Query().Get("aimsid")
  257. if aimsid == "" {
  258. h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: aimsid")
  259. return
  260. }
  261. sess, err := h.SessionManager.GetSession(r.Context(), aimsid)
  262. if err != nil {
  263. if err == state.ErrNoWebAPISession || err == state.ErrWebAPISessionExpired {
  264. h.sendErrorResponse(w, http.StatusUnauthorized, "invalid or expired session")
  265. } else {
  266. h.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
  267. }
  268. return
  269. }
  270. // Update session activity
  271. if err := h.SessionManager.TouchSession(r.Context(), aimsid); err != nil {
  272. h.Logger.WarnContext(ctx, "failed to touch session", "aimsid", aimsid, "error", err)
  273. }
  274. // Parse parameters
  275. recipient := r.URL.Query().Get("t")
  276. if recipient == "" {
  277. h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: t (recipient)")
  278. return
  279. }
  280. typingStr := r.URL.Query().Get("typing")
  281. typing := false
  282. if typingStr != "" {
  283. var err error
  284. typing, err = strconv.ParseBool(typingStr)
  285. if err != nil {
  286. // Try numeric format (0/1)
  287. typing = typingStr == "1"
  288. }
  289. }
  290. // Create recipient identifier
  291. recipientIdent := state.NewIdentScreenName(recipient)
  292. // Check blocking relationship
  293. rel, err := h.RelationshipFetcher.Relationship(ctx, sess.ScreenName.IdentScreenName(), recipientIdent)
  294. if err != nil {
  295. h.Logger.ErrorContext(ctx, "failed to fetch relationship", "error", err)
  296. h.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
  297. return
  298. }
  299. // Check if sender blocks recipient or recipient blocks sender
  300. if rel.BlocksYou || rel.YouBlock {
  301. // Either party blocks the other - silently succeed without sending notification
  302. h.sendSuccessResponse(w, r, nil)
  303. return
  304. }
  305. // Check if recipient is online
  306. recipientSession := h.SessionRetriever.RetrieveSession(recipientIdent)
  307. if recipientSession == nil {
  308. // Silently succeed even if recipient is offline
  309. h.sendSuccessResponse(w, r, nil)
  310. return
  311. }
  312. // Generate typing notification cookie
  313. var cookie [8]byte
  314. if _, err := rand.Read(cookie[:]); err != nil {
  315. h.Logger.ErrorContext(ctx, "failed to generate typing cookie", "error", err)
  316. h.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
  317. return
  318. }
  319. cookieUint64 := binary.BigEndian.Uint64(cookie[:])
  320. // Create typing notification
  321. var notificationType uint16
  322. if typing {
  323. notificationType = 0x0002 // Typing started
  324. } else {
  325. notificationType = 0x0001 // Typing stopped
  326. }
  327. typingNotification := wire.SNAC_0x04_0x14_ICBMClientEvent{
  328. Cookie: cookieUint64,
  329. ChannelID: wire.ICBMChannelIM,
  330. ScreenName: sess.ScreenName.String(),
  331. Event: notificationType,
  332. }
  333. // Send typing notification to recipient
  334. h.MessageRelayer.RelayToScreenName(ctx, recipientIdent, wire.SNACMessage{
  335. Frame: wire.SNACFrame{
  336. FoodGroup: wire.ICBM,
  337. SubGroup: wire.ICBMClientEvent,
  338. RequestID: wire.ReqIDFromServer,
  339. },
  340. Body: typingNotification,
  341. })
  342. // Queue typing event for the recipient's WebAPI session if they have one
  343. if recipientWebSession, err := h.SessionManager.GetSessionByUser(r.Context(), recipientIdent); err == nil && recipientWebSession != nil {
  344. eventData := types.TypingEvent{
  345. From: sess.ScreenName.String(),
  346. Typing: typing,
  347. }
  348. recipientWebSession.EventQueue.Push(types.EventTypeTyping, eventData)
  349. }
  350. h.Logger.DebugContext(ctx, "sent typing notification",
  351. "from", sess.ScreenName.String(),
  352. "to", recipient,
  353. "typing", typing)
  354. // Send success response
  355. h.sendSuccessResponse(w, r, nil)
  356. }
  357. // sendSuccessResponse sends a success response in Web AIM API format
  358. func (h *MessagingHandler) sendSuccessResponse(w http.ResponseWriter, r *http.Request, data interface{}) {
  359. response := BaseResponse{}
  360. response.Response.StatusCode = 200
  361. response.Response.StatusText = "OK"
  362. response.Response.Data = data
  363. SendResponse(w, r, response, h.Logger)
  364. }