Skip to content
This repository was archived by the owner on Jan 13, 2024. It is now read-only.

Commit 52019a6

Browse files
author
DirectiveAthena
committed
Feat: start of message constructor
1 parent 4dedb97 commit 52019a6

File tree

8 files changed

+235
-56
lines changed

8 files changed

+235
-56
lines changed

src/AthenaTwitchBot/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,7 @@
44
from AthenaTwitchBot.decorators.command import commandmethod
55

66
from AthenaTwitchBot.models.twitch_bot import TwitchBot
7+
from AthenaTwitchBot.models.twitch_bot_protocol import TwitchBotProtocol
8+
9+
# keep this function to be the last to be imported
10+
from AthenaTwitchBot.functions.launch import launch

src/AthenaTwitchBot/decorators/command.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
# ----------------------------------------------------------------------------------------------------------------------
1414
def commandmethod(name:str):
1515
def decorator(fnc):
16-
fnc.is_command = True
17-
fnc.command_name = name
1816
def wrapper(*args, **kwargs):
1917
return fnc(*args, **kwargs)
18+
19+
# store attributes for later use by the bot
20+
wrapper.command_name = name
21+
2022
return wrapper
2123
return decorator
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# ----------------------------------------------------------------------------------------------------------------------
2+
# - Package Imports -
3+
# ----------------------------------------------------------------------------------------------------------------------
4+
# General Packages
5+
from __future__ import annotations
6+
import asyncio
7+
from typing import Callable
8+
9+
# Custom Library
10+
11+
# Custom Packages
12+
from AthenaTwitchBot.models.twitch_bot import TwitchBot
13+
from AthenaTwitchBot.models.twitch_bot_protocol import TwitchBotProtocol
14+
15+
# ----------------------------------------------------------------------------------------------------------------------
16+
# - Code -
17+
# ----------------------------------------------------------------------------------------------------------------------
18+
def launch(
19+
bot:TwitchBot=None,
20+
protocol_factory:Callable=None,
21+
*,
22+
host:str='irc.chat.twitch.tv',
23+
port:int=6667 #todo make this into the ssl port
24+
):
25+
# a bot always has to be defined
26+
if bot is None or not isinstance(bot, TwitchBot):
27+
raise SyntaxError("a proper bot has not been defined")
28+
29+
loop = asyncio.get_event_loop()
30+
31+
# assemble the protocol if a custom hasn't been defined
32+
if protocol_factory is None:
33+
protocol_factory = lambda: TwitchBotProtocol(
34+
bot=bot,
35+
main_loop=loop,
36+
)
37+
38+
loop.run_until_complete(
39+
loop.create_connection(
40+
protocol_factory=protocol_factory,
41+
host=host,
42+
port=port,
43+
)
44+
)
45+
loop.run_forever()
46+
loop.close()
47+

