Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Chat for SteamClient #13

Open
5 of 7 tasks
rossengeorgiev opened this issue Jan 14, 2016 · 41 comments
Open
5 of 7 tasks

Implement Chat for SteamClient #13

rossengeorgiev opened this issue Jan 14, 2016 · 41 comments

Comments

@rossengeorgiev
Copy link
Member

rossengeorgiev commented Jan 14, 2016

Tasks:

  • SteamUser rework
  • research user to user chat protocol
  • implement user to user chat handling
  • research group chat protocol
  • implement group protocol messages
  • implement group chat
  • docs & examples
@rossengeorgiev rossengeorgiev added this to the Feature complete SteamClient milestone Jan 14, 2016
@thomassross
Copy link

I've looked into this a little bit and I've discovered:

You can listen to the message CMsgClientFriendMsgIncoming to receive messages from friends. This message contains the following fields:

  • steamid_from contains the steamid of the sender
  • chat_entry_type contains the type of the message -- 2 means the sender has started typing in the chat window, 1 means a message has been sent
  • from_limited_account - I haven't tested this one, but I assume it shows if the sender has a limited Steam account or not
  • message - if chat_entry_type is 1, it contains the sent message. Otherwise it is empty (with a NUL character (\0) at the end) -- This message always ends with a NUL character (\0)
  • rtime32_server_timestamp - The time the server sent the message (in Unix time)

@thomassross
Copy link

thomassross commented May 5, 2016

Sending a message:

  • Send the protobuf message CMsgClientFriendMsg
  • steamid is the steamid64 you want to send the message to
  • chat_entry_type is the same as before -- 2 means the sender has started typing in the chat window, 1 means a message has been sent
  • message is the message that you want to send
  • rtime32_server_timestamp - The time the client sent the message (in Unix time)

Putting this all together you could write a simple "copycat":

@client.on(EMsg.ClientFriendMsgIncoming)
def onMessage(msg):
    messageText = msg.body.message.rstrip().strip("\0")

    if messageText:
        sendMsg = MsgProto(EMsg.ClientFriendMsg)
        sendMsg.body.steamid = msg.body.steamid_from
        sendMsg.body.chat_entry_type = 1
        sendMsg.body.message = messageText
        sendMsg.body.rtime32_server_timestamp = int(time.time())

        client.send(sendMsg)

@rossengeorgiev
Copy link
Member Author

rossengeorgiev commented May 5, 2016

Hi @thomassross, thanks for the info I appreciate it. My fault for not adding any details on the issue, but the issue was more about implementing code in the module that abstracts the unnecessary details while providing a simple and initiative API. I already have something in the works.

rossengeorgiev added a commit that referenced this issue Jul 7, 2016
* added send_message() on SteamUser
* added `chat_message` event for incoming friend messages
@alexche8
Copy link

Can you please add thomassross example of chat to documentation or examle pages? It helps to do basic things.

@rossengeorgiev
Copy link
Member Author

rossengeorgiev commented Aug 12, 2016

You are right @alexche8, docs are a bit lacking on that. It will happen at some point. Currently you can send and receive messages from individual users.

Here is a simple message echo example, user parameter is a SteamUser instance.

@client.on('chat_message')
def handle_message(user, message_text):
    user.send_message('You said: %s' % message_text)

@alexche8
Copy link

@rossengeorgiev , thanks!

@nukeop
Copy link
Contributor

nukeop commented Aug 28, 2016

User chats work perfectly, are group chats getting implemented any time soon?
I noticed that some protobuf messages connected to this functionality are missing - sadly I don't have any idea how to implement this myself, but I might try figuring it out in the coming weeks.
For example to join a group chat, EMsg.ClientJoinChat protobuf needs to be sent, but attempting to create it with MsgProto (from steam.core.msg) results in an empty message. If I have the time I will try to learn how exactly messages are created and how they're defined, and maybe find a way to create support for joining and receiving/sending messages.

@b3mb4m
Copy link

b3mb4m commented Aug 31, 2016

User chat working tested on linux.I wish i had time to help but working too hard these days.

Thanks for everyone ~~

