messaging.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. package handlers
  2. import (
  3. "bytes"
  4. "context"
  5. "crypto/rand"
  6. "encoding/binary"
  7. "fmt"
  8. "log/slog"
  9. "net/http"
  10. "time"
  11. "github.com/mk6i/open-oscar-server/server/webapi/types"
  12. "github.com/mk6i/open-oscar-server/state"
  13. "github.com/mk6i/open-oscar-server/wire"
  14. )
  15. // ICBMService defines methods for ICBM operations
  16. type ICBMService interface {
  17. ChannelMsgToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*wire.SNACMessage, error)
  18. ClientEvent(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x04_0x14_ICBMClientEvent) error
  19. }
  20. // ChatService relays a message to the participants of a chat room. Backed by
  21. // foodgroup.ChatService. The web client addresses a room like a buddy (via the
  22. // im/sendIM `t` param), so SendIM routes room targets here.
  23. type ChatService interface {
  24. ChannelMsgToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error)
  25. }
  26. // MessagingHandler handles Web AIM API messaging endpoints
  27. type MessagingHandler struct {
  28. SessionManager *state.WebAPISessionManager
  29. ICBMService ICBMService
  30. ChatService ChatService
  31. LocateService LocateService
  32. FeedbagService FeedbagService
  33. Logger *slog.Logger
  34. }
  35. // queryOrFormParam returns a request parameter from the query string or, for POST
  36. // requests, from application/x-www-form-urlencoded body fields. The Web AIM client
  37. // sends t/offlineIM/etc. on the query string and puts message in the POST body.
  38. func queryOrFormParam(r *http.Request, key string) string {
  39. if v := r.URL.Query().Get(key); v != "" {
  40. return v
  41. }
  42. if r.Method == http.MethodPost {
  43. if err := r.ParseForm(); err == nil {
  44. return r.FormValue(key)
  45. }
  46. }
  47. return ""
  48. }
  49. // SendIM handles the /im/sendIM endpoint for sending instant messages
  50. func (h *MessagingHandler) SendIM(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
  51. ctx := r.Context()
  52. // Parse parameters
  53. recipient := queryOrFormParam(r, "t")
  54. if recipient == "" {
  55. h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: t (recipient)")
  56. return
  57. }
  58. message := queryOrFormParam(r, "message")
  59. if message == "" {
  60. h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: message")
  61. return
  62. }
  63. // A room is addressed exactly like a buddy: if t names a room this session has
  64. // joined, relay through the chat food group instead of ICBM.
  65. if chatInstance, isRoom := sess.ChatRoom(recipient); isRoom {
  66. h.sendRoomMessage(w, r, sess, chatInstance, recipient, message)
  67. return
  68. }
  69. // Parse optional parameters
  70. autoResponse := queryOrFormParam(r, "autoResponse") == "1"
  71. // Generate message cookie
  72. var cookie [8]byte
  73. if _, err := rand.Read(cookie[:]); err != nil {
  74. h.Logger.ErrorContext(ctx, "failed to generate message cookie", "error", err)
  75. h.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
  76. return
  77. }
  78. cookieUint64 := binary.BigEndian.Uint64(cookie[:])
  79. // Create message ID for response (UUID format like working implementation)
  80. // Using the cookie bytes to generate a UUID-like string
  81. messageID := fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
  82. binary.BigEndian.Uint32(cookie[:4]),
  83. binary.BigEndian.Uint16(cookie[4:6]),
  84. binary.BigEndian.Uint16(cookie[6:8]),
  85. binary.BigEndian.Uint16([]byte{0x80, 0x00}), // Version bits
  86. time.Now().UnixNano()&0xffffffffffff)
  87. now := float64(time.Now().Unix())
  88. nowSec := time.Now().Unix()
  89. // The client sends t as the normalized aimId it keys the conversation by, so
  90. // it is never a source of display names.
  91. recipientIdent := state.NewIdentScreenName(recipient)
  92. sess.AddStoredIM(recipientIdent.String(), sess.ScreenName.IdentScreenName().String(), message, messageID, nowSec)
  93. // Recipient is online, deliver message
  94. clientIM := wire.SNAC_0x04_0x06_ICBMChannelMsgToHost{
  95. Cookie: cookieUint64,
  96. ChannelID: wire.ICBMChannelIM,
  97. ScreenName: recipient,
  98. TLVRestBlock: wire.TLVRestBlock{},
  99. }
  100. // Add message data
  101. frags, err := wire.ICBMFragmentList(message)
  102. if err != nil {
  103. h.sendErrorResponse(w, http.StatusInternalServerError, "failed to send message")
  104. return
  105. }
  106. clientIM.Append(wire.NewTLVBE(wire.ICBMTLVAOLIMData, frags))
  107. // Add auto-response flag if applicable
  108. if autoResponse {
  109. clientIM.Append(wire.NewTLVBE(wire.ICBMTLVAutoResponse, []byte{}))
  110. }
  111. frame := wire.SNACFrame{
  112. FoodGroup: wire.ICBM,
  113. SubGroup: wire.ICBMChannelMsgToHost,
  114. RequestID: wire.ReqIDFromServer,
  115. }
  116. resp, err := h.ICBMService.ChannelMsgToHost(r.Context(), sess.OSCARSession, frame, clientIM)
  117. if err != nil {
  118. h.sendErrorResponse(w, http.StatusInternalServerError, "failed to send message")
  119. return
  120. }
  121. if resp != nil {
  122. switch {
  123. case resp.Frame.FoodGroup == wire.ICBM && resp.Frame.SubGroup == wire.ICBMErr:
  124. if errSn, ok := resp.Body.(wire.SNACError); ok {
  125. switch errSn.Code {
  126. case wire.ErrorCodeNotLoggedOn:
  127. subCode, hasSubCode := errSn.Uint16BE(wire.ErrorTLVErrorSubcode)
  128. if hasSubCode {
  129. if subCode == wire.ICBMSubErrOfflineIMExceedMax {
  130. h.Logger.DebugContext(r.Context(), "user's offline messages full")
  131. }
  132. } else {
  133. h.Logger.DebugContext(r.Context(), "recipient offline")
  134. }
  135. return
  136. case wire.ErrorCodeInLocalPermitDeny:
  137. h.Logger.DebugContext(r.Context(), "you blocked this user")
  138. return
  139. }
  140. }
  141. case resp.Frame.FoodGroup == wire.ICBM && resp.Frame.SubGroup == wire.ICBMHostAck:
  142. h.Logger.DebugContext(r.Context(), "received host ack")
  143. }
  144. }
  145. recipientDisplay := h.resolveDisplayName(ctx, sess.OSCARSession, recipientIdent)
  146. // The alias lives in the sender's feedbag, so unlike the display name it cannot
  147. // be read off a locate reply.
  148. recipientAlias := sess.Aliases(ctx)[recipientIdent.String()]
  149. h.pushSenderWebAPIEvents(sess, recipientIdent, recipientDisplay, recipientAlias, message, messageID, now, autoResponse)
  150. h.Logger.DebugContext(ctx, "queued sentIM event for sender",
  151. "from", sess.ScreenName.String(),
  152. "to", recipient,
  153. "eventType", types.EventTypeSentIM,
  154. )
  155. // Send success response
  156. responseData := map[string]interface{}{
  157. "msgId": messageID,
  158. "state": "delivered",
  159. }
  160. response := BaseResponse{}
  161. response.Response.StatusCode = 200
  162. response.Response.StatusText = "OK"
  163. response.Response.Data = responseData
  164. SendResponse(w, r, response, h.Logger)
  165. }
  166. // sendRoomMessage relays a message to a joined chat room's participants via the
  167. // chat food group, then echoes the sender's own line back to their event queue.
  168. // Mirrors TOC's ChatSend (server/toc/cmd_client.go): reflection is enabled so the
  169. // server returns the canonical (transformed) message synchronously; other
  170. // participants receive it via the relay, which excludes the sender.
  171. func (h *MessagingHandler) sendRoomMessage(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession, chatInstance *state.SessionInstance, roomID, message string) {
  172. ctx := r.Context()
  173. block := wire.TLVRestBlock{}
  174. // TLV order matters for AIM 2.x: out of order, screen names do not appear with
  175. // each chat message.
  176. block.Append(wire.NewTLVBE(wire.ChatTLVEnableReflectionFlag, uint8(1)))
  177. block.Append(wire.NewTLVBE(wire.ChatTLVSenderInformation, chatInstance.Session().TLVUserInfo()))
  178. block.Append(wire.NewTLVBE(wire.ChatTLVPublicWhisperFlag, []byte{}))
  179. block.Append(wire.NewTLVBE(wire.ChatTLVMessageInfo, wire.TLVRestBlock{
  180. TLVList: wire.TLVList{
  181. wire.NewTLVBE(wire.ChatTLVMessageInfoText, message),
  182. },
  183. }))
  184. snac := wire.SNAC_0x0E_0x05_ChatChannelMsgToHost{
  185. Channel: wire.ICBMChannelMIME,
  186. TLVRestBlock: block,
  187. }
  188. reply, err := h.ChatService.ChannelMsgToHost(ctx, chatInstance, wire.SNACFrame{}, snac)
  189. if err != nil {
  190. h.Logger.ErrorContext(ctx, "failed to send chat message", "room", roomID, "err", err.Error())
  191. h.sendErrorResponse(w, http.StatusInternalServerError, "failed to send message")
  192. return
  193. }
  194. // Echo the sender's own line. The reflected reply carries the canonical text
  195. // and sender; fall back to the raw inputs if reflection returned nothing.
  196. senderAimID := sess.ScreenName.IdentScreenName().String()
  197. text := message
  198. if reply != nil {
  199. if body, ok := reply.Body.(wire.SNAC_0x0E_0x06_ChatChannelMsgToClient); ok {
  200. if info, ok := body.Bytes(wire.ChatTLVSenderInformation); ok {
  201. var userInfo wire.TLVUserInfo
  202. if err := wire.UnmarshalBE(&userInfo, bytes.NewReader(info)); err == nil {
  203. senderAimID = state.NewIdentScreenName(userInfo.ScreenName).String()
  204. }
  205. }
  206. if msgInfo, ok := body.Bytes(wire.ChatTLVMessageInfo); ok {
  207. if reflected, err := wire.UnmarshalChatMessageText(msgInfo); err == nil {
  208. text = reflected
  209. }
  210. }
  211. }
  212. }
  213. sess.PushRoomLine(roomID, senderAimID, text)
  214. responseData := map[string]interface{}{
  215. "state": "delivered",
  216. }
  217. response := BaseResponse{}
  218. response.Response.StatusCode = 200
  219. response.Response.StatusText = "OK"
  220. response.Response.Data = responseData
  221. SendResponse(w, r, response, h.Logger)
  222. }
  223. // resolveDisplayName returns the recipient's screen name as they formatted it,
  224. // or "" when it cannot be determined because they are offline or blocked.
  225. func (h *MessagingHandler) resolveDisplayName(ctx context.Context, instance *state.SessionInstance, recipient state.IdentScreenName) string {
  226. reply, err := h.LocateService.UserInfoQuery(ctx, instance, wire.SNACFrame{},
  227. wire.SNAC_0x02_0x05_LocateUserInfoQuery{
  228. Type: uint16(wire.LocateTypeUnavailable),
  229. ScreenName: recipient.String(),
  230. })
  231. if err != nil {
  232. h.Logger.DebugContext(ctx, "failed to resolve recipient display name",
  233. "screenName", recipient.String(), "error", err)
  234. return ""
  235. }
  236. info, ok := reply.Body.(wire.SNAC_0x02_0x06_LocateUserInfoReply)
  237. if !ok {
  238. return ""
  239. }
  240. return info.ScreenName
  241. }
  242. // pushSenderWebAPIEvents echoes a just-sent IM back to the sender's own event
  243. // queue. recipientDisplay is the recipient's own formatting of their screen name,
  244. // or "" when it could not be resolved; recipientAlias is the sender's private name
  245. // for them, or "" when unaliased.
  246. //
  247. // The web client merges every user map it receives onto the single user object it
  248. // keys by aimId, so a displayId here overwrites the name the buddy list already
  249. // rendered. Echoing the normalized aimId as a displayId would reduce a buddy named
  250. // "Mike Lee" to "mikelee" the moment you message him. Omitting displayId leaves the
  251. // client's existing name untouched. The merge also deletes any alias it holds, so
  252. // friendly has to be repeated here even though the buddy list already sent it.
  253. func (h *MessagingHandler) pushSenderWebAPIEvents(sess *state.WebAPISession, recipient state.IdentScreenName, recipientDisplay, recipientAlias, message, messageID string, now float64, autoResponse bool) {
  254. senderAimID := sess.ScreenName.IdentScreenName().String()
  255. recipientAimID := recipient.String()
  256. senderEventData := types.SentIMEvent{
  257. Sender: types.UserInfo{
  258. AimID: senderAimID,
  259. DisplayID: sess.ScreenName.String(),
  260. UserType: "aim",
  261. },
  262. Dest: types.UserInfo{
  263. AimID: recipientAimID,
  264. DisplayID: recipientDisplay,
  265. Friendly: recipientAlias,
  266. UserType: "aim",
  267. },
  268. Message: message,
  269. MsgID: messageID,
  270. Timestamp: now,
  271. AutoResp: autoResponse,
  272. }
  273. sess.EventQueue.Push(types.EventTypeSentIM, senderEventData)
  274. if sess.IsSubscribedTo("conversation") {
  275. sess.EventQueue.Push(types.EventTypeConversation, types.ConversationEventData("update", []map[string]interface{}{
  276. types.ConversationEntry(recipientAimID, recipientDisplay, message, messageID, senderAimID, true, 0),
  277. }))
  278. }
  279. }
  280. // sendErrorResponse sends an error response in Web AIM API format
  281. func (h *MessagingHandler) sendErrorResponse(w http.ResponseWriter, statusCode int, errorText string) {
  282. SendError(w, statusCode, errorText)
  283. }
  284. // SetTyping handles the /im/setTyping endpoint for typing indicators
  285. func (h *MessagingHandler) SetTyping(w http.ResponseWriter, r *http.Request, sess *state.WebAPISession) {
  286. ctx := r.Context()
  287. // Parse parameters
  288. recipient := r.URL.Query().Get("t")
  289. if recipient == "" {
  290. h.sendErrorResponse(w, http.StatusBadRequest, "missing required parameter: t (recipient)")
  291. return
  292. }
  293. typingStatus := r.URL.Query().Get("typingStatus")
  294. if typingStatus == "" {
  295. typingStatus = "none"
  296. }
  297. var event uint16
  298. switch typingStatus {
  299. case "typing":
  300. event = 0x0002
  301. case "typed":
  302. event = 0x0001
  303. default:
  304. event = 0x0000
  305. }
  306. inBody := wire.SNAC_0x04_0x14_ICBMClientEvent{
  307. ChannelID: wire.ICBMChannelIM,
  308. ScreenName: recipient,
  309. Event: event,
  310. }
  311. if err := h.ICBMService.ClientEvent(ctx, sess.OSCARSession, wire.SNACFrame{}, inBody); err != nil {
  312. h.Logger.ErrorContext(ctx, "failed to send typing notification", "error", err)
  313. h.sendErrorResponse(w, http.StatusInternalServerError, "internal server error")
  314. return
  315. }
  316. h.sendSuccessResponse(w, r, nil)
  317. }
  318. // sendSuccessResponse sends a success response in Web AIM API format
  319. func (h *MessagingHandler) sendSuccessResponse(w http.ResponseWriter, r *http.Request, data interface{}) {
  320. response := BaseResponse{}
  321. response.Response.StatusCode = 200
  322. response.Response.StatusText = "OK"
  323. response.Response.Data = data
  324. SendResponse(w, r, response, h.Logger)
  325. }