| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327 |
- package foodgroup
- import (
- "bytes"
- "context"
- "errors"
- "fmt"
- "io"
- "math"
- "math/rand/v2"
- "regexp"
- "strconv"
- "strings"
- "golang.org/x/net/html"
- "github.com/mk6i/open-oscar-server/state"
- "github.com/mk6i/open-oscar-server/wire"
- )
- var (
- // sessOnlineHost represents the OnlineHost user that announcements die
- // roll results.
- sessOnlineHost = func() *state.SessionInstance {
- sn := state.DisplayScreenName("OnlineHost")
- sess := state.NewSession()
- sess.SetDisplayScreenName(sn)
- sess.SetIdentScreenName(sn.IdentScreenName())
- return sess.AddInstance()
- }()
- // rollDiceRgxp matches a roll dice chat command.
- // ex: //roll //roll-sides3 //roll-dice2 //role-sides3-dice2
- rollDiceRgxp = regexp.MustCompile(`^//roll(?:-(dice|sides)([0-9]{1,3}))?(?:-(dice|sides)([0-9]{1,3}))?\s*$`)
- )
- // NewChatService creates a new instance of ChatService.
- func NewChatService(chatMessageRelayer ChatMessageRelayer) *ChatService {
- return &ChatService{
- chatMessageRelayer: chatMessageRelayer,
- randRollDie: func(sides int) int {
- // generate random number between 1 and sides
- return rand.IntN(sides) + 1
- },
- }
- }
- // ChatService provides functionality for the Chat food group, which is
- // responsible for sending and receiving chat messages.
- type ChatService struct {
- chatMessageRelayer ChatMessageRelayer
- randRollDie func(sides int) int
- }
- // ChannelMsgToHost relays wire.ChatChannelMsgToClient to chat room
- // participants. If TLV wire.ChatTLVWhisperToUser is set, "whisper" the message
- // to just that user and omit the remaining participants. If TLV
- // wire.ChatTLVEnableReflectionFlag is set, return the message ("reflect") back
- // to the caller.
- func (s ChatService) ChannelMsgToHost(ctx context.Context, instance *state.SessionInstance, inFrame wire.SNACFrame, inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*wire.SNACMessage, error) {
- frameOut := wire.SNACFrame{
- FoodGroup: wire.Chat,
- SubGroup: wire.ChatChannelMsgToClient,
- }
- bodyOut := wire.SNAC_0x0E_0x06_ChatChannelMsgToClient{
- Cookie: inBody.Cookie,
- Channel: inBody.Channel,
- }
- if bodyOut.Channel == math.MaxUint16 {
- // fix incorrect channel bug in macOS client v4.0.9.
- bodyOut.Channel = wire.ICBMChannelMIME
- }
- var err error
- if bodyOut.TLVRestBlock, err = s.transformChatMessage(inBody, instance); err != nil {
- return nil, err
- }
- if inBody.HasTag(wire.ChatTLVWhisperToUser) && !inBody.HasTag(wire.ChatTLVPublicWhisperFlag) {
- // forward a whisper message to just one recipient
- r, _ := inBody.String(wire.ChatTLVWhisperToUser)
- recip := state.NewIdentScreenName(r)
- s.chatMessageRelayer.RelayToScreenName(ctx, instance.ChatRoomCookie(), recip, wire.SNACMessage{
- Frame: frameOut,
- Body: bodyOut,
- })
- } else {
- // forward message all participants, except sender
- s.chatMessageRelayer.RelayToAllExcept(ctx, instance.ChatRoomCookie(), instance.IdentScreenName(), wire.SNACMessage{
- Frame: frameOut,
- Body: bodyOut,
- })
- }
- var ret *wire.SNACMessage
- if _, ackMsg := inBody.Bytes(wire.ChatTLVEnableReflectionFlag); ackMsg {
- // reflect the message back to the sender
- ret = &wire.SNACMessage{
- Frame: frameOut,
- Body: bodyOut,
- }
- ret.Frame.RequestID = inFrame.RequestID
- }
- return ret, nil
- }
- // transformChatMessage inspects and modifies the incoming chat message payload.
- // - If message contains a properly formatted //roll command, return a roll
- // die response.
- // - Else return the unmodified incoming message.
- //
- // In the future, this function will validate the incoming message for correct form.
- func (s ChatService) transformChatMessage(inBody wire.SNAC_0x0E_0x05_ChatChannelMsgToHost, sender *state.SessionInstance) (wire.TLVRestBlock, error) {
- messageBlob, hasMessage := inBody.Bytes(wire.ChatTLVMessageInfo)
- if !hasMessage {
- return wire.TLVRestBlock{}, errors.New("SNAC(0x0E,0x05) does not contain a message TLV")
- }
- msgBlock := wire.TLVRestBlock{}
- if err := wire.UnmarshalBE(&msgBlock, bytes.NewBuffer(messageBlob)); err != nil {
- return wire.TLVRestBlock{}, err
- }
- txt, err := extractChatMessage(msgBlock)
- if err != nil {
- return wire.TLVRestBlock{}, err
- }
- if doRoll, dice, sides := parseDiceCommand(txt); doRoll {
- payload := s.rollDice(sender, dice, sides)
- // send die roll results from OnlineHost user
- return newChatTLVBlock(inBody, sessOnlineHost, payload), nil
- }
- if enc, ok := msgBlock.String(wire.ChatTLVMessageInfoEncoding); ok {
- if enc == "ISO 8859" {
- // fix malformed content encoding type sent by Kopete, which causes
- // chat messages to show up blank in Windows AIM chat windows
- msgBlock.Set(wire.NewTLVBE(wire.ChatTLVMessageInfoEncoding, []byte("ISO-8859-1")))
- }
- }
- newRestBlock := wire.TLVRestBlock{}
- // Strip down to the essential TLVs for cross-client compatibility.
- // Some newer clients include extra metadata that cause older clients
- // to crash when they encounter unfamiliar TLVs. For example, chat messages
- // sent by Windows AIM 5.9 will cause macOS AIM 2.x to crash. Rather than
- // implement complex per-client filtering, we simply preserve only the three
- // TLVs that every client expects.
- for _, tlv := range msgBlock.TLVList {
- if tlv.Tag == wire.ChatTLVMessageInfoText ||
- tlv.Tag == wire.ChatTLVMessageInfoEncoding ||
- tlv.Tag == wire.ChatTLVMessageInfoLang {
- newRestBlock.TLVList = append(newRestBlock.TLVList, tlv)
- }
- }
- return newChatTLVBlock(inBody, sender, newRestBlock), nil
- }
- func newChatTLVBlock(body wire.SNAC_0x0E_0x05_ChatChannelMsgToHost, instance *state.SessionInstance, msg any) wire.TLVRestBlock {
- block := wire.TLVRestBlock{}
- // the order of these TLVs matters for AIM 2.x. if out of order, screen
- // names do not appear with each chat message.
- block.Append(wire.NewTLVBE(wire.ChatTLVSenderInformation, instance.Session().TLVUserInfo()))
- if body.HasTag(wire.ChatTLVPublicWhisperFlag) {
- // send message to all chat room participants
- block.Append(wire.NewTLVBE(wire.ChatTLVPublicWhisperFlag, []byte{}))
- }
- block.Append(wire.NewTLVBE(wire.ChatTLVMessageInfo, msg))
- return block
- }
- // rollDice generates a chat response for the results of a die roll.
- func (s ChatService) rollDice(instance *state.SessionInstance, dice int, sides int) wire.TLVRestBlock {
- sb := strings.Builder{}
- sb.WriteString("<HTML><BODY BGCOLOR=\"#ffffff\"><FONT LANG=\"0\">")
- fmt.Fprintf(&sb, "%s rolled %d %d-sided dice:", instance.DisplayScreenName().String(), dice, sides)
- for i := 0; i < dice; i++ {
- fmt.Fprintf(&sb, " %d", s.randRollDie(sides))
- }
- sb.WriteString("</FONT></BODY></HTML>")
- block := wire.TLVRestBlock{}
- block.Append(wire.NewTLVBE(wire.ChatTLVMessageInfoEncoding, "us-ascii"))
- block.Append(wire.NewTLVBE(wire.ChatTLVMessageInfoLang, "en"))
- block.Append(wire.NewTLVBE(wire.ChatTLVMessageInfoText, sb.String()))
- return block
- }
- // extractChatMessage extracts plaintext message text from HTML located in
- // chat message info TLV(0x05).
- func extractChatMessage(msg wire.TLVRestBlock) ([]byte, error) {
- b, hasMsg := msg.Bytes(wire.ChatTLVMessageInfoText)
- if !hasMsg {
- return nil, errors.New("SNAC(0x0E,0x05) has no chat msg text TLV")
- }
- tok := html.NewTokenizer(bytes.NewBuffer(b))
- for {
- switch tok.Next() {
- case html.TextToken:
- return tok.Text(), nil
- case html.ErrorToken:
- err := tok.Err()
- if err == io.EOF {
- err = nil
- }
- return nil, err
- }
- }
- }
- // parseDiceCommand gets the number of dice and sides from a die roll command.
- //
- // The roll command is activated with //roll followed by up to two arguments to
- // indicate die count and side count. By default, there are 2 dice and 6 sides.
- //
- // - //roll 2x 6-sided dice
- // - //roll-dice4 4x 6-sided dice
- // - //roll-sides8 2x 8-sided dice
- // - //roll-dice4-sides8 4x 8-sided dice
- //
- // The -dice param can not exceed 15 and -sides param cannot exceed 999.
- func parseDiceCommand(in []byte) (valid bool, dice int, sides int) {
- matches := rollDiceRgxp.FindSubmatch(in)
- if len(matches) == 0 {
- return false, 0, 0
- }
- args := matches[1:]
- if len(args[0]) > 0 && bytes.Equal(args[0], args[2]) {
- // "sides" or "dice" appears twice
- return false, 0, 0
- }
- dice = 2
- sides = 6
- for i := 0; i < len(args); i += 2 {
- cmd := string(args[i])
- val := string(args[i+1])
- switch cmd {
- case "sides":
- var err error
- sides, err = strconv.Atoi(val)
- if err != nil || sides == 0 || sides > 999 {
- return false, 0, 0
- }
- case "dice":
- var err error
- dice, err = strconv.Atoi(val)
- if err != nil || dice == 0 || dice > 15 {
- return false, 0, 0
- }
- }
- }
- return true, dice, sides
- }
- func setOnlineChatUsers(ctx context.Context, instance *state.SessionInstance, chatMessageRelayer ChatMessageRelayer) {
- snacPayloadOut := wire.SNAC_0x0E_0x03_ChatUsersJoined{}
- sessions := chatMessageRelayer.AllSessions(instance.ChatRoomCookie())
- for _, session := range sessions {
- snacPayloadOut.Users = append(snacPayloadOut.Users, session.TLVUserInfo())
- }
- chatMessageRelayer.RelayToScreenName(ctx, instance.ChatRoomCookie(), instance.IdentScreenName(), wire.SNACMessage{
- Frame: wire.SNACFrame{
- FoodGroup: wire.Chat,
- SubGroup: wire.ChatUsersJoined,
- },
- Body: snacPayloadOut,
- })
- }
- func alertUserJoined(ctx context.Context, instance *state.SessionInstance, chatMessageRelayer ChatMessageRelayer) {
- chatMessageRelayer.RelayToAllExcept(ctx, instance.ChatRoomCookie(), instance.IdentScreenName(), wire.SNACMessage{
- Frame: wire.SNACFrame{
- FoodGroup: wire.Chat,
- SubGroup: wire.ChatUsersJoined,
- },
- Body: wire.SNAC_0x0E_0x03_ChatUsersJoined{
- Users: []wire.TLVUserInfo{
- instance.Session().TLVUserInfo(),
- },
- },
- })
- }
- func alertUserLeft(ctx context.Context, sess *state.Session, chatMessageRelayer ChatMessageRelayer) {
- chatMessageRelayer.RelayToAllExcept(ctx, sess.ChatRoomCookie(), sess.IdentScreenName(), wire.SNACMessage{
- Frame: wire.SNACFrame{
- FoodGroup: wire.Chat,
- SubGroup: wire.ChatUsersLeft,
- },
- Body: wire.SNAC_0x0E_0x04_ChatUsersLeft{
- Users: []wire.TLVUserInfo{
- sess.TLVUserInfo(),
- },
- },
- })
- }
- func sendChatRoomInfoUpdate(ctx context.Context, instance *state.SessionInstance, chatMessageRelayer ChatMessageRelayer, room state.ChatRoom) {
- chatMessageRelayer.RelayToScreenName(ctx, instance.ChatRoomCookie(), instance.IdentScreenName(), wire.SNACMessage{
- Frame: wire.SNACFrame{
- FoodGroup: wire.Chat,
- SubGroup: wire.ChatRoomInfoUpdate,
- },
- Body: wire.SNAC_0x0E_0x02_ChatRoomInfoUpdate{
- Exchange: room.Exchange(),
- Cookie: room.Cookie(),
- InstanceNumber: room.InstanceNumber(),
- DetailLevel: room.DetailLevel(),
- TLVBlock: wire.TLVBlock{
- TLVList: room.TLVList(),
- },
- },
- })
- }
|