@nukeop
Copy link
Contributor

nukeop commented Nov 2, 2016

I'm slowly figuring out group chat. For example, I managed to get joining group chats to work. You have to use non-protobuf messages (Msg class) serialized to bytes. For example, given an invite with chatroomid, to join that chatroom we need something like this:

msg = Msg(EMsg.ClientJoinChat, extended=True)
msg.body = MsgClientJoinChat(chatroomid)
client.send(msg)

Where MsgClientJoinChat is a class encapsulating the required fields, and providing a method for serialization to bytes. A crude version of this class could look like this:

import StringIO

class MsgClientJoinChat(object):
    def __init__(self, sidc):
        self.SteamIdChat = sidc
        self.IsVoiceSpeaker = False

    def serialize(self):
        out = StringIO.StringIO()
        hsteamidchat = format(self.SteamIdChat, '02x')
        if len(hsteamidchat)%4!=0:
            hsteamidchat = (len(hsteamidchat)%4)*'0' + hsteamidchat

        newstr = ""
        for i in range(len(hsteamidchat), 2, -2):
            newstr += hsteamidchat[i-2:i]

        hsteamidchat = bytearray.fromhex(newstr)
        out.write(hsteamidchat)
        out.write("\x00")
        return out.getvalue()

The hard part is converting the steam id into bytes and reversing their order (unless I missed some built-in python function that does that).

After sending the message in the way described in the first block of code, the bot joins the chatroom. I was unable to find non-protobuf messages with bodies specific to their types (like this MsgClientJoinChat class I pasted here). Have I missed them or is this functionality not implemented in the library?

@nukeop
Copy link
Contributor

nukeop commented Nov 4, 2016

I figured out something more advanced: receiving group chat messages.

In steam/core/msg.py, in the Msg class constructor, I added this condition to the chain creating the appropriate message body:

elif msg == EMsg.ClientChatMsg:
        self.body = ClientChatMsg(data)

ClientChatMsg is a class I created in the same file after looking at the format in which steam sends group chat messages. This is basically:

steamIdChatter - 64-bit ID of the author of the message
steamIdChatRoom - 64 bit ID of the chatroom the message was sent in
ChatMsgType - 32-bit int message type
ChatMsg - the message itself. Size is as big as needed.

This can be unpacked with struct.unpack_from with the format string "<QQI16s", similarly to other messages, although this particular string will only unpack the first 16 characters correctly, not sure how to make it unpack everything until the end of the message.

Once we have this unpacked, we set a ClientChatMsg object as the message body and voila. The last piece of the puzzle is figuring out the format string for struct.unpack_from, and I can post a pull request.

An ugly way to do that would be just getting the length of the message in bytes and subtracting 8+8+4=20 bytes, since that's how much the first three attributes take. Then we can use s in the format string.

@rossengeorgiev
Copy link
Member Author

rossengeorgiev commented Nov 4, 2016

Nice, those seem to be correct. They are indeed not protos for them

The hard part is converting the steam id into bytes and reversing their order (unless I missed some built-in python function that does that).

If it is endianness just use <> in the unpack format.

An ugly way to do that would be just getting the length of the message in bytes

It's not ugly at all.

"<QQI{}s".format(len(body) - struct.calcsize("QQI"))

@nukeop
Copy link
Contributor

nukeop commented Nov 4, 2016

Yes, that works. I will post a pull request later today (with example usage in the docs) and next week I'll try handling events where people enter/exit chat, and sending messages to group chats.

@rossengeorgiev
Copy link
Member Author

Everything is here btw: https://github.com/SteamRE/SteamKit/blob/master/Resources/SteamLanguage/steammsg.steamd

Should probably write a script to parse those one day

@nukeop
Copy link
Contributor

nukeop commented Nov 5, 2016

This is useful, but I do not believe this is 100% correct though.

