diff --git a/.env-dist b/.env-dist index c84e723..525b5bd 100644 --- a/.env-dist +++ b/.env-dist @@ -4,4 +4,6 @@ DJANGO_DEBUG=true SENDY_API_KEY= SENDY_ENDPOINT_URL= SLACK_OAUTH_TOKEN= +SLACK_CHANNEL_ID= TITO_SECURITY_TOKEN= +MASTODON_ACCESS_TOKEN= \ No newline at end of file diff --git a/config/settings.py b/config/settings.py index 078c3e6..37dfa93 100644 --- a/config/settings.py +++ b/config/settings.py @@ -71,6 +71,7 @@ "tickets", "titowebhooks", "travel_safety", + "social_monitor", ] MIDDLEWARE = [ @@ -236,6 +237,7 @@ # Slack settings SLACK_OAUTH_TOKEN = env("SLACK_OAUTH_TOKEN", default="") +SLACK_CHANNEL_ID = env("SLACK_CHANNEL_ID", default="") # Conference settings @@ -253,3 +255,8 @@ TAILWIND_CLI_PATH = env.str("TAILWIND_CLI_PATH", default="~/.local/bin/") TAILWIND_CLI_SRC_CSS = env.str("TAILWIND_CLI_SRC_CSS", default="frontend/index.css") TAILWIND_CLI_VERSION = env.str("TAILWIND_CLI_VERSION", default="4.1.18") + +# Social monitor + +MASTODON_ACCESS_TOKEN = env("MASTODON_ACCESS_TOKEN", default="") +MASTODON_API_BASE_URL = env("MASTODON_API_BASE_URL", default="https://mastodon.social") \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4fe4843..7f08021 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,8 @@ dependencies = [ "requests", "rich", "whitenoise", + "mastodon-py>=2.1.4", + "slack-sdk>=3.41.0", ] [tool.bumpver] diff --git a/social_monitor/__init__.py b/social_monitor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/social_monitor/admin.py b/social_monitor/admin.py new file mode 100644 index 0000000..b9c4659 --- /dev/null +++ b/social_monitor/admin.py @@ -0,0 +1,22 @@ +from django.contrib import admin + +from .models import PlatformHashTag, SocialPlatform + + +@admin.register(SocialPlatform) +class SocialPlatformAdmin(admin.ModelAdmin): + list_display = ( + "name", + "last_seen", + "get_mentions", + ) + + +@admin.register(PlatformHashTag) +class PlatformHashTagAdmin(admin.ModelAdmin): + list_display = ( + "platform", + "query", + "last_seen", + "is_active", + ) diff --git a/social_monitor/apps.py b/social_monitor/apps.py new file mode 100644 index 0000000..70fe1e2 --- /dev/null +++ b/social_monitor/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class SocialMonitorConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'social_monitor' diff --git a/social_monitor/management/__init__.py b/social_monitor/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/social_monitor/management/commands/__init__.py b/social_monitor/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/social_monitor/management/commands/social.py b/social_monitor/management/commands/social.py new file mode 100644 index 0000000..dc9c950 --- /dev/null +++ b/social_monitor/management/commands/social.py @@ -0,0 +1,108 @@ +import djclick as click +from django_q.models import Schedule +from rich import print + +from social_monitor.models import PlatformHashTag, SocialPlatform + + +@click.group() +def command(): + """Social monitor command""" + pass + + +@command.command() +def monitor(): + """Set up a periodic task to monitor for mentions and hashtags""" + name = "social-monitor" + schedule, created = Schedule.objects.update_or_create( + name=name, + defaults={ + "func": "social_monitor.mastodon_client.collect_social_activity", + "schedule_type": Schedule.HOURLY, + } + ) + + action = "Created" if created else "Updated" + print(f"[green]{action} schedule for {name}[/green]") + +@command.command() +@click.option("--fetch", "fetch_flag", is_flag=True, help="fetch all platforms") +@click.option("--add", "add_platform_name", help="add new platform") +@click.option("--remove", "remove_platform_name", help="remove new platform") +@click.option("--fetch-mentions", "fetch_mentions", default=True, help="fetch mentions") +def platform(fetch_flag, add_platform_name, remove_platform_name, fetch_mentions): + """add social platform""" + + if fetch_flag: + platforms = SocialPlatform.objects.all() + if platforms: + for i, p in enumerate(platforms): + print(f"{i+1}) [green]{p}[/green]") + else: + print("[red]No platforms found![/red]") + return + + if add_platform_name: + p, created = SocialPlatform.objects.get_or_create( + name=add_platform_name, + get_mentions=fetch_mentions + ) + + if created: + print(f"[green]Platform `{add_platform_name}` added![/green]") + else: + print(f"[yellow]Platform `{add_platform_name}` already exists.[/yellow]") + + if remove_platform_name: + try: + p = SocialPlatform.objects.get(name=remove_platform_name) + p.delete() + print(f"[green]Platform `{remove_platform_name} removed![/green]`") + except SocialPlatform.DoesNotExist: + print(f"[red]Platform `{remove_platform_name}` does not exists![/red]`") + +@command.command() +@click.argument('platform_name') +@click.argument("query_str", required=False) +@click.option("--add", "add_flag", is_flag=True, help="add query to social platform") +@click.option("--remove", "remove_flag", is_flag=True, help="remove query from social platform") +@click.option("--fetch", "fetch_query", is_flag=True, help="fetch queries") +def query(platform_name, query_str, add_flag, remove_flag, fetch_query): + """add social query""" + try: + p = SocialPlatform.objects.get(name=platform_name) + except SocialPlatform.DoesNotExist: + print(f"[red]Platform `{platform_name}` does not exist![/red]") + return + + if add_flag: + if PlatformHashTag.objects.filter(platform=p, query=query_str).exists(): + print(f"[yellow]Query `{query_str}` already exists for {platform_name}.[/yellow]") + else: + PlatformHashTag.objects.create(platform=p, query=query_str) + print(f"[green]Query `{query_str}` added to {platform_name}![/green]") + + if remove_flag: + try: + query_obj = PlatformHashTag.objects.get(platform=p, query=query_str) + query_obj.delete() + print(f"[green]Query `{query_str}` removed from {platform_name}![/green]") + except PlatformHashTag.DoesNotExist: + print(f"[red]Query `{query_str}` not found for {platform_name}![/red]") + return + + if fetch_query: + try: + query_obj = PlatformHashTag.objects.filter(platform=p) + for q in query_obj: + print(f"[green]{q}[/green]") + return + except PlatformHashTag.DoesNotExist: + print(f"[red]Query `{query_str}` not found for {platform_name}![/red]") + + + if not add_flag and not remove_flag: + print(f"[blue]No action specified. Use --add or --remove for `{query_str}` on {platform_name}.[/blue]") + + diff --git a/social_monitor/mastodon_client.py b/social_monitor/mastodon_client.py new file mode 100644 index 0000000..db737aa --- /dev/null +++ b/social_monitor/mastodon_client.py @@ -0,0 +1,113 @@ +import logging +from typing import List + +from django.conf import settings +from mastodon import Mastodon + +from social_monitor.models import PlatformHashTag, SocialPlatform +from social_monitor.notification import send_slack_notification +from social_monitor.schemas import ItemType, SocialItem + +PLATFORM_NAME = "mastodon" + + +mastodon = Mastodon( + access_token=settings.MASTODON_ACCESS_TOKEN, + api_base_url=settings.MASTODON_API_BASE_URL, +) + + +def _get_hashtags(): + platform = SocialPlatform.objects.get(name=PLATFORM_NAME) + query_obj = PlatformHashTag.objects.filter(platform=platform) + return query_obj + + +def fetch_mentions(limit: int = 5) -> List[SocialItem]: + """fetch the most recent mentions of the current Mastodon account.""" + mastodon_mentions = [] + try: + platform = SocialPlatform.objects.get(name=PLATFORM_NAME) + + if platform.get_mentions: + mentions = mastodon.notifications(types=['mention'], limit=limit, since_id=0 if platform.last_seen is None else int(platform.last_seen)) + for mention in mentions: + mastodon_mentions.append( + SocialItem( + id=int(mention.id), + platform=PLATFORM_NAME, + type=ItemType.MENTION, + author=mention.account.acct, + content=mention.status.content, + url=mention.status.url, + tag=ItemType.MENTION.name, + created_at=mention.status.created_at + ) + ) + else: + logging.info(f"mentions for `{platform.name}` are not being collected. Please enable it first.") + except Exception as e: + logging.error(e) + + return mastodon_mentions + + +def fetch_posts(limit: int = 5) -> List[SocialItem]: + """fetch the most recent posts containing a specific hashtag from Mastodon.""" + mastodon_posts = [] + try: + queries = _get_hashtags() + for query in queries: + if query.is_active: + posts = mastodon.timeline_hashtag(query.query, limit=limit, since_id=0 if query.last_seen is None else int(query.last_seen)) + for post in posts: + mastodon_posts.append( + SocialItem( + id=int(post.id), + platform=PLATFORM_NAME, + type=ItemType.HASHTAG, + author=post.account.acct, + content=post.content, + url=post.url, + tag=query.query, + created_at=post.created_at, + ) + ) + else: + logging.info(f"`{query.query}` not active") + except Exception as e: + logging.error(e) + + return mastodon_posts + + + + +def collect_social_activity(): + + mentions = fetch_mentions() + posts = fetch_posts() + + all_activities = mentions + posts + last_seen_marked = [] + + if all_activities: + platform = SocialPlatform.objects.get(name=PLATFORM_NAME) + for activity in all_activities: + notified = send_slack_notification(activity) + + if notified: + if activity.type == ItemType.MENTION and ItemType.MENTION not in last_seen_marked: + platform.last_seen = activity.id + platform.save() + last_seen_marked.append(ItemType.MENTION) + + if activity.type == ItemType.HASHTAG and activity.tag not in last_seen_marked: + social_query = PlatformHashTag.objects.get(platform=platform, query=activity.tag) + social_query.last_seen = activity.id + social_query.save() + last_seen_marked.append(activity.tag) + else: + logging.error(f"error while sending notification for `{activity.id}` `{activity.tag}`") + + diff --git a/social_monitor/migrations/0001_initial.py b/social_monitor/migrations/0001_initial.py new file mode 100644 index 0000000..84b20a6 --- /dev/null +++ b/social_monitor/migrations/0001_initial.py @@ -0,0 +1,40 @@ +# Generated by Django 5.1.15 on 2026-03-31 18:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='SocialPlatform', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True)), + ('get_mentions', models.BooleanField(default=True)), + ('last_seen', models.CharField(blank=True, help_text='Last seen timestamp for mentions', max_length=200, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='PlatformHashTag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('query', models.CharField(max_length=200)), + ('last_seen', models.CharField(blank=True, help_text='Last seen timestamp for the query', max_length=200, null=True)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('platform', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='social_monitor.socialplatform')), + ], + options={ + 'unique_together': {('platform', 'query')}, + }, + ), + ] diff --git a/social_monitor/migrations/__init__.py b/social_monitor/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/social_monitor/models.py b/social_monitor/models.py new file mode 100644 index 0000000..d51834f --- /dev/null +++ b/social_monitor/models.py @@ -0,0 +1,38 @@ +from django.db import models + + +class SocialPlatform(models.Model): + name = models.CharField(max_length=100, unique=True) + get_mentions = models.BooleanField(default=True) + last_seen = models.CharField( + max_length=200, + blank=True, + null=True, + help_text="Last seen timestamp for mentions", + ) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.name + + +class PlatformHashTag(models.Model): + platform = models.ForeignKey(SocialPlatform, on_delete=models.CASCADE) + + query = models.CharField(max_length=200) + last_seen = models.CharField( + max_length=200, + blank=True, + null=True, + help_text="Last seen timestamp for the query", + ) + is_active = models.BooleanField(default=True) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + unique_together = ('platform', 'query') + + def __str__(self): + return f"{self.platform.name} -> #{self.query}" diff --git a/social_monitor/notification.py b/social_monitor/notification.py new file mode 100644 index 0000000..b96e099 --- /dev/null +++ b/social_monitor/notification.py @@ -0,0 +1,43 @@ +import logging + +from django.conf import settings +from django.utils.html import strip_tags +from slack_sdk import WebClient +from slack_sdk.errors import SlackApiError + +from social_monitor.schemas import SocialItem + +client = WebClient(token=settings.SLACK_OAUTH_TOKEN) + + +def _format_item(item: SocialItem) -> list: + type_emoji = ":speech_balloon:" if item.type == "mention" else ":hash:" + content_text = strip_tags(item.content) + content_preview = (content_text[:200] + "...") if len(content_text) > 200 else content_text + + blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"*{item.author}* on {item.platform} ({item.created_at:%Y-%m-%d %H:%M})\n\n{type_emoji} {item.tag}\n\n{content_preview}\n{item.url}\n" + } + }, + ] + + return blocks + + +def send_slack_notification(content: SocialItem) -> bool: + try: + response = client.chat_postMessage( + channel=settings.SLACK_CHANNEL_ID, + blocks=_format_item(content), + ) + + return response.get("ok", False) + except SlackApiError as e: + logging.error(e) + + return False + diff --git a/social_monitor/schemas.py b/social_monitor/schemas.py new file mode 100644 index 0000000..40a4c32 --- /dev/null +++ b/social_monitor/schemas.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass +from datetime import datetime +from enum import Enum + + +class ItemType(str, Enum): + MENTION = "mention" + HASHTAG = "hashtag" + +@dataclass +class SocialItem: + id: int + platform: str + type: ItemType + + author: str + content: str + url: str + tag: str + created_at: datetime diff --git a/social_monitor/tests/__init__.py b/social_monitor/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/social_monitor/tests/test_models.py b/social_monitor/tests/test_models.py new file mode 100644 index 0000000..ed17419 --- /dev/null +++ b/social_monitor/tests/test_models.py @@ -0,0 +1,42 @@ +import pytest + +from social_monitor.models import PlatformHashTag, SocialPlatform + + +@pytest.fixture +def social_platform(): + return SocialPlatform.objects.create( + name="Mastodon", + ) + +@pytest.fixture +def hashtags(social_platform): + return PlatformHashTag.objects.create( + platform=social_platform, + query="djangoconus", + ) + + +@pytest.mark.django_db +class TestSocialPlatform: + def test_name(self, social_platform): + assert social_platform.name == "Mastodon" + + def test_mentions(self, social_platform): + assert social_platform.get_mentions + + +@pytest.mark.django_db +class TestPlatformHashTag: + def test_social_platform(self, social_platform, hashtags): + assert social_platform == hashtags.platform + + def test_query(self, hashtags): + assert hashtags.query == "djangoconus" + + def test_last_seen(self, hashtags): + assert hashtags.last_seen is None + + def test_is_active(self, hashtags): + assert hashtags.is_active + diff --git a/social_monitor/views.py b/social_monitor/views.py new file mode 100644 index 0000000..b8e4ee0 --- /dev/null +++ b/social_monitor/views.py @@ -0,0 +1,2 @@ + +# Create your views here. diff --git a/uv.lock b/uv.lock index a9ab2df..8ea66ae 100644 --- a/uv.lock +++ b/uv.lock @@ -45,6 +45,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7c/2c/e9b6dd824fb6e76dbd39a308fc6f497320afd455373aac8518ca3eba7948/blessed-1.25.0-py3-none-any.whl", hash = "sha256:e52b9f778b9e10c30b3f17f6b5f5d2208d1e9b53b270f1d94fc61a243fc4708f", size = 95646, upload-time = "2025-11-18T18:43:50.924Z" }, ] +[[package]] +name = "blurhash" +version = "1.1.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/f3/9e636182d0e6b3f6b7879242f7f8add78238a159e8087ec39941f5d65af7/blurhash-1.1.5.tar.gz", hash = "sha256:181e1484b6a8ab5cff0ef37739150c566f4a72f2ab0dcb79660b6cee69c137a9", size = 50859, upload-time = "2025-08-17T10:36:12.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/dc/cadbf64b335a2ee0f31a84d05f34551c2199caa6f639a90c9157b564d0d6/blurhash-1.1.5-py2.py3-none-any.whl", hash = "sha256:96a8686e8b9fced1676550b814e59256214e2d4033202b16c91271ed4d317fec", size = 6632, upload-time = "2025-08-17T10:36:11.404Z" }, +] + [[package]] name = "bumpver" version = "2025.1131" @@ -165,11 +174,13 @@ dependencies = [ { name = "django-test-plus" }, { name = "environs", extra = ["django"] }, { name = "httpx" }, + { name = "mastodon-py" }, { name = "psycopg", extra = ["binary"] }, { name = "pytest" }, { name = "pytest-django" }, { name = "requests" }, { name = "rich" }, + { name = "slack-sdk" }, { name = "whitenoise" }, ] @@ -188,14 +199,25 @@ requires-dist = [ { name = "django-test-plus" }, { name = "environs", extras = ["django"] }, { name = "httpx" }, + { name = "mastodon-py", specifier = ">=2.1.4" }, { name = "psycopg", extras = ["binary"] }, { name = "pytest" }, { name = "pytest-django" }, { name = "requests" }, { name = "rich" }, + { name = "slack-sdk", specifier = ">=3.41.0" }, { name = "whitenoise" }, ] +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, +] + [[package]] name = "dj-database-url" version = "3.1.0" @@ -501,6 +523,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/b6/0a907f92c2158c9841da0227c7074ce1490f578f34d67cbba82ba8f9146e/marshmallow-4.2.0-py3-none-any.whl", hash = "sha256:1dc369bd13a8708a9566d6f73d1db07d50142a7580f04fd81e1c29a4d2e10af4", size = 48448, upload-time = "2026-01-04T16:07:34.269Z" }, ] +[[package]] +name = "mastodon-py" +version = "2.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blurhash" }, + { name = "decorator" }, + { name = "python-dateutil" }, + { name = "python-magic", marker = "sys_platform != 'win32'" }, + { name = "python-magic-bin", marker = "sys_platform == 'win32'" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/ec/1eccba4dda197e6993dd1b8a4fa5728f8ed64d3ba54d61ebfe2420a20f4e/mastodon_py-2.1.4.tar.gz", hash = "sha256:6602e9ca4db37c70b5adae5964d02e9a529f6cc8473947a314261008add208a5", size = 11636752, upload-time = "2025-09-23T09:39:04.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/eb/23afadb9a0aee04a52adfc010384da267b42b66be6cbb3ed2d3c3edc20f4/mastodon_py-2.1.4-py3-none-any.whl", hash = "sha256:447ce341cf9a67e70789abf6a2c1a54b52cd2cd021818ccb32c52f34804c7896", size = 123469, upload-time = "2025-09-23T09:39:02.515Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -611,6 +650,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.1" @@ -620,6 +671,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "python-magic" +version = "0.4.27" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/db/0b3e28ac047452d079d375ec6798bf76a036a08182dbb39ed38116a49130/python-magic-0.4.27.tar.gz", hash = "sha256:c1ba14b08e4a5f5c31a302b7721239695b2f0f058d125bd5ce1ee36b9d9d3c3b", size = 14677, upload-time = "2022-06-07T20:16:59.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/73/9f872cb81fc5c3bb48f7227872c28975f998f3e7c2b1c16e95e6432bbb90/python_magic-0.4.27-py2.py3-none-any.whl", hash = "sha256:c212960ad306f700aa0d01e5d7a325d20548ff97eb9920dcd29513174f0294d3", size = 13840, upload-time = "2022-06-07T20:16:57.763Z" }, +] + +[[package]] +name = "python-magic-bin" +version = "0.4.14" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/5d/10b9ac745d9fd2f7151a2ab901e6bb6983dbd70e87c71111f54859d1ca2e/python_magic_bin-0.4.14-py2.py3-none-win32.whl", hash = "sha256:34a788c03adde7608028203e2dbb208f1f62225ad91518787ae26d603ae68892", size = 397784, upload-time = "2017-10-02T16:30:15.806Z" }, + { url = "https://files.pythonhosted.org/packages/07/c2/094e3d62b906d952537196603a23aec4bcd7c6126bf80eb14e6f9f4be3a2/python_magic_bin-0.4.14-py2.py3-none-win_amd64.whl", hash = "sha256:90be6206ad31071a36065a2fc169c5afb5e0355cbe6030e87641c6c62edc2b69", size = 409299, upload-time = "2017-10-02T16:30:18.545Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -666,6 +735,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "slack-sdk" +version = "3.41.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/35/fc009118a13187dd9731657c60138e5a7c2dea88681a7f04dc406af5da7d/slack_sdk-3.41.0.tar.gz", hash = "sha256:eb61eb12a65bebeca9cb5d36b3f799e836ed2be21b456d15df2627cfe34076ca", size = 250568, upload-time = "2026-03-12T16:10:11.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/df/2e4be347ff98281b505cc0ccf141408cdd25eb5ca9f3830deb361b2472d3/slack_sdk-3.41.0-py2.py3-none-any.whl", hash = "sha256:bb18dcdfff1413ec448e759cf807ec3324090993d8ab9111c74081623b692a89", size = 313885, upload-time = "2026-03-12T16:10:09.811Z" }, +] + [[package]] name = "sqlparse" version = "0.5.5"