Skip to content

Commit bec2c6b

Browse files
authored
Merge pull request #4 from Jeewhan/feature/community
출석체크: 다진마늘, 책읽어또
2 parents dfd19aa + 0544513 commit bec2c6b

File tree

13 files changed

+826
-20
lines changed

13 files changed

+826
-20
lines changed

.envrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
source .venv/bin/activate

.github/workflows/push.yml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
name: Push
2+
3+
on:
4+
schedule:
5+
- cron: "0 20 * * *" # UTC 20:00 = KST 05:00
6+
workflow_dispatch:
7+
8+
jobs:
9+
check-attendance:
10+
runs-on: ubuntu-latest
11+
12+
permissions:
13+
contents: "read"
14+
id-token: "write"
15+
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- uses: actions/setup-python@v4
20+
with:
21+
python-version: "3.12"
22+
23+
- name: Install Poetry
24+
run: |
25+
python -m pip install poetry
26+
27+
- name: Install dependencies
28+
run: |
29+
poetry install --no-interaction --no-root
30+
31+
- name: Run attendance checks
32+
run: |
33+
echo "::group::Book Read Check"
34+
poetry run python push.py 책읽어또
35+
echo "::endgroup::"
36+
37+
echo "::group::Garlic Check"
38+
poetry run python push.py 다진마늘
39+
echo "::endgroup::"
40+
env:
41+
SHEETS_ID: ${{ secrets.SHEETS_ID }}
42+
GEULTTO_SLACK_TOKEN: ${{ secrets.GEULTTO_SLACK_TOKEN }}
43+
PROJECT_ID: ${{ secrets.PROJECT_ID }}
44+
PRIVATE_KEY_ID: ${{ secrets.PRIVATE_KEY_ID }}
45+
PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }}
46+
CLIENT_EMAIL: ${{ secrets.CLIENT_EMAIL }}
47+
CLIENT_ID: ${{ secrets.CLIENT_ID }}
48+
CLIENT_X509_CERT_URL: ${{ secrets.CLIENT_X509_CERT_URL }}
49+
MINCED_GARLIC_CHANNEL_ID: ${{ vars.MINCED_GARLIC_CHANNEL_ID }}
50+
BOOK_READ_CHANNEL_ID: ${{ vars.BOOK_READ_CHANNEL_ID }}

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,7 @@ __pycache__
2323

2424
*.zip
2525

26-
app/slack/**
26+
app/slack/**
27+
28+
.direnv
29+
.env

app/adapter/output/slack.py

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
11
import logging
22

3-
from slack_sdk import WebClient
3+
from typing import TypeVar
4+
from slack_sdk.web.client import WebClient
45
from slack_sdk.errors import SlackApiError
56

67

8+
T = TypeVar("T")
9+
10+
711
class SlackClient:
8-
def __init__(self, token):
12+
def __init__(self, token: str) -> None:
913
self.client = WebClient(token)
1014

11-
def __get_response(self, response):
15+
def __get_response(self, response: T) -> T:
1216
if not response["ok"]:
1317
raise SlackApiError(response["error"])
1418
return response
1519

16-
def get_member_id_by_channel(self, channel_id, cursor=None):
20+
def get_member_id_by_channel(
21+
self, channel_id: str, cursor: None | str = None
22+
) -> list[str]:
1723
response = self.client.conversations_members(channel=channel_id, cursor=cursor)
1824
response = self.__get_response(response)
1925

@@ -26,7 +32,7 @@ def get_member_id_by_channel(self, channel_id, cursor=None):
2632
member_id += self.get_member_id_by_channel(channel_id, next_cursor)
2733
return member_id
2834

29-
def get_reaction_members(self, channel_id, timestamp):
35+
def get_reaction_members(self, channel_id: str, timestamp: str) -> set[str]:
3036
response = self.client.reactions_get(channel=channel_id, timestamp=timestamp)
3137
response = self.__get_response(response)
3238

@@ -42,7 +48,9 @@ def get_reaction_members(self, channel_id, timestamp):
4248
user_id.update(reaction["users"])
4349
return user_id
4450

45-
def post_remind_message(self, channel_id, timestamp, users):
51+
def post_remind_message(
52+
self, channel_id: str, timestamp: str, users: list[str]
53+
) -> None:
4654
user_list = ""
4755
for user in users:
4856
user_list += "<@" + user + "> "
@@ -55,7 +63,9 @@ def post_remind_message(self, channel_id, timestamp, users):
5563
response = self.__get_response(response)
5664
return
5765

58-
def post_message(self, channel_id, user, timestamp, message):
66+
def post_message(
67+
self, channel_id: str, user: str, timestamp: str, message: str
68+
) -> None:
5969
response = self.client.chat_postMessage(
6070
channel=channel_id,
6171
text=message,
@@ -66,14 +76,69 @@ def post_message(self, channel_id, user, timestamp, message):
6676
response = self.__get_response(response)
6777
return
6878

69-
def is_bot_user(self, user):
79+
def is_bot_user(self, user: str) -> bool:
7080
response = self.client.users_info(user=user)
7181
response = self.__get_response(response)
7282
return response["user"].get("is_bot")
7383

74-
def get_message_info(self, channel_id, timestamp):
84+
def get_message_info(self, channel_id: str, timestamp: str) -> str:
7585
response = self.client.conversations_history(
7686
channel=channel_id, oldest=timestamp, limit=1, inclusive=True
7787
)
7888
response = self.__get_response(response)
7989
return response["messages"][0]["text"]
90+
91+
def get_conversation_history(
92+
self,
93+
channel_id: str,
94+
*,
95+
limit: int = 200,
96+
oldest: None | str = None,
97+
latest: None | str = None,
98+
) -> list[dict]:
99+
response = self.client.conversations_history(
100+
channel=channel_id, limit=limit, oldest=oldest, latest=latest
101+
)
102+
return self.__get_response(response)
103+
104+
def get_all_conversation_histories(
105+
self,
106+
channel_id: str,
107+
*,
108+
limit: int = 200,
109+
oldest: None | str = None,
110+
latest: None | str = None,
111+
) -> list[dict]:
112+
all_messages = []
113+
cursor: None | str = None
114+
115+
while True:
116+
response = self.client.conversations_history(
117+
channel=channel_id,
118+
limit=limit,
119+
oldest=oldest,
120+
latest=latest,
121+
cursor=cursor,
122+
)
123+
124+
all_messages.extend(response["messages"])
125+
126+
if not (cursor := response.get("response_metadata", {}).get("next_cursor")):
127+
break
128+
129+
return all_messages
130+
131+
def get_conversations_replies(self, channel_id: str, timestamp: str) -> list[dict]:
132+
response = self.client.conversations_replies(channel=channel_id, ts=timestamp)
133+
response = self.__get_response(response)
134+
return response["messages"]
135+
136+
def get_conversation_members(self, channel_id: str) -> list[str]:
137+
response = self.client.conversations_members(channel=channel_id, limit=1000)
138+
response = self.__get_response(response)
139+
return response["members"]
140+
141+
def get_user_name(self, user_id: str) -> str:
142+
response = self.client.users_info(user=user_id)
143+
response = self.__get_response(response)
144+
return response["user"]["real_name"]
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import os
2+
from gspread import service_account_from_dict, Worksheet
3+
from typing import Type, TypeVar
4+
from dataclasses import fields
5+
from abc import abstractmethod
6+
from dotenv import load_dotenv
7+
8+
from app.adapter.output.slack import SlackClient
9+
10+
T = TypeVar("T")
11+
12+
13+
load_dotenv()
14+
15+
gc = service_account_from_dict(
16+
{
17+
"type": "service_account",
18+
"project_id": os.getenv("PROJECT_ID"),
19+
"private_key_id": os.getenv("PRIVATE_KEY_ID"),
20+
"private_key": os.getenv("PRIVATE_KEY").replace("\\n", "\n"),
21+
"client_email": os.getenv("CLIENT_EMAIL"),
22+
"client_id": os.getenv("CLIENT_ID"),
23+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
24+
"token_uri": "https://oauth2.googleapis.com/token",
25+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
26+
"client_x509_cert_url": os.getenv("CLIENT_X509_CERT_URL"),
27+
"universe_domain": "googleapis.com",
28+
}
29+
)
30+
31+
32+
class AttendanceService:
33+
def __init__(self) -> None:
34+
if not os.getenv("SHEETS_ID"):
35+
raise ValueError("SHEETS_ID is not set")
36+
if not os.getenv("GEULTTO_SLACK_TOKEN"):
37+
raise ValueError("GEULTTO_SLACK_TOKEN is not set")
38+
39+
self.slack = SlackClient(os.getenv("GEULTTO_SLACK_TOKEN"))
40+
self.sheets = gc.open_by_key(os.getenv("SHEETS_ID"))
41+
42+
@property
43+
def sheet(self) -> Worksheet:
44+
if not hasattr(self, "sheet_title") or self.sheet_title is None:
45+
raise ValueError("sheet_title is not set")
46+
return self.sheets.worksheet(self.sheet_title)
47+
48+
@abstractmethod
49+
def check(self) -> None:
50+
pass
51+
52+
@classmethod
53+
def convert_records_to_models(
54+
cls, records: list[dict[str, object]], model: Type[T]
55+
) -> list[T]:
56+
return [
57+
model(
58+
**{
59+
k: v
60+
for k, v in record.items()
61+
if k in {f.name for f in fields(model)}
62+
}
63+
)
64+
for record in records
65+
]
66+
67+
def get_sheet_records_to(self, model: Type[T]) -> list[T]:
68+
records = self.sheet.get_all_records()
69+
return self.convert_records_to_models(records, model)
70+
71+
def update_members(self) -> None:
72+
if not hasattr(self, "slack_channel_id") or self.slack_channel_id is None:
73+
raise ValueError("slack_channel_id is not set")
74+
75+
sheet = self.sheets.worksheet("참여자")
76+
77+
sheet_records = {record["user"] for record in sheet.get_all_records()}
78+
slack_records = set(self.slack.get_conversation_members(self.slack_channel_id))
79+
80+
missing_records = slack_records - sheet_records
81+
82+
rows_to_append = [
83+
[user, self.slack.get_user_name(user)] for user in missing_records
84+
]
85+
86+
if rows_to_append:
87+
sheet.append_rows(rows_to_append)

0 commit comments

Comments
 (0)