├── 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("aaabbbcccdddeee
fff")
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 |
--------------------------------------------------------------------------------