For example, I am now working on enter/exit events for group chat. The client receives ClientChatMemberInfo when that happens, and according to that file it should only have an 8 byte field and a 4 byte field, but it actually has 8-4-8-4-8 (which is, respectively, id of the group, enum with the action, id of the user acted on (needed when user X kicks/bans user Y), enum with chat action, and id of the user who acted.

So it turns out that this message has also a EMsg::ClientChatAction inside it, but steammsg.steamd doesn't mention it.

rossengeorgiev added a commit that referenced this issue Nov 5, 2016
@nukeop
Copy link
Contributor

nukeop commented Nov 14, 2016

I want to implement the ClientChatEnter event which happens when you enter the group.
It should return these parameters:

    steamIdChat
    steamIdFriend
    chatRoomType
    steamIdOwner
    steamIdClan
    chatFlags
    enterResponse
    numMembers
    chatRoomName
    memberList

Now the first 8 are easy, struct.unpack_from with "<QQIQQ?II" format string.
After that, the message contains a null-terminated string with the group's name (still easy enough), and after that a list of objects in a different format - it's a list of users currently in the chat, but every item begins with MessageObject, then has steamId, permissions, and Detailswith attribute names as strings and the rest of the data as bytes. Any ideas how to parse that? Maybe something else uses this format?

@thomassross
Copy link

@nukeop maybe you're looking for some of these functions?

@nukeop
Copy link
Contributor

nukeop commented Nov 14, 2016

I will check this out once I get home.

I am able to parse this correctly in a primitive way like this:

nullId = struct.calcsize("<QQIQQ?II") + data[struct.calcsize("<QQIQQ?II"):].index('\x00')
        self.chatRoomName = data[struct.calcsize("<QQIQQ?II"):nullId]

        for i, t in enumerate(data[struct.calcsize("<QQIQQ?II") + nullId:].split('MessageObject')[1:]):
            member_data = (t[t.index('steamid')+8:t.index('permissions')] +
             t[t.index('permissions')+12:t.index('Details')] +
             t[t.index('Details')+8:])

            member = ChatMemberInfo(member_data)

            self.memberList.append(member)

ChatMemberInfo uses struct.unpack_from with"<QII"format string. I'm basically extracting what's between the null-terminated strings. I could probably just keep reading until I encounter the next null value and repeat three times for every user in the chat.

@rossengeorgiev
Copy link
Member Author

rossengeorgiev commented Nov 15, 2016

@nukeop I've refactored the msg.py as it was getting overcrowded. It's now split into multiple modules. Struct messages are now are located into steam/core/msg/structs.py and there are some slight changes on how they are defined. Have look.

@nukeop
Copy link
Contributor

nukeop commented Nov 15, 2016

Great, when I make a pull request I'll use the new structure.

Are those MessageObjects parsed somewhere, or are they only declared? I figured I could add this as a class somewhere with a method that could load them from this string-null-data format, either loading the attributes into a dictionary or turning them into object's own attributes.

@rossengeorgiev
Copy link
Member Author

Classes inheriting from StructMessage are automatically mapped based on their name. They need to be named exactly as the corresponding EMsg. You only need to declare them

@rossengeorgiev
Copy link
Member Author

@nukeop oh, if you see MessageObject, that's most likely binary VDF. You can parse that using vdf.binary_loads. If you give me a raw sample I can figure out how to parse it.

@nukeop
Copy link
Contributor

nukeop commented Nov 16, 2016

Nope, vdf.binary_loads gives me this error:

SyntaxError: Unknown data type at index 15: '`M

Here's example binary data in urlsafe base64-encoded form (use base64.urlsafe_b64decode to get the data; I'm not sure if I'd be able to post raw bytes in a comment on Github):

AABNZXNzYWdlT2JqZWN0AAdzdGVhbWlkAH_SAAQBABABAnBlcm1pc3Npb25zABoDAAACRGV0YWlscwACAAAACAgATWVzc2FnZU9iamVjdAAHc3RlYW1pZACVMcoFAQAQAQJwZXJtaXNzaW9ucwAKAAAAAkRldGFpbHMABAAAAAgI6AMAAA==

The above string contains everything after the group name.

@rossengeorgiev
Copy link
Member Author

rossengeorgiev commented Nov 16, 2016

Ok. This is not the whole message. You can just use repr(data) to get pasteable representation.

'\x00\x00MessageObject\x00\x07steamid\x00\x7f\xd2\x00\x04\x01\x00\x10\x01\x02permissions\x00\x1a\x03\x00\x00\x02Details\x00\x02\x00\x00\x00\x08\x08\x00MessageObject\x00\x07steamid\x00\x951\xca\x05\x01\x00\x10\x01\x02permissions\x00\n\x00\x00\x00\x02Details\x00\x04\x00\x00\x00\x08\x08\xe8\x03\x00\x00'

There are two binary VDFs in there with some extra bytes. There is probably a field telling you how many bin VDFs there are.

In [30]: vdf.binary_loads('\x00MessageObject\x00\x07steamid\x00\x951\xca\x05\x01\x00\x10\x01\x02permissions\x00\n\x00\x00\x00\x02Details\x00\x04\x00\x00\x00\x08\x08')
Out[30]:
{'MessageObject': {'Details': 4,
  'permissions': 10,
  'steamid': UINT_64(76561198057402773)}}

@nukeop
Copy link
Contributor

nukeop commented Nov 16, 2016

This is the entire example message:

\x13@Z\x01\x00\x00\x88\x01\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x13@Z\x01\x00\x00p\x01\x13@Z\x01\x00\x00p\x01\x00\x01\x00\x00\x00\x02\x00\x00\x00Relay Bot\xe2\x84\xa2\x00\x00MessageObject\x00\x07steamid\x00\x7f\xd2\x00\x04\x01\x00\x10\x01\x02permissions\x00\x1a\x03\x00\x00\x02Details\x00\x02\x00\x00\x00\x08\x08\x00MessageObject\x00\x07steamid\x00\x951\xca\x05\x01\x00\x10\x01\x02permissions\x00\n\x00\x00\x00\x02Details\x00\x04\x00\x00\x00\x08\x08\xe8\x03\x00\x00

Should it end at 08 08?

The number of these VDF blocks is equal to numMemberswhich is decoded earlier with struct.unpack_from.

@nukeop
Copy link
Contributor

nukeop commented Nov 16, 2016

This does the trick:

def load(self, data):
        (self.steamIdChat,
         self.steamIdFriend,
         self.chatRoomType,
         self.steamIdOwner,
         self.steamIdClan,
         self.chatFlags,
         self.enterResponse,
         self.numMembers
         ) = struct.unpack_from("<QQIQQ?II", data)

        nullId = struct.calcsize("<QQIQQ?II") + data[struct.calcsize("<QQIQQ?II"):].index('\x00')
        self.chatRoomName = data[struct.calcsize("<QQIQQ?II"):nullId]

        for x in data[nullId+1:].split('\x08\x08')[:-1]:
                self.memberList.append(vdf.binary_loads(x+'\x08\x08'))

Is this clean enough? I don't like the magic 08 08 but I don't know how to avoid it.

@rossengeorgiev
Copy link
Member Author

I really don't like that parsing code, so I made steam.util.binary.StructReader, which should simplify things for this type of messages. I am assuming the VDF size doesn't change, so we just hardcode it for now..

def load(self, data):
    buf, self.memberList = StructReader(data), list()

    (self.steamIdChat, self.steamIdFriend, self.chatRoomType, self.steamIdOwner,
     self.steamIdClan, self.chatFlags, self.enterResponse, self.numMembers
     ) = buf.unpack("<QQIQQ?II")
    self.chatRoomName = buf.read_cstring().decode('utf-8')

    for _ in range(self.numMembers):
        self.memberList.append(vdf.binary_loads(buf.read(64))['MessageObject'])

    self.UNKNOWN1, = buf.unpack("<I")

@nukeop
Copy link
Contributor

nukeop commented Nov 20, 2016

After ClientChatEnter, what are the remaining group chat features that need to be implemented?

What examples/recipes are needed for the documentation?

@Lambda14
Copy link

Lambda14 commented Oct 22, 2018

Have that code

sendMsg = MsgProto(EMsg.ClientFriendMsg)
sendMsg.body.steamid = 76561198864244185
sendMsg.body.chat_entry_type = 1
sendMsg.body.message = str.encode("HW!")
sendMsg.body.rtime32_server_timestamp = int(time.time())
client.send(sendMsg)

But it doesn't work. Have that error: TypeError: Expected "data" to be of type "dict". pls help me. I need just send message to one steamid.

@rossengeorgiev
Copy link
Member Author

@Lambda14 open a new issue and include the full stack trace

@rossengeorgiev
Copy link
Member Author

rossengeorgiev commented Oct 25, 2018

Not sure if the old group chat exist anymore, but there are now protos for the new one. Added in 8c80ab8

@rossengeorgiev rossengeorgiev removed this from the v1.0.0 milestone Apr 19, 2019
@Gobot1234
Copy link

Have there been any updates on this? I am interested in sending and receiving messages but only to one user, and was wondering if this was going to be made any easier in V.1

Is there also a discord server that I could join to get help?

@Gobot1234
Copy link

I'm not sure what you mean by commands, videos and pictures can't currently be sent but I can't imagine they are particularly high on the priorities list.

@rossengeorgiev
Copy link
Member Author

Let me consult my 🎱

@wanderrful
Copy link

wanderrful commented Sep 19, 2020

At first glance in the code, it looks like (as you pointed out above @rossengeorgiev) we have private methods to handle the sending and receiving of the chat_message events. So, what remains to be done for this?

Do you just need help with creating that subscription so that we can listen to these chat_message events in the first place? Do you need an API of public functions in the SteamUser class so that we can send and receive the messages? I think it'd be helpful if you explain a bit more about what you need for this to happen.

@Gobot1234
Copy link

#13 (comment) all of that can already be done now.

@Exioncore
Copy link

Is there no way to filter out own messages when listening to the event EVENT_CHAT_MESSAGE?
It seems like the user always return the user of the other party of the chat. If I am A and I send a message to B user contains B, if B sends message to me A user contains B.

@rossengeorgiev
Copy link
Member Author

rossengeorgiev commented Feb 10, 2021

That's a bug. That will only happen if you are logged into another session, which you didn't mention. So I'm guessing you are running your code on the same user you are logged in with Steam. When you send a message from either client, it will echo it into the other to keep the chats in sync.
You can always listen for the raw message and implement your own logic.

client.on("FriendMessagesClient.IncomingMessage#1")
def handle_priv_messages(self, msg):
  if msg.body.chat_entry_type == EChatEntryType.ChatMsg and not msg.body.local_echo:
    user = client.get_user(msg.body.steamid_friend)
    text = msg.body.message
    print("{} said: {}".format(user.name, text))

rossengeorgiev added a commit that referenced this issue Feb 10, 2021
tomprince pushed a commit to tomprince/steam that referenced this issue Feb 11, 2021
@isFakeAccount
Copy link

Hello I send a message to my friend using this guide https://steam.readthedocs.io/en/latest/api/steam.client.user.html#steam.client.user.SteamUser.send_message

but nothing really happened. I checked the message tab from steam client and no message appeared. I am not sure what I am doing wrong. Here is my code

from steam.client import SteamClient
client = SteamClient()
client.cli_login()

friend = client.get_user(<STEAM64 ID>)
friend.send_message("Hello")

@rossengeorgiev
Copy link
Member Author

Hello I send a message to my friend using this guide https://steam.readthedocs.io/en/latest/api/steam.client.user.html#steam.client.user.SteamUser.send_message

but nothing really happened. I checked the message tab from steam client and no message appeared. I am not sure what I am doing wrong. Here is my code

from steam.client import SteamClient
client = SteamClient()
client.cli_login()

friend = client.get_user(<STEAM64 ID>)
friend.send_message("Hello")

You didn't actually send the message. You queued a message to be send, but your code never gives a chance for that to happen. You have to yield to the event loop, so that the message gets processed and sent. For example, you could simply sleep for a short time client.sleep(0.5)

@H357753
Copy link

H357753 commented Jun 13, 2024

Hello, I would like to ask, can I send messages to the Steam chat group now? This issue is from 2022

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests