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]]
name = "aiofiles"
version = "24.1.0"
description = "File support for asyncio."
category = "main"
optional = false
python-versions = ">=3.8"
files = [
@@ -16,7 +15,6 @@ files = [
name = "aiohappyeyeballs"
version = "2.3.5"
description = "Happy Eyeballs for asyncio"
category = "main"
optional = false
python-versions = ">=3.8"
files = [
@@ -28,7 +26,6 @@ files = [
name = "aiohttp"
version = "3.10.2"
description = "Async http client/server framework (asyncio)"
category = "main"
optional = false
python-versions = ">=3.8"
files = [
@@ -125,7 +122,6 @@ speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"]
name = "aiosignal"
version = "1.3.1"
description = "aiosignal: a list of registered asynchronous callbacks"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -140,7 +136,6 @@ frozenlist = ">=1.1.0"
name = "annotated-types"
version = "0.7.0"
description = "Reusable constraint types to use with typing.Annotated"
category = "main"
optional = false
python-versions = ">=3.8"
files = [
@@ -148,11 +143,30 @@ files = [
{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]]
name = "attrs"
version = "24.2.0"
description = "Classes Without Boilerplate"
category = "main"
optional = false
python-versions = ">=3.7"
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-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]]
name = "discord-py"
version = "2.4.0"
description = "A Python wrapper for the Discord API"
category = "main"
optional = false
python-versions = ">=3.8"
files = [
@@ -193,7 +217,6 @@ voice = ["PyNaCl (>=1.3.0,<1.6)"]
name = "frozenlist"
version = "1.4.1"
description = "A list-like structure which implements collections.abc.MutableSequence"
category = "main"
optional = false
python-versions = ">=3.8"
files = [
@@ -276,11 +299,66 @@ files = [
{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]]
name = "idna"
version = "3.7"
description = "Internationalized Domain Names in Applications (IDNA)"
category = "main"
optional = false
python-versions = ">=3.5"
files = [
@@ -292,7 +370,6 @@ files = [
name = "multidict"
version = "6.0.5"
description = "multidict implementation"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -392,7 +469,6 @@ files = [
name = "pydantic"
version = "2.8.2"
description = "Data validation using Python type hints"
category = "main"
optional = false
python-versions = ">=3.8"
files = [
@@ -415,7 +491,6 @@ email = ["email-validator (>=2.0.0)"]
name = "pydantic-core"
version = "2.20.1"
description = "Core functionality for Pydantic validation and serialization"
category = "main"
optional = false
python-versions = ">=3.8"
files = [
@@ -517,7 +592,6 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
name = "pydantic-settings"
version = "2.4.0"
description = "Settings management using Pydantic"
category = "main"
optional = false
python-versions = ">=3.8"
files = [
@@ -538,7 +612,6 @@ yaml = ["pyyaml (>=6.0.1)"]
name = "python-dateutil"
version = "2.9.0.post0"
description = "Extensions to the standard Python datetime module"
category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
files = [
@@ -553,7 +626,6 @@ six = ">=1.5"
name = "python-dotenv"
version = "1.0.1"
description = "Read key-value pairs from a .env file and set them as environment variables"
category = "main"
optional = false
python-versions = ">=3.8"
files = [
@@ -568,7 +640,6 @@ cli = ["click (>=5.0)"]
name = "six"
version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
@@ -576,11 +647,21 @@ files = [
{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]]
name = "twitchapi"
version = "4.2.1"
description = "A Python 3.7+ implementation of the Twitch Helix API, PubSub, EventSub and Chat"
category = "main"
optional = false
python-versions = "*"
files = [
@@ -597,7 +678,6 @@ typing-extensions = "*"
name = "typing-extensions"
version = "4.12.2"
description = "Backported and Experimental Type Hints for Python 3.8+"
category = "main"
optional = false
python-versions = ">=3.8"
files = [
@@ -609,7 +689,6 @@ files = [
name = "yarl"
version = "1.9.4"
description = "Yet another URL library"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
@@ -712,4 +791,4 @@ multidict = ">=4.0"
[metadata]
lock-version = "2.0"
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-settings = "^2.4.0"
aiofiles = "^24.1.0"
httpx = "^0.27.0"
[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 datetime import datetime
import json
from twitchAPI.helper import first
from twitchAPI.eventsub.webhook import EventSubWebhook
from twitchAPI.twitch import Twitch
from twitchAPI.type import AuthScope
from twitchAPI.object.eventsub import ChannelChatMessageEvent, StreamOnlineEvent, StreamOfflineEvent
from twitchAPI.object.eventsub import ChannelChatMessageEvent, StreamOnlineEvent, StreamOfflineEvent, ChannelUpdateEvent
import aiofiles
from pydantic import BaseModel
from config import config
from services.notification import notify
class State:
pass
class State(BaseModel):
title: str
category: str
is_live: bool
last_live_at: datetime
class TokenStorage:
@@ -42,6 +50,8 @@ class TwitchService:
AuthScope.CHAT_EDIT,
]
ONLINE_NOTIFICATION_DELAY = 5 * 60
def __init__(self, twitch: Twitch):
self.twitch = twitch
@@ -61,14 +71,83 @@ class TwitchService:
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):
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):
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):
print("on_stream_offline", event)
if self.state:
self.state.is_live = False
self.last_live_at = datetime.now()
async def run(self):
eventsub = EventSubWebhook(
@@ -78,12 +157,22 @@ class TwitchService:
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:
await eventsub.unsubscribe_all()
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_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_offline(config.TWITCH_CHANNEL_ID, self.on_stream_offline)