src/AthenaTwitchBot/functions/twitch_irc_messages.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,8 @@ def prep_message(message:str) -> bytes:
2020
nick = lambda nickname: prep_message(f"NICK {nickname}")
2121
password = lambda oauth_token: prep_message(f"PASS oauth:{oauth_token}")
2222
join = lambda channel: prep_message(f"JOIN #{channel}")
23-
pong = lambda message: prep_message(f"PONG {message}")
23+
pong = lambda message: prep_message(f"PONG {message}")
24+
25+
request_commands = prep_message("CAP REQ :twitch.tv/commands")
26+
request_membership = prep_message("CAP REQ :twitch.tv/membership")
27+
request_tags = prep_message("CAP REQ :twitch.tv/tags")
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# ----------------------------------------------------------------------------------------------------------------------
2+
# - Package Imports -
3+
# ----------------------------------------------------------------------------------------------------------------------
4+
# General Packages
5+
from __future__ import annotations
6+
from typing import Callable
7+
from datetime import datetime
8+
9+
# Custom Library
10+
from AthenaColor import StyleNest, ForeNest, HEX
11+
12+
# Custom Packages
13+
from AthenaTwitchBot.models.twitch_message import TwitchMessage, TwitchMessagePing, TwitchMessageOnlyForBot
14+
15+
# ----------------------------------------------------------------------------------------------------------------------
16+
# - Support Code -
17+
# ----------------------------------------------------------------------------------------------------------------------
18+
def _find_PING(content:list[str]) -> TwitchMessagePing|False:
19+
if content[0] == "PING":
20+
# construct the ping message again as this has to be sent to TWITCH again
21+
return TwitchMessagePing(text=" ".join(content[1:]))
22+
return False
23+
24+
def _find_bot_only(content:list[str],message:str, bot_name:str) -> TwitchMessageOnlyForBot|False:
25+
if content[0] == ":tmi.twitch.tv":
26+
return TwitchMessageOnlyForBot(text=message)
27+
elif content[0] == f":{bot_name}!{bot_name}@{bot_name}.tmi.twitch.tv":
28+
return TwitchMessageOnlyForBot(text=message)
29+
return False
30+
31+
TAG_MAPPING:dict[str:Callable] = {
32+
"@badge-info": lambda tm, tag_value: setattr(tm, "badge_info", tag_value),
33+
"badges": lambda tm, tag_value: setattr(tm, "badges", tag_value),
34+
"client-nonce": lambda tm, tag_value: setattr(tm, "client_nonce", tag_value),
35+
"color": lambda tm, tag_value: setattr(tm, "color", HEX(tag_value)),
36+
"display-name": lambda tm, tag_value: setattr(tm, "display_name", tag_value),
37+
"emotes": lambda tm, tag_value: setattr(tm, "emotes", tag_value),
38+
"first-msg": lambda tm, tag_value: setattr(tm, "first_msg", bool(tag_value)),
39+
"flags": lambda tm, tag_value: setattr(tm, "flags", tag_value),
40+
"id": lambda tm, tag_value: setattr(tm, "message_id", int(tag_value)),
41+
"mod": lambda tm, tag_value: setattr(tm, "mod", bool(tag_value)),
42+
"room-id": lambda tm, tag_value: setattr(tm, "room_id", tag_value),
43+
"subscriber": lambda tm, tag_value: setattr(tm, "subscriber", bool(tag_value)),
44+
"tmi-sent-ts": lambda tm, tag_value: setattr(tm, "tmi_sent_ts", datetime.fromtimestamp(int(tag_value))),
45+
"turbo": lambda tm, tag_value: setattr(tm, "turbo", bool(tag_value)),
46+
"user-id": lambda tm, tag_value: setattr(tm, "user_id", int(tag_value)),
47+
"user-type": lambda tm, tag_value: setattr(tm, "user-type", tag_value),
48+
49+
}
50+
51+
# ----------------------------------------------------------------------------------------------------------------------
52+
# - Code -
53+
# ----------------------------------------------------------------------------------------------------------------------
54+
def twitch_message_constructor_tags(message_bytes:bytearray, bot_name:str) -> TwitchMessage:
55+
print(message_bytes)
56+
message = message_bytes.decode("UTF_8").replace("\r\n", "")
57+
content = message.split(" ")
58+
59+
# Certain twitch sent messages have a different consistency than a regular user sent message
60+
# If this happens, this has to be caught before the message is parsed further
61+
if ping_message := _find_PING(content):
62+
return ping_message
63+
64+
if bot_only_message := _find_bot_only(content, message, bot_name):
65+
return bot_only_message
66+
67+
# with tags enabled, we know that the first element of the list above contains all the user's tags
68+
# This enables us to loop them and assign them to the message
69+
# This is done to make them accessible to the command parsing
70+
# The second part of the split message is the user definement. The user id is found in the tags
71+
# IRC message string is found next
72+
# The channel from which it is sent is also recieved.
73+
# When the bot is only installed in one channel, this isn't usefull, but if a bot is used in multiple channels
74+
# this is part of the usefull known context
75+
# Finally all text should be clumped together again, to be searched though for a custom command
76+
# This is to be done by the protocol class, not the message constructir
77+
78+
tags, user, irc_message, channel, *text = content
79+
twitch_message:TwitchMessage = TwitchMessage(
80+
message=message,
81+
message_type=irc_message,
82+
channel=channel,
83+
text=" ".join(text)
84+
)
85+
86+
87+
for tag in tags.split(";"):
88+
# dict mapping is easier than a whole IF-ELSE or match case check
89+
tag_name, tag_value = tag.split("=")
90+
try:
91+
TAG_MAPPING[tag_name](tm=twitch_message,tag_value=tag_value)
92+
except KeyError:
93+
print(StyleNest.Bold(ForeNest.Maroon(f"Unkown tag of '{tag_name}' found. Please create a bug report on the git repo")))
94+
pass
95+
96+
return twitch_message

src/AthenaTwitchBot/models/twitch_bot.py

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,15 @@
44
# General Packages
55
from __future__ import annotations
66
import asyncio
7-
from dataclasses import dataclass, field
8-
import socket
7+
from dataclasses import dataclass, field, InitVar
98
from typing import Callable
9+
import inspect
1010

