This commit is contained in:
2024-08-10 00:19:26 +02:00
parent da1d0b2511
commit 00ca93f70e
4 changed files with 232 additions and 27 deletions

121
poetry.lock generated
View File

@@ -1,10 +1,9 @@
# This file is automatically @generated by Poetry and should not be changed by hand. # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]] [[package]]
name = "aiofiles" name = "aiofiles"
version = "24.1.0" version = "24.1.0"
description = "File support for asyncio." description = "File support for asyncio."
category = "main"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
@@ -16,7 +15,6 @@ files = [
name = "aiohappyeyeballs" name = "aiohappyeyeballs"
version = "2.3.5" version = "2.3.5"
description = "Happy Eyeballs for asyncio" description = "Happy Eyeballs for asyncio"
category = "main"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
@@ -28,7 +26,6 @@ files = [
name = "aiohttp" name = "aiohttp"
version = "3.10.2" version = "3.10.2"
description = "Async http client/server framework (asyncio)" description = "Async http client/server framework (asyncio)"
category = "main"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
@@ -125,7 +122,6 @@ speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"]
name = "aiosignal" name = "aiosignal"
version = "1.3.1" version = "1.3.1"
description = "aiosignal: a list of registered asynchronous callbacks" description = "aiosignal: a list of registered asynchronous callbacks"
category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
@@ -140,7 +136,6 @@ frozenlist = ">=1.1.0"
name = "annotated-types" name = "annotated-types"
version = "0.7.0" version = "0.7.0"
description = "Reusable constraint types to use with typing.Annotated" description = "Reusable constraint types to use with typing.Annotated"
category = "main"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
@@ -148,11 +143,30 @@ files = [
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
] ]
[[package]]
name = "anyio"
version = "4.4.0"
description = "High level compatibility layer for multiple asynchronous event loop implementations"
optional = false
python-versions = ">=3.8"
files = [
{file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"},
{file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"},
]
[package.dependencies]
idna = ">=2.8"
sniffio = ">=1.1"
[package.extras]
doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"]
trio = ["trio (>=0.23)"]
[[package]] [[package]]
name = "attrs" name = "attrs"
version = "24.2.0" version = "24.2.0"
description = "Classes Without Boilerplate" description = "Classes Without Boilerplate"
category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
@@ -168,11 +182,21 @@ docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphi
tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"]
tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"]
[[package]]
name = "certifi"
version = "2024.7.4"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.6"
files = [
{file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"},
{file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"},
]
[[package]] [[package]]
name = "discord-py" name = "discord-py"
version = "2.4.0" version = "2.4.0"
description = "A Python wrapper for the Discord API" description = "A Python wrapper for the Discord API"
category = "main"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
@@ -193,7 +217,6 @@ voice = ["PyNaCl (>=1.3.0,<1.6)"]
name = "frozenlist" name = "frozenlist"
version = "1.4.1" version = "1.4.1"
description = "A list-like structure which implements collections.abc.MutableSequence" description = "A list-like structure which implements collections.abc.MutableSequence"
category = "main"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
@@ -276,11 +299,66 @@ files = [
{file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"},
] ]
[[package]]
name = "h11"
version = "0.14.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.7"
files = [
{file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"},
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
]
[[package]]
name = "httpcore"
version = "1.0.5"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
files = [
{file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"},
{file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"},
]
[package.dependencies]
certifi = "*"
h11 = ">=0.13,<0.15"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
trio = ["trio (>=0.22.0,<0.26.0)"]
[[package]]
name = "httpx"
version = "0.27.0"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
files = [
{file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"},
{file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"},
]
[package.dependencies]
anyio = "*"
certifi = "*"
httpcore = "==1.*"
idna = "*"
sniffio = "*"
[package.extras]
brotli = ["brotli", "brotlicffi"]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.7" version = "3.7"
description = "Internationalized Domain Names in Applications (IDNA)" description = "Internationalized Domain Names in Applications (IDNA)"
category = "main"
optional = false optional = false
python-versions = ">=3.5" python-versions = ">=3.5"
files = [ files = [
@@ -292,7 +370,6 @@ files = [
name = "multidict" name = "multidict"
version = "6.0.5" version = "6.0.5"
description = "multidict implementation" description = "multidict implementation"
category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
@@ -392,7 +469,6 @@ files = [
name = "pydantic" name = "pydantic"
version = "2.8.2" version = "2.8.2"
description = "Data validation using Python type hints" description = "Data validation using Python type hints"
category = "main"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
@@ -415,7 +491,6 @@ email = ["email-validator (>=2.0.0)"]
name = "pydantic-core" name = "pydantic-core"
version = "2.20.1" version = "2.20.1"
description = "Core functionality for Pydantic validation and serialization" description = "Core functionality for Pydantic validation and serialization"
category = "main"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
@@ -517,7 +592,6 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
name = "pydantic-settings" name = "pydantic-settings"
version = "2.4.0" version = "2.4.0"
description = "Settings management using Pydantic" description = "Settings management using Pydantic"
category = "main"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
@@ -538,7 +612,6 @@ yaml = ["pyyaml (>=6.0.1)"]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"
description = "Extensions to the standard Python datetime module" description = "Extensions to the standard Python datetime module"
category = "main"
optional = false optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
files = [ files = [
@@ -553,7 +626,6 @@ six = ">=1.5"
name = "python-dotenv" name = "python-dotenv"
version = "1.0.1" version = "1.0.1"
description = "Read key-value pairs from a .env file and set them as environment variables" description = "Read key-value pairs from a .env file and set them as environment variables"
category = "main"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
@@ -568,7 +640,6 @@ cli = ["click (>=5.0)"]
name = "six" name = "six"
version = "1.16.0" version = "1.16.0"
description = "Python 2 and 3 compatibility utilities" description = "Python 2 and 3 compatibility utilities"
category = "main"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
files = [ files = [
@@ -576,11 +647,21 @@ files = [
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
] ]
[[package]]
name = "sniffio"
version = "1.3.1"
description = "Sniff out which async library your code is running under"
optional = false
python-versions = ">=3.7"
files = [
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
]
[[package]] [[package]]
name = "twitchapi" name = "twitchapi"
version = "4.2.1" version = "4.2.1"
description = "A Python 3.7+ implementation of the Twitch Helix API, PubSub, EventSub and Chat" description = "A Python 3.7+ implementation of the Twitch Helix API, PubSub, EventSub and Chat"
category = "main"
optional = false optional = false
python-versions = "*" python-versions = "*"
files = [ files = [
@@ -597,7 +678,6 @@ typing-extensions = "*"
name = "typing-extensions" name = "typing-extensions"
version = "4.12.2" version = "4.12.2"
description = "Backported and Experimental Type Hints for Python 3.8+" description = "Backported and Experimental Type Hints for Python 3.8+"
category = "main"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
@@ -609,7 +689,6 @@ files = [
name = "yarl" name = "yarl"
version = "1.9.4" version = "1.9.4"
description = "Yet another URL library" description = "Yet another URL library"
category = "main"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
@@ -712,4 +791,4 @@ multidict = ">=4.0"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.11" python-versions = "^3.11"
content-hash = "ecdeecc583d261bc5c6fbf0c07c2d31e7b11941f2a63667d280b14a9104dae03" content-hash = "20a20db770b371c4b3e388034c4bd30335349d9a69f61a52c997ea3d2fdcd4fe"

View File

@@ -13,6 +13,7 @@ twitchapi = "^4.2.1"
pydantic = "^2.8.2" pydantic = "^2.8.2"
pydantic-settings = "^2.4.0" pydantic-settings = "^2.4.0"
aiofiles = "^24.1.0" aiofiles = "^24.1.0"
httpx = "^0.27.0"
[build-system] [build-system]

View File

@@ -0,0 +1,36 @@
from asyncio import gather
from httpx import AsyncClient
from config import config
async def notify_telegram(msg: str):
async with AsyncClient() as client:
await client.post(
f"https://api.telegram.org/bot{config.TELEGRAM_BOT_TOKEN}/sendMessage",
json={
"chat_id": config.TELEGRAM_CHANNEL_ID,
"text": msg,
}
)
async def notify_discord(msg: str):
async with AsyncClient() as client:
await client.post(
f"https://discord.com/api/v10/channels/{config.DISCORD_CHANNEL_ID}/messages",
headers={
"Authorization": f"Bot {config.DISCORD_BOT_TOKEN}"
},
json={
"content": msg,
}
)
async def notify(msg: str):
await gather(
notify_telegram(msg),
notify_discord(msg)
)

View File

@@ -1,18 +1,26 @@
from asyncio import Lock, sleep from asyncio import Lock, sleep
from datetime import datetime
import json import json
from twitchAPI.helper import first
from twitchAPI.eventsub.webhook import EventSubWebhook from twitchAPI.eventsub.webhook import EventSubWebhook
from twitchAPI.twitch import Twitch from twitchAPI.twitch import Twitch
from twitchAPI.type import AuthScope from twitchAPI.type import AuthScope
from twitchAPI.object.eventsub import ChannelChatMessageEvent, StreamOnlineEvent, StreamOfflineEvent from twitchAPI.object.eventsub import ChannelChatMessageEvent, StreamOnlineEvent, StreamOfflineEvent, ChannelUpdateEvent
import aiofiles import aiofiles
from pydantic import BaseModel
from config import config from config import config
from services.notification import notify
class State: class State(BaseModel):
pass title: str
category: str
is_live: bool
last_live_at: datetime
class TokenStorage: class TokenStorage:
@@ -42,6 +50,8 @@ class TwitchService:
AuthScope.CHAT_EDIT, AuthScope.CHAT_EDIT,
] ]
ONLINE_NOTIFICATION_DELAY = 5 * 60
def __init__(self, twitch: Twitch): def __init__(self, twitch: Twitch):
self.twitch = twitch self.twitch = twitch
@@ -61,14 +71,83 @@ class TwitchService:
return twitch return twitch
async def notify_online(self):
if self.state is None:
raise RuntimeError("State is None")
msg = f"HafMC сейчас стримит {self.state.title} ({self.state.category})! \nПрисоединяйся: https://twitch.tv/hafmc"
await notify(msg)
async def notify_change_category(self):
if self.state is None:
raise RuntimeError("State is None")
msg = f"HafMC начал играть в {self.state.category}! \nПрисоединяйся: https://twitch.tv/hafmc"
await notify(msg)
async def get_current_stream(self, retry_count: int = 5, delay: int = 5):
remain_retry = retry_count
while remain_retry > 0:
stream = await first(self.twitch.get_streams(user_id=[config.TWITCH_CHANNEL_ID]))
if stream is not None:
return stream
remain_retry -= 1
await sleep(delay)
return None
async def on_channel_chat_message(self, event: ChannelChatMessageEvent): async def on_channel_chat_message(self, event: ChannelChatMessageEvent):
print("on_channel_chat_message", event) if self.state is None or (datetime.now() - self.state.last_live_at).seconds <= self.ONLINE_NOTIFICATION_DELAY:
return
current_stream = await self.get_current_stream()
if current_stream is None:
return
self.state.last_live_at = datetime.now()
async def on_channel_update(self, event: ChannelUpdateEvent):
if self.state is None:
return
if self.state.category == event.event.category_name:
return
self.state.title = event.event.title
self.state.category = event.event.category_name
self.state.last_live_at = datetime.now()
await self.notify_change_category()
async def on_stream_online(self, event: StreamOnlineEvent): async def on_stream_online(self, event: StreamOnlineEvent):
print("on_stream_online", event) current_stream = await self.get_current_stream()
if current_stream is None:
raise RuntimeError("Stream not found")
state = State(
title=current_stream.title,
category=current_stream.game_name,
is_live=True,
last_live_at=datetime.now()
)
if self.state is None:
self.state = state
await self.notify_online()
if (datetime.now() - self.state.last_live_at).seconds >= self.ONLINE_NOTIFICATION_DELAY:
self.state = state
await self.notify_online()
async def on_stream_offline(self, event: StreamOfflineEvent): async def on_stream_offline(self, event: StreamOfflineEvent):
print("on_stream_offline", event) if self.state:
self.state.is_live = False
self.last_live_at = datetime.now()
async def run(self): async def run(self):
eventsub = EventSubWebhook( eventsub = EventSubWebhook(
@@ -78,12 +157,22 @@ class TwitchService:
message_deduplication_history_length=50 message_deduplication_history_length=50
) )
current_stream = await self.get_current_stream()
if current_stream:
self.state = State(
title=current_stream.title,
category=current_stream.game_name,
is_live=current_stream.type == "live",
last_live_at=datetime.now()
)
try: try:
await eventsub.unsubscribe_all() await eventsub.unsubscribe_all()
eventsub.start() eventsub.start()
await eventsub.listen_channel_chat_message(config.TWITCH_CHANNEL_ID, config.TWITCH_ADMIN_USER_ID, self.on_channel_chat_message) await eventsub.listen_channel_chat_message(config.TWITCH_CHANNEL_ID, config.TWITCH_ADMIN_USER_ID, self.on_channel_chat_message)
await eventsub.listen_channel_update_v2(config.TWITCH_CHANNEL_ID, self.on_channel_update)
await eventsub.listen_stream_online(config.TWITCH_CHANNEL_ID, self.on_stream_online) await eventsub.listen_stream_online(config.TWITCH_CHANNEL_ID, self.on_stream_online)
await eventsub.listen_stream_offline(config.TWITCH_CHANNEL_ID, self.on_stream_offline) await eventsub.listen_stream_offline(config.TWITCH_CHANNEL_ID, self.on_stream_offline)