chat.go 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327
  1. package foodgroup
  2. import (
  3. "bytes"
  4. "context"
  5. "errors"
  6. "fmt"
  7. "io"
  8. "math"
  9. "math/rand/v2"
  10. "regexp"
  11. "strconv"
  12. "strings"
  13. "golang.org/x/net/html"
  14. "github.com/mk6i/open-oscar-server/state"
  15. "github.com/mk6i/open-oscar-server/wire"
  16. )
  17. var (
  18. // sessOnlineHost represents the OnlineHost user that announcements die
  19. // roll results.
  20. sessOnlineHost = func() *state.SessionInstance {
  21. sn := state.DisplayScreenName("OnlineHost")
  22. sess := state.NewSession()
  23. sess.SetDisplayScreenName(sn)
  24. sess.SetIdentScreenName(sn.IdentScreenName())
  25. return sess.AddInstance()
  26. }()
  27. // rollDiceRgxp matches a roll dice chat command.
  28. // ex: //roll //roll-sides3 //roll-dice2 //role-sides3-dice2
  29. rollDiceRgxp = regexp.MustCompile(`^//roll(?:-(dice|sides)([0-9]{1,3}))?(?:-(dice|sides)([0-9]{1,3}))?\s*$`)
  30. )
  31. // NewChatService creates a new instance of ChatService.
  32. func NewChatService(chatMessageRelayer ChatMessageRelayer) *ChatService {
  33. return &ChatService{
  34. chatMessageRelayer: chatMessageRelayer,
  35. randRollDie: func(sides int) int {
  36. // generate random number between 1 and sides
  37. return rand.IntN(sides) + 1
  38. },
  39. }
  40. }
  41. // ChatService provides functionality for the Chat food group, which is
  42. // responsible for sending and receiving chat messages.
  43. type ChatService struct {
  44. chatMessageRelayer ChatMessageRelayer
  45. randRollDie func(sides int) int
  46. }
  47. // ChannelMsgToHost relays wire.ChatChannelMsgToClient to chat room
  48. // participants. If TLV wire.ChatTLVWhisperToUser is set, "whisper" the message
  49. // to just that user and omit the remaining participants. If TLV
  50. // wire.ChatTLVEnableReflectionFlag is set, return the message ("reflect") back
  51. // to the caller.
  52. func (s ChatService) ChannelMsgToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error) {
  53. frameOut := wire.SNACFrame{
  54. FoodGroup: wire.Chat,
  55. SubGroup: wire.ChatChannelMsgToClient,
  56. }
  57. bodyOut := wire.SNAC_0x0E_0x06_ChatChannelMsgToClient{
  58. Cookie: inBody.Cookie,
  59. Channel: inBody.Channel,
  60. }
  61. if bodyOut.Channel == math.MaxUint16 {
  62. // fix incorrect channel bug in macOS client v4.0.9.
  63. bodyOut.Channel = wire.ICBMChannelMIME
  64. }
  65. var err error
  66. if bodyOut.TLVRestBlock, err = s.transformChatMessage(inBody, instance); err != nil {
  67. return nil, err
  68. }
  69. if inBody.HasTag(wire.ChatTLVWhisperToUser) && !inBody.HasTag(wire.ChatTLVPublicWhisperFlag) {
  70. // forward a whisper message to just one recipient
  71. r, _ := inBody.String(wire.ChatTLVWhisperToUser)
  72. recip := state.NewIdentScreenName(r)
  73. s.chatMessageRelayer.RelayToScreenName(ctx, instance.ChatRoomCookie(), recip, wire.SNACMessage{
  74. Frame: frameOut,
  75. Body: bodyOut,
  76. })
  77. } else {
  78. // forward message all participants, except sender
  79. s.chatMessageRelayer.RelayToAllExcept(ctx, instance.ChatRoomCookie(), instance.IdentScreenName(), wire.SNACMessage{
  80. Frame: frameOut,
  81. Body: bodyOut,
  82. })
  83. }
  84. var ret *wire.SNACMessage
  85. if _, ackMsg := inBody.Bytes(wire.ChatTLVEnableReflectionFlag); ackMsg {
  86. // reflect the message back to the sender
  87. ret = &wire.SNACMessage{
  88. Frame: frameOut,
  89. Body: bodyOut,
  90. }
  91. ret.Frame.RequestID = inFrame.RequestID
  92. }
  93. return ret, nil
  94. }
  95. // transformChatMessage inspects and modifies the incoming chat message payload.
  96. // - If message contains a properly formatted //roll command, return a roll
  97. // die response.
  98. // - Else return the unmodified incoming message.
  99. //
  100. // In the future, this function will validate the incoming message for correct form.
  101. func (s ChatService) transformChatMessage(inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost, sender *state.SessionInstance) (wire.TLVRestBlock, error) {
  102. messageBlob, hasMessage := inBody.Bytes(wire.ChatTLVMessageInfo)
  103. if !hasMessage {
  104. return wire.TLVRestBlock{}, errors.New("SNAC(0x0E,0x05) does not contain a message TLV")
  105. }
  106. msgBlock := wire.TLVRestBlock{}
  107. if err := wire.UnmarshalBE(&msgBlock, bytes.NewBuffer(messageBlob)); err != nil {
  108. return wire.TLVRestBlock{}, err
  109. }
  110. txt, err := extractChatMessage(msgBlock)
  111. if err != nil {
  112. return wire.TLVRestBlock{}, err
  113. }
  114. if doRoll, dice, sides := parseDiceCommand(txt); doRoll {
  115. payload := s.rollDice(sender, dice, sides)
  116. // send die roll results from OnlineHost user
  117. return newChatTLVBlock(inBody, sessOnlineHost, payload), nil
  118. }
  119. if enc, ok := msgBlock.String(wire.ChatTLVMessageInfoEncoding); ok {
  120. if enc == "ISO 8859" {
  121. // fix malformed content encoding type sent by Kopete, which causes
  122. // chat messages to show up blank in Windows AIM chat windows
  123. msgBlock.Set(wire.NewTLVBE(wire.ChatTLVMessageInfoEncoding, []byte("ISO-8859-1")))
  124. }
  125. }
  126. newRestBlock := wire.TLVRestBlock{}
  127. // Strip down to the essential TLVs for cross-client compatibility.
  128. // Some newer clients include extra metadata that cause older clients
  129. // to crash when they encounter unfamiliar TLVs. For example, chat messages
  130. // sent by Windows AIM 5.9 will cause macOS AIM 2.x to crash. Rather than
  131. // implement complex per-client filtering, we simply preserve only the three
  132. // TLVs that every client expects.
  133. for _, tlv := range msgBlock.TLVList {
  134. if tlv.Tag == wire.ChatTLVMessageInfoText ||
  135. tlv.Tag == wire.ChatTLVMessageInfoEncoding ||
  136. tlv.Tag == wire.ChatTLVMessageInfoLang {
  137. newRestBlock.TLVList = append(newRestBlock.TLVList, tlv)
  138. }
  139. }
  140. return newChatTLVBlock(inBody, sender, newRestBlock), nil
  141. }
  142. func newChatTLVBlock(body wire.SNAC_0x0E_0x05_ChatChannelMsgToHost, instance *state.SessionInstance, msg any) wire.TLVRestBlock {
  143. block := wire.TLVRestBlock{}
  144. // the order of these TLVs matters for AIM 2.x. if out of order, screen
  145. // names do not appear with each chat message.
  146. block.Append(wire.NewTLVBE(wire.ChatTLVSenderInformation, instance.Session().TLVUserInfo()))
  147. if body.HasTag(wire.ChatTLVPublicWhisperFlag) {
  148. // send message to all chat room participants
  149. block.Append(wire.NewTLVBE(wire.ChatTLVPublicWhisperFlag, []byte{}))
  150. }
  151. block.Append(wire.NewTLVBE(wire.ChatTLVMessageInfo, msg))
  152. return block
  153. }
  154. // rollDice generates a chat response for the results of a die roll.
  155. func (s ChatService) rollDice(instance *state.SessionInstance, dice int, sides int) wire.TLVRestBlock {
  156. sb := strings.Builder{}
  157. sb.WriteString("<HTML><BODY BGCOLOR=\"#ffffff\"><FONT LANG=\"0\">")
  158. fmt.Fprintf(&sb, "%s rolled %d %d-sided dice:", instance.DisplayScreenName().String(), dice, sides)
  159. for i := 0; i < dice; i++ {
  160. fmt.Fprintf(&sb, " %d", s.randRollDie(sides))
  161. }
  162. sb.WriteString("</FONT></BODY></HTML>")
  163. block := wire.TLVRestBlock{}
  164. block.Append(wire.NewTLVBE(wire.ChatTLVMessageInfoEncoding, "us-ascii"))
  165. block.Append(wire.NewTLVBE(wire.ChatTLVMessageInfoLang, "en"))
  166. block.Append(wire.NewTLVBE(wire.ChatTLVMessageInfoText, sb.String()))
  167. return block
  168. }
  169. // extractChatMessage extracts plaintext message text from HTML located in
  170. // chat message info TLV(0x05).
  171. func extractChatMessage(msg wire.TLVRestBlock) ([]byte, error) {
  172. b, hasMsg := msg.Bytes(wire.ChatTLVMessageInfoText)
  173. if !hasMsg {
  174. return nil, errors.New("SNAC(0x0E,0x05) has no chat msg text TLV")
  175. }
  176. tok := html.NewTokenizer(bytes.NewBuffer(b))
  177. for {
  178. switch tok.Next() {
  179. case html.TextToken:
  180. return tok.Text(), nil
  181. case html.ErrorToken:
  182. err := tok.Err()
  183. if err == io.EOF {
  184. err = nil
  185. }
  186. return nil, err
  187. }
  188. }
  189. }
  190. // parseDiceCommand gets the number of dice and sides from a die roll command.
  191. //
  192. // The roll command is activated with //roll followed by up to two arguments to
  193. // indicate die count and side count. By default, there are 2 dice and 6 sides.
  194. //
  195. // - //roll 2x 6-sided dice
  196. // - //roll-dice4 4x 6-sided dice
  197. // - //roll-sides8 2x 8-sided dice
  198. // - //roll-dice4-sides8 4x 8-sided dice
  199. //
  200. // The -dice param can not exceed 15 and -sides param cannot exceed 999.
  201. func parseDiceCommand(in []byte) (valid bool, dice int, sides int) {
  202. matches := rollDiceRgxp.FindSubmatch(in)
  203. if len(matches) == 0 {
  204. return false, 0, 0
  205. }
  206. args := matches[1:]
  207. if len(args[0]) > 0 && bytes.Equal(args[0], args[2]) {
  208. // "sides" or "dice" appears twice
  209. return false, 0, 0
  210. }
  211. dice = 2
  212. sides = 6
  213. for i := 0; i < len(args); i += 2 {
  214. cmd := string(args[i])
  215. val := string(args[i+1])
  216. switch cmd {
  217. case "sides":
  218. var err error
  219. sides, err = strconv.Atoi(val)
  220. if err != nil || sides == 0 || sides > 999 {
  221. return false, 0, 0
  222. }
  223. case "dice":
  224. var err error
  225. dice, err = strconv.Atoi(val)
  226. if err != nil || dice == 0 || dice > 15 {
  227. return false, 0, 0
  228. }
  229. }
  230. }
  231. return true, dice, sides
  232. }
  233. func setOnlineChatUsers(ctx context.Context, instance *state.SessionInstance, chatMessageRelayer ChatMessageRelayer) {
  234. snacPayloadOut := wire.SNAC_0x0E_0x03_ChatUsersJoined{}
  235. sessions := chatMessageRelayer.AllSessions(instance.ChatRoomCookie())
  236. for _, session := range sessions {
  237. snacPayloadOut.Users = append(snacPayloadOut.Users, session.TLVUserInfo())
  238. }
  239. chatMessageRelayer.RelayToScreenName(ctx, instance.ChatRoomCookie(), instance.IdentScreenName(), wire.SNACMessage{
  240. Frame: wire.SNACFrame{
  241. FoodGroup: wire.Chat,
  242. SubGroup: wire.ChatUsersJoined,
  243. },
  244. Body: snacPayloadOut,
  245. })
  246. }
  247. func alertUserJoined(ctx context.Context, instance *state.SessionInstance, chatMessageRelayer ChatMessageRelayer) {
  248. chatMessageRelayer.RelayToAllExcept(ctx, instance.ChatRoomCookie(), instance.IdentScreenName(), wire.SNACMessage{
  249. Frame: wire.SNACFrame{
  250. FoodGroup: wire.Chat,
  251. SubGroup: wire.ChatUsersJoined,
  252. },
  253. Body: wire.SNAC_0x0E_0x03_ChatUsersJoined{
  254. Users: []wire.TLVUserInfo{
  255. instance.Session().TLVUserInfo(),
  256. },
  257. },
  258. })
  259. }
  260. func alertUserLeft(ctx context.Context, sess *state.Session, chatMessageRelayer ChatMessageRelayer) {
  261. chatMessageRelayer.RelayToAllExcept(ctx, sess.ChatRoomCookie(), sess.IdentScreenName(), wire.SNACMessage{
  262. Frame: wire.SNACFrame{
  263. FoodGroup: wire.Chat,
  264. SubGroup: wire.ChatUsersLeft,
  265. },
  266. Body: wire.SNAC_0x0E_0x04_ChatUsersLeft{
  267. Users: []wire.TLVUserInfo{
  268. sess.TLVUserInfo(),
  269. },
  270. },
  271. })
  272. }
  273. func sendChatRoomInfoUpdate(ctx context.Context, instance *state.SessionInstance, chatMessageRelayer ChatMessageRelayer, room state.ChatRoom) {
  274. chatMessageRelayer.RelayToScreenName(ctx, instance.ChatRoomCookie(), instance.IdentScreenName(), wire.SNACMessage{
  275. Frame: wire.SNACFrame{
  276. FoodGroup: wire.Chat,
  277. SubGroup: wire.ChatRoomInfoUpdate,
  278. },
  279. Body: wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
  280. Exchange: room.Exchange(),
  281. Cookie: room.Cookie(),
  282. InstanceNumber: room.InstanceNumber(),
  283. DetailLevel: room.DetailLevel(),
  284. TLVBlock: wire.TLVBlock{
  285. TLVList: room.TLVList(),
  286. },
  287. },
  288. })
  289. }