1111
# Custom Library
1212
import AthenaLib
1313
import AthenaColor
1414

1515
# Custom Packages
16-
from AthenaTwitchBot.models.twitch_bot_protocol import TwitchBotProtocol
1716

1817
# ----------------------------------------------------------------------------------------------------------------------
1918
# - Code -
@@ -25,38 +24,33 @@ class TwitchBot:
2524
channel:str
2625
prefix:str
2726

28-
commands:dict[str: Callable]=field(default_factory=dict) # made part of init if someone wants to feel the pain of adding commands manually
27+
# Twitch-specific capabilities : https://dev.twitch.tv/docs/irc/capabilities
28+
twitch_capibility_commands:bool=False
29+
twitch_capibility_membership:bool=False
30+
twitch_capibility_tags:bool=True # only one that has the default set to true, as this is required to make reply's work
31+
32+
predefined_commands:InitVar[dict[str: Callable]]=None # made part of init if someone wants to feel the pain of adding commands manually
33+
34+
# noinspection PyDataclass
35+
commands:dict[str: Callable]=field(init=False)
2936

3037
# non init slots
3138

3239
# ------------------------------------------------------------------------------------------------------------------
3340
# - Code -
3441
# ------------------------------------------------------------------------------------------------------------------
3542
def __new__(cls, *args, **kwargs):
36-
# don't set self.commands to a new dictionary
37-
# as this might have been defined due to the field default factory
3843
# Loop over own functions to see if any is decorated with the command setup
44+
cls.commands = {}
45+
for k,v in cls.__dict__.items():
46+
if inspect.isfunction(v) and getattr(v, "command_name", False):
47+
cls.commands[v.command_name] = v
3948

40-
# surpressed because of pycharm being an ass
41-
# noinspection PyTypeChecker
42-
return type.__new__(cls, *args, **kwargs)
43-
44-
def launch(self):
45-
loop = asyncio.get_event_loop()
46-
coro = loop.create_connection(
47-
protocol_factory = lambda: TwitchBotProtocol(
48-
bot_nickname=self.nickname,
49-
bot_oauth_token=self.oauth_token,
50-
bot_channel=self.channel,
51-
main_loop=loop,
52-
command_prefix=self.prefix
53-
),
54-
host='irc.chat.twitch.tv',
55-
port=6667,
56-
)
57-
loop.run_until_complete(coro)
58-
loop.run_forever()
59-
loop.close()
49+
# create the actual instance
50+
return super(TwitchBot, cls).__new__(cls,*args,**kwargs)
6051

52+
def __post_init__(self, predefined_commands: dict[str: Callable]=None):
53+
if predefined_commands is not None:
54+
self.commands |= predefined_commands
6155

6256

src/AthenaTwitchBot/models/twitch_bot_protocol.py

Lines changed: 29 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,62 +5,69 @@
55
from __future__ import annotations
66
import asyncio
77
from dataclasses import dataclass, field
8+
from typing import Callable
89

910
# Custom Library
1011
from AthenaColor import ForeNest
1112

1213
# Custom Packages
1314
import AthenaTwitchBot.functions.twitch_irc_messages as messages
14-
from AthenaTwitchBot.models.twitch_message import TwitchMessage
15+
from AthenaTwitchBot.functions.twitch_message_constructors import twitch_message_constructor_tags
16+
17+
from AthenaTwitchBot.models.twitch_message import TwitchMessage, TwitchMessagePing
18+
from AthenaTwitchBot.models.twitch_bot import TwitchBot
1519

1620
# ----------------------------------------------------------------------------------------------------------------------
1721
# - Code -
1822
# ----------------------------------------------------------------------------------------------------------------------
1923
@dataclass(kw_only=True, slots=True, eq=False, order=False)
2024
class TwitchBotProtocol(asyncio.Protocol):
21-
bot_nickname:str
22-
bot_oauth_token:str
23-
bot_channel:str
24-
command_prefix:str
25+
bot:TwitchBot
2526
main_loop:asyncio.AbstractEventLoop
2627

2728
# non init slots
2829
transport:asyncio.transports.Transport = field(init=False)
30+
message_constructor:Callable = field(init=False)
31+
32+
def __post_init__(self):
33+
if self.bot.twitch_capibility_tags:
34+
self.message_constructor = twitch_message_constructor_tags
35+
else:
36+
raise NotImplementedError("This needs to be created")
2937

3038
def connection_made(self, transport: asyncio.transports.Transport) -> None:
3139
# todo make some sort of connector to make t
3240
self.transport = transport
3341
# first write the password then the nickname else the connection will fail
34-
self.transport.write(messages.password(oauth_token=self.bot_oauth_token))
35-
self.transport.write(messages.nick(nickname=self.bot_nickname))
36-
self.transport.write(messages.join(channel=self.bot_channel))
42+
self.transport.write(messages.password(oauth_token=self.bot.oauth_token))
43+
self.transport.write(messages.nick(nickname=self.bot.nickname))
44+
self.transport.write(messages.join(channel=self.bot.channel))
45+
self.transport.write(messages.request_tags)
3746

3847
def data_received(self, data: bytearray) -> None:
39-
match (twitch_message := TwitchMessage(data)):
48+
match (twitch_message := self.message_constructor(data, bot_name=self.bot.nickname)):
4049
# Keepalive messages : https://dev.twitch.tv/docs/irc#keepalive-messages
41-
case TwitchMessage(message=["PING", *_]):
50+
case TwitchMessagePing():
4251
print(ForeNest.ForestGreen("PINGED BY TWITCH"))
43-
self.transport.write(pong_message := messages.pong(
44-
message=twitch_message.message[1:-1]
45-
))
46-
print(data, pong_message)
52+
self.transport.write(pong_message := messages.pong(message=twitch_message.text))
53+
print(pong_message)
4754

4855
# catch a message which starts with a command:
49-
case TwitchMessage(message=[_,_,_,str(user_message),*user_message_other]) if user_message.startswith(f":{self.command_prefix}"):
56+
case TwitchMessage(message=[_,_,_,str(user_message),*user_message_other]) if user_message.startswith(f":{self.bot.prefix}"):
5057
user_message:str
5158
print(ForeNest.ForestGreen("COMMAND CAUGHT"))
52-
print(user_message, user_message_other)
59+
try:
60+
user_cmd = user_message.replace(f":{self.bot.prefix}", "")
61+
result = self.bot.commands[user_cmd](self=self.bot,transport=self.transport)
62+
print(result)
63+
except KeyError:
64+
pass
5365

5466
# catch a message which has a command within it:
5567
case TwitchMessage(message=[_,_,_,*messages_parts]):
56-
print(messages_parts)
5768
for message in messages_parts:
58-
if message.startswith(self.command_prefix):
69+
if message.startswith(self.bot.prefix):
5970
print(ForeNest.ForestGreen("COMMAND CAUGHT"))
60-
print(messages_parts)
61-
print(data)
62-
case _:
63-
print(data)
6471

6572
def connection_lost(self, exc: Exception | None) -> None:
6673
self.main_loop.stop()

src/AthenaTwitchBot/models/twitch_message.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,43 @@
33
# ----------------------------------------------------------------------------------------------------------------------
44
# General Packages
55
from __future__ import annotations
6-
from dataclasses import dataclass
6+
from dataclasses import dataclass, field
7+
from datetime import datetime
78

89
# Custom Library
10+
from AthenaColor import HEX
911

1012
# Custom Packages
1113

1214
# ----------------------------------------------------------------------------------------------------------------------
1315
# - Code -
1416
# ----------------------------------------------------------------------------------------------------------------------
15-
@dataclass(init=False, slots=True, eq=True, match_args=True)
17+
@dataclass(slots=True, eq=True, match_args=True)
1618
class TwitchMessage:
17-
message:list[str]
19+
message:str="" # complete message without the sufix: "\r\n"
20+
message_type:str=""
21+
channel:str=""
22+
text:str=""
1823

19-
def __init__(self, message_bytes:bytearray):
20-
self.message = message_bytes.decode("utf_8").replace("\r\n", "").split(" ")
24+
# optional info if tags is enabled
25+
badge_info:str=""
26+
badges:list[str]=field(default_factory=list)
27+
client_nonce:str=""
28+
color:HEX=field(default_factory=HEX)
29+
display_name:str=""
30+
first_msg:bool=False
31+
message_id:int=0
32+
mod:bool=False
33+
room_id:str=""
34+
subscriber:bool=False
35+
tmi_sent_ts:datetime=field(default_factory=datetime.now)
36+
turbo:bool=False
37+
user_id:int=0
38+
# ----------------------------------------------------------------------------------------------------------------------
39+
# - Special message types -
40+
# ----------------------------------------------------------------------------------------------------------------------
41+
class TwitchMessagePing(TwitchMessage):
42+
pass
43+
44+
class TwitchMessageOnlyForBot(TwitchMessage):
45+
pass

0 commit comments

Comments
 (0)