├── tests ├── __init__.py ├── api │ ├── __init__.py │ ├── apps_test.py │ └── users_test.py ├── apps │ ├── __init__.py │ ├── date │ │ ├── __init__.py │ │ ├── dday_test.py │ │ ├── utils_test.py │ │ └── day_test.py │ ├── fun │ │ ├── __init__.py │ │ ├── hassan_test.py │ │ ├── answer_test.py │ │ ├── relax_test.py │ │ └── code_test.py │ ├── info │ │ ├── __init__.py │ │ ├── d2tz │ │ │ └── __init__.py │ │ ├── memo │ │ │ └── __init__.py │ │ ├── about_test.py │ │ └── help_test.py │ ├── owner │ │ ├── __init__.py │ │ ├── quit_test.py │ │ ├── say_test.py │ │ └── update_test.py │ ├── compute │ │ ├── __init__.py │ │ ├── calc │ │ │ ├── __init__.py │ │ │ ├── evaluator │ │ │ │ ├── __init__.py │ │ │ │ ├── conftest.py │ │ │ │ ├── op_test.py │ │ │ │ └── primitive_test.py │ │ │ └── types_test.py │ │ ├── select_test.py │ │ ├── gamble_test.py │ │ └── exchange_test.py │ ├── manage │ │ ├── __init__.py │ │ └── cleanup │ │ │ ├── __init__.py │ │ │ ├── tasks_test.py │ │ │ └── handlers_test.py │ ├── search │ │ ├── __init__.py │ │ ├── dic_test.py │ │ └── book_test.py │ ├── weather │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── temerature_test.py │ │ ├── sun_test.py │ │ ├── wind_test.py │ │ ├── geo_test.py │ │ └── commands_test.py │ ├── ping_test.py │ ├── core_test.py │ ├── hi_test.py │ └── welcome_test.py ├── box │ ├── __init__.py │ ├── apps │ │ ├── __init__.py │ │ └── basic_test.py │ ├── utils_test.py │ └── box_test.py ├── types │ ├── __init__.py │ ├── handler_test.py │ └── slack │ │ ├── __init__.py │ │ ├── block_test.py │ │ ├── attachment_test.py │ │ └── action_test.py ├── utils │ ├── __init__.py │ ├── html_test.py │ ├── url_test.py │ ├── format_test.py │ ├── cast_test.py │ ├── datetime_test.py │ └── fuzz_test.py ├── command │ └── __init__.py ├── cli_test.py └── event_test.py ├── yui ├── __init__.py ├── apps │ ├── __init__.py │ ├── compute │ │ ├── __init__.py │ │ ├── calc │ │ │ ├── __init__.py │ │ │ └── exceptions.py │ │ └── select.py │ ├── date │ │ ├── __init__.py │ │ ├── weekend.py │ │ ├── utils.py │ │ ├── dday.py │ │ ├── day.py │ │ ├── age.py │ │ └── work.py │ ├── fun │ │ ├── __init__.py │ │ ├── hassan.py │ │ ├── relax.py │ │ ├── code.py │ │ └── answer.py │ ├── info │ │ ├── __init__.py │ │ ├── d2tz │ │ │ ├── __init__.py │ │ │ └── models.py │ │ ├── memo │ │ │ ├── __init__.py │ │ │ ├── models.py │ │ │ └── commands.py │ │ ├── rss │ │ │ ├── __init__.py │ │ │ └── models.py │ │ ├── packtpub │ │ │ ├── __init__.py │ │ │ ├── tasks.py │ │ │ ├── commands.py │ │ │ └── commons.py │ │ ├── about.py │ │ └── help.py │ ├── manage │ │ ├── __init__.py │ │ └── cleanup │ │ │ ├── __init__.py │ │ │ ├── models.py │ │ │ ├── handlers.py │ │ │ └── tasks.py │ ├── owner │ │ ├── __init__.py │ │ ├── quit.py │ │ ├── say.py │ │ └── update.py │ ├── search │ │ ├── __init__.py │ │ ├── book.py │ │ └── dic.py │ ├── weather │ │ ├── __init__.py │ │ ├── utils.py │ │ ├── exceptions.py │ │ ├── wind.py │ │ ├── temperature.py │ │ ├── sun.py │ │ ├── geo.py │ │ └── commands.py │ ├── welcome │ │ ├── __init__.py │ │ ├── item4.py │ │ └── the_9xd.py │ ├── ping.py │ ├── core.py │ └── hi.py ├── utils │ ├── __init__.py │ ├── json.py │ ├── http.py │ ├── url.py │ ├── html.py │ ├── datetime.py │ ├── attrs.py │ ├── format.py │ ├── report.py │ └── fuzz.py ├── migrations │ ├── __init__.py │ ├── versions │ │ ├── fbbdf21dcebe_drop_saomd.py │ │ ├── 75c91e7af605_drop_toranoana.py │ │ ├── 2d8d76d94b27_drop_shared_cache_app.py │ │ ├── 59c36093b2ed_add_unique_constrant_for_eventlog.py │ │ ├── 26696ef86a04_terrorzonelog.py │ │ ├── 139d8fcc4d5a_drop_timezone_fields_and_rename_.py │ │ └── 0e7bdd5c7473_refactor_datetime_fields.py │ ├── script.py.mako │ ├── alembic.ini │ └── env.py ├── box │ ├── apps │ │ ├── __init__.py │ │ └── base.py │ ├── utils.py │ ├── __init__.py │ └── tasks.py ├── orm │ ├── types.py │ ├── __init__.py │ ├── session.py │ ├── engine.py │ ├── model.py │ └── columns.py ├── types │ ├── slack │ │ ├── __init__.py │ │ ├── response.py │ │ ├── attachment.py │ │ └── action.py │ ├── objects.py │ ├── base.py │ ├── __init__.py │ ├── user.py │ └── channel.py ├── log.py ├── api │ ├── apps.py │ ├── encoder.py │ ├── users.py │ ├── endpoint.py │ └── __init__.py ├── command │ ├── __init__.py │ └── cooltime.py └── cache.py ├── .python-version ├── examples └── with_docker_compose │ ├── run.sh │ ├── compose.yaml │ └── yui.config.toml ├── .coveragerc ├── compose.yaml ├── .github └── FUNDING.yml ├── .pre-commit-config.yaml ├── LICENSE ├── Dockerfile ├── example.config.toml ├── .gitignore └── .dockerignore /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /yui/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/box/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/types/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /yui/apps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /yui/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /tests/apps/date/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/fun/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/info/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/owner/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/box/apps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/command/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/types/handler_test.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /yui/apps/compute/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /yui/apps/date/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /yui/apps/fun/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /yui/apps/info/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /yui/apps/manage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /yui/apps/owner/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /yui/apps/search/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /yui/apps/weather/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /yui/apps/welcome/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /yui/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/compute/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/info/d2tz/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/info/memo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/manage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/search/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/weather/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/types/slack/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /yui/apps/compute/calc/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /yui/apps/info/d2tz/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /yui/apps/info/memo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /yui/apps/info/rss/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/compute/calc/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/manage/cleanup/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /yui/apps/info/packtpub/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /yui/apps/manage/cleanup/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/apps/compute/calc/evaluator/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/with_docker_compose/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | python -m yui.cli upgrade head 3 | python -m yui.cli run 4 | -------------------------------------------------------------------------------- /yui/box/apps/__init__.py: -------------------------------------------------------------------------------- 1 | from . import route 2 | from .base import BaseApp 3 | from .basic import App 4 | 5 | __all__ = [ 6 | "App", 7 | "BaseApp", 8 | "route", 9 | ] 10 | -------------------------------------------------------------------------------- /tests/utils/html_test.py: -------------------------------------------------------------------------------- 1 | from yui.utils.html import strip_tags 2 | 3 | 4 | def test_strip_tags(): 5 | assert ( 6 | strip_tags("aaabbbcccdddeeefff") 7 | == "aaabbbcccdddeeefff" 8 | ) 9 | -------------------------------------------------------------------------------- /tests/utils/url_test.py: -------------------------------------------------------------------------------- 1 | from yui.utils.url import b64_redirect 2 | 3 | 4 | def test_b64_redirect(): 5 | assert b64_redirect("item4").startswith( 6 | "https://item4.github.io/yui/helpers/b64-redirect.html?b64=", 7 | ) 8 | -------------------------------------------------------------------------------- /yui/orm/types.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from sqlalchemy.orm import mapped_column 4 | 5 | type PrimaryKey = Annotated[int, mapped_column(primary_key=True)] 6 | type Text = Annotated[str, mapped_column(deferred=True)] 7 | -------------------------------------------------------------------------------- /yui/apps/info/packtpub/tasks.py: -------------------------------------------------------------------------------- 1 | from ....box import box 2 | from .commons import say_packtpub_dotd 3 | 4 | 5 | @box.cron("5 9 * * *") 6 | async def auto_packtpub_dotd(bot): 7 | await say_packtpub_dotd(bot, bot.config.CHANNELS["general"]) 8 | -------------------------------------------------------------------------------- /yui/types/slack/__init__.py: -------------------------------------------------------------------------------- 1 | from . import action 2 | from . import attachment 3 | from . import block 4 | from . import response 5 | 6 | __all__ = [ 7 | "action", 8 | "attachment", 9 | "block", 10 | "response", 11 | ] 12 | -------------------------------------------------------------------------------- /yui/utils/json.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import orjson 4 | 5 | 6 | def loads(value: str) -> Any: 7 | return orjson.loads(value) 8 | 9 | 10 | def dumps(value: Any) -> str: 11 | return orjson.dumps(value).decode() 12 | -------------------------------------------------------------------------------- /tests/cli_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.cli import error 4 | 5 | 6 | def test_error(capsys): 7 | with pytest.raises(SystemExit): 8 | error("test error") 9 | captured = capsys.readouterr() 10 | assert captured.err == "Error: test error\n" 11 | -------------------------------------------------------------------------------- /yui/utils/http.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | # NOTE: https://www.whatismybrowser.com/guides/the-latest-user-agent/chrome 4 | USER_AGENT: Final = ( 5 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36" 6 | ) 7 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | yui/migrations/**/*.py 4 | 5 | [report] 6 | exclude_lines = 7 | pragma: no cover 8 | def __repr__ 9 | raise NotImplementedError 10 | if TYPE_CHECKING: 11 | if __name__ == .__main__.: 12 | omit = 13 | yui/migrations/**/*.py 14 | -------------------------------------------------------------------------------- /tests/apps/compute/calc/evaluator/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.compute.calc.evaluator import Evaluator 4 | 5 | 6 | @pytest.fixture 7 | def e(): 8 | return Evaluator() 9 | 10 | 11 | @pytest.fixture 12 | def ed(): 13 | return Evaluator(decimal_mode=True) 14 | -------------------------------------------------------------------------------- /yui/orm/__init__.py: -------------------------------------------------------------------------------- 1 | from .columns import DateTime 2 | from .engine import create_database_engine 3 | from .model import Base 4 | from .session import sessionmaker 5 | 6 | __all__ = [ 7 | "Base", 8 | "DateTime", 9 | "create_database_engine", 10 | "sessionmaker", 11 | ] 12 | -------------------------------------------------------------------------------- /yui/apps/ping.py: -------------------------------------------------------------------------------- 1 | from ..box import box 2 | from ..event import Message 3 | 4 | 5 | @box.command("ping", ["핑"]) 6 | async def ping(bot, event: Message): 7 | """ 8 | 간단한 핑퐁 9 | 10 | `{PREFIX}ping` 11 | 12 | """ 13 | await bot.say(event.channel, f"<@{event.user}>, pong!") 14 | -------------------------------------------------------------------------------- /yui/orm/session.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | from sqlalchemy.orm import sessionmaker as _sessionmaker 3 | 4 | 5 | def sessionmaker(*args, **kwargs): 6 | kwargs["class_"] = AsyncSession 7 | kwargs["expire_on_commit"] = False 8 | return _sessionmaker(*args, **kwargs) 9 | -------------------------------------------------------------------------------- /yui/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | class GetLoggerMixin: 5 | def get_logger(self, name: str | None = None) -> logging.Logger: 6 | names = [self.__class__.__module__, self.__class__.__name__] 7 | if name: 8 | names.append(name) 9 | return logging.getLogger(".".join(names)) 10 | -------------------------------------------------------------------------------- /tests/types/slack/block_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.types.slack.block import PlainTextField 4 | from yui.types.slack.block import TextFieldType 5 | 6 | 7 | def test_plain_text_field(): 8 | with pytest.raises(ValueError, match="this field support only plain text"): 9 | PlainTextField(text="*test*", type=TextFieldType.mrkdwn) 10 | -------------------------------------------------------------------------------- /yui/utils/url.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from urllib.parse import urlencode 3 | 4 | 5 | def b64_redirect(url: str) -> str: 6 | """Redirect helper for non-http protocols.""" 7 | 8 | return "https://item4.github.io/yui/helpers/b64-redirect.html?{}".format( 9 | urlencode({"b64": base64.urlsafe_b64encode(url.encode()).decode()}), 10 | ) 11 | -------------------------------------------------------------------------------- /tests/api/apps_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.anyio 5 | async def test_slack_api_apps_connections_open(bot): 6 | await bot.api.apps.connections.open(token="TEST_TOKEN") # noqa: S106 7 | call = bot.call_queue.pop() 8 | assert call.method == "apps.connections.open" 9 | assert call.data == {} 10 | assert call.token == "TEST_TOKEN" # noqa: S105 11 | -------------------------------------------------------------------------------- /yui/box/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import shlex 3 | 4 | SPACE_RE = re.compile(r"[\s\xa0]+") 5 | 6 | 7 | def split_chunks(text: str, *, use_shlex: bool) -> list[str]: 8 | if use_shlex: 9 | lex = shlex.shlex(text, posix=True) 10 | lex.whitespace_split = True 11 | lex.whitespace += "\xa0" 12 | lex.commenters = "" 13 | return list(lex) 14 | return SPACE_RE.split(text) 15 | -------------------------------------------------------------------------------- /tests/apps/ping_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.ping import ping 4 | 5 | 6 | @pytest.mark.anyio 7 | async def test_ping_command(bot): 8 | event = bot.create_message() 9 | 10 | await ping(bot, event) 11 | said = bot.call_queue.pop() 12 | assert said.method == "chat.postMessage" 13 | assert said.data["channel"] == event.channel 14 | assert said.data["text"] == f"<@{event.user}>, pong!" 15 | -------------------------------------------------------------------------------- /yui/apps/info/rss/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy.orm import Mapped 4 | 5 | from ....orm import Base 6 | from ....orm.types import PrimaryKey 7 | 8 | 9 | class RSSFeedURL(Base): 10 | """RSS Feed URL""" 11 | 12 | __tablename__ = "rss_feed_url" 13 | 14 | id: Mapped[PrimaryKey] 15 | 16 | url: Mapped[str] 17 | 18 | channel: Mapped[str] 19 | 20 | updated_at: Mapped[datetime] 21 | -------------------------------------------------------------------------------- /yui/apps/weather/utils.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | 4 | def shorten(input_value: float) -> str: 5 | decimal_string = ( 6 | str(Decimal(format(input_value, "f")).quantize(Decimal("1.00"))) 7 | if input_value 8 | else "0" 9 | ) 10 | return ( 11 | decimal_string.rstrip("0").rstrip(".") 12 | if "." in decimal_string 13 | else decimal_string 14 | ) 15 | -------------------------------------------------------------------------------- /yui/migrations/versions/fbbdf21dcebe_drop_saomd.py: -------------------------------------------------------------------------------- 1 | """Drop SAOMD 2 | 3 | Revision ID: fbbdf21dcebe 4 | Revises: 2d8d76d94b27 5 | Create Date: 2021-08-31 12:32:51.183992 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = "fbbdf21dcebe" 11 | down_revision = "2d8d76d94b27" 12 | branch_labels = None 13 | depends_on = None 14 | 15 | 16 | def upgrade(): 17 | pass 18 | 19 | 20 | def downgrade(): 21 | pass 22 | -------------------------------------------------------------------------------- /yui/apps/info/packtpub/commands.py: -------------------------------------------------------------------------------- 1 | from ....box import box 2 | from ....event import Message 3 | from .commons import say_packtpub_dotd 4 | 5 | 6 | @box.command("무료책", ["freebook"]) 7 | async def packtpub_dotd(bot, event: Message): 8 | """ 9 | PACKT Book 무료책 안내 10 | 11 | PACKT Book에서 날마다 무료로 배부하는 Deal of The Day를 조회합니다. 12 | 13 | `{PREFIX}무료책` (오늘의 무료책) 14 | 15 | """ 16 | 17 | await say_packtpub_dotd(bot, event.channel) 18 | -------------------------------------------------------------------------------- /yui/migrations/versions/75c91e7af605_drop_toranoana.py: -------------------------------------------------------------------------------- 1 | """Drop toranoana 2 | 3 | Revision ID: 75c91e7af605 4 | Revises: fbbdf21dcebe 5 | Create Date: 2022-01-08 14:41:53.252409 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = "75c91e7af605" 11 | down_revision = "fbbdf21dcebe" 12 | branch_labels = None 13 | depends_on = None 14 | 15 | 16 | def upgrade(): 17 | pass 18 | 19 | 20 | def downgrade(): 21 | pass 22 | -------------------------------------------------------------------------------- /yui/migrations/versions/2d8d76d94b27_drop_shared_cache_app.py: -------------------------------------------------------------------------------- 1 | """Drop shared cache app 2 | 3 | Revision ID: 2d8d76d94b27 4 | Revises: 59c36093b2ed 5 | Create Date: 2021-01-02 10:59:32.722200 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = "2d8d76d94b27" 11 | down_revision = "59c36093b2ed" 12 | branch_labels = None 13 | depends_on = None 14 | 15 | 16 | def upgrade(): 17 | pass 18 | 19 | 20 | def downgrade(): 21 | pass 22 | -------------------------------------------------------------------------------- /tests/apps/core_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.core import on_start 4 | from yui.apps.core import team_migration_started 5 | from yui.bot import BotReconnect 6 | 7 | 8 | @pytest.mark.anyio 9 | async def test_on_start(bot): 10 | assert await on_start(bot) 11 | assert bot.is_ready.is_set() 12 | 13 | 14 | @pytest.mark.anyio 15 | async def test_team_migration_started(): 16 | with pytest.raises(BotReconnect): 17 | await team_migration_started() 18 | -------------------------------------------------------------------------------- /yui/types/slack/response.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Mapping 2 | from typing import Any 3 | 4 | from attrs import define 5 | 6 | from ...utils.attrs import field_transformer 7 | 8 | 9 | @define(kw_only=True, field_transformer=field_transformer) 10 | class APIResponse: 11 | body: dict[str, Any] 12 | status: int 13 | headers: Mapping[str, Any] 14 | 15 | def is_ok(self) -> bool: 16 | return isinstance(self.body, dict) and bool(self.body.get("ok")) 17 | -------------------------------------------------------------------------------- /yui/apps/info/memo/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy.orm import Mapped 4 | 5 | from ....orm import Base 6 | from ....orm.types import PrimaryKey 7 | from ....orm.types import Text 8 | 9 | 10 | class Memo(Base): 11 | """Memo""" 12 | 13 | __tablename__ = "memo" 14 | 15 | id: Mapped[PrimaryKey] 16 | 17 | keyword: Mapped[str] 18 | 19 | text: Mapped[Text] 20 | 21 | author: Mapped[str] 22 | 23 | created_at: Mapped[datetime] 24 | -------------------------------------------------------------------------------- /yui/apps/manage/cleanup/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.orm import Mapped 2 | from sqlalchemy.schema import UniqueConstraint 3 | 4 | from ....orm import Base 5 | from ....orm.types import PrimaryKey 6 | 7 | 8 | class EventLog(Base): 9 | """EventLog for cleanup function""" 10 | 11 | __tablename__ = "event_log" 12 | 13 | id: Mapped[PrimaryKey] 14 | 15 | ts: Mapped[str] 16 | 17 | channel: Mapped[str] 18 | 19 | __table_args__ = (UniqueConstraint("ts", "channel"),) 20 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:16 4 | volumes: 5 | - ./docker/postgres:/var/lib/postgresql/data 6 | environment: 7 | - POSTGRES_PASSWORD=DEV_ONLY 8 | - TZ=UTC 9 | healthcheck: 10 | test: "pg_isready -h localhost -p 5432 -q -U postgres" 11 | interval: 3s 12 | timeout: 1s 13 | retries: 10 14 | ports: 15 | - "5432:5432" 16 | valkey: 17 | image: valkey/valkey:latest 18 | ports: 19 | - "6379:6379" 20 | -------------------------------------------------------------------------------- /yui/orm/engine.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncEngine 2 | from sqlalchemy.ext.asyncio import create_async_engine 3 | from sqlalchemy.pool import Pool 4 | 5 | 6 | def create_database_engine( 7 | url: str, 8 | *, 9 | echo: bool, 10 | poolclass: type[Pool] | None = None, 11 | ) -> AsyncEngine: 12 | return create_async_engine( 13 | url, 14 | future=True, 15 | echo=echo, 16 | poolclass=poolclass, 17 | pool_pre_ping=True, 18 | ) 19 | -------------------------------------------------------------------------------- /tests/apps/weather/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture 7 | def google_api_key(): 8 | key = os.getenv("GOOGLE_API_KEY") 9 | if not key: 10 | pytest.skip("Can not test this without GOOGLE_API_KEY envvar") 11 | return key 12 | 13 | 14 | @pytest.fixture(scope="session") 15 | def address() -> str: 16 | return "부천" 17 | 18 | 19 | @pytest.fixture(scope="session") 20 | def unavailable_address() -> str: 21 | return "WRONG" 22 | -------------------------------------------------------------------------------- /yui/apps/fun/hassan.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from ...box import box 4 | from ...event import Message 5 | from ...utils import format 6 | 7 | HASSAN_TRIGGER_PATTERN = re.compile(r"^똑바로\s*서라\s*[,\.!]*\s*유이") 8 | 9 | 10 | @box.on(Message) 11 | async def hassan(bot, event: Message): 12 | if event.text and HASSAN_TRIGGER_PATTERN.search(event.text): 13 | await bot.say( 14 | event.channel, 15 | f"저한테 왜 그러세요 {format.link(event.user)}님?", 16 | ) 17 | return False 18 | return True 19 | -------------------------------------------------------------------------------- /yui/apps/weather/exceptions.py: -------------------------------------------------------------------------------- 1 | from aiohttp.client_exceptions import ClientConnectorCertificateError 2 | from aiohttp.client_exceptions import ClientPayloadError 3 | 4 | 5 | class WeatherResponseError(Exception): 6 | pass 7 | 8 | 9 | class WeatherRequestError(Exception): 10 | pass 11 | 12 | 13 | EXCEPTIONS = ( 14 | ClientPayloadError, # Bad HTTP Response 15 | ValueError, # JSON Error 16 | ClientConnectorCertificateError, # TLS expired 17 | WeatherResponseError, # Bad HTTP Response 18 | ) 19 | -------------------------------------------------------------------------------- /tests/apps/weather/temerature_test.py: -------------------------------------------------------------------------------- 1 | from yui.apps.weather.temperature import clothes_by_temperature 2 | 3 | 4 | def test_clothes_by_temperature(): 5 | cases = [ 6 | clothes_by_temperature(5), 7 | clothes_by_temperature(9), 8 | clothes_by_temperature(11), 9 | clothes_by_temperature(16), 10 | clothes_by_temperature(19), 11 | clothes_by_temperature(22), 12 | clothes_by_temperature(26), 13 | clothes_by_temperature(30), 14 | ] 15 | assert len(cases) == len(set(cases)) 16 | -------------------------------------------------------------------------------- /yui/apps/weather/wind.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | DIRECTION_NAME: Final[list[str]] = [ 4 | "N", 5 | "NNE", 6 | "NE", 7 | "ENE", 8 | "E", 9 | "ESE", 10 | "SE", 11 | "SSE", 12 | "S", 13 | "SSW", 14 | "SW", 15 | "WSW", 16 | "W", 17 | "WNW", 18 | "NW", 19 | "NNW", 20 | "N", 21 | ] 22 | 23 | 24 | def degree_to_direction(degree: int) -> str: 25 | # 북에서 다시 북으로, 360으로 나누면서 index로 계산 26 | return DIRECTION_NAME[round((degree % 360) / 22.5)] 27 | -------------------------------------------------------------------------------- /yui/box/__init__.py: -------------------------------------------------------------------------------- 1 | from ._box import Box 2 | from .apps import App 3 | from .apps import BaseApp 4 | from .apps import route 5 | from .parsers import KWARGS_DICT 6 | from .parsers import parse_option_and_arguments 7 | from .tasks import CronTask 8 | from .utils import SPACE_RE 9 | 10 | # (:class:`Box`) Default Box instance 11 | box = Box() 12 | 13 | __all__ = [ 14 | "KWARGS_DICT", 15 | "SPACE_RE", 16 | "App", 17 | "BaseApp", 18 | "Box", 19 | "CronTask", 20 | "box", 21 | "parse_option_and_arguments", 22 | "route", 23 | ] 24 | -------------------------------------------------------------------------------- /yui/apps/core.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from ..bot import BotReconnect 4 | from ..box import box 5 | from ..event import TeamMigrationStarted 6 | from ..event import YuiSystemStart 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | @box.on(YuiSystemStart) 12 | async def on_start(bot): # noqa: RUF029 13 | bot.is_ready.set() 14 | return True 15 | 16 | 17 | @box.on(TeamMigrationStarted) 18 | async def team_migration_started(): # noqa: RUF029 19 | logger.info("Slack sent team_migration_started. restart bot") 20 | raise BotReconnect 21 | -------------------------------------------------------------------------------- /yui/apps/owner/quit.py: -------------------------------------------------------------------------------- 1 | from ...box import box 2 | from ...event import Message 3 | 4 | box.assert_user_required("owner") 5 | 6 | 7 | @box.command("quit") 8 | async def quit(bot, event: Message): 9 | """ 10 | 봇을 종료합니다 11 | 12 | `{PREFIX}quit` 13 | 14 | 봇 주인만 사용 가능합니다. 15 | 16 | """ 17 | 18 | if event.user == bot.config.USERS["owner"]: 19 | await bot.say(event.channel, "안녕히 주무세요!") 20 | raise SystemExit 21 | await bot.say( 22 | event.channel, 23 | f"<@{event.user}> 이 명령어는 아빠만 사용할 수 있어요!", 24 | ) 25 | -------------------------------------------------------------------------------- /tests/apps/info/about_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.info.about import MESSAGE 4 | from yui.apps.info.about import about 5 | 6 | 7 | @pytest.mark.anyio 8 | async def test_about_command(bot): 9 | event = bot.create_message(ts="1234.56") 10 | 11 | await about(bot, event) 12 | 13 | said = bot.call_queue.pop(0) 14 | assert said.method == "chat.postMessage" 15 | assert said.data["channel"] == event.channel 16 | assert said.data["text"] == MESSAGE.format(prefix=bot.config.PREFIX) 17 | assert said.data["thread_ts"] == event.ts 18 | -------------------------------------------------------------------------------- /yui/api/apps.py: -------------------------------------------------------------------------------- 1 | from ..types.slack.response import APIResponse 2 | from .endpoint import Endpoint 3 | 4 | 5 | class Connections(Endpoint): 6 | name = "apps.connections" 7 | 8 | async def open( 9 | self, 10 | *, 11 | token: str, 12 | ) -> APIResponse: 13 | """https://api.slack.com/methods/apps.connections.open""" 14 | 15 | return await self._call("open", {}, token=token, json_mode=True) 16 | 17 | 18 | class Apps: 19 | connections: Connections 20 | 21 | def __init__(self, bot): 22 | self.connections = Connections(bot) 23 | -------------------------------------------------------------------------------- /yui/orm/model.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import types 4 | from sqlalchemy.orm import DeclarativeBase 5 | from sqlalchemy.orm import registry 6 | 7 | from .columns import DateTime 8 | from .types import PrimaryKey 9 | from .types import Text 10 | 11 | 12 | class Base(DeclarativeBase): 13 | registry = registry( 14 | type_annotation_map={ 15 | PrimaryKey: types.Integer, 16 | Text: types.Text, 17 | datetime: DateTime(timezone=True), 18 | list[int]: types.ARRAY(types.Integer), 19 | }, 20 | ) 21 | -------------------------------------------------------------------------------- /tests/apps/fun/hassan_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.fun.hassan import hassan 4 | 5 | 6 | @pytest.mark.anyio 7 | async def test_hassan_handler(bot): 8 | event = bot.create_message(text="똑바로 서라 유이") 9 | 10 | assert not await hassan(bot, event) 11 | 12 | said = bot.call_queue.pop(0) 13 | assert said.method == "chat.postMessage" 14 | assert said.data["channel"] == event.channel 15 | assert said.data["text"] == f"저한테 왜 그러세요 <@{event.user}>님?" 16 | 17 | event = bot.create_message(text="아무말 대잔치") 18 | 19 | assert await hassan(bot, event) 20 | 21 | assert not bot.call_queue 22 | -------------------------------------------------------------------------------- /yui/migrations/versions/59c36093b2ed_add_unique_constrant_for_eventlog.py: -------------------------------------------------------------------------------- 1 | """Add unique constrant for eventlog 2 | 3 | Revision ID: 59c36093b2ed 4 | Revises: 0e7bdd5c7473 5 | Create Date: 2020-12-24 09:42:17.075631 6 | 7 | """ 8 | 9 | from alembic import op 10 | 11 | # revision identifiers, used by Alembic. 12 | revision = "59c36093b2ed" 13 | down_revision = "0e7bdd5c7473" 14 | branch_labels = None 15 | depends_on = None 16 | 17 | 18 | def upgrade(): 19 | op.create_unique_constraint(None, "event_log", ["ts", "channel"]) 20 | 21 | 22 | def downgrade(): 23 | op.drop_constraint("", "event_log", type_="unique") 24 | -------------------------------------------------------------------------------- /yui/migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | 9 | from alembic import op 10 | 11 | import sqlalchemy as sa 12 | 13 | ${imports if imports else ""} 14 | 15 | # revision identifiers, used by Alembic. 16 | revision = ${repr(up_revision)} 17 | down_revision = ${repr(down_revision)} 18 | branch_labels = ${repr(branch_labels)} 19 | depends_on = ${repr(depends_on)} 20 | 21 | 22 | def upgrade(): 23 | ${upgrades if upgrades else "pass"} 24 | 25 | 26 | def downgrade(): 27 | ${downgrades if downgrades else "pass"} 28 | -------------------------------------------------------------------------------- /yui/command/__init__.py: -------------------------------------------------------------------------------- 1 | from .decorators import ARGUMENT_COUNT_ERROR 2 | from .decorators import ARGUMENT_TRANSFORM_ERROR 3 | from .decorators import ARGUMENT_TYPE_ERROR 4 | from .decorators import OPTION_COUNT_ERROR 5 | from .decorators import OPTION_TRANSFORM_ERROR 6 | from .decorators import OPTION_TYPE_ERROR 7 | from .decorators import argument 8 | from .decorators import option 9 | 10 | __all__ = [ 11 | "ARGUMENT_COUNT_ERROR", 12 | "ARGUMENT_TRANSFORM_ERROR", 13 | "ARGUMENT_TYPE_ERROR", 14 | "OPTION_COUNT_ERROR", 15 | "OPTION_TRANSFORM_ERROR", 16 | "OPTION_TYPE_ERROR", 17 | "argument", 18 | "option", 19 | ] 20 | -------------------------------------------------------------------------------- /yui/apps/compute/select.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from ...box import box 4 | from ...command import argument 5 | from ...command import option 6 | from ...event import Message 7 | 8 | 9 | @box.command("select", ["선택", "골라"]) 10 | @option("--seed") 11 | @argument("items", nargs=-1) 12 | async def select(bot, event: Message, items: list[str], seed: int): 13 | """ 14 | 주어진 항목중에 랜덤으로 선택해서 알려줍니다. 15 | 16 | `{PREFIX}선택 멍멍이 냐옹이` (멍멍이와 냐옹이중에 랜덤으로 선택) 17 | 18 | 이 명령어는 `select`, `선택`, `골라` 중 편한 이름으로 사용할 수 있습니다. 19 | 20 | """ 21 | 22 | random.seed(seed) 23 | 24 | await bot.say(event.channel, f"선택결과: {random.choice(items)}") 25 | 26 | random.seed(None) 27 | -------------------------------------------------------------------------------- /yui/apps/weather/temperature.py: -------------------------------------------------------------------------------- 1 | def clothes_by_temperature(temperature: float) -> str: 2 | if temperature <= 5: 3 | return "패딩, 두꺼운 코트, 목도리, 기모제품" 4 | if temperature <= 9: 5 | return "코트, 가죽재킷, 니트, 스카프, 두꺼운 바지" 6 | if temperature <= 11: 7 | return "재킷, 트랜치코트, 니트, 면바지, 청바지, 검은색 스타킹" 8 | if temperature <= 16: 9 | return "얇은 재킷, 가디건, 간절기 야상, 맨투맨, 니트, 살구색 스타킹" 10 | if temperature <= 19: 11 | return "얇은 니트, 얇은 재킷, 가디건, 맨투맨, 면바지, 청바지" 12 | if temperature <= 22: 13 | return "긴팔티, 얇은 가디건, 면바지, 청바지" 14 | if temperature <= 26: 15 | return "반팔티, 얇은 셔츠, 반바지, 면바지" 16 | return "민소매티, 반바지, 반팔티, 치마" 17 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: item4 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: https://www.buymeacoffee.com/item4 13 | -------------------------------------------------------------------------------- /tests/apps/compute/select_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.compute.select import select 4 | 5 | 6 | @pytest.mark.anyio 7 | async def test_select_command(bot): 8 | event = bot.create_message() 9 | seed = 1 10 | 11 | await select(bot, event, ["cat", "dog"], seed) 12 | said = bot.call_queue.pop() 13 | assert said.method == "chat.postMessage" 14 | assert said.data["channel"] == event.channel 15 | assert said.data["text"] == "선택결과: cat" 16 | 17 | await select(bot, event, ["키리가야 카즈토", "유지오"], seed) 18 | said = bot.call_queue.pop() 19 | assert said.method == "chat.postMessage" 20 | assert said.data["channel"] == event.channel 21 | assert said.data["text"] == "선택결과: 키리가야 카즈토" 22 | -------------------------------------------------------------------------------- /yui/apps/hi.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import re 3 | 4 | from ..box import box 5 | from ..event import Message 6 | 7 | HI_PATTERN_1 = re.compile( 8 | r"^(?:안녕(?:하세요)?|헬로우?|할로|하이|hello|hi)[!,?]*\s*(?:유이|yui)", 9 | ) 10 | HI_PATTERN_2 = re.compile( 11 | r"^(?:유이|yui)\s*(?:안녕(?:하세요)?|헬로우?|할로|하이|hello|hi)[!,?]*", 12 | ) 13 | 14 | 15 | @box.on(Message) 16 | async def hi(bot, event: Message): 17 | if isinstance(event.text, str) and ( 18 | HI_PATTERN_1.search(event.text.lower()) 19 | or HI_PATTERN_2.search(event.text.lower()) 20 | ): 21 | with contextlib.suppress(AttributeError): 22 | await bot.say(event.channel, f"안녕하세요! <@{event.user}>") 23 | return False 24 | return True 25 | -------------------------------------------------------------------------------- /yui/apps/manage/cleanup/handlers.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.dialects.postgresql import insert 2 | from sqlalchemy.ext.asyncio import AsyncSession 3 | 4 | from ....box import box 5 | from ....event import Message 6 | from .models import EventLog 7 | 8 | 9 | @box.on(Message, subtype="*") 10 | async def make_log(bot, event: Message, sess: AsyncSession): 11 | channels = bot.config.CHANNELS.get("auto_cleanup_targets", []) 12 | 13 | if event.subtype != "message_deleted" and event.channel in channels: 14 | await sess.execute( 15 | insert(EventLog) 16 | .values(channel=event.channel, ts=event.ts) 17 | .on_conflict_do_nothing(), 18 | ) 19 | await sess.commit() 20 | return True 21 | -------------------------------------------------------------------------------- /tests/apps/weather/sun_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.weather.sun import get_emoji_by_sun 4 | from yui.utils.datetime import datetime 5 | 6 | 7 | @pytest.mark.anyio 8 | async def test_get_emoji_by_sun(): 9 | before_sunrise = datetime(2022, 11, 6, 2) 10 | after_sunrise = datetime(2022, 11, 6, 9) 11 | noon = datetime(2022, 11, 6, 13) # Start of SAO Official Service 12 | after_sunset = datetime(2022, 11, 6, 22) 13 | moon = ":crescent_moon:" 14 | sun = ":sunny:" 15 | assert (await get_emoji_by_sun(before_sunrise)) == moon 16 | assert (await get_emoji_by_sun(after_sunrise)) == sun 17 | assert (await get_emoji_by_sun(noon)) == sun 18 | assert (await get_emoji_by_sun(after_sunset)) == moon 19 | -------------------------------------------------------------------------------- /tests/apps/weather/wind_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.weather.wind import degree_to_direction 4 | 5 | 6 | @pytest.mark.parametrize( 7 | ("degree", "direction"), 8 | [ 9 | (0, "N"), 10 | (22.5, "NNE"), 11 | (45, "NE"), 12 | (67.5, "ENE"), 13 | (90, "E"), 14 | (112.5, "ESE"), 15 | (135, "SE"), 16 | (157.5, "SSE"), 17 | (180, "S"), 18 | (202.5, "SSW"), 19 | (225, "SW"), 20 | (247.5, "WSW"), 21 | (270, "W"), 22 | (292.5, "WNW"), 23 | (315, "NW"), 24 | (337.5, "NNW"), 25 | ], 26 | ) 27 | def test_degree_to_direction(degree, direction): 28 | assert direction == degree_to_direction(degree) 29 | -------------------------------------------------------------------------------- /yui/command/cooltime.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from datetime import timedelta 3 | 4 | from ..bot import Bot 5 | from ..utils.datetime import now 6 | 7 | 8 | class Cooltime: 9 | def __init__(self, *, bot: Bot, key: str, cooltime: timedelta) -> None: 10 | self.bot = bot 11 | self.key = key 12 | self.cooltime = cooltime 13 | self.now = now() 14 | 15 | async def rejected(self) -> datetime | None: 16 | last_call = await self.bot.cache.get_dt(self.key) 17 | if last_call and self.now - last_call < self.cooltime: 18 | return last_call + self.cooltime 19 | return None 20 | 21 | async def record(self): 22 | await self.bot.cache.set_dt(self.key, self.now) 23 | -------------------------------------------------------------------------------- /yui/apps/info/about.py: -------------------------------------------------------------------------------- 1 | from ...box import box 2 | from ...event import Message 3 | 4 | MESSAGE = """\ 5 | 안녕하세요! 저는 유이라고 해요! <@item4>님이 만든 *다목적 Slack 봇* 이에요! 6 | 사람이 아니라 자동 응답 프로그램이에요. 7 | 유이의 명령어 목록은 `{prefix}도움` 을 입력하시면 보실 수 있어요. 8 | 9 | 금전적 후원(1회): https://www.buymeacoffee.com/item4 10 | 금전적 후원(구독): https://www.patreon.com/item4 11 | 기술적 후원: https://github.com/item4/yui 12 | 13 | 유이에게 많은 사랑과 관심 부탁드려요! 14 | 15 | """ 16 | 17 | 18 | @box.command("about", ["봇소개", "자기소개"]) 19 | async def about(bot, event: Message): 20 | """ 21 | 봇 소개 22 | 23 | `{PREFIX}about` (봇 소개) 24 | 25 | """ 26 | 27 | await bot.say( 28 | event.channel, 29 | MESSAGE.format(prefix=bot.config.PREFIX), 30 | thread_ts=event.ts, 31 | ) 32 | -------------------------------------------------------------------------------- /yui/types/objects.py: -------------------------------------------------------------------------------- 1 | from attrs import define 2 | 3 | from ..utils.attrs import field 4 | from ..utils.attrs import field_transformer 5 | from ..utils.attrs import ts_field 6 | from ..utils.attrs import user_id_field 7 | from .base import Ts 8 | from .base import UserID 9 | 10 | 11 | @define(kw_only=True, field_transformer=field_transformer) 12 | class MessageMessageEdited: 13 | """edited attr in MessageMessage.""" 14 | 15 | user: UserID = user_id_field() 16 | ts: Ts = ts_field() 17 | 18 | 19 | @define(kw_only=True, field_transformer=field_transformer) 20 | class MessageMessage: 21 | """Message in Message.""" 22 | 23 | user: UserID = user_id_field() 24 | ts: Ts = ts_field() 25 | type: str = field() 26 | text: str = field() 27 | edited: MessageMessageEdited | None = field(repr=True) 28 | -------------------------------------------------------------------------------- /tests/apps/owner/quit_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.owner.quit import quit 4 | 5 | 6 | @pytest.mark.anyio 7 | async def test_quit_command(bot, owner_id): 8 | event = bot.create_message(user_id=owner_id) 9 | 10 | with pytest.raises(SystemExit): 11 | await quit(bot, event) 12 | 13 | said = bot.call_queue.pop(0) 14 | assert said.method == "chat.postMessage" 15 | assert said.data["channel"] == event.channel 16 | assert said.data["text"] == "안녕히 주무세요!" 17 | 18 | event = bot.create_message() 19 | 20 | await quit(bot, event) 21 | 22 | said = bot.call_queue.pop(0) 23 | assert said.method == "chat.postMessage" 24 | assert said.data["channel"] == event.channel 25 | assert ( 26 | said.data["text"] 27 | == f"<@{event.user}> 이 명령어는 아빠만 사용할 수 있어요!" 28 | ) 29 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | fail_fast: true 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v6.0.0 5 | hooks: 6 | - id: check-case-conflict 7 | - id: check-json 8 | - id: check-merge-conflict 9 | - id: check-symlinks 10 | - id: check-toml 11 | - id: check-xml 12 | - id: check-yaml 13 | - id: fix-byte-order-marker 14 | - repo: local 15 | hooks: 16 | - id: black 17 | name: black 18 | entry: uv run black 19 | language: system 20 | types: [ python ] 21 | - id: ruff 22 | name: ruff 23 | entry: uv run ruff check --fix 24 | language: system 25 | types: [ python ] 26 | - id: mypy 27 | name: mypy 28 | entry: uv run mypy 29 | language: system 30 | types: [ python ] 31 | -------------------------------------------------------------------------------- /tests/apps/hi_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.hi import hi 4 | 5 | 6 | @pytest.mark.anyio 7 | async def test_hi_handler(bot): 8 | event = bot.create_message(text="안녕 유이") 9 | 10 | await hi(bot, event) 11 | 12 | said = bot.call_queue.pop(0) 13 | assert said.method == "chat.postMessage" 14 | assert said.data["channel"] == event.channel 15 | assert said.data["text"] == f"안녕하세요! <@{event.user}>" 16 | 17 | event = bot.create_message(text="유이 안녕") 18 | 19 | await hi(bot, event) 20 | 21 | said = bot.call_queue.pop(0) 22 | assert said.method == "chat.postMessage" 23 | assert said.data["channel"] == event.channel 24 | assert said.data["text"] == f"안녕하세요! <@{event.user}>" 25 | 26 | event = bot.create_message(text="아무말 대잔치") 27 | 28 | await hi(bot, event) 29 | 30 | assert not bot.call_queue 31 | -------------------------------------------------------------------------------- /yui/types/base.py: -------------------------------------------------------------------------------- 1 | from typing import NewType 2 | 3 | #: :type:`type` User ID type. It must start with 'U' or 'W'. 4 | UserID = NewType("UserID", str) 5 | 6 | #: :type:`type` Public Channel ID type. It must start with 'C'. 7 | PublicChannelID = NewType("PublicChannelID", str) 8 | 9 | #: :type:`type` IM(as known as Direct Message) Channel ID type. 10 | #: It must start with 'D'. 11 | DirectMessageChannelID = NewType("DirectMessageChannelID", str) 12 | 13 | #: :type:`type` Group(as known as Private Channel) ID type. 14 | #: It must start with 'G'. 15 | PrivateChannelID = NewType("PrivateChannelID", str) 16 | 17 | type ChannelID = PublicChannelID | PrivateChannelID | DirectMessageChannelID 18 | 19 | #: :type:`type` Type for slack event unique ID. 20 | Ts = NewType("Ts", str) 21 | 22 | #: :type:`type` Type for store UnixTimestamp. 23 | type UnixTimestamp = int 24 | -------------------------------------------------------------------------------- /yui/api/encoder.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from contextlib import suppress 3 | 4 | import attrs 5 | 6 | from ..utils import json 7 | 8 | 9 | def bool2str(value: bool) -> str: # noqa: FBT001 10 | """Return bool as str.""" 11 | 12 | if value: 13 | return "1" 14 | return "0" 15 | 16 | 17 | def encode(obj): 18 | if isinstance(obj, (list, tuple, set)): 19 | return [encode(x) for x in obj] 20 | if issubclass(obj.__class__, enum.Enum): 21 | return obj.value 22 | if isinstance(obj, dict): 23 | return {encode(k): encode(v) for k, v in obj.items() if v is not None} 24 | if isinstance(obj, bool): 25 | return bool2str(obj) 26 | with suppress(attrs.exceptions.NotAnAttrsClassError): 27 | return encode(attrs.asdict(obj)) 28 | 29 | return obj 30 | 31 | 32 | def to_json(obj) -> str: 33 | return json.dumps(encode(obj)) 34 | -------------------------------------------------------------------------------- /tests/apps/compute/calc/types_test.py: -------------------------------------------------------------------------------- 1 | from yui.apps.compute.calc.types import Decimal as D 2 | 3 | 4 | def test_decimal(): 5 | assert -D("1") == D("-1") 6 | assert +D("1") == D("1") 7 | assert abs(D("-1")) == D("1") 8 | assert D("1") + 1 == D("2") 9 | assert 1 + D("1") == D("2") 10 | assert D("1") - 1 == D("0") 11 | assert 1 - D("1") == D("0") 12 | assert D("2") * 3 == D("6") 13 | assert 2 * D("3") == D("6") 14 | assert D("10") // 2 == D("5") 15 | assert 10 // D("2") == D("5") 16 | assert D("10") / 2.5 == D("4") 17 | assert 10 / D("2.5") == D("4") 18 | assert D("5") % 2 == D("1") 19 | assert 5 % D("2") == D("1") 20 | assert divmod(D("5"), 2) == (D("2"), D("1")) 21 | assert divmod(5, D("2")) == (D("2"), D("1")) 22 | assert D("3") ** 2 == D("9") 23 | assert 3 ** D("2") == D("9") 24 | assert pow(D("3"), D("2"), 2) == D("1") 25 | -------------------------------------------------------------------------------- /tests/apps/date/dday_test.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pytest 4 | 5 | from yui.apps.date.dday import dday 6 | 7 | 8 | @pytest.mark.anyio 9 | async def test_dday_command(bot): 10 | event = bot.create_message() 11 | jan = datetime.date(2000, 1, 1) 12 | feb = datetime.date(2000, 2, 1) 13 | 14 | await dday(bot, event, jan, feb) 15 | said = bot.call_queue.pop() 16 | assert said.method == "chat.postMessage" 17 | assert said.data["channel"] == event.channel 18 | assert ( 19 | said.data["text"] 20 | == "2000년 01월 01일로부터 2000년 02월 01일까지 31일 남았어요!" 21 | ) 22 | 23 | await dday(bot, event, feb, jan) 24 | said = bot.call_queue.pop() 25 | assert said.method == "chat.postMessage" 26 | assert said.data["channel"] == event.channel 27 | assert ( 28 | said.data["text"] 29 | == "2000년 01월 01일로부터 2000년 02월 01일까지 31일 지났어요!" 30 | ) 31 | -------------------------------------------------------------------------------- /yui/orm/columns.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from zoneinfo import ZoneInfo 3 | 4 | from sqlalchemy.types import DateTime as _DateTime 5 | from sqlalchemy.types import TypeDecorator 6 | 7 | KST = ZoneInfo("Asia/Seoul") 8 | UTC = datetime.UTC 9 | 10 | 11 | class DateTime(TypeDecorator): 12 | impl = _DateTime 13 | cache_ok = True 14 | 15 | def process_bind_param(self, value, dialect): 16 | if isinstance(value, datetime.datetime): 17 | if ( 18 | value.tzinfo is not None 19 | and value.tzinfo.utcoffset(value) is not None 20 | ): 21 | value = value.astimezone(UTC) 22 | return value.replace(tzinfo=None) 23 | return value 24 | 25 | def process_result_value(self, value, dialect): 26 | if isinstance(value, datetime.datetime): 27 | return value.replace(tzinfo=UTC).astimezone(KST) 28 | return value 29 | -------------------------------------------------------------------------------- /yui/utils/html.py: -------------------------------------------------------------------------------- 1 | from lxml.etree import strip_elements 2 | from lxml.html import HTMLParser 3 | from lxml.html import fromstring 4 | 5 | USELESS_TAGS = frozenset( 6 | { 7 | "head", 8 | "script", 9 | "style", 10 | "iframe", 11 | "noscript", 12 | }, 13 | ) 14 | 15 | 16 | def get_root( 17 | html: str | bytes, 18 | *, 19 | useless_tags: list[str] | None = None, 20 | remove_comments: bool = True, 21 | ): 22 | """Get root of DOM Tree without useless data""" 23 | 24 | parser = HTMLParser(remove_comments=remove_comments) 25 | h = fromstring(html, parser=parser) 26 | if useless_tags is None: 27 | useless_tags = list(USELESS_TAGS) 28 | strip_elements(h, *useless_tags, with_tail=False) 29 | return h 30 | 31 | 32 | def strip_tags(text: str) -> str: 33 | """Remove HTML Tags from input text""" 34 | 35 | return str(fromstring(text).text_content()) 36 | -------------------------------------------------------------------------------- /tests/apps/fun/answer_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.fun.answer import RESPONSES 4 | from yui.apps.fun.answer import magic_conch 5 | 6 | 7 | @pytest.mark.anyio 8 | async def test_magic_conch(bot): 9 | event = bot.create_message(text="마법의 유이님") 10 | 11 | assert await magic_conch(bot, event) 12 | 13 | assert not bot.call_queue 14 | 15 | event = bot.create_message(text="마법의 소라고둥님") 16 | 17 | assert not await magic_conch(bot, event) 18 | 19 | said = bot.call_queue.pop(0) 20 | assert said.method == "chat.postMessage" 21 | assert said.data["channel"] == event.channel 22 | assert said.data["text"] in RESPONSES 23 | 24 | event = bot.create_message(text="마법 소라고동") 25 | 26 | assert not await magic_conch(bot, event) 27 | 28 | said = bot.call_queue.pop(0) 29 | assert said.method == "chat.postMessage" 30 | assert said.data["channel"] == event.channel 31 | assert said.data["text"] in RESPONSES 32 | -------------------------------------------------------------------------------- /yui/apps/welcome/item4.py: -------------------------------------------------------------------------------- 1 | from ...box import box 2 | from ...event import TeamJoin 3 | 4 | box.assert_channel_required("welcome") 5 | 6 | GREETING = """\ 7 | <@{user_id}>님 item4 개인 Slack에 오신걸 환영합니다! :tada: 8 | 갑자기 알림이 울려서 놀라셨죠? 저는 Slack 봇 유이라고 해요. 9 | 제 도움이 필요하면 언제든지 `{prefix}도움`을 입력해서 도움말을 확인해주세요! 10 | 11 | 먼저, 다른 참가자분들과 구분하기 쉽도록 프로필 사진 설정을 부탁드립니다. 12 | 13 | item4 개인 슬랙에는 다음과 같은 채널들이 있으니 참가해보셔도 좋을 것 같아요! 14 | 15 | - #_general - 일상적인 대화는 여기서 하면 돼요. 16 | - #_notice - 공지사항이 올라오는 곳이에요. 여기는 읽기만 가능해요. 17 | - #dev - 개발/개발자에 대한 이야기를 하는 곳이에요. 18 | - #gender - 성별 이슈, 성 소수자 등에 대한 주제들을 위한 특별 채널이에요. 19 | - #subculture - 서브컬쳐 덕질은 여기서 하면 됩니다. 20 | - #suggest - 슬랙 운영에 대한 건의는 여기서 해주세요 21 | - #test - 제게 이것저것 시켜보고 싶으실 땐 이곳에서 해주세요! 22 | 이 외에도 채널들이 있으니 천천히 찾아보세요! 23 | 그럼 즐거운 Slack 이용이 되셨으면 좋겠습니다! 잘 부탁드려요! 24 | """ 25 | 26 | 27 | @box.on(TeamJoin) 28 | async def welcome_item4(bot, event: TeamJoin): 29 | await bot.say( 30 | bot.config.CHANNELS["welcome"], 31 | GREETING.format(user_id=event.user, prefix=bot.config.PREFIX), 32 | ) 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Jinsu Kim 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /examples/with_docker_compose/compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | bot_item4: 3 | image: ghcr.io/item4/yui:latest 4 | volumes: 5 | - ./item4:/yui/data 6 | environment: 7 | - YUI_CONFIG_FILE_PATH=data/yui.config.toml 8 | depends_on: 9 | - db 10 | - valkey 11 | links: 12 | - db 13 | - valkey 14 | command: ./data/run.sh 15 | networks: 16 | yuinet: 17 | ipv4_address: 10.5.0.101 18 | db: 19 | image: postgres:16 20 | volumes: 21 | - ./postgres@16data:/var/lib/postgresql/data 22 | environment: 23 | - POSTGRES_PASSWORD=MYSECRET 24 | healthcheck: 25 | test: "pg_isready -h localhost -p 5432 -q -U postgres" 26 | interval: 3s 27 | timeout: 1s 28 | retries: 10 29 | networks: 30 | yuinet: 31 | ipv4_address: 10.5.0.2 32 | valkey: 33 | image: valkey/valkey:latest 34 | networks: 35 | yuinet: 36 | ipv4_address: 10.5.0.4 37 | 38 | networks: 39 | yuinet: 40 | driver: bridge 41 | ipam: 42 | config: 43 | - subnet: 10.5.0.0/16 44 | -------------------------------------------------------------------------------- /tests/api/users_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.api.encoder import bool2str 4 | 5 | 6 | @pytest.mark.anyio 7 | async def test_slack_api_users_info(bot): 8 | user_id = "U1234" 9 | user = bot.create_user(user_id, "item4") 10 | 11 | await bot.api.users.info(user_id) 12 | 13 | call = bot.call_queue.pop() 14 | assert call.method == "users.info" 15 | assert call.data == {"user": user_id} 16 | 17 | await bot.api.users.info(user) 18 | 19 | call = bot.call_queue.pop() 20 | assert call.method == "users.info" 21 | assert call.data == {"user": user_id} 22 | 23 | 24 | @pytest.mark.anyio 25 | async def test_slack_api_users_list(bot): 26 | await bot.api.users.list( 27 | curser="asdf1234", 28 | include_locale=True, 29 | limit=20, 30 | presence=True, 31 | ) 32 | 33 | call = bot.call_queue.pop() 34 | assert call.method == "users.list" 35 | assert call.data == { 36 | "cursor": "asdf1234", 37 | "include_locale": bool2str(True), 38 | "limit": "20", 39 | "presence": bool2str(True), 40 | } 41 | -------------------------------------------------------------------------------- /tests/apps/fun/relax_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.fun.relax import relax 4 | 5 | from ...util import FakeBot 6 | 7 | 8 | @pytest.mark.anyio 9 | async def test_relax_command(bot_config): 10 | bot_config.USERS["villain"] = "U2" 11 | bot = FakeBot(bot_config) 12 | event = bot.create_message(text="") 13 | 14 | await relax(bot, event) 15 | 16 | said = bot.call_queue.pop(0) 17 | assert said.method == "chat.postMessage" 18 | assert said.data["channel"] == event.channel 19 | assert ( 20 | said.data["text"] == "유이에게 나쁜 것을 주입하려는 사악한 <@U2>!" 21 | " 악당은 방금 이 너굴맨이 처치했으니 안심하라구!" 22 | ) 23 | assert said.data["username"] == "너굴맨" 24 | 25 | event = bot.create_message(text="스테이크") 26 | 27 | await relax(bot, event) 28 | 29 | said = bot.call_queue.pop(0) 30 | assert said.method == "chat.postMessage" 31 | assert said.data["channel"] == event.channel 32 | assert ( 33 | said.data["text"] == "사람들에게 스테이크를 사주지 않는 편협한 <@U2>!" 34 | " 악당은 방금 이 너굴맨이 처치했으니 안심하라구!" 35 | ) 36 | assert said.data["username"] == "너굴맨" 37 | -------------------------------------------------------------------------------- /yui/apps/weather/sun.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Literal 3 | from zoneinfo import ZoneInfo 4 | 5 | import aiohttp 6 | 7 | from ...utils import json 8 | 9 | TZ = ZoneInfo("Asia/Seoul") 10 | 11 | 12 | async def get_emoji_by_sun( 13 | dt: datetime, 14 | ) -> Literal[":sunny:", ":crescent_moon:"]: 15 | async with ( 16 | aiohttp.ClientSession() as session, 17 | session.get( 18 | "https://api.sunrise-sunset.org/json", 19 | params={ 20 | "lat": "37.558213", # NOTE: 서울역 21 | "lng": "126.971354", # NOTE: 서울역 22 | "formatted": "0", 23 | "date": dt.date().isoformat(), 24 | }, 25 | ) as resp, 26 | ): 27 | data = await resp.json(loads=json.loads) 28 | result = data["results"] 29 | sunrise = datetime.fromisoformat(result["sunrise"]).astimezone(TZ) 30 | sunset = datetime.fromisoformat(result["sunset"]).astimezone(TZ) 31 | if sunrise <= dt < sunset: 32 | return ":sunny:" 33 | return ":crescent_moon:" 34 | -------------------------------------------------------------------------------- /tests/apps/search/dic_test.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | from yui.apps.search.dic import dic 6 | 7 | MULTIPLE_PATTERN = re.compile(r"검색결과 (\d+)개의 링크를 찾았어요!") 8 | 9 | 10 | @pytest.mark.anyio 11 | async def test_dic_multiple( 12 | bot, 13 | ): 14 | event = bot.create_message(ts="1234.5678") 15 | 16 | await dic(bot, event, "영어", "bad") 17 | 18 | said = bot.call_queue.pop(0) 19 | assert said.method == "chat.postMessage" 20 | assert said.data["channel"] == event.channel 21 | matched = MULTIPLE_PATTERN.match(said.data["text"]) 22 | assert matched 23 | assert int(matched[1]) == len(said.data["attachments"]) 24 | 25 | 26 | @pytest.mark.anyio 27 | async def test_dic_redirect( 28 | bot, 29 | ): 30 | event = bot.create_message(ts="1234.5678") 31 | 32 | await dic(bot, event, "영어", "apple") 33 | 34 | said = bot.call_queue.pop(0) 35 | assert said.method == "chat.postMessage" 36 | assert said.data["channel"] == event.channel 37 | assert ( 38 | said.data["text"] 39 | == "https://dic.daum.net/word/view.do?wordid=ekw000008211&q=apple" 40 | ) 41 | -------------------------------------------------------------------------------- /yui/migrations/versions/26696ef86a04_terrorzonelog.py: -------------------------------------------------------------------------------- 1 | """TerrorZoneLog 2 | 3 | Revision ID: 26696ef86a04 4 | Revises: 139d8fcc4d5a 5 | Create Date: 2025-06-01 19:10:52.374654 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from alembic import op 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = "26696ef86a04" 14 | down_revision = "139d8fcc4d5a" 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade(): 20 | op.create_table( 21 | "terrorzonelog", 22 | sa.Column("id", sa.Integer(), nullable=False), 23 | sa.Column("levels", sa.ARRAY(sa.Integer()), nullable=False), 24 | sa.Column("start_at", sa.DateTime(timezone=True), nullable=False), 25 | sa.Column("fetched_at", sa.DateTime(timezone=True), nullable=False), 26 | sa.Column("broadcasted_at", sa.DateTime(timezone=True), nullable=True), 27 | sa.Column("next_fetch_at", sa.DateTime(timezone=True), nullable=False), 28 | sa.PrimaryKeyConstraint("id"), 29 | sa.UniqueConstraint("start_at"), 30 | ) 31 | 32 | 33 | def downgrade(): 34 | op.drop_table("terrorzonelog") 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13 2 | 3 | LABEL maintainer="item4 " 4 | LABEL org.opencontainers.image.title=YUI 5 | LABEL org.opencontainers.image.source=https://github.com/item4/yui 6 | LABEL org.opencontainers.image.description="Yui is a bot for Slack" 7 | LABEL org.opencontainers.image.licenses=MIT 8 | 9 | ENV HOME="/home/kazuto" 10 | ENV TZ="Asia/Seoul" 11 | ENV PATH="${HOME}/.local/bin:${PATH}" 12 | 13 | RUN apt-get update -q \ 14 | && apt-get install --no-install-recommends -y \ 15 | build-essential\ 16 | libffi-dev\ 17 | libxml2-dev\ 18 | libxslt-dev\ 19 | tzdata\ 20 | postgresql\ 21 | postgresql-contrib\ 22 | curl\ 23 | && rm -rf /var/lib/apt/lists/* 24 | RUN pip install --upgrade pip setuptools wheel 25 | 26 | RUN groupadd --gid 1007 kirigaya && useradd --create-home --uid 1007 --gid 1007 kazuto && mkdir -p $HOME/yui/data && chown -R kazuto:kirigaya $HOME 27 | USER kazuto 28 | 29 | COPY --chown=kazuto:kirigaya ./requirements.txt ${HOME}/yui/ 30 | 31 | WORKDIR ${HOME}/yui/ 32 | 33 | RUN pip install -r requirements.txt && rm requirements.txt 34 | 35 | COPY --chown=kazuto:kirigaya . ${HOME}/yui/ 36 | -------------------------------------------------------------------------------- /yui/apps/fun/relax.py: -------------------------------------------------------------------------------- 1 | from ...box import box 2 | from ...event import Message 3 | from ...utils import format 4 | 5 | box.assert_user_required("villain") 6 | 7 | RESPONSE = { 8 | "스테이크": "사람들에게 스테이크를 사주지 않는 편협한", 9 | "멸망": "인류문명을 멸망시키려 하는 사악한", 10 | "멸종": "모든 생명의 멸종을 추진하는 잔학한", 11 | "기업": "모든 생명의 멸종을 바람직하게 여기는 잔혹한", 12 | "회사": "회사원들이 퇴근하지 못하게 블랙 회사을 종용하는", 13 | "퇴근": "직장인들의 퇴근을 방해하는", 14 | "퇴사": "직장인들로 하여금 퇴사하고 싶은 생각이 들게 만드는", 15 | "야근": "직장인들의 소중한 저녁시간을 빼앗는", 16 | "질병": "세계에 은밀하게 불치병을 흩뿌리고 다니는", 17 | "전염": "전 세계에 유해한 전염병을 유행시키는", 18 | } 19 | 20 | 21 | @box.command("안심") 22 | async def relax(bot, event: Message): 23 | """세계를 지키는 수호자를 소환하는 명령어""" 24 | 25 | message = "유이에게 나쁜 것을 주입하려는 사악한" 26 | jv = format.link(bot.config.USERS["villain"]) 27 | for key, m in RESPONSE.items(): 28 | if key in event.text: 29 | message = m 30 | break 31 | 32 | await bot.api.chat.postMessage( 33 | channel=event.channel, 34 | text=f"{message} {jv}! 악당은 방금 이 너굴맨이 처치했으니 안심하라구!", 35 | icon_url="https://i.imgur.com/dG6wXTX.jpg", 36 | username="너굴맨", 37 | ) 38 | -------------------------------------------------------------------------------- /yui/types/slack/attachment.py: -------------------------------------------------------------------------------- 1 | from attrs import Factory 2 | from attrs import define 3 | 4 | from ...utils.attrs import field_transformer 5 | from .action import Action 6 | from .block import Block 7 | 8 | 9 | @define(field_transformer=field_transformer) 10 | class Field: 11 | """Field on Attachment""" 12 | 13 | title: str 14 | value: str 15 | short: bool 16 | 17 | 18 | @define(kw_only=True, field_transformer=field_transformer) 19 | class Attachment: 20 | """Slack Attachment""" 21 | 22 | fallback: str | None = None 23 | color: str | None = None 24 | pretext: str | None = None 25 | author_name: str | None = None 26 | author_link: str | None = None 27 | author_icon: str | None = None 28 | title: str | None = None 29 | title_link: str | None = None 30 | text: str | None = None 31 | blocks: list[Block] | None = None 32 | fields: list[Field] = Factory(list) 33 | actions: list[Action] | None = None 34 | image_url: str | None = None 35 | thumb_url: str | None = None 36 | footer: str | None = None 37 | footer_icon: str | None = None 38 | ts: int | None = None 39 | callback_id: str | None = None 40 | -------------------------------------------------------------------------------- /yui/apps/info/d2tz/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy.orm import Mapped 4 | from sqlalchemy.schema import UniqueConstraint 5 | 6 | from ....orm import Base 7 | from ....orm.types import PrimaryKey 8 | from .commons import tz_id_to_names 9 | 10 | 11 | class TerrorZoneLog(Base): 12 | """TerrorZone Log""" 13 | 14 | __tablename__ = "terrorzonelog" 15 | 16 | id: Mapped[PrimaryKey] 17 | 18 | levels: Mapped[list[int]] 19 | 20 | start_at: Mapped[datetime] 21 | 22 | fetched_at: Mapped[datetime] 23 | 24 | broadcasted_at: Mapped[datetime | None] 25 | 26 | next_fetch_at: Mapped[datetime] 27 | 28 | __table_args__ = (UniqueConstraint("start_at"),) 29 | 30 | def to_slack_text(self, /) -> str: 31 | tz_names = ", ".join(tz_id_to_names(self.levels[:])) 32 | fallback_dt = self.start_at.strftime("%Y-%m-%d %H:%M") 33 | return f"[] {tz_names}" 34 | 35 | def to_discord_text(self, /) -> str: 36 | tz_names = ", ".join(tz_id_to_names(self.levels[:])) 37 | return f": {tz_names}" 38 | -------------------------------------------------------------------------------- /yui/types/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import ChannelID 2 | from .base import DirectMessageChannelID 3 | from .base import PrivateChannelID 4 | from .base import PublicChannelID 5 | from .base import Ts 6 | from .base import UnixTimestamp 7 | from .base import UserID 8 | from .channel import ChannelPurpose 9 | from .channel import ChannelTopic 10 | from .channel import DirectMessageChannel 11 | from .channel import PrivateChannel 12 | from .channel import PublicChannel 13 | from .handler import Argument 14 | from .handler import Handler 15 | from .handler import Option 16 | from .objects import MessageMessage 17 | from .objects import MessageMessageEdited 18 | from .user import User 19 | from .user import UserProfile 20 | 21 | __all__ = [ 22 | "Argument", 23 | "ChannelID", 24 | "ChannelPurpose", 25 | "ChannelTopic", 26 | "DirectMessageChannel", 27 | "DirectMessageChannelID", 28 | "Handler", 29 | "MessageMessage", 30 | "MessageMessageEdited", 31 | "Option", 32 | "PrivateChannel", 33 | "PrivateChannelID", 34 | "PublicChannel", 35 | "PublicChannelID", 36 | "Ts", 37 | "UnixTimestamp", 38 | "User", 39 | "UserID", 40 | "UserProfile", 41 | ] 42 | -------------------------------------------------------------------------------- /tests/event_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.event import Hello 4 | from yui.event import TeamMigrationStarted 5 | from yui.event import UnknownEvent 6 | from yui.event import create_event 7 | from yui.event import create_unknown_event 8 | 9 | 10 | def test_create_event(): 11 | hello_event = create_event("hello", {}) 12 | assert isinstance(hello_event, Hello) 13 | assert hello_event.type == "hello" 14 | 15 | team_migration_event: TeamMigrationStarted = create_event( 16 | "team_migration_started", 17 | {}, 18 | ) 19 | assert isinstance(team_migration_event, TeamMigrationStarted) 20 | assert team_migration_event.type == "team_migration_started" 21 | 22 | with pytest.raises(TypeError, match=""): 23 | create_event("not exists it", {}) 24 | 25 | with pytest.raises(TypeError, match="Error at creating Message: "): 26 | create_event("message", {}) 27 | 28 | 29 | def test_create_unknown_event(): 30 | source = {"a": 1, "b": [2, 3], "c": True, "d": {"e": 4, "f": 5}} 31 | event = create_unknown_event("unknown", source) 32 | assert isinstance(event, UnknownEvent) 33 | assert event.type == "unknown" 34 | assert event.kwargs == source 35 | -------------------------------------------------------------------------------- /yui/api/users.py: -------------------------------------------------------------------------------- 1 | from ..types.base import UserID 2 | from ..types.slack.response import APIResponse 3 | from ..types.user import User 4 | from .encoder import bool2str 5 | from .endpoint import Endpoint 6 | 7 | 8 | class Users(Endpoint): 9 | name = "users" 10 | 11 | async def info(self, user: User | UserID) -> APIResponse: 12 | """https://api.slack.com/methods/users.info""" 13 | 14 | user_id = user.id if isinstance(user, User) else user 15 | 16 | return await self._call("info", {"user": user_id}) 17 | 18 | async def list( 19 | self, 20 | *, 21 | curser: str | None = None, 22 | include_locale: bool | None = None, 23 | limit: int = 0, 24 | presence: bool | None = None, 25 | ) -> APIResponse: 26 | params = {} 27 | 28 | if curser: 29 | params["cursor"] = curser 30 | 31 | if include_locale is not None: 32 | params["include_locale"] = bool2str(include_locale) 33 | 34 | if limit: 35 | params["limit"] = str(limit) 36 | 37 | if presence is not None: 38 | params["presence"] = bool2str(presence) 39 | 40 | return await self._call("list", params) 41 | -------------------------------------------------------------------------------- /yui/migrations/versions/139d8fcc4d5a_drop_timezone_fields_and_rename_.py: -------------------------------------------------------------------------------- 1 | """Drop timezone fields and rename datetime fields 2 | 3 | Revision ID: 139d8fcc4d5a 4 | Revises: 75c91e7af605 5 | Create Date: 2023-02-09 09:43:29.021093 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from alembic import op 11 | from sqlalchemy_utils import TimezoneType 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "139d8fcc4d5a" 15 | down_revision = "75c91e7af605" 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | op.alter_column("memo", "created_datetime", new_column_name="created_at") 22 | op.alter_column( 23 | "rss_feed_url", 24 | "updated_datetime", 25 | new_column_name="updated_at", 26 | ) 27 | op.drop_column("memo", "created_timezone") 28 | op.drop_column("rss_feed_url", "updated_timezone") 29 | 30 | 31 | def downgrade(): 32 | op.alter_column("memo", "created_at", new_column_name="created_datetime") 33 | op.alter_column( 34 | "rss_feed_url", 35 | "updated_at", 36 | new_column_name="updated_datetime", 37 | ) 38 | op.add_column("memo", sa.Column("created_timezone", TimezoneType())) 39 | op.add_column("rss_feed_url", sa.Column("updated_timezone", TimezoneType())) 40 | -------------------------------------------------------------------------------- /yui/apps/weather/geo.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | 3 | from ...utils import json 4 | from .exceptions import WeatherResponseError 5 | 6 | 7 | async def get_geometric_info_by_address( 8 | address: str, 9 | api_key: str, 10 | ) -> tuple[str, float, float]: 11 | params = { 12 | "region": "kr", 13 | "address": address, 14 | "key": api_key, 15 | } 16 | 17 | async with ( 18 | aiohttp.ClientSession( 19 | headers={"Accept-Language": "ko-KR"}, 20 | ) as session, 21 | session.get( 22 | "https://maps.googleapis.com/maps/api/geocode/json", 23 | params=params, 24 | ) as resp, 25 | ): 26 | if resp.status != 200: 27 | error = f"Bad HTTP Response: {resp.status}" 28 | raise WeatherResponseError(error) 29 | 30 | data = await resp.json(loads=json.loads) 31 | 32 | result = data["results"][0] 33 | full_address = result["formatted_address"] 34 | lat = result["geometry"]["location"]["lat"] 35 | lng = result["geometry"]["location"]["lng"] 36 | 37 | # 주소가 대한민국의 주소일 경우, 앞의 "대한민국 "을 자른다. 38 | # 캐시를 위해 함수의 반환 결과부터 미리 처리를 해놓는다. 39 | full_address = full_address.removeprefix("대한민국 ") 40 | 41 | return full_address, lat, lng 42 | -------------------------------------------------------------------------------- /yui/apps/info/help.py: -------------------------------------------------------------------------------- 1 | from ...box import box 2 | from ...event import Message 3 | 4 | 5 | @box.command("help", ["도움", "도움말"]) 6 | async def help(bot, event: Message, raw: str): 7 | """ 8 | 봇 명령어들의 도움말 모음 9 | 10 | `{PREFIX}help` (전체 명령어 목록) 11 | `{PREFIX}help quit` (개별 명령어 도움말) 12 | 13 | """ 14 | p = bot.config.PREFIX 15 | if not raw: 16 | await bot.say( 17 | event.channel, 18 | "\n".join( 19 | a.get_short_help(p) for a in bot.box.apps if a.has_short_help 20 | ), 21 | thread_ts=event.ts, 22 | ) 23 | else: 24 | apps = [h for h in bot.box.apps if h.has_short_help and raw in h.names] 25 | 26 | if apps: 27 | for app in apps: 28 | help_text = ( 29 | app.get_full_help(p) 30 | if app.has_full_help 31 | else app.get_short_help(p) 32 | ) 33 | 34 | await bot.say( 35 | event.channel, 36 | help_text, 37 | thread_ts=event.ts, 38 | ) 39 | else: 40 | await bot.say( 41 | event.channel, 42 | "그런 명령어는 없어요!", 43 | thread_ts=event.ts, 44 | ) 45 | -------------------------------------------------------------------------------- /yui/utils/datetime.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | from zoneinfo import ZoneInfo 3 | 4 | 5 | def now(tzname: str = "Asia/Seoul") -> dt.datetime: 6 | """Helper to make current datetime.""" 7 | 8 | return dt.datetime.now(ZoneInfo(tzname)) 9 | 10 | 11 | def today(tzname: str = "Asia/Seoul") -> dt.date: 12 | return now(tzname).date() 13 | 14 | 15 | def datetime( 16 | year: int, 17 | month: int, 18 | day: int, 19 | hour: int = 0, 20 | minute: int = 0, 21 | second: int = 0, 22 | tzname: str = "Asia/Seoul", 23 | ) -> dt.datetime: 24 | return dt.datetime( 25 | year, 26 | month, 27 | day, 28 | hour, 29 | minute, 30 | second, 31 | tzinfo=ZoneInfo(tzname), 32 | ) 33 | 34 | 35 | def fromtimestamp(timestamp: float, tzname: str = "Asia/Seoul") -> dt.datetime: 36 | return dt.datetime.fromtimestamp(timestamp, tz=ZoneInfo(tzname)) 37 | 38 | 39 | def fromtimestampoffset(timestamp: float, offset: int) -> dt.datetime: 40 | tz = dt.timezone(dt.timedelta(seconds=offset)) 41 | return dt.datetime.fromtimestamp(timestamp, tz=tz) 42 | 43 | 44 | def fromisoformat(date_str: str, tzname: str = "Asia/Seoul") -> dt.datetime: 45 | return dt.datetime.fromisoformat(date_str).replace( 46 | tzinfo=ZoneInfo(tzname), 47 | ) 48 | -------------------------------------------------------------------------------- /example.config.toml: -------------------------------------------------------------------------------- 1 | APP_TOKEN = "FILL_ME" 2 | BOT_TOKEN = "FILL_ME" 3 | 4 | DEBUG = true 5 | PREFIX = "=" 6 | 7 | APPS = [ 8 | # Core apps 9 | "yui.apps.core", 10 | 11 | # Add your apps 12 | "yui.apps.hi", 13 | ] 14 | 15 | DATABASE_URL = "postgresql+psycopg://postgres:DEV_ONLY@localhost/yui_item4" 16 | DATABASE_ECHO = false 17 | 18 | NAVER_CLIENT_ID = "FILL_ME" 19 | NAVER_CLIENT_SECRET = "FILL_ME" 20 | 21 | GOOGLE_API_KEY = "FILL_ME" 22 | 23 | WEBSOCKETDEBUGGERURL = "http://localhost:9222/json/version" 24 | 25 | WEEKEND_LOADING_TIME = [] 26 | 27 | [CHANNELS] 28 | general = "FILL_ME" 29 | notice = "FILL_ME" 30 | welcome = "FILL_ME" 31 | auto_cleanup_targets = ["FILL_ME"] 32 | 33 | 34 | [USERS] 35 | owner = "FILL_ME" 36 | force_cleanup = ["FILL_ME"] 37 | 38 | 39 | [CACHE] 40 | URL = "valkey://localhost:6379/0" 41 | PREFIX = "YUI_" 42 | 43 | 44 | [LOGGING] 45 | version = 1 46 | disable_existing_loggers = false 47 | 48 | [LOGGING.formatters.brief] 49 | format = "%(message)s" 50 | 51 | [LOGGING.formatters.default] 52 | format = "%(asctime)s %(levelname)s %(name)s %(message)s" 53 | datefmt = "%Y-%m-%d %H:%M:%S" 54 | 55 | [LOGGING.handlers.console] 56 | class = "logging.StreamHandler" 57 | formatter = "default" 58 | level = "DEBUG" 59 | filters = [] 60 | stream = "ext://sys.stdout" 61 | 62 | [LOGGING.loggers.yui] 63 | handlers = ["console"] 64 | propagate = true 65 | level = "DEBUG" 66 | -------------------------------------------------------------------------------- /yui/apps/date/weekend.py: -------------------------------------------------------------------------------- 1 | from ...box import box 2 | from ...event import Message 3 | from ...utils.datetime import now 4 | from .utils import weekend_loading_box 5 | from .utils import weekend_loading_percent 6 | 7 | box.assert_config_required("WEEKEND_LOADING_TIME", list[int]) 8 | box.assert_channel_required("general") 9 | 10 | 11 | @box.cron("0 * * * 1-5") 12 | async def auto_weekend_loading(bot): 13 | now_dt = now() 14 | if now_dt.hour in bot.config.WEEKEND_LOADING_TIME: 15 | percent = weekend_loading_percent(now_dt) 16 | blocks = weekend_loading_box(percent) 17 | await bot.say( 18 | bot.config.CHANNELS["general"], 19 | f"주말로딩… {blocks} {percent:.2f}%", 20 | ) 21 | 22 | 23 | @box.cron("0 0 * * 6") 24 | async def auto_weekend_start(bot): 25 | await bot.say( 26 | bot.config.CHANNELS["general"], 27 | "주말이에요! 즐거운 주말 되세요!", 28 | ) 29 | 30 | 31 | @box.command("주말로딩") 32 | async def weekend_loading(bot, event: Message): 33 | """ 34 | 주말로딩 35 | 36 | 주말까지 얼마나 남았는지 출력합니다. 37 | 38 | `{PREFIX}주말로딩` 39 | 40 | """ 41 | 42 | now_dt = now() 43 | percent = weekend_loading_percent(now_dt) 44 | blocks = weekend_loading_box(percent) 45 | if percent == 100.0: 46 | await bot.say(event.channel, "주말이에요! 즐거운 주말 되세요!") 47 | else: 48 | await bot.say(event.channel, f"주말로딩… {blocks} {percent:.2f}%") 49 | -------------------------------------------------------------------------------- /yui/apps/welcome/the_9xd.py: -------------------------------------------------------------------------------- 1 | from ...box import box 2 | from ...event import TeamJoin 3 | 4 | box.assert_channel_required("welcome") 5 | 6 | GREETING = """\ 7 | <@{user_id}>님 9XD Slack에 오신걸 환영합니다! :tada: 8 | 갑자기 알림이 울려서 놀라셨죠? 저는 Slack 봇 유이라고 해요. 9 | 제 도움이 필요하면 언제든지 `{prefix}도움`을 입력해서 도움말을 확인해주세요! 10 | 9XD Slack에는 여러가지 채널들이 있으니 제가 남기는 thread의 설명을 읽어주세요! 11 | 그럼 즐거운 Slack 이용이 되셨으면 좋겠습니다! 잘 부탁드려요! 12 | """ 13 | 14 | GUIDE = """\ 15 | 9XD Slack에는 다음과 같은 채널들이 있으니 참가해보셔도 좋을 것 같아요! 16 | 17 | - #_general - 일상적인 대화는 여기서 하면 돼요. 18 | - #_notice - 공지사항이 올라오는 곳이에요. 여기는 대화 말고 읽기만 해주세요. 19 | - #blogs - 9XD 회원분들의 블로그 글이 자동으로 공유되는 채널이에요. 20 | - #game - 게임 이야기는 여기서! 게임 개발은 #game-dev 21 | - #job - 일자리 이야기를 하는 곳이에요. 스타트업은 #startup 22 | - #hobby - 지름신, 술, 음식 기타 취미에 관한 이야기를 나누는 곳이에요. 23 | - #music - 올 어바웃 음악! 노동요, 페스티벌 자유롭게 이야기 해주세요! 24 | - #animal - 귀여운 동물 보면서 힐링하는 곳이에요. 25 | - #otaku - 서브컬쳐 덕질은 여기서 하면 돼요. 26 | - #laboratory - 제게 이것저것 시켜보고 싶으실 땐 이곳에서 해주세요! 27 | 28 | 이외에도 더 많은 채널들이 있어요. 채널 목록을 참조해주세요! 29 | """ 30 | 31 | 32 | @box.on(TeamJoin) 33 | async def welcome_9xd(bot, event: TeamJoin): 34 | channel = bot.config.CHANNELS["welcome"] 35 | chat = await bot.say( 36 | channel, 37 | GREETING.format(user_id=event.user, prefix=bot.config.PREFIX), 38 | ) 39 | if chat.body["ok"]: 40 | await bot.say( 41 | channel, 42 | GUIDE, 43 | thread_ts=chat.body["ts"], 44 | ) 45 | -------------------------------------------------------------------------------- /tests/box/utils_test.py: -------------------------------------------------------------------------------- 1 | from yui.box.utils import split_chunks 2 | 3 | 4 | def test_split_chunks(): 5 | assert split_chunks("ls -al", use_shlex=True) == ["ls", "-al"] 6 | assert split_chunks("git commit -m 'test'", use_shlex=True) == [ 7 | "git", 8 | "commit", 9 | "-m", 10 | "test", 11 | ] 12 | assert split_chunks("test --value=b", use_shlex=True) == [ 13 | "test", 14 | "--value=b", 15 | ] 16 | assert split_chunks("test --value='b c d'", use_shlex=True) == [ 17 | "test", 18 | "--value=b c d", 19 | ] 20 | assert split_chunks("test\xa0--value='b c d'", use_shlex=True) == [ 21 | "test", 22 | "--value=b c d", 23 | ] 24 | 25 | assert split_chunks("ls -al", use_shlex=False) == ["ls", "-al"] 26 | assert split_chunks("git commit -m 'test'", use_shlex=False) == [ 27 | "git", 28 | "commit", 29 | "-m", 30 | "'test'", 31 | ] 32 | assert split_chunks("test --value=b", use_shlex=False) == [ 33 | "test", 34 | "--value=b", 35 | ] 36 | assert split_chunks("test --value='b c d'", use_shlex=False) == [ 37 | "test", 38 | "--value='b", 39 | "c", 40 | "d'", 41 | ] 42 | assert split_chunks("test\xa0--value='b c d'", use_shlex=False) == [ 43 | "test", 44 | "--value='b", 45 | "c", 46 | "d'", 47 | ] 48 | -------------------------------------------------------------------------------- /yui/apps/date/utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import Final 3 | 4 | import aiohttp 5 | 6 | from ...utils import json 7 | 8 | WEEKEND: Final = frozenset({5, 6}) 9 | 10 | 11 | class APIDoesNotSupport(Exception): 12 | pass 13 | 14 | 15 | async def get_holiday_names(date: datetime.date) -> list[str]: 16 | url = "https://item4.net/api/holiday" 17 | async with ( 18 | aiohttp.ClientSession() as session, 19 | session.get( 20 | "{}/{}".format(url, date.strftime("%Y/%m/%d")), 21 | ) as resp, 22 | ): 23 | if resp.status == 200: 24 | return await resp.json(loads=json.loads) 25 | raise APIDoesNotSupport 26 | 27 | 28 | def weekend_loading_percent(dt: datetime.datetime) -> float: 29 | weekday = dt.weekday() 30 | if weekday in WEEKEND: 31 | return 100.0 32 | monday = (dt - datetime.timedelta(days=weekday)).replace( 33 | hour=0, 34 | minute=0, 35 | second=0, 36 | microsecond=0, 37 | ) 38 | delta = dt - monday 39 | return delta.total_seconds() / (5 * 24 * 60 * 60) * 100 40 | 41 | 42 | def weekend_loading_box(percent: float) -> str: 43 | total_block_count = 20 44 | if percent >= 100: 45 | return "[" + "■" * total_block_count + "]" 46 | 47 | black_blocks = "■" * int(percent // 5) 48 | white_blocks = "□" * (total_block_count - len(black_blocks)) 49 | return "[" + black_blocks + white_blocks + "]" 50 | -------------------------------------------------------------------------------- /yui/api/endpoint.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from contextlib import suppress 3 | from typing import Any 4 | 5 | import attrs 6 | 7 | from ..types.slack.response import APIResponse 8 | 9 | 10 | def prepare_for_json(obj: Any) -> Any: 11 | if isinstance(obj, (list, tuple, set)): 12 | return [prepare_for_json(x) for x in obj] 13 | if issubclass(obj.__class__, enum.Enum): 14 | return obj.value 15 | if isinstance(obj, dict): 16 | return { 17 | prepare_for_json(k): prepare_for_json(v) 18 | for k, v in obj.items() 19 | if v is not None 20 | and ((isinstance(v, list) and v) or not isinstance(v, list)) 21 | } 22 | if isinstance(obj, str): 23 | return obj 24 | with suppress(attrs.exceptions.NotAnAttrsClassError): 25 | return prepare_for_json(attrs.asdict(obj)) 26 | 27 | return obj 28 | 29 | 30 | class Endpoint: 31 | """Slack API endpoint.""" 32 | 33 | name: str 34 | 35 | def __init__(self, bot) -> None: 36 | self.bot = bot 37 | 38 | async def _call( 39 | self, 40 | method: str, 41 | data: dict[str, Any], 42 | *, 43 | token=None, 44 | json_mode: bool = False, 45 | ) -> APIResponse: 46 | if json_mode: 47 | data = prepare_for_json(data) 48 | 49 | return await self.bot.call( 50 | f"{self.name}.{method}", 51 | data, 52 | token=token, 53 | json_mode=json_mode, 54 | ) 55 | -------------------------------------------------------------------------------- /yui/apps/weather/commands.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from ...box import box 4 | from ...command import argument 5 | from ...event import Message # noqa: TC001 6 | from .exceptions import WeatherRequestError 7 | from .exceptions import WeatherResponseError 8 | from .weather import get_weather_by_keyword 9 | 10 | 11 | @box.command("날씨", ["aws", "weather"]) 12 | @argument("keyword", nargs=-1, concat=True) 13 | async def weather( 14 | bot, 15 | event: Message, 16 | keyword: str, 17 | ): 18 | """ 19 | 지역의 현재 기상상태를 조회합니다. 20 | 21 | `{PREFIX}날씨 부천` (한국 기상청 부천 관측소의 현재 계측값을 출력) 22 | """ 23 | 24 | if len(keyword) < 2: 25 | await bot.say( 26 | event.channel, 27 | "검색어가 너무 짧아요! 2글자 이상의 검색어를 사용해주세요!", 28 | ) 29 | return 30 | 31 | try: 32 | result = await get_weather_by_keyword(keyword) 33 | except WeatherRequestError as e: 34 | await bot.say( 35 | event.channel, 36 | str(e), 37 | ) 38 | return 39 | except WeatherResponseError as e: 40 | await bot.say( 41 | event.channel, 42 | f"날씨 조회중 에러가 발생했어요! ({e!s})", 43 | ) 44 | return 45 | 46 | weather_text = result.as_str() 47 | weather_emoji = await result.get_emoji_by_weather() 48 | 49 | await bot.api.chat.postMessage( 50 | channel=event.channel, 51 | text=weather_text, 52 | username=f"{result.name} 날씨", 53 | icon_emoji=weather_emoji, 54 | ) 55 | -------------------------------------------------------------------------------- /yui/apps/date/dday.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from ...box import box 4 | from ...command import argument 5 | from ...command import option 6 | from ...event import Message 7 | from ...transform import str_to_date 8 | 9 | 10 | @box.command("dday", ["디데이", "d-day"]) 11 | @option( 12 | "--at", 13 | default=datetime.date.today, 14 | transform_func=str_to_date(datetime.date.today), 15 | ) 16 | @argument( 17 | "date", 18 | nargs=-1, 19 | concat=True, 20 | transform_func=str_to_date(), 21 | count_error="날짜를 입력해주세요", 22 | transform_error="인식할 수 있는 날짜가 아니에요", 23 | ) 24 | async def dday(bot, event: Message, at: datetime.date, date: datetime.date): 25 | """ 26 | D-Day 계산 27 | 28 | 주어진 날짜를 기준으로 날짜의 차이를 출력합니다. 29 | 30 | `{PREFIX}dday 2003년 6월 3일` 31 | (오늘을 기준으로 2003년 6월 3일로부터 며칠 지났는지 계산) 32 | `{PREFIX}dday --at="2010년 1월 1일" 2003년 6월 3일 33 | (2010년 1월 1일을 기준으로 계산) 34 | 35 | """ 36 | 37 | diff = (date - at).days 38 | if diff > 0: 39 | await bot.say( 40 | event.channel, 41 | "{}로부터 {}까지 {:,}일 남았어요!".format( 42 | at.strftime("%Y년 %m월 %d일"), 43 | date.strftime("%Y년 %m월 %d일"), 44 | diff, 45 | ), 46 | ) 47 | else: 48 | await bot.say( 49 | event.channel, 50 | "{}로부터 {}까지 {:,}일 지났어요!".format( 51 | date.strftime("%Y년 %m월 %d일"), 52 | at.strftime("%Y년 %m월 %d일"), 53 | -diff, 54 | ), 55 | ) 56 | -------------------------------------------------------------------------------- /yui/apps/owner/say.py: -------------------------------------------------------------------------------- 1 | from ...box import box 2 | from ...command import argument 3 | from ...command import option 4 | from ...event import Message 5 | from ...transform import get_channel_id 6 | from ...transform import get_user_id 7 | from ...types.base import ChannelID 8 | from ...types.base import UserID 9 | 10 | box.assert_user_required("owner") 11 | 12 | 13 | @box.command("say", aliases=["말", "말해"]) 14 | @option("--channel", "-c", transform_func=get_channel_id) 15 | @option("--user", "-u", transform_func=get_user_id) 16 | @argument("message", nargs=-1, concat=True) 17 | async def say( 18 | bot, 19 | event: Message, 20 | channel: ChannelID | None, 21 | user: UserID | None, 22 | message: str, 23 | ): 24 | """ 25 | 봇이 말하게 합니다 26 | 27 | `{PREFIX}say payload` (현재 채널) 28 | `{PREFIX}say --channel=#test payload` (`#test` 채널) 29 | `{PREFIX}say --user=@admin payload` (`@admin` 유저) 30 | 31 | 봇 주인만 사용 가능합니다. 32 | 33 | """ 34 | target: ChannelID | UserID = event.channel 35 | if event.user == bot.config.USERS["owner"]: 36 | if channel and user: 37 | text = "`--channel` 옵션과 `--user` 옵션은 동시에 사용할 수 없어요!" 38 | else: 39 | text = message 40 | if channel: 41 | target = channel 42 | elif user: 43 | resp = await bot.api.conversations.open(users=[user]) 44 | target = resp.body["channel"]["id"] 45 | else: 46 | text = f"<@{event.user}> 이 명령어는 아빠만 사용할 수 있어요!" 47 | 48 | await bot.say( 49 | target, 50 | text, 51 | ) 52 | -------------------------------------------------------------------------------- /examples/with_docker_compose/yui.config.toml: -------------------------------------------------------------------------------- 1 | APP_TOKEN = "FILL_ME" 2 | BOT_TOKEN = "FILL_ME" 3 | 4 | PREFIX = "." 5 | 6 | APPS = [ 7 | # Core apps 8 | "yui.apps.core", 9 | 10 | # Add your apps 11 | "yui.apps.hi", 12 | ] 13 | # MYSECRET is your postgres password (see compose.yaml) 14 | DATABASE_URL = "postgresql://postgres+psycopg:MYSECRET@db/dbname" 15 | DATABASE_ECHO = false 16 | 17 | NAVER_CLIENT_ID = "NAVER_CLIENT_ID" 18 | NAVER_CLIENT_SECRET = "NAVER_CLIENT_SECRET" 19 | 20 | GOOGLE_API_KEY = "GOOGLE_API_KEY" 21 | 22 | WEBSOCKETDEBUGGERURL = "http://10.5.0.3:9222/json/version" 23 | 24 | [CHANNELS] 25 | general = "C1111" 26 | game = "C2222" 27 | game_and_test = ["C2222", "C3333"] 28 | welcome = "C1111" 29 | 30 | [USERS] 31 | owner = "U11111111" 32 | 33 | [CACHE] 34 | URL = "valkey://valkey:6379/0" 35 | PREFIX = "YUI_" 36 | 37 | [LOGGING] 38 | version = 1 39 | disable_existing_loggers = false 40 | 41 | [LOGGING.formatters.brief] 42 | format = "%(message)s" 43 | 44 | [LOGGING.formatters.default] 45 | format = "%(asctime)s %(levelname)s %(name)s %(message)s" 46 | datefmt = "%Y-%m-%d %H:%M:%S" 47 | 48 | [LOGGING.handlers.console] 49 | class = "logging.StreamHandler" 50 | formatter = "default" 51 | level = "DEBUG" 52 | filters = [] 53 | stream = "ext://sys.stdout" 54 | 55 | [LOGGING.handlers.file] 56 | class = "logging.handlers.RotatingFileHandler" 57 | formatter = "default" 58 | level = "WARNING" 59 | filename = "log/warning.log" 60 | maxBytes = 102400 61 | backupCount = 3 62 | 63 | [LOGGING.loggers.yui] 64 | handlers = ["console", "file"] 65 | propagate = true 66 | level = "DEBUG" 67 | -------------------------------------------------------------------------------- /tests/apps/manage/cleanup/tasks_test.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | import pytest 4 | from more_itertools import flatten 5 | 6 | from yui.apps.manage.cleanup.tasks import cleanup_channels 7 | from yui.apps.manage.cleanup.tasks import get_old_history 8 | 9 | from ....util import assert_crontab_match 10 | from ....util import assert_crontab_spec 11 | 12 | 13 | def test_get_old_history_spec(): 14 | assert_crontab_spec(get_old_history) 15 | 16 | 17 | @pytest.mark.parametrize( 18 | ("delta", "result"), 19 | flatten( 20 | [ 21 | (timedelta(days=x), False), 22 | (timedelta(days=x, minutes=5), True), 23 | ] 24 | for x in range(7) 25 | ), 26 | ) 27 | def test_get_old_history_match(sunday, delta, result): 28 | assert_crontab_match(get_old_history, sunday + delta, expected=result) 29 | 30 | 31 | def test_cleanup_channels_spec(): 32 | assert_crontab_spec(cleanup_channels) 33 | 34 | 35 | @pytest.mark.parametrize( 36 | ("delta", "result"), 37 | flatten( 38 | [ 39 | (timedelta(days=x), True), 40 | (timedelta(days=x, minutes=5), False), 41 | (timedelta(days=x, minutes=10), True), 42 | (timedelta(days=x, minutes=20), True), 43 | (timedelta(days=x, minutes=30), True), 44 | (timedelta(days=x, minutes=40), True), 45 | (timedelta(days=x, minutes=50), True), 46 | ] 47 | for x in range(7) 48 | ), 49 | ) 50 | def test_cleanup_channels_match(sunday, delta, result): 51 | assert_crontab_match(cleanup_channels, sunday + delta, expected=result) 52 | -------------------------------------------------------------------------------- /yui/types/user.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from attrs import define 4 | 5 | from ..utils.attrs import field 6 | from ..utils.attrs import field_transformer 7 | from ..utils.attrs import name_field 8 | from ..utils.attrs import user_id_field 9 | 10 | 11 | @define(kw_only=True, field_transformer=field_transformer) 12 | class UserProfile: 13 | """Profile of User.""" 14 | 15 | first_name: str = field() 16 | last_name: str = field() 17 | avatar_hash: str = field() 18 | title: str = field() 19 | real_name: str = field() 20 | display_name: str = field() 21 | real_name_normalized: str = field(repr=True) 22 | display_name_normalized: str = field() 23 | email: str = field() 24 | image_24: str = field() 25 | image_32: str = field() 26 | image_48: str = field() 27 | image_72: str = field() 28 | image_192: str = field() 29 | image_512: str = field() 30 | 31 | 32 | @define(kw_only=True, field_transformer=field_transformer) 33 | class User: 34 | id: str = user_id_field() 35 | name: str = name_field() 36 | deleted: bool = field() 37 | color: str = field() 38 | real_name: str = field() 39 | tz: str = field() 40 | tz_label: str = field() 41 | tz_offset: int = field() 42 | profile: UserProfile = field() 43 | is_admin: bool = field() 44 | is_owner: bool = field() 45 | is_primary_owner: bool = field() 46 | is_restricted: bool = field() 47 | is_ultra_restricted: bool = field() 48 | is_bot: bool = field() 49 | updated: datetime = field() 50 | is_app_user: bool = field() 51 | has_2fa: bool = field() 52 | locale: str = field() 53 | presence: str = field() 54 | is_unknown: bool = field(init=False, repr=True, default=False) 55 | -------------------------------------------------------------------------------- /yui/box/tasks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from ..types.handler import FuncType 6 | from ..types.handler import Handler 7 | 8 | if TYPE_CHECKING: 9 | from collections.abc import Callable 10 | 11 | from ._box import Box 12 | 13 | 14 | class CronTask: 15 | """Cron Task""" 16 | 17 | handler: Handler 18 | start: Callable[[], None] 19 | stop: Callable[[], None] 20 | 21 | def __init__(self, box: Box, spec: str, args: tuple, kwargs: dict) -> None: 22 | """Initialize.""" 23 | 24 | if "start" not in kwargs: 25 | kwargs["start"] = True 26 | 27 | self.box = box 28 | self.spec = spec 29 | self.args = args 30 | self.kwargs = kwargs 31 | 32 | def __call__(self, target: FuncType | Handler) -> Handler: 33 | """Use as decorator""" 34 | 35 | handler = Handler.from_callable(target) 36 | 37 | self.handler = handler 38 | handler.cron = self 39 | 40 | return handler 41 | 42 | def __repr__(self) -> str: 43 | return f"CronTask(spec={self.spec!r}, func={self.handler!r})" 44 | 45 | __str__ = __repr__ 46 | 47 | 48 | class PollingTask: 49 | """Polling Task""" 50 | 51 | handler: Handler 52 | 53 | def __init__(self, box: Box) -> None: 54 | self.box = box 55 | 56 | def __call__(self, target: FuncType | Handler) -> Handler: 57 | """Use as decorator""" 58 | 59 | handler = Handler.from_callable(target) 60 | 61 | self.handler = handler 62 | handler.polling_task = self 63 | 64 | return handler 65 | 66 | def __repr__(self) -> str: 67 | return f"PollingTask(func={self.handler!r})" 68 | 69 | __str__ = __repr__ 70 | -------------------------------------------------------------------------------- /yui/apps/fun/code.py: -------------------------------------------------------------------------------- 1 | import random 2 | import re 3 | from datetime import timedelta 4 | 5 | from ...box import box 6 | from ...command.cooltime import Cooltime 7 | from ...event import Message 8 | from ...types.slack.attachment import Attachment 9 | 10 | COOLTIME = timedelta(minutes=15) 11 | PATTERN = re.compile( 12 | r"[제내저이]?\s*(?:코드|[PM]R|풀리퀘)좀?\s*리뷰", 13 | ) 14 | 15 | ICON_URL = "https://i.imgur.com/bGVUlSp.jpg" 16 | IMAGES = { 17 | "https://i.imgur.com/btkBRvc.png": 1, # 루왁커피 18 | "https://i.imgur.com/v3bu01T.png": 1, # 스파게티 19 | "https://i.imgur.com/UXyyFiM.png": 1, # 갈아넣으면 된다더라 20 | "https://i.imgur.com/zDm1KBL.png": 1, # 피클 21 | "https://i.imgur.com/XlYygAx.png": 0.3, # Brilliant! 22 | "https://i.imgur.com/ODOVLQA.png": 0.5, # 지사제 23 | "https://i.imgur.com/eu4SDBu.png": 0.5, # DDLC Monika 24 | } 25 | 26 | 27 | async def write_code_review(bot, event: Message, *, seed=None): 28 | random.seed(seed) 29 | image_url = random.choices(*zip(*IMAGES.items(), strict=True))[0] 30 | random.seed(None) 31 | await bot.api.chat.postMessage( 32 | channel=event.channel, 33 | attachments=[Attachment(fallback=image_url, image_url=image_url)], 34 | icon_url=ICON_URL, 35 | username="코드램지", 36 | ) 37 | 38 | 39 | @box.on(Message) 40 | async def code_review(bot, event: Message): 41 | if event.text and PATTERN.search(event.text.upper()): 42 | cooltime = Cooltime( 43 | bot=bot, 44 | key=f"YUI_APPS_FUN_CODE_REVIEW_{event.channel}", 45 | cooltime=COOLTIME, 46 | ) 47 | if await cooltime.rejected() is None: 48 | await write_code_review(bot, event) 49 | await cooltime.record() 50 | return False 51 | return True 52 | -------------------------------------------------------------------------------- /yui/apps/info/packtpub/commons.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | 3 | from ....bot import Bot 4 | from ....types.slack.attachment import Attachment 5 | from ....utils.html import get_root 6 | from ....utils.http import USER_AGENT 7 | 8 | PACKTPUB_URL = "https://www.packtpub.com/free-learning" 9 | HEADERS = { 10 | "User-Agent": USER_AGENT, 11 | } 12 | 13 | 14 | async def say_packtpub_dotd(bot: Bot, channel): 15 | attachments: list[Attachment] = [] 16 | url = "https://www.packtpub.com/free-learning" 17 | async with ( 18 | aiohttp.ClientSession(headers=HEADERS) as session, 19 | session.get( 20 | url, 21 | ) as resp, 22 | ): 23 | data = await resp.read() 24 | 25 | h = get_root(data) 26 | 27 | try: 28 | container = h.cssselect("main.product")[0] 29 | title = str( 30 | container.cssselect("h3.product-info__title")[0].text_content(), 31 | ).replace("Free eBook - ", "") 32 | image_url = str(container.cssselect("img.product-image")[0].get("src")) 33 | 34 | attachments.append( 35 | Attachment( 36 | fallback=f"{title} - {PACKTPUB_URL}", 37 | title=title, 38 | title_link=PACKTPUB_URL, 39 | text=( 40 | f"오늘의 Packt Book Deal of The Day: {title} -" 41 | f" {PACKTPUB_URL}" 42 | ), 43 | image_url=image_url, 44 | ), 45 | ) 46 | except IndexError: 47 | pass 48 | 49 | if attachments: 50 | await bot.api.chat.postMessage( 51 | channel=channel, 52 | text="오늘자 PACKT Book의 무료책이에요!", 53 | attachments=attachments, 54 | ) 55 | else: 56 | await bot.say(channel, "오늘은 PACKT Book의 무료책이 없는 것 같아요") 57 | -------------------------------------------------------------------------------- /tests/apps/fun/code_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.fun.code import code_review 4 | from yui.apps.fun.code import write_code_review 5 | 6 | 7 | @pytest.fixture(name="bot") 8 | async def bot_with_cache(bot, cache): 9 | async with bot.use_cache(cache): 10 | yield bot 11 | 12 | 13 | @pytest.mark.anyio 14 | async def test_write_code_review(bot): 15 | event = bot.create_message(text="코드 리뷰") 16 | await write_code_review(bot, event, seed=100) 17 | 18 | said = bot.call_queue.pop(0) 19 | assert said.method == "chat.postMessage" 20 | assert said.data["channel"] == event.channel 21 | 22 | attachments = said.data["attachments"] 23 | 24 | assert len(attachments) == 1 25 | assert ( 26 | attachments[0]["fallback"] 27 | == attachments[0]["image_url"] 28 | == "https://i.imgur.com/btkBRvc.png" 29 | ) 30 | 31 | 32 | @pytest.mark.anyio 33 | async def test_code_review(bot, channel_id): 34 | event = bot.create_message(channel_id=channel_id, text="영화 리뷰") 35 | 36 | assert await code_review(bot, event) 37 | 38 | last_call = await bot.cache.get(f"YUI_APPS_FUN_CODE_REVIEW_{event.channel}") 39 | assert last_call is None 40 | 41 | assert not bot.call_queue 42 | 43 | event = bot.create_message(channel_id=channel_id, text="코드 리뷰") 44 | 45 | assert not await code_review(bot, event) 46 | 47 | last_call = await bot.cache.get(f"YUI_APPS_FUN_CODE_REVIEW_{event.channel}") 48 | assert isinstance(last_call, float) 49 | 50 | said = bot.call_queue.pop(0) 51 | assert said.method == "chat.postMessage" 52 | assert said.data["channel"] == event.channel 53 | assert len(said.data["attachments"]) == 1 54 | 55 | event = bot.create_message(channel_id=channel_id, text="코드 리뷰") 56 | 57 | assert await code_review(bot, event) 58 | -------------------------------------------------------------------------------- /tests/apps/manage/cleanup/handlers_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from sqlalchemy.sql.expression import func 3 | from sqlalchemy.sql.expression import select 4 | 5 | from yui.apps.manage.cleanup.handlers import make_log 6 | from yui.apps.manage.cleanup.models import EventLog 7 | 8 | from ....util import FakeBot 9 | 10 | 11 | @pytest.mark.anyio 12 | async def test_make_log(bot_config, fx_sess): 13 | channel_1 = "C111" 14 | channel_2 = "C222" 15 | channel_3 = "C333" 16 | bot_config.CHANNELS["auto_cleanup_targets"] = [channel_1, channel_2] 17 | bot = FakeBot(bot_config) 18 | assert ( 19 | await fx_sess.scalar( 20 | select(func.count(EventLog.id)), 21 | ) 22 | == 0 23 | ) 24 | event = bot.create_message(channel_id=channel_3, ts="11111.1") 25 | await make_log(bot, event, fx_sess) 26 | assert ( 27 | await fx_sess.scalar( 28 | select(func.count(EventLog.id)), 29 | ) 30 | == 0 31 | ) 32 | 33 | event = bot.create_message(channel_id=channel_1, ts="11112.2") 34 | await make_log(bot, event, fx_sess) 35 | assert ( 36 | await fx_sess.scalar( 37 | select(func.count(EventLog.id)), 38 | ) 39 | == 1 40 | ) 41 | await make_log(bot, event, fx_sess) 42 | assert ( 43 | await fx_sess.scalar( 44 | select(func.count(EventLog.id)), 45 | ) 46 | == 1 47 | ) 48 | 49 | event = bot.create_message( 50 | subtype="message_deleted", 51 | channel_id=channel_2, 52 | ts="11113.3", 53 | ) 54 | await make_log(bot, event, fx_sess) 55 | assert ( 56 | await fx_sess.scalar( 57 | select(func.count(EventLog.id)), 58 | ) 59 | == 1 60 | ) 61 | -------------------------------------------------------------------------------- /yui/utils/attrs.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from functools import partial 3 | from typing import get_args 4 | 5 | import attrs 6 | from attr import AttrsInstance 7 | 8 | from .datetime import fromtimestamp 9 | 10 | 11 | def make_instance[C: AttrsInstance]( 12 | cls: type[C], 13 | **kwargs, 14 | ) -> C: 15 | expected_attrs = {x.name for x in attrs.fields(cls)} 16 | actual_attrs = set(kwargs.keys()) 17 | for key in actual_attrs - expected_attrs: 18 | del kwargs[key] 19 | return cls(**kwargs) 20 | 21 | 22 | def _attrs_converter(t): 23 | def innner(value): 24 | if value: 25 | try: 26 | return make_instance(t, **value) 27 | except TypeError: 28 | return value 29 | return None 30 | 31 | return innner 32 | 33 | 34 | def _datetime_converter(value): 35 | if value: 36 | return fromtimestamp(value) 37 | return None 38 | 39 | 40 | def field_transformer(cls, fields): 41 | results = [] 42 | for field in fields: 43 | t = field.type 44 | if get_args(t): 45 | t = get_args(t)[0] 46 | if field.converter is None: 47 | if hasattr(t, "__attrs_attrs__"): 48 | results.append(field.evolve(converter=_attrs_converter(t))) 49 | elif t is datetime.datetime: 50 | results.append(field.evolve(converter=_datetime_converter)) 51 | else: 52 | results.append(field) 53 | else: 54 | results.append(field) 55 | return results 56 | 57 | 58 | channel_id_field = partial(attrs.field, repr=True) 59 | user_id_field = partial(attrs.field, repr=True) 60 | name_field = partial(attrs.field, repr=True) 61 | ts_field = partial(attrs.field, repr=True) 62 | field = partial(attrs.field, default=None, repr=False) 63 | -------------------------------------------------------------------------------- /yui/apps/manage/cleanup/tasks.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import timedelta 3 | 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | 6 | from ....box import box 7 | from ....utils.datetime import now 8 | from .commons import cleanup_by_event_logs 9 | from .commons import collect_history_from_channel 10 | 11 | box.assert_config_required("USER_TOKEN", str) 12 | box.assert_channels_required("auto_cleanup_targets") 13 | 14 | 15 | @box.cron("0,10,20,30,40,50 * * * *") 16 | async def cleanup_channels(bot, sess: AsyncSession): 17 | logger = logging.getLogger("yui.apps.manage.cleanup.tasks.cleanup_channels") 18 | channels = bot.config.CHANNELS.get("auto_cleanup_targets", []) 19 | 20 | if not channels: 21 | return 22 | 23 | time_limit = now() - timedelta(hours=12) 24 | ts = str(int(time_limit.timestamp())) 25 | 26 | logger.info("Delete messages before %s", ts) 27 | for channel in channels: 28 | logger.info("Start channel cleanup: %s", channel) 29 | deleted = await cleanup_by_event_logs( 30 | bot, 31 | sess, 32 | channel, 33 | ts, 34 | bot.config.USER_TOKEN, 35 | ) 36 | logger.info("Finish channel cleanup: %s, %d deleted", channel, deleted) 37 | 38 | 39 | @box.cron("5 * * * *") 40 | async def get_old_history(bot, sess: AsyncSession): 41 | logger = logging.getLogger("yui.apps.manage.cleanup.tasks.get_old_history") 42 | channels = bot.config.CHANNELS.get("auto_cleanup_targets", []) 43 | 44 | for channel in channels: 45 | logger.info("Start collect message in channel: %s", channel) 46 | collected = await collect_history_from_channel(bot, channel, sess) 47 | logger.info( 48 | "Finish collect message in channel: %s, %d collected", 49 | channel, 50 | collected, 51 | ) 52 | -------------------------------------------------------------------------------- /yui/apps/fun/answer.py: -------------------------------------------------------------------------------- 1 | import random 2 | import re 3 | 4 | from ...box import box 5 | from ...event import Message 6 | 7 | RESPONSES: list[str] = [ 8 | "그래", 9 | "그럴걸?", 10 | "돼", 11 | "될걸?", 12 | "안돼", 13 | "안될걸?", 14 | "기다려", 15 | "존버해", 16 | "맘대로 해", 17 | "당장 시작해", 18 | "지금이야", 19 | "몰라", 20 | "ㅁㄹ", 21 | "안들려", 22 | "같이가", 23 | "좋은건 나도", 24 | "너만 가", 25 | "혼자 가", 26 | "뭐", 27 | "뭐?", 28 | "맞아", 29 | "마즘", 30 | "아닌데", 31 | "아닌디", 32 | "^^...", 33 | "ㅎㅎ...", 34 | "ㅇㅇ", 35 | "ㅇㅇㅇㅇㅇㅇㅇ", 36 | "ㄴㄴ", 37 | "ㄴㄴㄴㄴㄴㄴㄴ", 38 | "어쩌라고", 39 | "어쩔", 40 | "후...", 41 | "하...", 42 | "빠잉", 43 | "ㅃㅃ", 44 | "반가워", 45 | "하잉", 46 | "잘가", 47 | "하이", 48 | "헬로", 49 | "공부하자", 50 | "생각좀 하자", 51 | "웅?", 52 | "우웅?", 53 | "난 그런거 몰라여", 54 | "어", 55 | "포기해", 56 | "손절 ㄱ", 57 | "포기하지마", 58 | "손절 ㄴ", 59 | "ㄱㄱ", 60 | "ㄱㄱㄱㄱㄱㄱ", 61 | "가즈아", 62 | "아아안 돼애에", 63 | "나중에 해", 64 | "지금 해", 65 | "허락할게", 66 | "허락 못 해", 67 | "모두 하지 마", 68 | "모두다 해", 69 | "응", 70 | "아니", 71 | "다시 한번 물어봐", 72 | "가만히 있어", 73 | "도망가", 74 | "해", 75 | "하지마", 76 | "ㅋㅋㅋ", 77 | "ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ", 78 | "ㅠㅠ", 79 | "ㅠㅠㅠㅠㅠㅠㅠ", 80 | "ㅋㅋㅋㅋㅋㅋ큐ㅠㅠㅠㅠㅠㅠ", 81 | ] 82 | icon_url = "https://i.imgur.com/uDcouRb.jpg" 83 | 84 | SUMMON_PREFIX = re.compile(r"^마법의?\s*소라고[둥동]님?\s*") 85 | 86 | 87 | @box.on(Message) 88 | async def magic_conch(bot, event: Message): 89 | if event.text and SUMMON_PREFIX.search(event.text): 90 | await bot.api.chat.postMessage( 91 | channel=event.channel, 92 | text=random.choice(RESPONSES), 93 | icon_url=icon_url, 94 | username="마법의 소라고둥", 95 | ) 96 | return False 97 | return True 98 | -------------------------------------------------------------------------------- /yui/utils/format.py: -------------------------------------------------------------------------------- 1 | from ..types.channel import PublicChannel 2 | from ..types.user import User 3 | 4 | 5 | def escape(text: str) -> str: 6 | """Make escaped text.""" 7 | 8 | return text.replace("&", "&").replace("<", "<").replace(">", ">") 9 | 10 | 11 | def bold(text: str) -> str: 12 | """Make text to bold.""" 13 | 14 | return f"*{text}*" 15 | 16 | 17 | def italics(text: str) -> str: 18 | """Make text to italics.""" 19 | 20 | return f"_{text}_" 21 | 22 | 23 | def strike(text: str) -> str: 24 | """Make text to strike.""" 25 | 26 | return f"~{text}~" 27 | 28 | 29 | def code(text: str) -> str: 30 | """Make text to code.""" 31 | 32 | return f"`{text}`" 33 | 34 | 35 | def preformatted(text: str) -> str: 36 | """Make text to pre-formatted text.""" 37 | 38 | return f"```{text}```" 39 | 40 | 41 | def quote(text: str) -> str: 42 | """Make text to qoute.""" 43 | 44 | return f">{text}" 45 | 46 | 47 | def link(x) -> str: 48 | if isinstance(x, User): 49 | return f"<@{x.id}>" 50 | if isinstance(x, PublicChannel): 51 | return f"<#{x.id}>" 52 | if isinstance(x, str): 53 | if x.startswith(("U", "W")): 54 | return f"<@{x}>" 55 | if x.startswith("C"): 56 | return f"<#{x}>" 57 | if x.startswith("S"): 58 | return f"" 59 | return f"<{x}>" 60 | 61 | 62 | def link_url(url: str, text: str | None = None) -> str: 63 | if text: 64 | return f"<{url}|{escape(text)}>" 65 | return f"<{url}>" 66 | 67 | 68 | def link_here(text: str = "here") -> str: 69 | return f"" 70 | 71 | 72 | def link_channel(text: str = "channel") -> str: 73 | return f"" 74 | 75 | 76 | def link_everyone(text: str = "everyone") -> str: 77 | return f"" 78 | -------------------------------------------------------------------------------- /yui/migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = yui/migrations 6 | 7 | # template used to generate migration files 8 | # file_template = %%(rev)s_%%(slug)s 9 | 10 | # timezone to use when rendering the date 11 | # within the migration file as well as the filename. 12 | # string value is passed to dateutil.tz.gettz() 13 | # leave blank for localtime 14 | # timezone = 15 | 16 | # max length of characters to apply to the 17 | # "slug" field 18 | #truncate_slug_length = 40 19 | 20 | # set to 'true' to run the environment during 21 | # the 'revision' command, regardless of autogenerate 22 | # revision_environment = false 23 | 24 | # set to 'true' to allow .pyc and .pyo files without 25 | # a source .py file to be detected as revisions in the 26 | # versions/ directory 27 | # sourceless = false 28 | 29 | # version location specification; this defaults 30 | # to yui/migrations/versions. When using multiple version 31 | # directories, initial revisions must be specified with --version-path 32 | # version_locations = %(here)s/bar %(here)s/bat yui/migrations/versions 33 | 34 | # the output encoding used when revision files 35 | # are written from script.py.mako 36 | # output_encoding = utf-8 37 | 38 | # Logging configuration 39 | [loggers] 40 | keys = root,sqlalchemy,alembic 41 | 42 | [handlers] 43 | keys = console 44 | 45 | [formatters] 46 | keys = generic 47 | 48 | [logger_root] 49 | level = WARN 50 | handlers = console 51 | qualname = 52 | 53 | [logger_sqlalchemy] 54 | level = WARN 55 | handlers = 56 | qualname = sqlalchemy.engine 57 | 58 | [logger_alembic] 59 | level = INFO 60 | handlers = 61 | qualname = alembic 62 | 63 | [handler_console] 64 | class = StreamHandler 65 | args = (sys.stderr,) 66 | level = NOTSET 67 | formatter = generic 68 | 69 | [formatter_generic] 70 | format = %(levelname)-5.5s [%(name)s] %(message)s 71 | datefmt = %H:%M:%S 72 | -------------------------------------------------------------------------------- /tests/apps/search/book_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | 4 | import pytest 5 | 6 | from yui.apps.search.book import book 7 | 8 | from ...util import FakeBot 9 | 10 | book_result_pattern_re = re.compile( 11 | r"키워드 \*(.+?)\* 으?로 네이버 책 DB 검색 결과," 12 | r" 총 \d+(?:,\d{3})*개의 결과가 나왔어요\." 13 | r" 그 중 상위 (\d+)개를 보여드릴게요!", 14 | ) 15 | 16 | 17 | @pytest.fixture 18 | def naver_client_id(): 19 | token = os.getenv("NAVER_CLIENT_ID") 20 | if not token: 21 | pytest.skip("Can not test this without NAVER_CLIENT_ID envvar") 22 | return token 23 | 24 | 25 | @pytest.fixture 26 | def naver_client_secret(): 27 | key = os.getenv("NAVER_CLIENT_SECRET") 28 | if not key: 29 | pytest.skip("Can not test this without NAVER_CLIENT_SECRET envvar") 30 | return key 31 | 32 | 33 | @pytest.mark.anyio 34 | async def test_book( 35 | bot_config, 36 | naver_client_id, 37 | naver_client_secret, 38 | ): 39 | bot_config.NAVER_CLIENT_ID = naver_client_id 40 | bot_config.NAVER_CLIENT_SECRET = naver_client_secret 41 | bot = FakeBot(bot_config) 42 | 43 | event = bot.create_message(ts="1234.5678") 44 | 45 | await book(bot, event, "소드 아트 온라인") 46 | 47 | said = bot.call_queue.pop(0) 48 | assert said.method == "chat.postMessage" 49 | assert said.data["channel"] == event.channel 50 | matched = book_result_pattern_re.match(said.data["text"]) 51 | assert matched 52 | assert matched.group(1) == "소드 아트 온라인" 53 | assert len(said.data["attachments"]) == int(matched.group(2)) 54 | assert said.data["thread_ts"] == event.ts 55 | 56 | await book( 57 | bot, 58 | event, 59 | "🙄 🐰😴😰🏄😋😍🍦😮🐖😫🍭🚬🚪🐳😞😎🚠😖🍲🙉😢🚔🐩👪🐮🚍🐎👱🎿😸👩🚇🍟👧🎺😒", 60 | ) 61 | 62 | said = bot.call_queue.pop(0) 63 | assert said.method == "chat.postMessage" 64 | assert said.data["channel"] == event.channel 65 | assert said.data["text"] == "검색 결과가 없어요!" 66 | -------------------------------------------------------------------------------- /tests/utils/format_test.py: -------------------------------------------------------------------------------- 1 | from yui.utils.format import bold 2 | from yui.utils.format import code 3 | from yui.utils.format import escape 4 | from yui.utils.format import italics 5 | from yui.utils.format import link 6 | from yui.utils.format import link_channel 7 | from yui.utils.format import link_everyone 8 | from yui.utils.format import link_here 9 | from yui.utils.format import link_url 10 | from yui.utils.format import preformatted 11 | from yui.utils.format import quote 12 | from yui.utils.format import strike 13 | 14 | 15 | def test_escape(): 16 | assert escape("&") == "&" 17 | assert escape("<") == "<" 18 | assert escape(">") == ">" 19 | 20 | 21 | def test_format_helpers(): 22 | """Test slack syntax helpers.""" 23 | 24 | assert bold("item4") == "*item4*" 25 | assert code("item4") == "`item4`" 26 | assert italics("item4") == "_item4_" 27 | assert preformatted("item4") == "```item4```" 28 | assert strike("item4") == "~item4~" 29 | assert quote("item4") == ">item4" 30 | 31 | 32 | def test_link(bot): 33 | user = bot.create_user("U1234", "tester") 34 | channel = bot.create_channel("C1234", "test") 35 | assert link(channel) == "<#C1234>" 36 | assert link(user) == "<@U1234>" 37 | assert link("C1234") == "<#C1234>" 38 | assert link("U1234") == "<@U1234>" 39 | assert link("W1234") == "<@W1234>" 40 | assert link("S1234") == "" 41 | assert link("unknown") == "" 42 | assert link(1234) == "<1234>" 43 | 44 | 45 | def test_link_url(): 46 | url = "https://github.com/item4/yui" 47 | assert link_url(url) == f"<{url}>" 48 | assert link_url(url, "Repo") == f"<{url}|Repo>" 49 | assert link_url(url, "Repo & Code") == f"<{url}|Repo & Code>" 50 | 51 | 52 | def test_special_mentions(): 53 | assert link_channel() == "" 54 | assert link_everyone() == "" 55 | assert link_here() == "" 56 | -------------------------------------------------------------------------------- /yui/migrations/versions/0e7bdd5c7473_refactor_datetime_fields.py: -------------------------------------------------------------------------------- 1 | """Refactor datetime fields 2 | 3 | Revision ID: 0e7bdd5c7473 4 | Revises: 5 | Create Date: 2020-05-10 17:28:07.620112 6 | 7 | """ 8 | 9 | import sqlalchemy as sa 10 | from alembic import op 11 | from sqlalchemy_utils import TimezoneType 12 | 13 | # revision identifiers, used by Alembic. 14 | revision = "0e7bdd5c7473" 15 | down_revision = None 16 | branch_labels = None 17 | depends_on = None 18 | 19 | 20 | def upgrade(): 21 | op.create_table( 22 | "event_log", 23 | sa.Column("id", sa.Integer(), nullable=False), 24 | sa.Column("ts", sa.String(), nullable=False), 25 | sa.Column("channel", sa.String(), nullable=False), 26 | sa.PrimaryKeyConstraint("id"), 27 | ) 28 | op.create_table( 29 | "memo", 30 | sa.Column("id", sa.Integer(), nullable=False), 31 | sa.Column("keyword", sa.String(), nullable=False), 32 | sa.Column("text", sa.Text(), nullable=False), 33 | sa.Column("author", sa.String(), nullable=False), 34 | sa.Column( 35 | "created_datetime", 36 | sa.DateTime(timezone=True), 37 | nullable=False, 38 | ), 39 | sa.Column("created_timezone", TimezoneType(), nullable=True), 40 | sa.PrimaryKeyConstraint("id"), 41 | ) 42 | op.create_table( 43 | "rss_feed_url", 44 | sa.Column("id", sa.Integer(), nullable=False), 45 | sa.Column("url", sa.String(), nullable=False), 46 | sa.Column("channel", sa.String(), nullable=False), 47 | sa.Column( 48 | "updated_datetime", 49 | sa.DateTime(timezone=True), 50 | nullable=False, 51 | ), 52 | sa.Column("updated_timezone", TimezoneType(), nullable=True), 53 | sa.PrimaryKeyConstraint("id"), 54 | ) 55 | 56 | 57 | def downgrade(): 58 | op.drop_table("rss_feed_url") 59 | op.drop_table("memo") 60 | op.drop_table("event_log") 61 | -------------------------------------------------------------------------------- /tests/apps/info/help_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.info.help import help 4 | from yui.box import Box 5 | 6 | from ...util import FakeBot 7 | 8 | 9 | @pytest.mark.anyio 10 | async def test_help_command(bot_config): 11 | box = Box() 12 | 13 | @box.command("dog") 14 | async def dog(bot, event): 15 | """Dog 16 | 17 | It's a dog""" 18 | 19 | @box.command("cat") 20 | async def cat(bot, event): 21 | """Cat 22 | 23 | It's a cat""" 24 | 25 | @box.command("bat") 26 | async def bat(bot, event): 27 | """Bat""" 28 | 29 | bot_config.PREFIX = "." 30 | bot = FakeBot(bot_config, using_box=box) 31 | event = bot.create_message(ts="1234.56") 32 | 33 | await help(bot, event, "") 34 | 35 | said = bot.call_queue.pop(0) 36 | assert said.method == "chat.postMessage" 37 | assert said.data["channel"] == event.channel 38 | assert said.data["text"] == ( 39 | f"`{bot_config.PREFIX}dog`: Dog\n`{bot_config.PREFIX}cat`: Cat\n`{bot_config.PREFIX}bat`: Bat" 40 | ) 41 | assert said.data["thread_ts"] == event.ts 42 | 43 | await help(bot, event, "cat") 44 | 45 | said = bot.call_queue.pop(0) 46 | assert said.method == "chat.postMessage" 47 | assert said.data["channel"] == event.channel 48 | assert ( 49 | said.data["text"] 50 | == f"""*{bot_config.PREFIX}cat* 51 | Cat 52 | 53 | It's a cat""" 54 | ) 55 | assert said.data["thread_ts"] == "1234.56" 56 | 57 | await help(bot, event, "bat") 58 | 59 | said = bot.call_queue.pop(0) 60 | assert said.method == "chat.postMessage" 61 | assert said.data["channel"] == event.channel 62 | assert said.data["text"] == f"`{bot_config.PREFIX}bat`: Bat" 63 | assert said.data["thread_ts"] == "1234.56" 64 | 65 | await help(bot, event, "none") 66 | 67 | said = bot.call_queue.pop(0) 68 | assert said.method == "chat.postMessage" 69 | assert said.data["channel"] == event.channel 70 | assert said.data["text"] == "그런 명령어는 없어요!" 71 | assert said.data["thread_ts"] == "1234.56" 72 | -------------------------------------------------------------------------------- /tests/apps/welcome_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.welcome.item4 import welcome_item4 4 | from yui.apps.welcome.the_9xd import welcome_9xd 5 | from yui.event import create_event 6 | from yui.types.slack.response import APIResponse 7 | 8 | from ..util import FakeBot 9 | 10 | 11 | @pytest.mark.anyio 12 | async def test_welcome_item4_handler(bot_config, channel_id, user_id): 13 | bot_config.PREFIX = "." 14 | bot_config.CHANNELS = { 15 | "welcome": channel_id, 16 | } 17 | bot = FakeBot(bot_config) 18 | event = create_event("team_join", {"user": user_id}) 19 | 20 | await welcome_item4(bot, event) 21 | 22 | said = bot.call_queue.pop(0) 23 | assert said.method == "chat.postMessage" 24 | assert said.data["channel"] == channel_id 25 | assert said.data["text"].startswith( 26 | f"<@{user_id}>님 item4 개인 Slack에 오신걸 환영합니다! :tada:", 27 | ) 28 | assert "`.도움`" in said.data["text"] 29 | 30 | 31 | @pytest.mark.anyio 32 | async def test_welcome_9xd_handler(bot_config, channel_id, user_id): 33 | bot_config.PREFIX = "." 34 | bot_config.CHANNELS = { 35 | "welcome": channel_id, 36 | } 37 | bot = FakeBot(bot_config) 38 | event = create_event("team_join", {"user": user_id}) 39 | 40 | @bot.response("chat.postMessage") 41 | def team_join(data): 42 | return APIResponse( 43 | body={"ok": True, "ts": "1234.5678"}, 44 | status=200, 45 | headers={}, 46 | ) 47 | 48 | await welcome_9xd(bot, event) 49 | 50 | said = bot.call_queue.pop(0) 51 | assert said.method == "chat.postMessage" 52 | assert said.data["channel"] == channel_id 53 | assert said.data["text"].startswith( 54 | f"<@{user_id}>님 9XD Slack에 오신걸 환영합니다! :tada:", 55 | ) 56 | assert "`.도움`" in said.data["text"] 57 | 58 | thread = bot.call_queue.pop(0) 59 | assert thread.method == "chat.postMessage" 60 | assert thread.data["channel"] == channel_id 61 | assert thread.data["text"].startswith( 62 | "9XD Slack에는 다음과 같은 채널들이 있으니 참가해보셔도 좋을 것 같아요!", 63 | ) 64 | assert thread.data["thread_ts"] == "1234.5678" 65 | -------------------------------------------------------------------------------- /yui/box/apps/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | from typing import TYPE_CHECKING 5 | 6 | from ..utils import SPACE_RE 7 | 8 | if TYPE_CHECKING: 9 | import inspect 10 | from collections.abc import Mapping 11 | 12 | from ...bot import Bot 13 | from ...event import Event 14 | from ...event import Message 15 | 16 | 17 | class BaseApp: 18 | """Base class of App""" 19 | 20 | def get_short_help(self, prefix: str) -> str: 21 | raise NotImplementedError 22 | 23 | def get_full_help(self, prefix: str) -> str: 24 | raise NotImplementedError 25 | 26 | @property 27 | def has_short_help(self) -> bool: 28 | raise NotImplementedError 29 | 30 | @property 31 | def has_full_help(self) -> bool: 32 | raise NotImplementedError 33 | 34 | async def run(self, bot: Bot, event: Event): 35 | raise NotImplementedError 36 | 37 | def get_event_text(self, event: Message) -> str: 38 | if event.text: 39 | return event.text 40 | if ( 41 | event.message 42 | and hasattr(event.message, "text") 43 | and event.message.text 44 | ): 45 | return event.message.text 46 | return "" 47 | 48 | def split_call_and_args(self, text: str) -> tuple[str, str]: 49 | try: 50 | call, args = SPACE_RE.split(text, 1) 51 | except ValueError: 52 | call = text 53 | args = "" 54 | return call, args 55 | 56 | @contextlib.asynccontextmanager 57 | async def prepare_kwargs( 58 | self, 59 | *, 60 | bot: Bot, 61 | event: Event, 62 | func_params: Mapping[str, inspect.Parameter], 63 | **kwargs, 64 | ): 65 | sess = bot.session_maker() 66 | if "bot" in func_params: 67 | kwargs["bot"] = bot 68 | if "event" in func_params: 69 | kwargs["event"] = event 70 | if "sess" in func_params: 71 | kwargs["sess"] = sess 72 | 73 | try: 74 | yield kwargs 75 | finally: 76 | await sess.close() 77 | -------------------------------------------------------------------------------- /yui/apps/date/day.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import re 3 | 4 | import aiohttp.client_exceptions 5 | 6 | from ...box import box 7 | from ...event import Message 8 | from ...transform import str_to_date 9 | from ...utils import datetime 10 | from .utils import APIDoesNotSupport 11 | from .utils import get_holiday_names 12 | 13 | box.assert_channel_required("general") 14 | 15 | YEAR_PATTERN = re.compile(r"^(\d{4})년$") 16 | YEAR_MONTH_PATTERN = re.compile(r"^(\d{4})년\s*(\d{1,2})월$") 17 | 18 | 19 | @box.cron("0 0 * * 0,2,3,4,5,6") 20 | async def holiday_message(bot): 21 | holidays = None 22 | today = datetime.today() 23 | with contextlib.suppress(aiohttp.client_exceptions.ClientOSError): 24 | holidays = await get_holiday_names(today) 25 | 26 | if holidays: 27 | await bot.say( 28 | bot.config.CHANNELS["general"], 29 | f"오늘은 {holidays[0]}! 행복한 휴일 되세요!", 30 | ) 31 | 32 | 33 | @box.command("공휴일", ["휴일", "holiday"]) 34 | async def holiday(bot, event: Message, raw: str): 35 | """ 36 | 공휴일 조회 37 | 38 | 특정 날짜가 공휴일인지 조회합니다. 39 | 40 | `{PREFIX}공휴일` (오늘이 공휴일인지 조회) 41 | `{PREFIX}공휴일 2019년 1월 1일 (2019년 1월 1일이 공휴일인지 조회) 42 | 43 | """ 44 | 45 | if raw: 46 | try: 47 | date = str_to_date()(raw) 48 | except ValueError: 49 | await bot.say( 50 | event.channel, 51 | "인식할 수 없는 날짜 표현식이에요!", 52 | ) 53 | return 54 | else: 55 | date = datetime.today() 56 | 57 | try: 58 | holidays = await get_holiday_names(date) 59 | except APIDoesNotSupport: 60 | await bot.say( 61 | event.channel, 62 | "API가 해당 년월일시의 자료를 제공하지 않아요!", 63 | ) 64 | return 65 | 66 | if holidays: 67 | await bot.say( 68 | event.channel, 69 | "{}: {}".format( 70 | date.strftime("%Y년 %m월 %d일"), 71 | ", ".join(holidays), 72 | ), 73 | ) 74 | else: 75 | await bot.say( 76 | event.channel, 77 | "{}: 평일".format(date.strftime("%Y년 %m월 %d일")), 78 | ) 79 | -------------------------------------------------------------------------------- /yui/api/__init__.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from datetime import timedelta 3 | 4 | from .apps import Apps 5 | from .chat import Chat 6 | from .conversations import Conversations 7 | from .users import Users 8 | 9 | 10 | def _limit_to_timedelta(limit: int) -> timedelta: 11 | return timedelta(microseconds=(60 / (limit * 0.9)) * 1_000_000) 12 | 13 | 14 | TIER1 = _limit_to_timedelta(1) # 1+ per minute 15 | TIER2 = _limit_to_timedelta(20) # 20+ per minute 16 | TIER3 = _limit_to_timedelta(50) # 50+ per minute 17 | TIER4 = _limit_to_timedelta(100) # 100+ per minute 18 | POST_MESSAGE = _limit_to_timedelta(60) # 1 per second 19 | 20 | 21 | class SlackAPI: 22 | """Slack API Interface""" 23 | 24 | apps: Apps 25 | conversations: Conversations 26 | chat: Chat 27 | users: Users 28 | 29 | def __init__(self, bot) -> None: 30 | """Initialize""" 31 | 32 | self.apps = Apps(bot) 33 | self.chat = Chat(bot) 34 | self.conversations = Conversations(bot) 35 | self.users = Users(bot) 36 | 37 | self.throttle_interval: defaultdict[str, timedelta] = defaultdict( 38 | lambda: TIER3, 39 | ) 40 | 41 | # apps.connections tier 1 42 | self.throttle_interval["apps.connections.open"] = TIER1 43 | 44 | # chat tier 3 45 | self.throttle_interval["chat.delete"] = TIER3 46 | # chat tier 4 47 | self.throttle_interval["chat.postEphemeral"] = TIER4 48 | # chat special 49 | self.throttle_interval["chat.postMessage"] = POST_MESSAGE 50 | 51 | # conversations tier 2 52 | self.throttle_interval["conversations.list"] = TIER2 53 | # conversations tier 3 54 | self.throttle_interval["conversations.history"] = TIER3 55 | self.throttle_interval["conversations.info"] = TIER3 56 | self.throttle_interval["conversations.open"] = TIER3 57 | self.throttle_interval["conversations.replies"] = TIER3 58 | 59 | # users tier 2 60 | self.throttle_interval["users.list"] = TIER2 61 | # users tier 4 62 | self.throttle_interval["users.info"] = TIER4 63 | 64 | # rtm tier 1 65 | self.throttle_interval["rtm.start"] = TIER1 66 | -------------------------------------------------------------------------------- /yui/cache.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from datetime import datetime 3 | from decimal import Decimal 4 | 5 | from valkey.asyncio import Valkey 6 | 7 | from .utils import json 8 | from .utils.datetime import fromtimestamp 9 | 10 | type DataType = str | bytes | bool | int | float | Decimal | dict | list 11 | type CacheKey = str | bytes 12 | 13 | 14 | class Cache: 15 | def __init__(self, valkey_client: Valkey, prefix: str = "") -> None: 16 | self.valkey_client = valkey_client 17 | self.prefix = prefix.encode() 18 | self.is_ready = asyncio.Event() 19 | 20 | def _key(self, key: CacheKey) -> bytes: 21 | if isinstance(key, str): 22 | key = key.encode() 23 | return self.prefix + key 24 | 25 | async def set( 26 | self, 27 | key: CacheKey, 28 | value: DataType, 29 | exptime: int | None = None, 30 | ): 31 | data = json.dumps(value).encode() 32 | key = self._key(key) 33 | await self.valkey_client.set(key, data, ex=exptime) 34 | 35 | async def get[T: DataType]( 36 | self, 37 | key: CacheKey, 38 | default: T | None = None, 39 | ) -> T | None: 40 | key = self._key(key) 41 | data = await self.valkey_client.get(key) 42 | if data is None: 43 | return default 44 | return json.loads(data.decode()) 45 | 46 | async def set_dt( 47 | self, 48 | key: CacheKey, 49 | value: datetime, 50 | exptime: int | None = None, 51 | ): 52 | await self.set(key, value.timestamp(), exptime) 53 | 54 | async def get_dt( 55 | self, 56 | key: CacheKey, 57 | default: datetime | None = None, 58 | ) -> datetime | None: 59 | value: float | None = await self.get(key) # type: ignore[func-returns-value] 60 | if value is None: 61 | return default 62 | return fromtimestamp(value) 63 | 64 | async def delete(self, key: CacheKey): 65 | key = self._key(key) 66 | await self.valkey_client.delete(key) 67 | 68 | async def flushall(self): 69 | await self.valkey_client.flushall() 70 | 71 | async def close(self): 72 | await self.valkey_client.aclose() 73 | -------------------------------------------------------------------------------- /yui/apps/date/age.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from dateutil.relativedelta import relativedelta 4 | 5 | from ...box import box 6 | from ...command import argument 7 | from ...command import option 8 | from ...event import Message 9 | from ...transform import str_to_date 10 | 11 | 12 | @box.command("나이", ["age"]) 13 | @option( 14 | "--at", 15 | dest="today", 16 | default=datetime.date.today, 17 | transform_func=str_to_date(datetime.date.today), 18 | ) 19 | @argument( 20 | "birthday", 21 | nargs=-1, 22 | concat=True, 23 | count_error="생일을 입력해주세요", 24 | transform_func=str_to_date(), 25 | transform_error="인식할 수 있는 날짜가 아니에요!", 26 | ) 27 | async def age( 28 | bot, 29 | event: Message, 30 | today: datetime.date, 31 | birthday: datetime.date, 32 | ): 33 | """ 34 | 나이 계산 35 | 36 | 주어진 날짜를 기준으로 나이 및 생일 정보를 출력합니다. 37 | 38 | `{PREFIX}나이 2003년 6월 3일` 39 | (오늘을 기준으로 2003년 6월 3일생의 나이/생일 정보를 계산) 40 | `{PREFIX}나이 --at="2010년 1월 1일" 2003년 6월 3일 41 | (2010년 1월 1일을 기준으로 계산) 42 | 43 | """ 44 | 45 | if today < birthday: 46 | await bot.say(event.channel, "기준일 기준으로 아직 태어나지 않았어요!") 47 | return 48 | 49 | global_age = relativedelta(today, birthday).years 50 | year_age = relativedelta(today, datetime.date(birthday.year, 1, 1)).years 51 | korean_age = year_age + 1 52 | 53 | this_year_birthday = birthday + relativedelta(years=year_age) 54 | remain = this_year_birthday.toordinal() - today.toordinal() 55 | if remain < 1: 56 | next_year_birthday = birthday + relativedelta(years=year_age + 1) 57 | remain = next_year_birthday.toordinal() - today.toordinal() 58 | 59 | await bot.say( 60 | event.channel, 61 | ( 62 | "{} 출생자는 {} 기준으로 다음과 같아요!\n\n" 63 | "* 세는 나이(한국식 나이) {}세\n" 64 | "* 연 나이(한국 일부 법령상 나이) {}세\n" 65 | "* 만 나이(전세계 표준) {}세\n\n" 66 | "출생일로부터 {:,}일 지났어요." 67 | " 다음 생일까지 {}일 남았어요." 68 | ).format( 69 | birthday.strftime("%Y년 %m월 %d일"), 70 | today.strftime("%Y년 %m월 %d일"), 71 | korean_age, 72 | year_age, 73 | global_age, 74 | today.toordinal() - birthday.toordinal(), 75 | remain, 76 | ), 77 | ) 78 | -------------------------------------------------------------------------------- /yui/apps/compute/calc/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import NoReturn 2 | 3 | 4 | class BadSyntax(Exception): 5 | pass 6 | 7 | 8 | class RuntimeSyntaxError(SyntaxError): 9 | pass 10 | 11 | 12 | class RuntimeTypeError(TypeError): 13 | pass 14 | 15 | 16 | class UnavailableSyntaxError(RuntimeSyntaxError): 17 | pass 18 | 19 | 20 | class AsyncComprehensionError(RuntimeSyntaxError): 21 | pass 22 | 23 | 24 | class NotCallableError(RuntimeTypeError): 25 | pass 26 | 27 | 28 | class NotIterableError(RuntimeTypeError): 29 | pass 30 | 31 | 32 | class NotSubscriptableError(RuntimeTypeError): 33 | pass 34 | 35 | 36 | class CallableKeywordsError(RuntimeTypeError): 37 | pass 38 | 39 | 40 | class UnavailableTypeError(RuntimeTypeError): 41 | pass 42 | 43 | 44 | def error_maker( 45 | exc_cls: type[RuntimeSyntaxError] | type[RuntimeTypeError], 46 | *args, 47 | ) -> NoReturn: 48 | if exc_cls is UnavailableSyntaxError: 49 | x = args[0] 50 | error = f"Evaluation of {type(x).__name__!r} node is unavailable." 51 | raise UnavailableSyntaxError(error) 52 | 53 | if exc_cls is AsyncComprehensionError: 54 | x = args[0] 55 | error = f"Async syntax with {type(x).__name__!r} node is unavailable." 56 | raise AsyncComprehensionError(error) 57 | 58 | if exc_cls is NotCallableError: 59 | x = args[0] 60 | error = f"{type(x).__name__!r} object is not callable" 61 | raise NotCallableError(error) 62 | 63 | if exc_cls is NotIterableError: 64 | x = args[0] 65 | error = f"{type(x).__name__!r} object is not iterable" 66 | raise NotIterableError(error) 67 | 68 | if exc_cls is NotSubscriptableError: 69 | x = args[0] 70 | error = f"{type(x).__name__!r} object is not subscriptable" 71 | raise NotSubscriptableError(error) 72 | 73 | if exc_cls is CallableKeywordsError: 74 | error = "keywords must be strings" 75 | raise CallableKeywordsError(error) 76 | 77 | if exc_cls is UnavailableTypeError: 78 | x = args[0] 79 | error = f"{type(x).__name__!r} type is unavailable" 80 | raise UnavailableTypeError(error) 81 | 82 | error = "Unknown exception" # pragma: no cover 83 | raise TypeError(error) # pragma: no cover 84 | -------------------------------------------------------------------------------- /tests/apps/weather/geo_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from yarl import URL 3 | 4 | from yui.apps.weather.exceptions import WeatherResponseError 5 | from yui.apps.weather.geo import get_geometric_info_by_address 6 | 7 | 8 | @pytest.mark.parametrize( 9 | ("keyword", "expected_full_address", "expected_lat", "expected_lng"), 10 | [ 11 | ("부천", "경기도 부천시", 37.5038683, 126.7874615), 12 | ("서울", "서울특별시", 37.550263, 126.9970831), 13 | # 한국이 아니면 국가명이 붙는다. 14 | ("카와고에", "일본 사이타마현 가와고에시", 35.9251335, 139.4858042), 15 | ], 16 | ) 17 | @pytest.mark.anyio 18 | async def test_get_geometric_info_by_address( 19 | google_api_key, 20 | keyword, 21 | expected_full_address, 22 | expected_lat, 23 | expected_lng, 24 | ): 25 | full_address, lat, lng = await get_geometric_info_by_address( 26 | keyword, 27 | google_api_key, 28 | ) 29 | 30 | assert full_address == expected_full_address 31 | assert (lat, lng) == pytest.approx((expected_lat, expected_lng), abs=1e-1) 32 | 33 | 34 | @pytest.mark.anyio 35 | async def test_get_weather_wrong_geometric_info( 36 | response_mock, 37 | unavailable_address, 38 | ): 39 | key = "XXX" 40 | response_mock.get( 41 | URL("https://maps.googleapis.com/maps/api/geocode/json").with_query( 42 | region="kr", 43 | address=unavailable_address, 44 | key=key, 45 | ), 46 | payload={ 47 | "results": [], 48 | }, 49 | ) 50 | with pytest.raises(IndexError): 51 | await get_geometric_info_by_address( 52 | unavailable_address, 53 | key, 54 | ) 55 | 56 | 57 | @pytest.mark.anyio 58 | async def test_get_weather_google_427( 59 | response_mock, 60 | unavailable_address, 61 | ): 62 | key = "XXX" 63 | response_mock.get( 64 | URL("https://maps.googleapis.com/maps/api/geocode/json").with_query( 65 | region="kr", 66 | address=unavailable_address, 67 | key=key, 68 | ), 69 | payload={ 70 | "results": [], 71 | }, 72 | status=427, 73 | ) 74 | with pytest.raises(WeatherResponseError): 75 | await get_geometric_info_by_address( 76 | unavailable_address, 77 | key, 78 | ) 79 | -------------------------------------------------------------------------------- /tests/apps/owner/say_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.owner.say import say 4 | from yui.types.slack.response import APIResponse 5 | 6 | 7 | @pytest.mark.anyio 8 | async def test_say_command(bot, owner_id, user_id): 9 | test = bot.create_channel("C2", "test") 10 | 11 | @bot.response("conversations.open") 12 | def callback(data): 13 | return APIResponse( 14 | body={ 15 | "ok": True, 16 | "channel": { 17 | "id": data["users"].split(",")[0].replace("U", "D"), 18 | }, 19 | }, 20 | status=200, 21 | headers={}, 22 | ) 23 | 24 | text = "안녕하세요! 하고 유이인 척 하기" 25 | 26 | event = bot.create_message(user_id=owner_id) 27 | 28 | await say(bot, event, None, None, text) 29 | 30 | said = bot.call_queue.pop(0) 31 | assert said.method == "chat.postMessage" 32 | assert said.data["channel"] == event.channel 33 | assert said.data["text"] == text 34 | 35 | await say(bot, event, test.id, None, text) 36 | 37 | said = bot.call_queue.pop(0) 38 | assert said.method == "chat.postMessage" 39 | assert said.data["channel"] == test.id 40 | assert said.data["text"] == text 41 | 42 | await say(bot, event, None, user_id, text) 43 | 44 | conversations_open = bot.call_queue.pop(0) 45 | assert conversations_open.method == "conversations.open" 46 | assert conversations_open.data["users"] == user_id 47 | said = bot.call_queue.pop(0) 48 | assert said.method == "chat.postMessage" 49 | assert said.data["channel"] == user_id.replace("U", "D") 50 | assert said.data["text"] == text 51 | 52 | await say(bot, event, test.id, user_id, text) 53 | 54 | said = bot.call_queue.pop(0) 55 | assert said.method == "chat.postMessage" 56 | assert said.data["channel"] == event.channel 57 | assert ( 58 | said.data["text"] 59 | == "`--channel` 옵션과 `--user` 옵션은 동시에 사용할 수 없어요!" 60 | ) 61 | 62 | event = bot.create_message() 63 | 64 | await say(bot, event, None, None, "죽어라!") 65 | 66 | said = bot.call_queue.pop(0) 67 | assert said.method == "chat.postMessage" 68 | assert said.data["channel"] == event.channel 69 | assert ( 70 | said.data["text"] 71 | == f"<@{event.user}> 이 명령어는 아빠만 사용할 수 있어요!" 72 | ) 73 | -------------------------------------------------------------------------------- /yui/migrations/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from alembic import context 4 | from sqlalchemy import engine_from_config 5 | from sqlalchemy import pool 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | fileConfig(str(config.config_file_name)) 14 | 15 | # add your model's MetaData object here 16 | # for 'autogenerate' support 17 | # from myapp import mymodel 18 | # target_metadata = mymodel.Base.metadata 19 | target_metadata = config.attributes["Base"].metadata 20 | 21 | # other values from the config, defined by the needs of env.py, 22 | # can be acquired: 23 | # my_important_option = config.get_main_option("my_important_option") 24 | # ... etc. 25 | 26 | 27 | def run_migrations_offline(): 28 | """Run migrations in 'offline' mode. 29 | 30 | This configures the context with just a URL 31 | and not an Engine, though an Engine is acceptable 32 | here as well. By skipping the Engine creation 33 | we don't even need a DBAPI to be available. 34 | 35 | Calls to context.execute() here emit the given string to the 36 | script output. 37 | 38 | """ 39 | url = config.get_main_option("sqlalchemy.url") 40 | context.configure( 41 | url=url, 42 | target_metadata=target_metadata, 43 | literal_binds=True, 44 | ) 45 | 46 | with context.begin_transaction(): 47 | context.run_migrations() 48 | 49 | 50 | def run_migrations_online(): 51 | """Run migrations in 'online' mode. 52 | 53 | In this scenario we need to create an Engine 54 | and associate a connection with the context. 55 | 56 | """ 57 | connectable = engine_from_config( 58 | config.get_section(config.config_ini_section) or {}, 59 | prefix="sqlalchemy.", 60 | poolclass=pool.NullPool, 61 | ) 62 | 63 | with connectable.connect() as connection: 64 | context.configure( 65 | connection=connection, 66 | target_metadata=target_metadata, 67 | ) 68 | 69 | with context.begin_transaction(): 70 | context.run_migrations() 71 | 72 | 73 | if context.is_offline_mode(): 74 | run_migrations_offline() 75 | else: 76 | run_migrations_online() 77 | -------------------------------------------------------------------------------- /tests/types/slack/attachment_test.py: -------------------------------------------------------------------------------- 1 | from yui.types.slack.attachment import Attachment 2 | from yui.types.slack.attachment import Field 3 | 4 | 5 | def test_field_class(): 6 | title = "Test title for pytest" 7 | value = "123" 8 | field = Field(title=title, value=value, short=True) 9 | 10 | assert field.title == title 11 | assert field.value == value 12 | assert field.short 13 | 14 | 15 | def test_attachment_class(): 16 | fallback = "fallback" 17 | color = "black" 18 | pretext = "pretext" 19 | author_name = "item4" 20 | author_link = "https://item4.github.io/" 21 | author_icon = "https://item4.github.io/static/images/item4.png" 22 | title = "title" 23 | text = "text" 24 | fields = [ 25 | Field("field1", "1", short=False), 26 | Field("field2", "2", short=True), 27 | ] 28 | image_url = ( 29 | "https://item4.github.io/static/images/favicon/apple-icon-60x60.png" 30 | ) 31 | thumb_url = ( 32 | "https://item4.github.io/static/images/favicon/apple-icon-57x57.png" 33 | ) 34 | footer = "footer" 35 | footer_icon = ( 36 | "https://item4.github.io/static/images/favicon/apple-icon-72x72.png" 37 | ) 38 | ts = 123456 39 | attach = Attachment( 40 | fallback=fallback, 41 | color=color, 42 | pretext=pretext, 43 | author_name=author_name, 44 | author_link=author_link, 45 | author_icon=author_icon, 46 | title=title, 47 | text=text, 48 | fields=fields, 49 | image_url=image_url, 50 | thumb_url=thumb_url, 51 | footer=footer, 52 | footer_icon=footer_icon, 53 | ts=ts, 54 | ) 55 | 56 | assert attach.fallback == fallback 57 | assert attach.color == color 58 | assert attach.pretext == pretext 59 | assert attach.author_name == author_name 60 | assert attach.author_link == author_link 61 | assert attach.author_icon == author_icon 62 | assert attach.title == title 63 | assert attach.text == text 64 | assert len(attach.fields) == 2 65 | assert attach.fields[0].title == "field1" 66 | assert attach.fields[1].title == "field2" 67 | assert attach.image_url == image_url 68 | assert attach.thumb_url == thumb_url 69 | assert attach.footer == footer 70 | assert attach.footer_icon == footer_icon 71 | assert attach.ts == ts 72 | assert attach.actions is None 73 | -------------------------------------------------------------------------------- /yui/utils/report.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import re 5 | import sys 6 | import traceback 7 | from typing import TYPE_CHECKING 8 | 9 | from .format import bold 10 | from .format import code 11 | from .format import preformatted 12 | 13 | if TYPE_CHECKING: 14 | from ..bot import APICallError 15 | from ..bot import Bot 16 | from ..event import Event 17 | 18 | LIMIT = 3500 19 | SITE_PACKAGES = re.compile(r'(?:\s*File ")?/.+?/site-packages/') 20 | BUILTIN_PACKAGES = re.compile(r'(?:\s*File ")?/.+?/lib/python[^/]+?/') 21 | IN_YUI = re.compile(r'(?:\s*File ")?/.+?/yui/yui/') 22 | START_SPACE = re.compile(r" {4,}") 23 | 24 | 25 | def get_simple_tb_text(tb: list[str]) -> list[str]: 26 | result: list[str] = [] 27 | for row in tb: 28 | line = SITE_PACKAGES.sub('File "site-packages/', row) 29 | line = BUILTIN_PACKAGES.sub('File "python/', line) 30 | line = IN_YUI.sub('File "proj/yui/', line) 31 | line = START_SPACE.sub(" ", line) 32 | result.append(line) 33 | 34 | return result 35 | 36 | 37 | async def report( 38 | bot: Bot, 39 | *, 40 | event: Event | None = None, 41 | exception: APICallError | None = None, 42 | ): 43 | tb_lines = get_simple_tb_text(traceback.format_exception(*sys.exc_info())) 44 | messages: list[str] = [] 45 | message = "" 46 | if event: 47 | message += f"""\ 48 | {bold('Event')} 49 | {preformatted(str(event))} 50 | """ 51 | if exception: 52 | message += f"""\ 53 | {bold('Method')}: {code(exception.method)} 54 | {bold('Data')} 55 | {preformatted(json.dumps(exception.data, ensure_ascii=False, indent=2))} 56 | {bold('Headers')} 57 | {preformatted(json.dumps(exception.headers, ensure_ascii=False, indent=2))} 58 | """ 59 | message += bold("Traceback") 60 | message += "\n" 61 | length = len(message) + 6 62 | 63 | block = "" 64 | for line in tb_lines: 65 | if length + len(block) >= LIMIT: 66 | message += preformatted(block) 67 | messages.append(message) 68 | message = "" 69 | block = "" 70 | length = 6 71 | block += line 72 | length += len(line) 73 | 74 | resp = await bot.api.conversations.open( 75 | users=[bot.config.USERS["owner"]], 76 | ) 77 | 78 | for message in messages: 79 | await bot.say( 80 | resp.body["channel"]["id"], 81 | message, 82 | length_limit=None, 83 | ) 84 | -------------------------------------------------------------------------------- /tests/box/box_test.py: -------------------------------------------------------------------------------- 1 | from yui.box import Box 2 | from yui.box.apps.basic import App 3 | from yui.event import Hello 4 | 5 | 6 | def test_box_class(): 7 | box = Box() 8 | 9 | assert not box.apps 10 | assert not box.cron_tasks 11 | 12 | @box.command("test1") 13 | async def test1(bot, event): 14 | """ 15 | TEST SHORT HELP 16 | 17 | LONG CAT IS LONG 18 | 19 | """ 20 | 21 | h1 = box.apps.pop() 22 | assert isinstance(h1, App) 23 | assert h1.is_command 24 | assert h1.use_shlex 25 | assert h1.handler == test1 26 | assert h1.short_help == "TEST SHORT HELP" 27 | assert h1.help == "LONG CAT IS LONG" 28 | assert not box.cron_tasks 29 | 30 | @box.command("test2", ["t2"], use_shlex=False) 31 | async def test2(): 32 | """Short only""" 33 | 34 | h2 = box.apps.pop() 35 | assert isinstance(h2, App) 36 | assert h2.is_command 37 | assert not h2.use_shlex 38 | assert h2.handler == test2 39 | assert h2.short_help == "Short only" 40 | assert h2.help is None 41 | assert not box.cron_tasks 42 | 43 | @box.on(Hello) 44 | async def test3(): 45 | pass 46 | 47 | h3 = box.apps.pop() 48 | assert isinstance(h3, App) 49 | assert not h3.is_command 50 | assert not h3.use_shlex 51 | assert h3.handler == test3 52 | assert not box.cron_tasks 53 | 54 | @box.cron("*/3 * * * *") 55 | async def test_cron(): 56 | pass 57 | 58 | assert box.cron_tasks[0].spec == "*/3 * * * *" 59 | assert box.cron_tasks[0].handler == test_cron 60 | 61 | @box.on("message") 62 | async def test4(): 63 | pass 64 | 65 | h4 = box.apps.pop() 66 | assert isinstance(h4, App) 67 | assert not h4.is_command 68 | assert not h4.use_shlex 69 | assert h4.handler == test4 70 | 71 | box.assert_config_required("OWNER_TOKEN", str) 72 | assert box.config_required["OWNER_TOKEN"] is str 73 | 74 | box.assert_channel_required("game") 75 | assert box.channel_required == {"game"} 76 | 77 | box.assert_channels_required("test") 78 | assert box.channels_required == {"test"} 79 | 80 | box.assert_user_required("admin") 81 | assert box.user_required == {"admin"} 82 | 83 | box.assert_users_required("player") 84 | assert box.users_required == {"player"} 85 | 86 | class Test(App): 87 | pass 88 | 89 | testapp = Test(handler=test4, type="message", subtype=None) 90 | box.register(testapp) 91 | assert box.apps.pop() == testapp 92 | -------------------------------------------------------------------------------- /tests/box/apps/basic_test.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from yui.box import Box 4 | from yui.box.apps.basic import App 5 | from yui.command.decorators import argument 6 | from yui.command.decorators import option 7 | from yui.event import Message 8 | from yui.event import MessageMessage 9 | from yui.types.base import Ts 10 | 11 | 12 | def test_basic_app(channel_id, user_id): 13 | box = Box() 14 | ts = Ts("123.456") 15 | event_ts = Ts("456.789") 16 | message_ts = Ts("789.012") 17 | 18 | @box.command("test", aliases=["tttt"]) 19 | @option("--foo", "-f") 20 | @option("--bar") 21 | @argument("baz") 22 | @argument("kw", nargs=-1, concat=True) 23 | async def test(bot, event: Message, foo: int, bar: str, baz: str, kw: str): 24 | """ 25 | TEST TITLE 26 | 27 | LONG 28 | CAT 29 | IS 30 | LONG 31 | 32 | """ 33 | 34 | app = box.apps.pop() 35 | assert isinstance(app, App) 36 | assert app.name == "test" 37 | assert app.aliases == ["tttt"] 38 | assert app.names == ["tttt", "test"] 39 | assert app.handler == test 40 | assert app.is_command 41 | assert app.use_shlex 42 | assert app.has_short_help 43 | assert app.has_full_help 44 | assert app.short_help == "TEST TITLE" 45 | assert ( 46 | app.help 47 | == """LONG 48 | CAT 49 | IS 50 | LONG""" 51 | ) 52 | assert app.get_short_help("=") == "`=test`: TEST TITLE" 53 | assert ( 54 | app.get_full_help("=") 55 | == "*=test*\n(Aliases: `=tttt`)\nTEST TITLE\n\nLONG\nCAT\nIS\nLONG" 56 | ) 57 | event = Message( 58 | channel=channel_id, 59 | ts=ts, 60 | event_ts=event_ts, 61 | ) 62 | assert not app.get_event_text(event) 63 | event = Message( 64 | channel=channel_id, 65 | ts=ts, 66 | event_ts=event_ts, 67 | user=user_id, 68 | text="=test --foo 1 --bar 2 3 4 5", 69 | ) 70 | assert app.get_event_text(event) == "=test --foo 1 --bar 2 3 4 5" 71 | event = Message( 72 | channel=channel_id, 73 | ts=ts, 74 | event_ts=event_ts, 75 | user=user_id, 76 | message=MessageMessage( 77 | user=user_id, 78 | ts=message_ts, 79 | text="=test --foo 1 --bar 2 3 4 5", 80 | ), 81 | ) 82 | assert app.get_event_text(event) == "=test --foo 1 --bar 2 3 4 5" 83 | assert app.split_call_and_args("=test 1 2 3 4 5") == ("=test", "1 2 3 4 5") 84 | assert app.split_call_and_args("=test") == ("=test", "") 85 | -------------------------------------------------------------------------------- /yui/apps/search/book.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import tossicat 3 | 4 | from ...box import box 5 | from ...command import argument 6 | from ...event import Message 7 | from ...types.slack.attachment import Attachment 8 | from ...utils import json 9 | from ...utils.html import strip_tags 10 | 11 | box.assert_config_required("NAVER_CLIENT_ID", str) 12 | box.assert_config_required("NAVER_CLIENT_SECRET", str) 13 | 14 | 15 | @box.command("책", ["book"]) 16 | @argument("keyword", nargs=-1, concat=True) 17 | async def book(bot, event: Message, keyword: str): 18 | """ 19 | 책 검색 20 | 21 | 책 제목으로 네이버 책 DB에서 검색합니다. 22 | 23 | `{PREFIX}책 소드 아트 온라인` (`소드 아트 온라인`으로 책 검색) 24 | 25 | """ 26 | 27 | url = "https://openapi.naver.com/v1/search/book.json" 28 | params = { 29 | "query": keyword, 30 | } 31 | headers = { 32 | "X-Naver-Client-Id": bot.config.NAVER_CLIENT_ID, 33 | "X-Naver-Client-Secret": bot.config.NAVER_CLIENT_SECRET, 34 | } 35 | 36 | async with ( 37 | aiohttp.ClientSession() as session, 38 | session.get( 39 | url, 40 | params=params, 41 | headers=headers, 42 | ) as resp, 43 | ): 44 | data = await resp.json(loads=json.loads) 45 | 46 | attachments: list[Attachment] = [] 47 | 48 | count = min(5, len(data["items"])) 49 | 50 | for i in range(count): 51 | book = data["items"][i] 52 | title = strip_tags(book["title"]) 53 | text = "저자: {} / 출판사: {}".format( 54 | strip_tags(book["author"]).replace("^", ", "), 55 | strip_tags(book["publisher"]), 56 | ) 57 | attachments.append( 58 | Attachment( 59 | fallback="{} - {}".format(title, book["link"]), 60 | title=title, 61 | title_link=book["link"], 62 | thumb_url=book["image"], 63 | text=text, 64 | ), 65 | ) 66 | 67 | if attachments: 68 | await bot.api.chat.postMessage( 69 | channel=event.channel, 70 | text=( 71 | "키워드 *{}* {} 네이버 책 DB 검색 결과," 72 | " 총 {:,}개의 결과가 나왔어요." 73 | " 그 중 상위 {}개를 보여드릴게요!" 74 | ).format( 75 | keyword, 76 | tossicat.transform(keyword, "(으)로")[1], 77 | data["total"], 78 | count, 79 | ), 80 | attachments=attachments, 81 | thread_ts=event.ts, 82 | ) 83 | else: 84 | await bot.say(event.channel, "검색 결과가 없어요!") 85 | -------------------------------------------------------------------------------- /yui/types/slack/action.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | from attrs import define 4 | from attrs import field 5 | 6 | from ...utils.attrs import field_transformer 7 | 8 | 9 | def call_or_none(c): 10 | def converter(value): 11 | if value is None: 12 | return None 13 | return c(value) 14 | 15 | return converter 16 | 17 | 18 | @define(kw_only=True, field_transformer=field_transformer) 19 | class Confirmation: 20 | """Confirmation of Action""" 21 | 22 | text: str 23 | dismiss_text: str | None = None 24 | ok_text: str | None = None 25 | title: str | None = None 26 | 27 | 28 | @define(kw_only=True, field_transformer=field_transformer) 29 | class OptionField: 30 | """Optional Option Field on Action""" 31 | 32 | text: str 33 | value: str 34 | description: str | None = None 35 | 36 | 37 | @define(kw_only=True, field_transformer=field_transformer) 38 | class OptionFieldGroup: 39 | """Optional Option Group on Action""" 40 | 41 | text: str 42 | options: list[OptionField] 43 | 44 | 45 | class ActionType(enum.Enum): 46 | button = "button" 47 | select = "select" 48 | 49 | 50 | class ActionStyle(enum.Enum): 51 | default = "default" 52 | primary = "primary" 53 | danger = "danger" 54 | 55 | 56 | class ActionDataSource(enum.Enum): 57 | default = "default" 58 | static = "static" 59 | users = "users" 60 | channels = "channels" 61 | conversations = "conversations" 62 | external = "external" 63 | 64 | 65 | @define(kw_only=True, field_transformer=field_transformer) 66 | class Action: 67 | """Action of Attachment""" 68 | 69 | name: str 70 | text: str 71 | type: str | ActionType = field(converter=ActionType) 72 | style: str | ActionStyle | None = field( 73 | converter=call_or_none(ActionStyle), # type: ignore[misc] 74 | default=None, 75 | ) 76 | data_source: str | ActionDataSource | None = field( 77 | converter=call_or_none(ActionDataSource), # type: ignore[misc] 78 | default=None, 79 | ) 80 | id: str | None = None 81 | confirm: Confirmation | None = None 82 | min_query_length: int | None = None 83 | options: list[OptionField] | None = None 84 | option_groups: list[OptionFieldGroup] | None = None 85 | selected_options: list[OptionField] | None = None 86 | value: str | None = None 87 | url: str | None = None 88 | 89 | def __attrs_post_init__(self): 90 | if self.data_source != ActionDataSource.external: 91 | self.min_query_length = None 92 | 93 | if self.options is not None and self.option_groups is not None: 94 | self.options = None 95 | -------------------------------------------------------------------------------- /yui/utils/fuzz.py: -------------------------------------------------------------------------------- 1 | import unicodedata 2 | 3 | from rapidfuzz import fuzz 4 | 5 | KOREAN_START = ord("가") 6 | KOREAN_END = ord("힣") 7 | KOREAN_ALPHABETS_FIRST_MAP: dict[str, str] = { 8 | "ㄱ": chr(4352 + 0), 9 | "ㄲ": chr(4352 + 1), 10 | "ㄴ": chr(4352 + 2), 11 | "ㄷ": chr(4352 + 3), 12 | "ㄸ": chr(4352 + 4), 13 | "ㄹ": chr(4352 + 5), 14 | "ㅁ": chr(4352 + 6), 15 | "ㅂ": chr(4352 + 7), 16 | "ㅃ": chr(4352 + 8), 17 | "ㅅ": chr(4352 + 9), 18 | "ㅆ": chr(4352 + 10), 19 | "ㅇ": chr(4352 + 11), 20 | "ㅈ": chr(4352 + 12), 21 | "ㅉ": chr(4352 + 13), 22 | "ㅊ": chr(4352 + 14), 23 | "ㅋ": chr(4352 + 15), 24 | "ㅌ": chr(4352 + 16), 25 | "ㅍ": chr(4352 + 17), 26 | "ㅎ": chr(4352 + 18), 27 | } 28 | 29 | KOREAN_ALPHABETS_MIDDLE_MAP: dict[str, str] = { 30 | chr(x + 12623): chr(x + 4449) for x in range(21 + 1) 31 | } 32 | 33 | 34 | def normalize_korean_nfc_to_nfd(value: str) -> str: 35 | """Normalize Korean string to NFD.""" 36 | 37 | for from_, to_ in KOREAN_ALPHABETS_FIRST_MAP.items(): 38 | value = value.replace(from_, to_) 39 | 40 | for from_, to_ in KOREAN_ALPHABETS_MIDDLE_MAP.items(): 41 | value = value.replace(from_, to_) 42 | 43 | return "".join( 44 | ( 45 | unicodedata.normalize("NFD", x) 46 | if KOREAN_START <= ord(x) <= KOREAN_END 47 | else x 48 | ) 49 | for x in list(value) 50 | ) 51 | 52 | 53 | def ratio(str1: str, str2: str) -> int: 54 | """Get fuzzy ratio with korean text""" 55 | 56 | return int( 57 | fuzz.ratio( 58 | normalize_korean_nfc_to_nfd(str1), 59 | normalize_korean_nfc_to_nfd(str2), 60 | ), 61 | ) 62 | 63 | 64 | def partial_ratio(str1: str, str2: str) -> int: 65 | """Get partial fuzzy ratio with korean text""" 66 | 67 | return int( 68 | fuzz.partial_ratio( 69 | normalize_korean_nfc_to_nfd(str1), 70 | normalize_korean_nfc_to_nfd(str2), 71 | ), 72 | ) 73 | 74 | 75 | def token_sort_ratio(str1: str, str2: str) -> int: 76 | """Get token sorted fuzzy ratio with korean text""" 77 | 78 | return int( 79 | fuzz.token_sort_ratio( 80 | normalize_korean_nfc_to_nfd(str1), 81 | normalize_korean_nfc_to_nfd(str2), 82 | ), 83 | ) 84 | 85 | 86 | def match(s1: str, s2: str) -> int: 87 | """Get custom ratio for yui functions""" 88 | if s1 == s2: 89 | return 100 90 | 91 | pr = partial_ratio(s1, s2) 92 | r = ratio(s1, s2) 93 | tsr = token_sort_ratio(s1, s2) 94 | maximum_ratio = max(pr, r, tsr) 95 | return min(100, int((pr + r + tsr + maximum_ratio * 2) / 5)) 96 | -------------------------------------------------------------------------------- /tests/types/slack/action_test.py: -------------------------------------------------------------------------------- 1 | from yui.types.slack.action import Action 2 | from yui.types.slack.action import ActionDataSource 3 | from yui.types.slack.action import ActionStyle 4 | from yui.types.slack.action import ActionType 5 | from yui.types.slack.action import Confirmation 6 | from yui.types.slack.action import OptionField 7 | from yui.types.slack.action import OptionFieldGroup 8 | 9 | 10 | def test_action_class(): 11 | id = None 12 | confirm = Confirmation( 13 | dismiss_text="dismiss", 14 | ok_text="ok", 15 | text="some text", 16 | title="some title", 17 | ) 18 | data_source = "default" 19 | min_query_length = 100 20 | name = "Test Button" 21 | options = [ 22 | OptionField( 23 | text="test", 24 | value="test", 25 | ), 26 | ] 27 | selected_options = [ 28 | OptionField( 29 | text="text", 30 | value="value", 31 | description="some description", 32 | ), 33 | ] 34 | style = "danger" 35 | text = "Test Text" 36 | type = "button" 37 | value = "" 38 | url = "https://item4.github.io" 39 | 40 | action = Action( 41 | id=id, 42 | confirm=confirm, 43 | data_source=data_source, 44 | min_query_length=min_query_length, 45 | name=name, 46 | options=options, 47 | selected_options=selected_options, 48 | style=style, 49 | text=text, 50 | type=type, 51 | value=value, 52 | url=url, 53 | ) 54 | 55 | assert action.id == id 56 | assert action.confirm == confirm 57 | assert action.data_source == ActionDataSource(data_source) 58 | assert action.min_query_length is None 59 | assert action.name == name 60 | assert action.options == options 61 | assert action.selected_options == selected_options 62 | assert action.style == ActionStyle(style) 63 | assert action.text == text 64 | assert action.type == ActionType(type) 65 | assert action.value == value 66 | assert action.url == url 67 | 68 | action = Action( 69 | name=name, 70 | text=text, 71 | type=type, 72 | options=[ 73 | OptionField( 74 | text="test", 75 | value="test", 76 | ), 77 | ], 78 | option_groups=[ 79 | OptionFieldGroup( 80 | text="text", 81 | options=[OptionField(text="test2", value="test2")], 82 | ), 83 | ], 84 | ) 85 | 86 | assert action.options is None 87 | assert action.option_groups == [ 88 | OptionFieldGroup( 89 | text="text", 90 | options=[OptionField(text="test2", value="test2")], 91 | ), 92 | ] 93 | -------------------------------------------------------------------------------- /tests/apps/weather/commands_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.weather.commands import weather 4 | 5 | 6 | @pytest.fixture(name="bot") 7 | async def bot_with_cache(bot, cache): 8 | async with bot.use_cache(cache): 9 | yield bot 10 | 11 | 12 | @pytest.mark.anyio 13 | async def test_weather_command(bot, address): 14 | event = bot.create_message() 15 | 16 | await weather(bot, event, address) 17 | 18 | weather_said = bot.call_queue.pop(0) 19 | 20 | assert weather_said.method == "chat.postMessage" 21 | assert weather_said.data["channel"] == event.channel 22 | 23 | if weather_said.data["text"] == "날씨 API 접근 중 에러가 발생했어요!": 24 | pytest.skip("Can not run test via AWS Weather API") 25 | 26 | assert weather_said.data["username"] == "부천 날씨" 27 | 28 | assert weather_said.data["text"] != "해당 주소는 찾을 수 없어요!" 29 | assert weather_said.data["text"] != "날씨 API 접근 중 에러가 발생했어요!" 30 | assert weather_said.data["text"] != "해당 이름의 관측소는 존재하지 않아요!" 31 | 32 | 33 | @pytest.mark.anyio 34 | async def test_weather_command_too_short(bot): 35 | event = bot.create_message() 36 | 37 | await weather(bot, event, "a") 38 | 39 | weather_said = bot.call_queue.pop(0) 40 | 41 | assert weather_said.method == "chat.postMessage" 42 | assert weather_said.data["channel"] == event.channel 43 | 44 | if weather_said.data["text"] == "날씨 API 접근 중 에러가 발생했어요!": 45 | pytest.skip("Can not run test via AWS Weather API") 46 | 47 | assert ( 48 | weather_said.data["text"] 49 | == "검색어가 너무 짧아요! 2글자 이상의 검색어를 사용해주세요!" 50 | ) 51 | 52 | 53 | @pytest.mark.anyio 54 | async def test_weather_command_wrong_address( 55 | bot, 56 | unavailable_address, 57 | ): 58 | event = bot.create_message() 59 | 60 | await weather(bot, event, unavailable_address) 61 | 62 | weather_said = bot.call_queue.pop(0) 63 | 64 | assert weather_said.method == "chat.postMessage" 65 | assert weather_said.data["channel"] == event.channel 66 | 67 | if weather_said.data["text"] == "날씨 API 접근 중 에러가 발생했어요!": 68 | pytest.skip("Can not run test via AWS Weather API") 69 | 70 | assert weather_said.data["text"] == "해당 이름의 관측소는 존재하지 않아요!" 71 | 72 | 73 | @pytest.mark.anyio 74 | async def test_weather_command_server_error( 75 | response_mock, 76 | bot, 77 | address, 78 | ): 79 | response_mock.get( 80 | "https://item4.net/api/weather/", 81 | body="[}", 82 | ) 83 | event = bot.create_message() 84 | 85 | await weather(bot, event, address) 86 | 87 | weather_said = bot.call_queue.pop(0) 88 | 89 | assert weather_said.method == "chat.postMessage" 90 | assert weather_said.data["channel"] == event.channel 91 | 92 | assert ( 93 | weather_said.data["text"] 94 | == "날씨 조회중 에러가 발생했어요! (JSON 파싱 실패)" 95 | ) 96 | -------------------------------------------------------------------------------- /tests/apps/compute/calc/evaluator/op_test.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import pytest 4 | 5 | from yui.apps.compute.calc.types import Decimal as D 6 | 7 | 8 | def test_binop(e): 9 | assert e.run("1 + 2") == 1 + 2 10 | assert e.run("3 & 2") == 3 & 2 11 | assert e.run("1 | 2") == 1 | 2 12 | assert e.run("3 ^ 2") == 3 ^ 2 13 | assert e.run("3 / 2") == 3 / 2 14 | assert e.run("3 // 2") == 3 // 2 15 | assert e.run("3 << 2") == 3 << 2 16 | with pytest.raises(TypeError): 17 | e.run("2 @ 3") 18 | assert e.run("3 * 2") == 3 * 2 19 | assert e.run("33 % 4") == 33 % 4 20 | assert e.run("3 ** 2") == 3**2 21 | assert e.run("100 >> 2") == 100 >> 2 22 | assert e.run("3 - 1") == 3 - 1 23 | 24 | 25 | def test_boolop(e): 26 | assert e.run("True and False") is (True and False) # noqa: SIM223 27 | assert e.run("True or False") is (True or False) # noqa: SIM222 28 | 29 | 30 | def test_unaryop(e): 31 | assert e.run("~100") == ~100 32 | assert e.run("not 100") == (not 100) 33 | assert e.run("+100") == +100 34 | assert e.run("-100") == -100 35 | 36 | 37 | def test_binop_decimal(ed): 38 | error_format = ( 39 | "unsupported operand type(s) for {op}: 'Decimal' and 'Decimal'" 40 | ) 41 | with pytest.raises(TypeError, match=re.escape(error_format.format(op="&"))): 42 | ed.run("3 & 2") 43 | 44 | with pytest.raises(TypeError, match=re.escape(error_format.format(op="|"))): 45 | ed.run("1 | 2") 46 | 47 | with pytest.raises(TypeError, match=re.escape(error_format.format(op="^"))): 48 | assert ed.run("3 ^ 2") 49 | 50 | with pytest.raises( 51 | TypeError, 52 | match=re.escape(error_format.format(op="<<")), 53 | ): 54 | ed.run("3 << 2") 55 | 56 | with pytest.raises(TypeError, match=re.escape(error_format.format(op="@"))): 57 | ed.run("2 @ 3") 58 | 59 | with pytest.raises( 60 | TypeError, 61 | match=re.escape(error_format.format(op=">>")), 62 | ): 63 | ed.run("100 >> 2") 64 | 65 | assert ed.run("1 + 2") == D(1) + D(2) 66 | assert ed.run("3 / 2") == D(3) / D(2) 67 | assert ed.run("3 // 2") == D(3) // D(2) 68 | assert ed.run("3 * 2") == D(3) * D(2) 69 | assert ed.run("33 % 4") == D(33) % D(4) 70 | assert ed.run("3 ** 2") == D(3) ** D(2) 71 | assert ed.run("3 - 1") == D(3) - D(1) 72 | 73 | 74 | def test_boolop_decimal(ed): 75 | assert ed.run("True and False") is (True and False) # noqa: SIM223 76 | assert ed.run("True or False") is (True or False) # noqa: SIM222 77 | 78 | 79 | def test_unaryop_decimal(ed): 80 | with pytest.raises( 81 | TypeError, 82 | match="bad operand type for unary ~: 'Decimal'", 83 | ): 84 | ed.run("~100") 85 | assert ed.run("not 100") == (not D(100)) 86 | assert ed.run("+100") == +D(100) 87 | assert ed.run("-100") == -D(100) 88 | -------------------------------------------------------------------------------- /tests/apps/compute/calc/evaluator/primitive_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.compute.calc.exceptions import UnavailableTypeError 4 | from yui.apps.compute.calc.types import Decimal as D 5 | 6 | 7 | def test_bytes(e): 8 | assert e.run('b""') == b"" 9 | assert e.run('b"asdf"') == b"asdf" 10 | 11 | 12 | def test_bytes_decimal_mode(ed): 13 | assert ed.run('b""') == b"" 14 | assert ed.run('b"asdf"') == b"asdf" 15 | 16 | 17 | def test_complex(e): 18 | with pytest.raises( 19 | UnavailableTypeError, 20 | match="'complex' type is unavailable", 21 | ): 22 | e.run("1j") 23 | 24 | 25 | def test_complex_decimal_mode(ed): 26 | with pytest.raises( 27 | UnavailableTypeError, 28 | match="'complex' type is unavailable", 29 | ): 30 | ed.run("1j") 31 | 32 | 33 | def test_dict(e): 34 | assert e.run("{}") == {} 35 | assert e.run("{1: 111, 2: 222}") == {1: 111, 2: 222} 36 | 37 | 38 | def test_dict_decimal_mode(ed): 39 | assert ed.run("{}") == {} 40 | assert ed.run("{1: 111, 2: 222}") == {D(1): D(111), D(2): D(222)} 41 | 42 | 43 | def test_list(e): 44 | assert e.run("[]") == [] 45 | assert e.run("[1, 2, 3]") == [1, 2, 3] 46 | 47 | 48 | def test_list_decimal_mode(ed): 49 | assert ed.run("[]") == [] 50 | assert ed.run("[1, 2, 3]") == [D(1), D(2), D(3)] 51 | 52 | 53 | def test_nameconstant(e): 54 | assert e.run("True") is True 55 | assert e.run("False") is False 56 | assert e.run("None") is None 57 | assert e.run("...") is Ellipsis 58 | 59 | 60 | def test_nameconstant_decimal_mode(ed): 61 | assert ed.run("True") is True 62 | assert ed.run("False") is False 63 | assert ed.run("None") is None 64 | assert ed.run("...") is Ellipsis 65 | 66 | 67 | def test_num(e): 68 | assert e.run("123") == 123 69 | assert e.run("123.45") == 123.45 70 | 71 | 72 | def test_num_decimal_mode(ed): 73 | assert ed.run("123") == D(123) 74 | assert ed.run("123.45") == D("123.45") 75 | 76 | 77 | def test_set(e): 78 | assert e.run("{1, 1, 2, 3, 3}") == {1, 2, 3} 79 | 80 | 81 | def test_set_decimal_mode(ed): 82 | assert ed.run("{1, 1, 2, 3, 3}") == {D(1), D(2), D(3)} 83 | 84 | 85 | def test_str(e): 86 | empty = e.run('""') 87 | assert isinstance(empty, str) 88 | assert not empty 89 | assert e.run('"asdf"') == "asdf" 90 | 91 | 92 | def test_str_decimal_mode(ed): 93 | empty = ed.run('""') 94 | assert isinstance(empty, str) 95 | assert not empty 96 | assert ed.run('"asdf"') == "asdf" 97 | 98 | 99 | def test_tuple(e): 100 | assert e.run("()") == () 101 | assert e.run("(1, 1, 2, 3, 3)") == (1, 1, 2, 3, 3) 102 | 103 | 104 | def test_tuple_decimal_mode(ed): 105 | assert ed.run("()") == () 106 | assert ed.run("(1, 1, 2, 3, 3)") == (D(1), D(1), D(2), D(3), D(3)) 107 | -------------------------------------------------------------------------------- /tests/apps/date/utils_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.date.utils import APIDoesNotSupport 4 | from yui.apps.date.utils import get_holiday_names 5 | from yui.apps.date.utils import weekend_loading_box 6 | from yui.apps.date.utils import weekend_loading_percent 7 | from yui.utils.datetime import datetime 8 | 9 | 10 | @pytest.mark.anyio 11 | async def test_get_holiday_names(): 12 | jan_first = datetime(2018, 1, 1) 13 | jan_second = datetime(2018, 1, 2) 14 | armed_forces_day = datetime(2018, 10, 1) 15 | unsupported = datetime(2000, 1, 1) 16 | 17 | holidays = await get_holiday_names(jan_first) 18 | assert holidays == ["신정"] 19 | 20 | holidays = await get_holiday_names(jan_second) 21 | assert holidays == [] 22 | 23 | holidays = await get_holiday_names(armed_forces_day) 24 | assert holidays == [] 25 | 26 | with pytest.raises(APIDoesNotSupport): 27 | await get_holiday_names(unsupported) 28 | 29 | 30 | def test_weekend_loading_percent(): 31 | assert weekend_loading_percent(datetime(2020, 6, 1)) == 0.0 32 | assert weekend_loading_percent(datetime(2020, 6, 2)) == 20.0 33 | assert weekend_loading_percent(datetime(2020, 6, 3)) == 40.0 34 | assert weekend_loading_percent(datetime(2020, 6, 4)) == 60.0 35 | assert weekend_loading_percent(datetime(2020, 6, 5)) == 80.0 36 | assert weekend_loading_percent(datetime(2020, 6, 6)) == 100.0 37 | assert weekend_loading_percent(datetime(2020, 6, 7)) == 100.0 38 | 39 | 40 | def test_weekend_loading_box(): 41 | assert weekend_loading_box(0.0) == "[□□□□□□□□□□□□□□□□□□□□]" 42 | assert weekend_loading_box(5.0) == "[■□□□□□□□□□□□□□□□□□□□]" 43 | assert weekend_loading_box(10.0) == "[■■□□□□□□□□□□□□□□□□□□]" 44 | assert weekend_loading_box(15.0) == "[■■■□□□□□□□□□□□□□□□□□]" 45 | assert weekend_loading_box(20.0) == "[■■■■□□□□□□□□□□□□□□□□]" 46 | assert weekend_loading_box(25.0) == "[■■■■■□□□□□□□□□□□□□□□]" 47 | assert weekend_loading_box(30.0) == "[■■■■■■□□□□□□□□□□□□□□]" 48 | assert weekend_loading_box(35.0) == "[■■■■■■■□□□□□□□□□□□□□]" 49 | assert weekend_loading_box(40.0) == "[■■■■■■■■□□□□□□□□□□□□]" 50 | assert weekend_loading_box(45.0) == "[■■■■■■■■■□□□□□□□□□□□]" 51 | assert weekend_loading_box(50.0) == "[■■■■■■■■■■□□□□□□□□□□]" 52 | assert weekend_loading_box(55.0) == "[■■■■■■■■■■■□□□□□□□□□]" 53 | assert weekend_loading_box(60.0) == "[■■■■■■■■■■■■□□□□□□□□]" 54 | assert weekend_loading_box(65.0) == "[■■■■■■■■■■■■■□□□□□□□]" 55 | assert weekend_loading_box(70.0) == "[■■■■■■■■■■■■■■□□□□□□]" 56 | assert weekend_loading_box(75.0) == "[■■■■■■■■■■■■■■■□□□□□]" 57 | assert weekend_loading_box(80.0) == "[■■■■■■■■■■■■■■■■□□□□]" 58 | assert weekend_loading_box(85.0) == "[■■■■■■■■■■■■■■■■■□□□]" 59 | assert weekend_loading_box(90.0) == "[■■■■■■■■■■■■■■■■■■□□]" 60 | assert weekend_loading_box(95.0) == "[■■■■■■■■■■■■■■■■■■■□]" 61 | assert weekend_loading_box(100.0) == "[■■■■■■■■■■■■■■■■■■■■]" 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | .Python 3 | [Bb]in 4 | [Ii]nclude 5 | [Ll]ib 6 | [Ll]ib64 7 | [Ll]ocal 8 | [Ss]cripts 9 | pyvenv.cfg 10 | .venv 11 | pip-selfcheck.json 12 | .dropbox 13 | .dropbox.attr 14 | .dropbox.cache 15 | [._]*.s[a-v][a-z] 16 | [._]*.sw[a-p] 17 | [._]s[a-v][a-z] 18 | [._]sw[a-p] 19 | Session.vim 20 | .netrwhist 21 | *~ 22 | tags 23 | [._]*.un~ 24 | cmake-build-debug/ 25 | cmake-build-release/ 26 | .vscode/* 27 | !.vscode/settings.json 28 | !.vscode/tasks.json 29 | !.vscode/launch.json 30 | !.vscode/extensions.json 31 | *.7z 32 | *.jar 33 | *.rar 34 | *.zip 35 | *.gz 36 | *.tgz 37 | *.bzip 38 | *.bz2 39 | *.xz 40 | *.lzma 41 | *.cab 42 | *.iso 43 | *.tar 44 | *.dmg 45 | *.xpi 46 | *.gem 47 | *.egg 48 | *.deb 49 | *.rpm 50 | *.msi 51 | *.msm 52 | *.msp 53 | *.tmlanguage.cache 54 | *.tmPreferences.cache 55 | *.stTheme.cache 56 | *.sublime-workspace 57 | sftp-config.json 58 | Package Control.last-run 59 | Package Control.ca-list 60 | Package Control.ca-bundle 61 | Package Control.system-ca-bundle 62 | Package Control.cache/ 63 | Package Control.ca-certs/ 64 | Package Control.merged-ca-bundle 65 | Package Control.user-ca-bundle 66 | oscrypto-ca-bundle.crt 67 | bh_unicode_properties.cache 68 | GitHub.sublime-settings 69 | Thumbs.db 70 | ehthumbs.db 71 | ehthumbs_vista.db 72 | *.stackdump 73 | [Dd]esktop.ini 74 | $RECYCLE.BIN/ 75 | *.cab 76 | *.msi 77 | *.msix 78 | *.msm 79 | *.msp 80 | *.lnk 81 | __pycache__/ 82 | *.py[cod] 83 | *$py.class 84 | *.so 85 | .Python 86 | build/ 87 | develop-eggs/ 88 | dist/ 89 | downloads/ 90 | eggs/ 91 | .eggs/ 92 | lib/ 93 | lib64/ 94 | parts/ 95 | sdist/ 96 | var/ 97 | share/ 98 | wheels/ 99 | *.egg-info/ 100 | .installed.cfg 101 | *.egg 102 | MANIFEST 103 | *.manifest 104 | *.spec 105 | pip-log.txt 106 | pip-delete-this-directory.txt 107 | htmlcov/ 108 | .tox/ 109 | .coverage 110 | .coverage.* 111 | .cache 112 | nosetests.xml 113 | coverage.xml 114 | *.cover 115 | .hypothesis/ 116 | .pytest_cache/ 117 | *.mo 118 | *.pot 119 | *.log 120 | local_settings.py 121 | db.sqlite3 122 | instance/ 123 | .webassets-cache 124 | .scrapy 125 | docs/_build/ 126 | target/ 127 | .ipynb_checkpoints 128 | celerybeat-schedule 129 | *.sage.py 130 | .env 131 | .venv 132 | env/ 133 | venv/ 134 | ENV/ 135 | env.bak/ 136 | venv.bak/ 137 | .spyderproject 138 | .spyproject 139 | .ropeproject 140 | /site 141 | .mypy_cache/ 142 | .DS_Store 143 | .AppleDouble 144 | .LSOverride 145 | Icon 146 | ._* 147 | .DocumentRevisions-V100 148 | .fseventsd 149 | .Spotlight-V100 150 | .TemporaryItems 151 | .Trashes 152 | .VolumeIcon.icns 153 | .com.apple.timemachine.donotpresent 154 | .AppleDB 155 | .AppleDesktop 156 | Network Trash Folder 157 | Temporary Items 158 | .apdisk 159 | .ruff_cache 160 | 161 | # yui only 162 | *.config.toml 163 | !example.config.toml 164 | log 165 | .project 166 | docker 167 | !examples/**/* 168 | yui/apps/personal 169 | -------------------------------------------------------------------------------- /yui/apps/date/work.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | 3 | from aiohttp.client_exceptions import ClientError 4 | 5 | from ...box import box 6 | from ...types.slack.attachment import Attachment 7 | from ...utils.datetime import now 8 | from .utils import APIDoesNotSupport 9 | from .utils import get_holiday_names 10 | 11 | box.assert_channel_required("general") 12 | 13 | 14 | async def say_raccoon_man(bot, holiday: str): 15 | await bot.api.chat.postMessage( 16 | channel=bot.config.CHANNELS["general"], 17 | text=f"오늘은 {holiday}! 출근하라는 상사는 이 너굴맨이 처리했으니 안심하라구!", 18 | icon_url="https://i.imgur.com/dG6wXTX.jpg", 19 | username="너굴맨", 20 | ) 21 | 22 | 23 | async def say_happy_cat(bot, holiday: str, hour: int): 24 | await bot.api.chat.postMessage( 25 | channel=bot.config.CHANNELS["general"], 26 | text=f"{holiday} 만세! {hour}시인데 집사 퇴근 안 기다려도 되니까 좋다냥!", 27 | icon_url="https://i.imgur.com/fuC7jv5.png", 28 | username="집사가 집에 있어서 기분 좋은 고양이", 29 | ) 30 | 31 | 32 | async def say_start_monday(bot): 33 | await bot.api.chat.postMessage( 34 | channel=bot.config.CHANNELS["general"], 35 | attachments=[ 36 | Attachment( 37 | fallback="https://i.imgur.com/Gv9GJBK.jpg", 38 | image_url="https://i.imgur.com/Gv9GJBK.jpg", 39 | ), 40 | ], 41 | icon_url="https://i.imgur.com/yO4RWyZ.jpg", 42 | username="현실부정중인 직장인", 43 | ) 44 | 45 | 46 | async def say_start_work(bot): 47 | await bot.api.chat.postMessage( 48 | channel=bot.config.CHANNELS["general"], 49 | text="한국인들은 세계 누구보다 출근을 사랑하면서 왜 본심을 숨기는 걸까?", 50 | icon_url="https://i.imgur.com/EGIUpE1.jpg", 51 | username="노동자 핫산", 52 | ) 53 | 54 | 55 | async def say_knife(bot, hour: int): 56 | await bot.api.chat.postMessage( 57 | channel=bot.config.CHANNELS["general"], 58 | text=( 59 | f"{hour}시가 되었습니다. {hour + 3}시에 출근하신 분들은 칼같이" 60 | " 퇴근하시길 바랍니다." 61 | ), 62 | icon_url="https://i.imgur.com/9asRVeZ.png", 63 | username="칼퇴의 요정", 64 | ) 65 | 66 | 67 | @box.cron("0 9 * * 1-5") 68 | async def work_start(bot): 69 | today = now() 70 | with contextlib.suppress(APIDoesNotSupport, ClientError): 71 | holidays = await get_holiday_names(today) 72 | if holidays: 73 | await say_raccoon_man(bot, holidays[0]) 74 | return 75 | 76 | if today.isoweekday() == 1: 77 | await say_start_monday(bot) 78 | else: 79 | await say_start_work(bot) 80 | 81 | 82 | @box.cron("0 18,19 * * 1-5") 83 | async def work_end(bot): 84 | today = now() 85 | hour = today.hour - 12 86 | with contextlib.suppress(APIDoesNotSupport, ClientError): 87 | holidays = await get_holiday_names(today) 88 | if holidays: 89 | await say_happy_cat(bot, holidays[0], hour) 90 | return 91 | 92 | await say_knife(bot, hour) 93 | -------------------------------------------------------------------------------- /tests/apps/owner/update_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.owner.update import FLAG_MAP 4 | from yui.apps.owner.update import update 5 | 6 | from ...util import FakeBot 7 | 8 | 9 | @pytest.mark.anyio 10 | async def test_update_command(bot_config, channel_id, owner_id): 11 | bot_config.CHANNELS["notice"] = channel_id 12 | bot = FakeBot(bot_config) 13 | 14 | event = bot.create_message(user_id=owner_id) 15 | 16 | await update( 17 | bot, 18 | event, 19 | """ 20 | TITLE=패치 21 | FLAG=PATCH 22 | LINK= 23 | 본문1 24 | 본문2 25 | 본문3 26 | --- 27 | TITLE=테스트 28 | FLAG=test 29 | - 테스트 코드가 작성되었습니다. 30 | --- 31 | TITLE=새 기능 32 | FLAG=NEW 33 | 1. 리스트1 34 | 2. 리스트2 35 | 3. 리스트3 36 | --- 37 | TITLE=위험 38 | FLAG=DANGER 39 | - 위험 40 | - 위험! 41 | - 위험!! 42 | --- 43 | TITLE=커스텀 44 | COLOR=123456 45 | 임의의 색상 지정 46 | PRETEXT=유이 업데이트 명령 테스트 47 | --- 48 | --- 49 | 본문만 있음 50 | --- 51 | TITLE=타이틀만 있음 52 | """, 53 | ) 54 | said = bot.call_queue.pop(0) 55 | assert said.method == "chat.postMessage" 56 | assert said.data["channel"] == channel_id 57 | assert said.data["text"] == "유이 업데이트 명령 테스트" 58 | assert said.data["attachments"] == [ 59 | { 60 | "fallback": "[patch] 패치: 본문1 본문2 본문3", 61 | "title": "[patch] 패치", 62 | "title_link": "https://item4.github.io", 63 | "color": FLAG_MAP["patch"], 64 | "text": "본문1\n본문2\n본문3", 65 | }, 66 | { 67 | "fallback": "[test] 테스트: - 테스트 코드가 작성되었습니다.", 68 | "title": "[test] 테스트", 69 | "color": FLAG_MAP["test"], 70 | "text": "- 테스트 코드가 작성되었습니다.", 71 | }, 72 | { 73 | "fallback": "[new] 새 기능: 1. 리스트1 2. 리스트2 3. 리스트3", 74 | "title": "[new] 새 기능", 75 | "color": FLAG_MAP["new"], 76 | "text": "1. 리스트1\n2. 리스트2\n3. 리스트3", 77 | }, 78 | { 79 | "fallback": "[danger] 위험: - 위험 - 위험! - 위험!!", 80 | "title": "[danger] 위험", 81 | "color": FLAG_MAP["danger"], 82 | "text": "- 위험\n- 위험!\n- 위험!!", 83 | }, 84 | { 85 | "fallback": "커스텀: 임의의 색상 지정", 86 | "title": "커스텀", 87 | "color": "123456", 88 | "text": "임의의 색상 지정", 89 | }, 90 | { 91 | "fallback": "본문만 있음", 92 | "text": "본문만 있음", 93 | }, 94 | { 95 | "fallback": "타이틀만 있음:", 96 | "title": "타이틀만 있음", 97 | }, 98 | ] 99 | 100 | event = bot.create_message() 101 | 102 | await update(bot, event, "") 103 | 104 | said = bot.call_queue.pop(0) 105 | assert said.method == "chat.postMessage" 106 | assert said.data["channel"] == event.channel 107 | assert ( 108 | said.data["text"] 109 | == f"<@{event.user}> 이 명령어는 아빠만 사용할 수 있어요!" 110 | ) 111 | -------------------------------------------------------------------------------- /tests/utils/cast_test.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from typing import NewType 3 | from typing import TypeVar 4 | 5 | import pytest 6 | from attrs import define 7 | 8 | from yui.utils.attrs import field_transformer 9 | from yui.utils.cast import cast 10 | from yui.utils.cast import is_container 11 | 12 | 13 | def test_is_container(): 14 | assert is_container(list[int]) 15 | assert is_container(set[int]) 16 | assert is_container(tuple[int]) 17 | assert is_container(list) 18 | assert is_container(set) 19 | assert is_container(tuple) 20 | assert not is_container(int) 21 | assert not is_container(float) 22 | assert not is_container(bool) 23 | 24 | 25 | @define(kw_only=True, field_transformer=field_transformer) 26 | class UserRecord: 27 | id: str 28 | pw: str 29 | 30 | 31 | def test_cast(bot): 32 | ID = NewType("ID", str) 33 | N = TypeVar("N", int, float) 34 | T = TypeVar("T") 35 | W = TypeVar("W", int, str) 36 | 37 | assert cast(bool, 1) is True 38 | assert cast(bool, 0) is False 39 | assert cast(type(None), 0) is None 40 | assert cast(int, "3") == 3 41 | assert cast(list[str], ("kirito", "eugeo")) == ["kirito", "eugeo"] 42 | assert cast(list[int], ("1", "2", "3")) == [1, 2, 3] 43 | assert cast(tuple[int, float, str], ["1", "2", "3"]) == (1, 2.0, "3") 44 | assert cast(set[int], ["1", "1", "2"]) == {1, 2} 45 | assert cast(int | None, 3) == 3 46 | assert cast(int | None, None) is None 47 | assert cast(int | float, "3.2") == 3.2 48 | assert cast(int | str, "e") == "e" 49 | assert cast(list[ID], [1, 2, 3]) == [ID("1"), ID("2"), ID("3")] 50 | assert cast(list[N], [1, 2, 3]) == [1, 2, 3] 51 | assert cast(list[T], [1, 2, 3]) == [1, 2, 3] 52 | assert cast(list[W], ["e"]) == ["e"] 53 | assert cast(dict[str, Any], {1: 1, 2: 2.2}) == {"1": 1, "2": 2.2} 54 | assert cast(dict[str, str], {1: 1, 2: 2.2}) == {"1": "1", "2": "2.2"} 55 | assert cast(list, ("kirito", "eugeo", 0)) == ["kirito", "eugeo", 0] 56 | assert cast(tuple, ["1", 2, 3.0]) == ("1", 2, 3.0) 57 | assert cast(set, ["1", 2, 3.0, 2]) == {"1", 2, 3.0} 58 | assert cast( 59 | dict, 60 | [("1p", "kirito"), ("2p", "eugeo"), ("boss", "admin")], 61 | ) == { 62 | "1p": "kirito", 63 | "2p": "eugeo", 64 | "boss": "admin", 65 | } 66 | user = cast(UserRecord, {"id": "item4", "pw": "supersecret"}) 67 | assert user.id == "item4" 68 | assert user.pw == "supersecret" 69 | users = cast( 70 | list[UserRecord], 71 | [ 72 | {"id": "item4", "pw": "supersecret"}, 73 | {"id": "item2", "pw": "weak", "addresses": [1, 2]}, 74 | ], 75 | ) 76 | assert users[0].id == "item4" 77 | assert users[0].pw == "supersecret" 78 | assert users[1].id == "item2" 79 | assert users[1].pw == "weak" 80 | 81 | with pytest.raises(ValueError): 82 | cast(int | float, "asdf") 83 | 84 | with pytest.raises(ValueError): 85 | cast(N, "asdf") 86 | -------------------------------------------------------------------------------- /tests/apps/compute/gamble_test.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from yui.apps.compute.gamble import dice 4 | from yui.apps.compute.gamble import parse_dice_syntax 5 | 6 | 7 | def test_parse_dice_syntax(): 8 | with pytest.raises(SyntaxError) as e: 9 | parse_dice_syntax("d2 " * 10) 10 | assert str(e.value) == "Too many queries" 11 | 12 | with pytest.raises(SyntaxError) as e: 13 | parse_dice_syntax("bug") 14 | assert str(e.value) == "Can not parse this chunk (`bug`)" 15 | 16 | with pytest.raises(SyntaxError) as e: 17 | parse_dice_syntax("0d6") 18 | assert str(e.value) == "Number of dice must be larger than 0" 19 | 20 | with pytest.raises(SyntaxError) as e: 21 | parse_dice_syntax("20d6") 22 | assert str(e.value) == "YOU JUST ACTIVATED COUNT TRAP CARD!" 23 | 24 | with pytest.raises(SyntaxError) as e: 25 | parse_dice_syntax("d1") 26 | assert str(e.value) == "Number of faces must be larger than 1" 27 | with pytest.raises(SyntaxError) as e: 28 | parse_dice_syntax("d1000") 29 | assert str(e.value) == "YOU JUST ACTIVATED FACES TRAP CARD!" 30 | 31 | result = parse_dice_syntax("1d6+0", seed=100) 32 | assert result[0].query == "d6" 33 | assert result[0].result == "2" 34 | 35 | result = parse_dice_syntax("2d6+2", seed=200) 36 | assert result[0].query == "2d6+2" 37 | assert result[0].result == "5 (1+2+2)" 38 | 39 | result = parse_dice_syntax("2d6-2", seed=300) 40 | assert result[0].query == "2d6-2" 41 | assert result[0].result == "6 (5+3-2)" 42 | 43 | 44 | @pytest.mark.anyio 45 | async def test_dice_handler(bot): 46 | event = bot.create_message() 47 | 48 | assert not await dice(bot, event, "", seed=100) 49 | 50 | said = bot.call_queue.pop(0) 51 | assert said.method == "chat.postMessage" 52 | assert said.data["channel"] == event.channel 53 | assert said.data["username"] == "딜러" 54 | assert ( 55 | said.data["text"] == "유이가 기도하며 주사위를 굴려줬습니다. 19입니다." 56 | ) 57 | 58 | assert not await dice(bot, event, "", seed=206) 59 | 60 | said = bot.call_queue.pop(0) 61 | assert said.method == "chat.postMessage" 62 | assert said.data["channel"] == event.channel 63 | assert said.data["username"] == "딜러" 64 | assert said.data["text"] == "콩" 65 | 66 | assert not await dice(bot, event, "", seed=503) 67 | 68 | said = bot.call_queue.pop(0) 69 | assert said.method == "chat.postMessage" 70 | assert said.data["channel"] == event.channel 71 | assert said.data["username"] == "딜러" 72 | assert said.data["text"] == "콩콩" 73 | 74 | assert not await dice(bot, event, "bug") 75 | 76 | said = bot.call_queue.pop(0) 77 | assert said.method == "chat.postMessage" 78 | assert said.data["channel"] == event.channel 79 | assert said.data["username"] == "딜러" 80 | assert said.data["text"] == "*Error*: Can not parse this chunk (`bug`)" 81 | 82 | assert not await dice(bot, event, "1d6+0", seed=100) 83 | 84 | said = bot.call_queue.pop(0) 85 | assert said.method == "chat.postMessage" 86 | assert said.data["channel"] == event.channel 87 | assert said.data["username"] == "딜러" 88 | assert said.data["text"] == "d6 == 2" 89 | -------------------------------------------------------------------------------- /yui/apps/info/memo/commands.py: -------------------------------------------------------------------------------- 1 | import tossicat 2 | from sqlalchemy.ext.asyncio import AsyncSession 3 | from sqlalchemy.orm import undefer 4 | from sqlalchemy.sql.expression import delete 5 | from sqlalchemy.sql.expression import select 6 | 7 | from ....box import box 8 | from ....command import argument 9 | from ....event import Message 10 | from ....utils import format 11 | from ....utils.datetime import now 12 | from .models import Memo 13 | 14 | 15 | @box.command("기억") 16 | @argument("keyword") 17 | @argument("text") 18 | async def memo_add( 19 | bot, 20 | event: Message, 21 | sess: AsyncSession, 22 | keyword: str, 23 | text: str, 24 | ): 25 | """ 26 | 기억 레코드 생성 27 | 28 | `{PREFIX}기억 키리토 귀엽다` 29 | (`키리토`라는 단어를 `귀엽다`라는 내용으로 저장) 30 | `{PREFIX}기억 "키리가야 카즈토" "키리토의 본명"` 31 | (`키리가야 카즈토`에 대한 정보를 저장) 32 | 33 | """ 34 | 35 | if len(keyword) > 20: 36 | await bot.say( 37 | event.channel, 38 | "기억하려는 키워드가 너무 길어요! 20자 이하의 키워드만 가능해요!", 39 | ) 40 | return 41 | if len(text) > 500: 42 | await bot.say( 43 | event.channel, 44 | "기억하려는 내용이 너무 길어요! 500자 이하의 내용만 가능해요!", 45 | ) 46 | return 47 | 48 | memo = Memo() 49 | memo.keyword = keyword 50 | memo.author = event.user 51 | memo.text = text 52 | memo.created_at = now() 53 | 54 | sess.add(memo) 55 | await sess.commit() 56 | 57 | await bot.say( 58 | event.channel, 59 | "{}{} 기억 레코드를 생성했어요!".format( 60 | format.code(keyword), 61 | tossicat.transform(keyword, "(으)로")[1], 62 | ), 63 | ) 64 | 65 | 66 | @box.command("알려") 67 | @argument("keyword", nargs=-1, concat=True) 68 | async def memo_show(bot, event: Message, sess: AsyncSession, keyword: str): 69 | """ 70 | 기억 레코드 출력 71 | 72 | `{PREFIX}알려 키리토` (`키리토`에 관한 모든 기억 레코드를 출력) 73 | 74 | """ 75 | 76 | memos = ( 77 | await sess.scalars( 78 | select(Memo) 79 | .options(undefer(Memo.text)) 80 | .where(Memo.keyword == keyword) 81 | .order_by(Memo.created_at.asc()), 82 | ) 83 | ).all() 84 | if memos: 85 | await bot.say( 86 | event.channel, 87 | f"{format.code(keyword)}: " + " | ".join(x.text for x in memos), 88 | ) 89 | else: 90 | await bot.say( 91 | event.channel, 92 | "{}{} 이름을 가진 기억 레코드가 없어요!".format( 93 | format.code(keyword), 94 | tossicat.transform(keyword, "(이)란")[1], 95 | ), 96 | ) 97 | 98 | 99 | @box.command("잊어") 100 | @argument("keyword", nargs=-1, concat=True) 101 | async def memo_delete(bot, event: Message, sess: AsyncSession, keyword: str): 102 | """ 103 | 기억 레코드 삭제 104 | 105 | `{PREFIX}잊어 키리토` (`키리토`에 관한 모든 기억 레코드를 삭제) 106 | 107 | """ 108 | 109 | await sess.execute(delete(Memo).where(Memo.keyword == keyword)) 110 | await sess.commit() 111 | 112 | await bot.say( 113 | event.channel, 114 | f"{format.code(keyword)}에 관한 기억 레코드를 모두 삭제했어요!", 115 | ) 116 | -------------------------------------------------------------------------------- /yui/apps/owner/update.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from ...box import box 4 | from ...event import Message 5 | from ...transform import extract_url 6 | from ...types.slack.attachment import Attachment 7 | 8 | box.assert_channel_required("notice") 9 | box.assert_user_required("owner") 10 | 11 | FLAG_MAP = { 12 | "primary": "428bca", 13 | "new": "428bca", 14 | "add": "428bca", 15 | "good": "5cb85c", 16 | "nice": "5cb85c", 17 | "update": "5cb85c", 18 | "patch": "5cb85c", 19 | "fix": "5cb85c", 20 | "danger": "d9534f", 21 | "del": "d9534f", 22 | "delete": "d9534f", 23 | "remove": "d9534f", 24 | "bug": "d9534f", 25 | "warning": "d9534f", 26 | "info": "5bc0de", 27 | "test": "5bc0de", 28 | "internal": "5bc0de", 29 | "mics": "5bc0de", 30 | } 31 | 32 | 33 | def prepare(kw): 34 | if not kw: 35 | return 36 | for key, value in kw.items(): 37 | kw[key] = value.strip() 38 | 39 | if "title" in kw: 40 | kw["fallback"] = "{}: {}".format( 41 | kw["title"], 42 | kw.get("text", "").replace("\n", " "), 43 | ).strip() 44 | else: 45 | kw["fallback"] = kw.get("text", "").replace("\n", " ").strip() 46 | 47 | 48 | @box.command("update", aliases=["업데이트"]) 49 | async def update(bot, event: Message, raw: str): 50 | """ 51 | 봇을 통해 업데이트 공지 메시지를 전송합니다. 52 | 53 | `{PREFIX}update payload` (현재 채널) 54 | 55 | 봇 주인만 사용 가능합니다. 56 | 57 | """ 58 | 59 | if event.user == bot.config.USERS["owner"]: 60 | lines = raw.splitlines() 61 | attachments: list[Attachment] = [] 62 | kw: dict[str, Any] = {} 63 | pretext = "유이 업데이트 안내" 64 | for line in lines: 65 | if line == "---": 66 | prepare(kw) 67 | if kw: 68 | attachments.append(Attachment(**kw)) 69 | kw.clear() 70 | elif line.startswith("PRETEXT="): 71 | pretext = line[8:] 72 | elif line.startswith("TITLE="): 73 | kw["title"] = line[6:] 74 | elif line.startswith("COLOR="): 75 | kw["color"] = line[6:] 76 | elif line.startswith("LINK="): 77 | kw["title_link"] = extract_url(line[5:]) 78 | elif line.startswith("FLAG="): 79 | flag = line[5:].lower() 80 | kw["color"] = FLAG_MAP[flag] 81 | if "title" in kw: 82 | kw["title"] = f"[{flag}] {kw['title']}" 83 | else: 84 | if "text" not in kw: 85 | kw["text"] = "" 86 | kw["text"] += f"{line.strip()}\n" 87 | if kw: 88 | prepare(kw) 89 | attachments.append(Attachment(**kw)) 90 | 91 | await bot.api.chat.postMessage( 92 | channel=bot.config.CHANNELS["notice"], 93 | text=pretext, 94 | attachments=attachments, 95 | ) 96 | else: 97 | await bot.say( 98 | event.channel, 99 | f"<@{event.user}> 이 명령어는 아빠만 사용할 수 있어요!", 100 | ) 101 | -------------------------------------------------------------------------------- /tests/apps/date/day_test.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | import pytest 4 | from time_machine import travel 5 | 6 | from yui.apps.date.day import holiday 7 | from yui.apps.date.day import holiday_message 8 | from yui.utils.datetime import datetime 9 | 10 | from ...util import FakeBot 11 | from ...util import assert_crontab_match 12 | from ...util import assert_crontab_spec 13 | 14 | 15 | def test_holiday_message_spec(): 16 | assert_crontab_spec(holiday_message) 17 | 18 | 19 | @pytest.mark.parametrize( 20 | ("delta", "result"), 21 | [ 22 | (timedelta(days=0), True), 23 | (timedelta(days=0, minutes=5), False), 24 | (timedelta(days=1), False), 25 | (timedelta(days=2), True), 26 | (timedelta(days=3), True), 27 | (timedelta(days=4), True), 28 | (timedelta(days=5), True), 29 | (timedelta(days=6), True), 30 | ], 31 | ) 32 | def test_holiday_message_match(sunday, delta, result): 33 | assert_crontab_match(holiday_message, sunday + delta, expected=result) 34 | 35 | 36 | @pytest.mark.anyio 37 | @travel(datetime(2019, 2, 6), tick=False) 38 | async def test_holiday_task_at_holiday(bot_config, channel_id): 39 | bot_config.CHANNELS["general"] = channel_id 40 | bot = FakeBot(bot_config) 41 | await holiday_message(bot) 42 | said = bot.call_queue.pop() 43 | assert said.method == "chat.postMessage" 44 | assert said.data["channel"] == channel_id 45 | assert said.data["text"] == "오늘은 설날연휴! 행복한 휴일 되세요!" 46 | 47 | 48 | @pytest.mark.anyio 49 | @travel(datetime(2019, 2, 7), tick=False) 50 | async def test_holiday_task_at_workday(bot_config, channel_id): 51 | bot_config.CHANNELS["general"] = channel_id 52 | bot = FakeBot(bot_config) 53 | await holiday_message(bot) 54 | assert not bot.call_queue 55 | 56 | 57 | @pytest.mark.anyio 58 | @travel(datetime(2019, 2, 4), tick=False) 59 | async def test_holiday_command(bot_config): 60 | bot = FakeBot(bot_config) 61 | event = bot.create_message() 62 | 63 | # empty body 64 | await holiday(bot, event, "") 65 | said = bot.call_queue.pop() 66 | assert said.method == "chat.postMessage" 67 | assert said.data["channel"] == event.channel 68 | assert said.data["text"] == "2019년 02월 04일: 설날연휴" 69 | 70 | # buggy input 71 | await holiday(bot, event, "버그발생") 72 | said = bot.call_queue.pop() 73 | assert said.method == "chat.postMessage" 74 | assert said.data["channel"] == event.channel 75 | assert said.data["text"] == "인식할 수 없는 날짜 표현식이에요!" 76 | 77 | # full date 78 | await holiday(bot, event, "2019년 2월 4일") 79 | said = bot.call_queue.pop() 80 | assert said.method == "chat.postMessage" 81 | assert said.data["channel"] == event.channel 82 | assert said.data["text"] == "2019년 02월 04일: 설날연휴" 83 | 84 | # no event 85 | await holiday(bot, event, "2019년 1월 4일") 86 | said = bot.call_queue.pop() 87 | assert said.method == "chat.postMessage" 88 | assert said.data["channel"] == event.channel 89 | assert said.data["text"] == "2019년 01월 04일: 평일" 90 | 91 | # API error 92 | await holiday(bot, event, "2010년 1월 1일") 93 | said = bot.call_queue.pop() 94 | assert said.method == "chat.postMessage" 95 | assert said.data["channel"] == event.channel 96 | assert said.data["text"] == "API가 해당 년월일시의 자료를 제공하지 않아요!" 97 | -------------------------------------------------------------------------------- /tests/utils/datetime_test.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from datetime import timezone 3 | from zoneinfo import ZoneInfo 4 | 5 | from time_machine import travel 6 | 7 | from yui.utils.datetime import datetime 8 | from yui.utils.datetime import fromisoformat 9 | from yui.utils.datetime import fromtimestamp 10 | from yui.utils.datetime import fromtimestampoffset 11 | from yui.utils.datetime import now 12 | 13 | 14 | @travel(datetime(2018, 10, 7, 19, 20, 30), tick=False) 15 | def test_now_kst(): 16 | now_kst = now() 17 | assert now_kst.year == 2018 18 | assert now_kst.month == 10 19 | assert now_kst.day == 7 20 | assert now_kst.hour == 19 21 | assert now_kst.minute == 20 22 | assert now_kst.second == 30 23 | assert now_kst.tzname() == "KST" 24 | assert now_kst.tzinfo == ZoneInfo("Asia/Seoul") 25 | 26 | 27 | @travel(datetime(2018, 10, 7, 19, 20, 30), tick=False) 28 | def test_now_utc(): 29 | now_utc = now("UTC") 30 | assert now_utc.year == 2018 31 | assert now_utc.month == 10 32 | assert now_utc.day == 7 33 | assert now_utc.hour == 10 # NOTE: KST to UTC 34 | assert now_utc.minute == 20 35 | assert now_utc.second == 30 36 | assert now_utc.tzname() == "UTC" 37 | assert now_utc.tzinfo == ZoneInfo("UTC") 38 | 39 | 40 | def test_fromtimestamp_kst(): 41 | dt = fromtimestamp(1234567890) 42 | assert dt.year == 2009 43 | assert dt.month == 2 44 | assert dt.day == 14 45 | assert dt.hour == 8 46 | assert dt.minute == 31 47 | assert dt.second == 30 48 | assert dt.tzname() == "KST" 49 | assert dt.tzinfo == ZoneInfo("Asia/Seoul") 50 | 51 | 52 | def test_fromtimestamp_utc(): 53 | dt = fromtimestamp(1234567890, tzname="UTC") 54 | assert dt.year == 2009 55 | assert dt.month == 2 56 | assert dt.day == 13 57 | assert dt.hour == 23 58 | assert dt.minute == 31 59 | assert dt.second == 30 60 | assert dt.tzname() == "UTC" 61 | assert dt.tzinfo == ZoneInfo("UTC") 62 | 63 | 64 | def test_fromtimestampoffset_zero(): 65 | dt = fromtimestampoffset(1234567890, 0) 66 | assert dt.year == 2009 67 | assert dt.month == 2 68 | assert dt.day == 13 69 | assert dt.hour == 23 70 | assert dt.minute == 31 71 | assert dt.second == 30 72 | assert dt.tzinfo == timezone(timedelta(seconds=0)) 73 | 74 | 75 | def test_fromtimestampoffset_kst(): 76 | dt = fromtimestampoffset(1234567890, 9 * 60 * 60) 77 | assert dt.year == 2009 78 | assert dt.month == 2 79 | assert dt.day == 14 80 | assert dt.hour == 8 81 | assert dt.minute == 31 82 | assert dt.second == 30 83 | assert dt.tzinfo == timezone(timedelta(hours=9)) 84 | 85 | 86 | def test_fromisoformat_kst(): 87 | dt = fromisoformat("2018-10-07T19:20:30") 88 | assert dt.year == 2018 89 | assert dt.month == 10 90 | assert dt.day == 7 91 | assert dt.hour == 19 92 | assert dt.minute == 20 93 | assert dt.second == 30 94 | assert dt.tzname() == "KST" 95 | assert dt.tzinfo == ZoneInfo("Asia/Seoul") 96 | 97 | 98 | def test_fromisoformat_utc(): 99 | dt = fromisoformat("2018-10-07T19:20:30", tzname="UTC") 100 | assert dt.year == 2018 101 | assert dt.month == 10 102 | assert dt.day == 7 103 | assert dt.hour == 19 104 | assert dt.minute == 20 105 | assert dt.second == 30 106 | assert dt.tzname() == "UTC" 107 | assert dt.tzinfo == ZoneInfo("UTC") 108 | -------------------------------------------------------------------------------- /tests/utils/fuzz_test.py: -------------------------------------------------------------------------------- 1 | from rapidfuzz import fuzz 2 | 3 | from yui.utils.fuzz import match 4 | from yui.utils.fuzz import normalize_korean_nfc_to_nfd 5 | from yui.utils.fuzz import partial_ratio 6 | from yui.utils.fuzz import ratio 7 | from yui.utils.fuzz import token_sort_ratio 8 | 9 | 10 | def test_normalize_nfd(): 11 | """Test Korean to NFD tool.""" 12 | 13 | assert normalize_korean_nfc_to_nfd( 14 | "123asdf가나다라밯맣희QWERTY", 15 | ) == "".join( 16 | chr(x) 17 | for x in [ 18 | 49, 19 | 50, 20 | 51, # 123 21 | 97, 22 | 115, 23 | 100, 24 | 102, # asdf 25 | 4352, 26 | 4449, 27 | 4354, 28 | 4449, 29 | 4355, 30 | 4449, 31 | 4357, 32 | 4449, # 가나다라 33 | 4359, 34 | 4449, 35 | 4546, 36 | 4358, 37 | 4449, 38 | 4546, 39 | 4370, 40 | 4468, # 밯맣희 41 | 81, 42 | 87, 43 | 69, 44 | 82, 45 | 84, 46 | 89, # QWERTY 47 | ] 48 | ) 49 | 50 | assert normalize_korean_nfc_to_nfd( 51 | "ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ", 52 | ) == "".join(chr(x) for x in range(4352, 4370 + 1)) 53 | 54 | assert normalize_korean_nfc_to_nfd( 55 | "ㅏㅐㅑㅒㅓㅔㅕㅖㅗㅘㅙㅚㅛㅜㅝㅞㅟㅠㅡㅢㅣ", 56 | ) == "".join(chr(x) for x in range(4449, 4469 + 1)) 57 | 58 | 59 | def test_partial_ratio(): 60 | title = "소드 아트 온라인 3기 엘리시제이션 인계편" 61 | assert partial_ratio("소드", title) == 100 62 | assert partial_ratio("소드 아트", title) == 100 63 | assert partial_ratio("소드 아트 온라인", title) == 100 64 | assert partial_ratio("소드 아트 온라인 3기", title) == 100 65 | assert partial_ratio("소드 아트 온라인 3기 엘리시", title) == 100 66 | assert partial_ratio("소드 아트 온라인 3기 엘리시제이션", title) == 100 67 | assert partial_ratio(title, title) == 100 68 | 69 | 70 | def test_ratio(): 71 | """Test Korean-specific fuzzy search.""" 72 | 73 | assert fuzz.ratio("강", "공") == 0 74 | assert ratio("강", "공") == 66 75 | 76 | assert fuzz.ratio("안녕", "인형") == 0 77 | assert ratio("안녕", "인형") == 66 78 | 79 | assert fuzz.ratio("사당", "ㅅㄷ") == 0 80 | assert ratio("사당", "ㅅㄷ") == 57 81 | 82 | assert fuzz.ratio("사당", "ㅏㅏ") == 0 83 | assert ratio("사당", "ㅏㅏ") == 57 84 | 85 | assert fuzz.ratio("사당", "ㅅㅏㄷㅏㅇ") == 0 86 | assert ratio("사당", "ㅅㅏㄷㅏㅇ") == 80 87 | 88 | 89 | def test_token_sort_ratio(): 90 | assert token_sort_ratio("밥 국 반찬", "반찬 밥 국") == 100 91 | 92 | 93 | def test_match(): 94 | sao = "소드 아트 온라인" 95 | assert match(sao, "소아온") == 56 96 | assert match(sao, "소드") == 74 97 | assert match(sao, "소드아트") == 76 98 | assert match(sao, "소드 아트") == 86 99 | assert match(sao, "아트") == 74 100 | assert match(sao, "온라인") == 84 101 | assert match(sao, sao) == 100 102 | assert match(sao, "소드아트온라인") == 92 103 | assert match(sao, "소드 오라토리아") == 70 104 | 105 | saop_movie = "극장판 소드 아트 온라인 -프로그레시브- 별 없는 밤의 아리아" 106 | assert match(saop_movie, "소아온") == 41 107 | assert match(saop_movie, "소드") == 64 108 | assert match(saop_movie, "소드아트") == 60 109 | assert match(saop_movie, "소드 아트") == 69 110 | assert match(saop_movie, "아트") == 64 111 | assert match(saop_movie, "온라인") == 68 112 | assert match(saop_movie, saop_movie) == 100 113 | assert match(saop_movie, "소드아트온라인") == 67 114 | assert match(saop_movie, "소드 오라토리아") == 56 115 | -------------------------------------------------------------------------------- /tests/apps/compute/exchange_test.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import aiohttp.client_exceptions 4 | import pytest 5 | from yarl import URL 6 | 7 | from yui.apps.compute.exchange import exchange 8 | from yui.apps.compute.exchange import get_exchange_rate 9 | 10 | YEN_PATTERN = re.compile( 11 | r"100 JPY == (?:\.?\d+,?)+ KRW \(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\)", 12 | ) 13 | 14 | 15 | @pytest.fixture 16 | async def skip_if_no_exchange_api(): 17 | try: 18 | await get_exchange_rate("KRW", "JPY", timeout=1.0) 19 | except (TimeoutError, aiohttp.client_exceptions.ContentTypeError): 20 | pytest.skip("Exchange API is not available") 21 | 22 | 23 | @pytest.mark.anyio 24 | async def test_exchange_command(bot, skip_if_no_exchange_api): 25 | event = bot.create_message() 26 | 27 | await exchange(bot, event, "100엔") 28 | 29 | said = bot.call_queue.pop(0) 30 | assert said.method == "chat.postMessage" 31 | assert said.data["channel"] == event.channel 32 | assert YEN_PATTERN.match(said.data["text"]) 33 | 34 | await exchange(bot, event, "JPY 100") 35 | 36 | said = bot.call_queue.pop(0) 37 | assert said.method == "chat.postMessage" 38 | assert said.data["channel"] == event.channel 39 | assert YEN_PATTERN.match(said.data["text"]) 40 | 41 | await exchange(bot, event, "100 JPY to KRW") 42 | 43 | said = bot.call_queue.pop(0) 44 | assert said.method == "chat.postMessage" 45 | assert said.data["channel"] == event.channel 46 | assert YEN_PATTERN.match(said.data["text"]) 47 | 48 | await exchange(bot, event, "100원") 49 | 50 | said = bot.call_queue.pop(0) 51 | assert said.method == "chat.postMessage" 52 | assert said.data["channel"] == event.channel 53 | assert said.data["text"] == "변환하려는 두 화폐가 같은 단위에요!" 54 | 55 | await exchange(bot, event, "100 BTC") 56 | 57 | said = bot.call_queue.pop(0) 58 | assert said.method == "chat.postMessage" 59 | assert said.data["channel"] == event.channel 60 | assert said.data["text"] == "지원되는 통화기호가 아니에요!" 61 | 62 | await exchange(bot, event, "아무말 대잔치") 63 | 64 | said = bot.call_queue.pop(0) 65 | assert said.method == "chat.postMessage" 66 | assert said.data["channel"] == event.channel 67 | assert said.data["text"] == "주문을 이해하는데에 실패했어요!" 68 | 69 | 70 | @pytest.mark.anyio 71 | async def test_exchange_error(bot, response_mock): 72 | response_mock.get( 73 | URL("https://api.manana.kr/exchange/rate.json").with_query( 74 | base="KRW", 75 | code="JPY", 76 | ), 77 | payload=[False], 78 | ) 79 | event = bot.create_message() 80 | 81 | await exchange(bot, event, "100엔") 82 | 83 | said = bot.call_queue.pop(0) 84 | assert said.method == "chat.postMessage" 85 | assert said.data["channel"] == event.channel 86 | assert ( 87 | said.data["text"] 88 | == "알 수 없는 에러가 발생했어요! 아빠에게 문의해주세요!" 89 | ) 90 | 91 | 92 | @pytest.mark.anyio 93 | async def test_exchange_timeout(bot, response_mock): 94 | response_mock.get( 95 | URL("https://api.manana.kr/exchange/rate.json").with_query( 96 | base="KRW", 97 | code="JPY", 98 | ), 99 | exception=TimeoutError(), 100 | ) 101 | event = bot.create_message() 102 | 103 | await exchange(bot, event, "100엔") 104 | 105 | said = bot.call_queue.pop(0) 106 | assert said.method == "chat.postMessage" 107 | assert said.data["channel"] == event.channel 108 | assert said.data["text"] == "현재 환율 API의 상태가 원활하지 않아요!" 109 | -------------------------------------------------------------------------------- /yui/types/channel.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from attrs import define 4 | 5 | from ..utils.attrs import channel_id_field 6 | from ..utils.attrs import field 7 | from ..utils.attrs import field_transformer 8 | from ..utils.attrs import name_field 9 | from ..utils.attrs import ts_field 10 | from ..utils.attrs import user_id_field 11 | from .base import DirectMessageChannelID 12 | from .base import PrivateChannelID 13 | from .base import PublicChannelID 14 | from .base import Ts 15 | from .base import UserID 16 | 17 | 18 | @define(kw_only=True, field_transformer=field_transformer) 19 | class ChannelTopic: 20 | """Topic of Channel.""" 21 | 22 | creator: UserID = user_id_field() 23 | value: str = field() 24 | last_set: datetime = field() 25 | 26 | 27 | @define(kw_only=True, field_transformer=field_transformer) 28 | class ChannelPurpose: 29 | """Purpose of Channel.""" 30 | 31 | creator: UserID = user_id_field() 32 | value: str = field() 33 | last_set: datetime = field() 34 | 35 | 36 | @define(kw_only=True, field_transformer=field_transformer) 37 | class PublicChannel: 38 | id: PublicChannelID = channel_id_field() 39 | name: str = name_field() 40 | is_channel: bool = field() 41 | is_group: bool = field() 42 | is_im: bool = field() 43 | created: datetime = field() 44 | creator: UserID = user_id_field() 45 | is_archived: bool = field() 46 | is_general: bool = field() 47 | unlinked: int = field() 48 | name_normalized: str = field() 49 | is_read_only: bool = field() 50 | is_shared: bool = field() 51 | parent_conversation: object = field() 52 | is_ext_shared: bool = field() 53 | is_org_shared: bool = field() 54 | is_pending_ext_shared: bool = field() 55 | is_member: bool = field() 56 | is_private: bool = field() 57 | is_mpim: bool = field() 58 | last_read: Ts = ts_field() 59 | topic: ChannelTopic = field() 60 | purpose: ChannelPurpose = field() 61 | locale: str = field() 62 | 63 | 64 | @define(kw_only=True, field_transformer=field_transformer) 65 | class DirectMessageChannel: 66 | id: DirectMessageChannelID = channel_id_field() 67 | created: datetime = field() 68 | is_im: bool = field() 69 | is_org_shared: bool = field() 70 | user: UserID = user_id_field() 71 | last_read: Ts = ts_field() 72 | latest: object = field() 73 | unread_count: int = field() 74 | unread_count_display: int = field() 75 | is_open: bool = field() 76 | locale: str = field() 77 | priority: float = field() 78 | num_members: int = field() 79 | 80 | 81 | @define(kw_only=True, field_transformer=field_transformer) 82 | class PrivateChannel: 83 | id: PrivateChannelID = channel_id_field() 84 | name: str = field() 85 | is_channel: bool = field() 86 | is_group: bool = field() 87 | is_im: bool = field() 88 | created: datetime = field() 89 | creator: UserID = user_id_field() 90 | is_archived: bool = field() 91 | is_general: bool = field() 92 | unlinked: int = field() 93 | name_normalized: str = field() 94 | is_read_only: bool = field() 95 | is_shared: bool = field() 96 | parent_conversation: object = field() 97 | is_ext_shared: bool = field() 98 | is_org_shared: bool = field() 99 | is_pending_ext_shared: bool = field() 100 | is_member: bool = field() 101 | is_private: bool = field() 102 | is_mpim: bool = field() 103 | last_read: Ts = ts_field() 104 | topic: ChannelTopic = field() 105 | purpose: ChannelPurpose = field() 106 | locale: str = field() 107 | -------------------------------------------------------------------------------- /yui/apps/search/dic.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import aiohttp 4 | 5 | from ...bot import Bot 6 | from ...box import box 7 | from ...command import argument 8 | from ...command import option 9 | from ...event import Message 10 | from ...transform import choice 11 | from ...types.slack.attachment import Attachment 12 | from ...utils.html import USELESS_TAGS 13 | from ...utils.html import get_root 14 | from ...utils.html import strip_tags 15 | from ...utils.http import USER_AGENT 16 | 17 | headers: dict[str, str] = { 18 | "User-Agent": USER_AGENT, 19 | } 20 | DICS: dict[str, str] = { 21 | "영어": "eng", 22 | "English": "ee", 23 | "한국어": "kor", 24 | "일본어": "jp", 25 | "중국어": "ch", 26 | "한자": "hanja", 27 | "베트남어": "vi", 28 | "인도네시아어": "id", 29 | "이탈리아어": "it", 30 | "프랑스어": "fr", 31 | "터키어": "tr", 32 | "태국어": "th", 33 | "폴란드어": "pl", 34 | "포르투갈어": "pt", 35 | "체코어": "cs", 36 | "헝가리어": "hu", 37 | "아랍어": "ar", 38 | "스웨덴어": "sv", 39 | "힌디어": "hi", 40 | "네덜란드어": "nl", 41 | "페르시아어": "fa", 42 | "스와힐리어": "sw", 43 | "루마니아어": "ro", 44 | "러시아어": "ru", 45 | } 46 | BLANK_RE = re.compile(r"^\s+", re.MULTILINE) 47 | 48 | 49 | def fix_url(url: str) -> str: 50 | return f"https://dic.daum.net{url}" 51 | 52 | 53 | def fix_blank(text: str) -> str: 54 | return BLANK_RE.sub("", text) 55 | 56 | 57 | def parse(html: str) -> tuple[str | None, list[Attachment]]: 58 | h = get_root(html, useless_tags=list(USELESS_TAGS - {"head"})) 59 | meta = h.cssselect("meta[http-equiv=Refresh]") 60 | if meta: 61 | return fix_url(meta[0].get("content", "")[7:]), [] 62 | words = h.cssselect("div.search_type") 63 | 64 | attachments: list[Attachment] = [] 65 | 66 | for word in words: 67 | w = word.cssselect(".txt_searchword")[0] 68 | attachments.append( 69 | Attachment( 70 | title=strip_tags(str(w.text_content())), 71 | title_link=fix_url(str(w.get("href"))), 72 | text=fix_blank( 73 | str(word.cssselect(".list_search")[0].text_content()), 74 | ), 75 | ), 76 | ) 77 | 78 | return None, attachments 79 | 80 | 81 | @box.command("dic", ["사전"]) 82 | @option( 83 | "--category", 84 | "-c", 85 | transform_func=choice(list(DICS.keys())), 86 | default="영어", 87 | ) 88 | @argument("keyword", nargs=-1, concat=True) 89 | async def dic(bot: Bot, event: Message, category: str, keyword: str): 90 | """ 91 | 다음 사전 검색 92 | 93 | 다음 사전에서 입력한 키워드로 검색하여 링크를 보여줍니다. 94 | 95 | `{PREFIX}dic 붕괴` (한영사전에서 `붕괴`로 검색) 96 | `{PREFIX}dic --category 일본어 붕괴` (일본어사전에서 `붕괴`로 검색) 97 | `{PREFIX}dic -c English fail` (영영사전에서 `fail`로 검색) 98 | 99 | 지원되는 카테고리는 다음과 같습니다. 100 | 101 | * 영어(기본값), English(영영사전), 한국어, 일본어, 중국어, 한자 102 | * 기타 다음사전에서 지원하는 언어들 103 | 104 | """ 105 | 106 | html = "" 107 | async with ( 108 | aiohttp.ClientSession() as session, 109 | session.get( 110 | "https://dic.daum.net/search.do", 111 | params={"q": keyword, "dic": DICS[category]}, 112 | ) as resp, 113 | ): 114 | html = await resp.text() 115 | 116 | redirect, attachments = await bot.run_in_other_process(parse, html) 117 | 118 | if redirect: 119 | await bot.say(event.channel, redirect) 120 | else: 121 | await bot.api.chat.postMessage( 122 | channel=event.channel, 123 | attachments=attachments, 124 | text=f"검색결과 {len(attachments)}개의 링크를 찾았어요!", 125 | ) 126 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | .Python 3 | [Bb]in 4 | [Ii]nclude 5 | [Ll]ib 6 | [Ll]ib64 7 | [Ll]ocal 8 | [Ss]cripts 9 | pyvenv.cfg 10 | .venv 11 | pip-selfcheck.json 12 | .dropbox 13 | .dropbox.attr 14 | .dropbox.cache 15 | [._]*.s[a-v][a-z] 16 | [._]*.sw[a-p] 17 | [._]s[a-v][a-z] 18 | [._]sw[a-p] 19 | Session.vim 20 | .netrwhist 21 | *~ 22 | tags 23 | [._]*.un~ 24 | .idea/**/workspace.xml 25 | .idea/**/tasks.xml 26 | .idea/**/dictionaries 27 | .idea/**/shelf 28 | .idea/**/dataSources/ 29 | .idea/**/dataSources.ids 30 | .idea/**/dataSources.local.xml 31 | .idea/**/sqlDataSources.xml 32 | .idea/**/dynamic.xml 33 | .idea/**/uiDesigner.xml 34 | .idea/**/dbnavigator.xml 35 | .idea/**/gradle.xml 36 | .idea/**/libraries 37 | cmake-build-debug/ 38 | cmake-build-release/ 39 | .idea/**/mongoSettings.xml 40 | *.iws 41 | out/ 42 | .idea_modules/ 43 | atlassian-ide-plugin.xml 44 | .idea/replstate.xml 45 | com_crashlytics_export_strings.xml 46 | crashlytics.properties 47 | crashlytics-build.properties 48 | fabric.properties 49 | .idea/httpRequests 50 | .vscode/* 51 | !.vscode/settings.json 52 | !.vscode/tasks.json 53 | !.vscode/launch.json 54 | !.vscode/extensions.json 55 | *.7z 56 | *.jar 57 | *.rar 58 | *.zip 59 | *.gz 60 | *.tgz 61 | *.bzip 62 | *.bz2 63 | *.xz 64 | *.lzma 65 | *.cab 66 | *.iso 67 | *.tar 68 | *.dmg 69 | *.xpi 70 | *.gem 71 | *.egg 72 | *.deb 73 | *.rpm 74 | *.msi 75 | *.msm 76 | *.msp 77 | *.tmlanguage.cache 78 | *.tmPreferences.cache 79 | *.stTheme.cache 80 | *.sublime-workspace 81 | sftp-config.json 82 | Package Control.last-run 83 | Package Control.ca-list 84 | Package Control.ca-bundle 85 | Package Control.system-ca-bundle 86 | Package Control.cache/ 87 | Package Control.ca-certs/ 88 | Package Control.merged-ca-bundle 89 | Package Control.user-ca-bundle 90 | oscrypto-ca-bundle.crt 91 | bh_unicode_properties.cache 92 | GitHub.sublime-settings 93 | Thumbs.db 94 | ehthumbs.db 95 | ehthumbs_vista.db 96 | *.stackdump 97 | [Dd]esktop.ini 98 | $RECYCLE.BIN/ 99 | *.cab 100 | *.msi 101 | *.msix 102 | *.msm 103 | *.msp 104 | *.lnk 105 | __pycache__/ 106 | *.py[cod] 107 | *$py.class 108 | *.so 109 | .Python 110 | build/ 111 | develop-eggs/ 112 | dist/ 113 | downloads/ 114 | eggs/ 115 | .eggs/ 116 | lib/ 117 | lib64/ 118 | parts/ 119 | sdist/ 120 | var/ 121 | wheels/ 122 | *.egg-info/ 123 | .installed.cfg 124 | *.egg 125 | MANIFEST 126 | *.manifest 127 | *.spec 128 | pip-log.txt 129 | pip-delete-this-directory.txt 130 | htmlcov/ 131 | .tox/ 132 | .coverage 133 | .coverage.* 134 | .cache 135 | nosetests.xml 136 | coverage.xml 137 | *.cover 138 | .hypothesis/ 139 | .pytest_cache/ 140 | *.mo 141 | *.pot 142 | *.log 143 | local_settings.py 144 | db.sqlite3 145 | instance/ 146 | .webassets-cache 147 | .scrapy 148 | docs/_build/ 149 | target/ 150 | .ipynb_checkpoints 151 | .python-version 152 | celerybeat-schedule 153 | *.sage.py 154 | .env 155 | .venv 156 | env/ 157 | venv/ 158 | ENV/ 159 | env.bak/ 160 | venv.bak/ 161 | .spyderproject 162 | .spyproject 163 | .ropeproject 164 | /site 165 | .mypy_cache/ 166 | .DS_Store 167 | .AppleDouble 168 | .LSOverride 169 | Icon 170 | ._* 171 | .DocumentRevisions-V100 172 | .fseventsd 173 | .Spotlight-V100 174 | .TemporaryItems 175 | .Trashes 176 | .VolumeIcon.icns 177 | .com.apple.timemachine.donotpresent 178 | .AppleDB 179 | .AppleDesktop 180 | Network Trash Folder 181 | Temporary Items 182 | .apdisk 183 | 184 | # yui docker only 185 | .git 186 | .github 187 | docker 188 | examples 189 | logs 190 | tests 191 | .coveragerc 192 | .gitignore 193 | .pre-commit-config.yaml 194 | poetry.lock 195 | README.rst 196 | setup.cfg 197 | 198 | .idea 199 | *.config.toml 200 | --------------------------------------------------------------------------------