integrations.slack.functions

integrations/slack/functions.py

  1"""
  2integrations/slack/functions.py
  3"""
  4
  5import logging
  6from typing import TYPE_CHECKING, Any, Union, cast
  7
  8import libs.global_value as g
  9from integrations.base.interface import FunctionsInterface
 10from libs.functions import lookup
 11from libs.types import ActionStatus
 12from libs.utils import validator
 13from libs.utils.timekit import Delimiter, Format
 14from libs.utils.timekit import ExtendedDatetime as ExtDt
 15
 16if TYPE_CHECKING:
 17    from slack_sdk.web import SlackResponse
 18
 19    from integrations.protocols import MessageParserProtocol
 20    from integrations.slack.adapter import ServiceAdapter
 21    from integrations.slack.api import AdapterAPI
 22    from integrations.slack.config import SvcConfig
 23
 24
 25class SvcFunctions(FunctionsInterface):
 26    """slack専用関数"""
 27
 28    def __init__(self, api: "AdapterAPI", conf: "SvcConfig"):
 29        super().__init__()
 30
 31        try:
 32            from slack_sdk.errors import SlackApiError
 33
 34            self.slack_api_error = SlackApiError
 35        except ModuleNotFoundError as err:
 36            raise ModuleNotFoundError(err.msg) from None
 37
 38        self.api = api
 39        """API操作オブジェクト"""
 40        self.conf = conf
 41        """個別設定"""
 42
 43    def get_messages(self, words: Union[str, list[str]]) -> list["MessageParserProtocol"]:
 44        """
 45        slackログからメッセージを検索して返す
 46
 47        Args:
 48            words (Union[str, list[str]]): 検索するワード
 49
 50        Returns:
 51            list[MessageParserProtocol]: 検索した結果
 52
 53        """
 54        g.adapter = cast("ServiceAdapter", g.adapter)
 55        if isinstance(words, list):
 56            words = " ".join(words)
 57
 58        # 検索クエリ
 59        after = ExtDt(days=-self.conf.search_after, hours=g.cfg.setting.time_adjust).format(Format.YMD, Delimiter.HYPHEN)
 60        channel = " ".join([f"in:{x}" for x in self.conf.search_channel])
 61        query = f"{words} {channel} after:{after}"
 62        logging.info("query=%s, check_db=%s", query, g.cfg.setting.database_file)
 63
 64        # データ取得
 65        response = self.api.webclient.search_messages(query=query, sort="timestamp", sort_dir="asc", count=100)
 66        matches = response["messages"]["matches"]  # 1ページ目
 67        for p in range(2, response["messages"]["paging"]["pages"] + 1):
 68            response = self.api.webclient.search_messages(query=query, sort="timestamp", sort_dir="asc", count=100, page=p)
 69            matches += response["messages"]["matches"]  # 2ページ目以降
 70
 71        # 必要なデータだけ辞書に格納
 72        data: list["MessageParserProtocol"] = []
 73        for x in matches:
 74            if isinstance(x, dict):
 75                work_m = cast("MessageParserProtocol", g.adapter.parser())
 76                work_m.parser(x)
 77                data.append(work_m)
 78
 79        return data
 80
 81    def get_message_details(self, matches: list["MessageParserProtocol"]) -> list["MessageParserProtocol"]:
 82        """
 83        メッセージ詳細情報取得
 84
 85        Args:
 86            matches (list[MessageParserProtocol]): 対象データ
 87
 88        Returns:
 89            list[MessageParserProtocol]: 詳細情報追加データ
 90
 91        """
 92        new_matches: list["MessageParserProtocol"] = []
 93
 94        # 詳細情報取得
 95        for key in matches:
 96            conversations = self.api.appclient.conversations_replies(channel=key.data.channel_id, ts=key.data.event_ts)
 97            if msg := conversations.get("messages"):
 98                res = cast(dict[str, Any], msg[0])
 99            else:
100                continue
101
102            if res:
103                # 各種時間取得
104                key.data.event_ts = str(res.get("ts", "0"))  # イベント発生時間
105                key.data.thread_ts = str(res.get("thread_ts", "0"))  # スレッドの先頭
106                key.data.edited_ts = str(cast(dict[str, Any], res.get("edited", {})).get("ts", "0"))  # 編集時間
107                # リアクション取得
108                key.data.reaction_ok, key.data.reaction_ng = self.get_reactions_list(res)
109
110            new_matches.append(key)
111
112        return new_matches
113
114    def get_conversations(self, m: "MessageParserProtocol") -> dict[str, Any]:
115        """
116        スレッド情報の取得
117
118        Args:
119            m (MessageParserProtocol): メッセージデータ
120
121        Returns:
122            dict[str, Any]: API response
123
124        """
125        try:
126            res = self.api.appclient.conversations_replies(channel=m.data.channel_id, ts=m.data.event_ts)
127            logging.trace(res.validate())  # type: ignore
128            return cast(dict[str, Any], res)
129        except self.slack_api_error as err:
130            logging.error("slack_api_error: %s", err)
131            return {}
132
133    def get_reactions_list(self, msg: dict[str, Any]) -> tuple[list[str], list[str]]:
134        """
135        botが付けたリアクションを取得
136
137        Args:
138            msg (dict[str, Any]): メッセージ内容
139
140        Returns:
141            tuple[list,list]:
142            - reaction_ok: okが付いているメッセージのタイムスタンプ
143            - reaction_ng: ngが付いているメッセージのタイムスタンプ
144
145        """
146        reaction_ok: list[str] = []
147        reaction_ng: list[str] = []
148
149        if msg.get("reactions"):
150            for reactions in msg.get("reactions", {}):
151                if isinstance(reactions, dict) and self.conf.bot_id in reactions.get("users", []):
152                    match reactions.get("name"):
153                        case self.conf.reaction_ok:
154                            reaction_ok.append(msg.get("ts", ""))
155                        case self.conf.reaction_ng:
156                            reaction_ng.append(msg.get("ts", ""))
157
158        return (reaction_ok, reaction_ng)
159
160    def get_channel_id(self) -> str:
161        """
162        チャンネルIDを取得する
163
164        Returns:
165            str: チャンネルID
166
167        """
168        channel_id = ""
169
170        try:
171            response = self.api.webclient.search_messages(
172                query=f"in:{self.conf.search_channel}",
173                count=1,
174            )
175            messages: dict[str, Any] = response.get("messages", {})
176            if messages.get("matches"):
177                channel = messages["matches"][0]["channel"]
178                if isinstance(self.conf.search_channel, str):
179                    if channel["name"] in self.conf.search_channel:
180                        channel_id = channel["id"]
181                else:
182                    channel_id = channel["id"]
183        except self.slack_api_error as err:
184            logging.error("slack_api_error: %s", err)
185
186        return channel_id
187
188    def get_dm_channel_id(self, user_id: str) -> str:
189        """
190        DMのチャンネルIDを取得する
191
192        Args:
193            user_id (str): DMの相手
194
195        Returns:
196            str: チャンネルID
197
198        """
199        channel_id = ""
200
201        try:
202            response = self.api.appclient.conversations_open(users=[user_id])
203            channel_id = response["channel"]["id"]
204        except self.slack_api_error as err:
205            logging.error("slack_api_error: %s", err)
206
207        return channel_id
208
209    def reaction_status(self, ch: str, ts: str) -> dict[str, list[str]]:
210        """
211        botが付けたリアクションの種類を返す
212
213        Args:
214            ch (str): チャンネルID
215            ts (str): メッセージのタイムスタンプ
216
217        Returns:
218            dict[str, list[str]]: リアクション
219            - str: "oK" or "ng"
220            - list[str]: タイムスタンプ
221
222        """
223        icon: dict[str, list[str]] = {
224            "ok": [],
225            "ng": [],
226        }
227
228        try:  # 削除済みメッセージはエラーになるので潰す
229            res = self.api.appclient.reactions_get(channel=ch, timestamp=ts)
230            logging.trace(res.validate())  # type: ignore
231        except self.slack_api_error:
232            return icon
233
234        if reactions := cast(dict[str, Any], res["message"]).get("reactions"):
235            for reaction in cast(list[dict[str, Any]], reactions):
236                if reaction.get("name") == self.conf.reaction_ok and self.conf.bot_id in reaction["users"]:
237                    icon["ok"].append(str(res["message"]["ts"]))
238                if reaction.get("name") == self.conf.reaction_ng and self.conf.bot_id in reaction["users"]:
239                    icon["ng"].append(str(res["message"]["ts"]))
240
241        logging.debug("ch=%s, ts=%s, icon=%s", ch, ts, icon)
242        return icon
243
244    def reaction_append(self, icon: str, ch: str, ts: str) -> None:
245        """
246        リアクション追加
247
248        Args:
249            icon (str): リアクション文字
250            ch (str): チャンネルID
251            ts (str): メッセージのタイムスタンプ
252
253        """
254        if not all([icon, ch, ts]):
255            logging.warning("deficiency: ts=%s, ch=%s, icon=%s", ts, ch, icon)
256            return
257
258        try:
259            res: SlackResponse = self.api.appclient.reactions_add(
260                channel=str(ch),
261                name=icon,
262                timestamp=str(ts),
263            )
264            logging.debug("ts=%s, ch=%s, icon=%s, %s", ts, ch, icon, res.validate())  # type: ignore[no-untyped-call]
265        except self.slack_api_error as err:
266            match cast(dict[str, Any], err.response).get("error"):
267                case "already_reacted":
268                    pass
269                case _:
270                    logging.error("slack_api_error: %s", err)
271                    logging.error("ts=%s, ch=%s, icon=%s", ts, ch, icon)
272
273    def reaction_remove(self, icon: str, ch: str, ts: str) -> None:
274        """
275        リアクション削除
276
277        Args:
278            icon (str): リアクション文字
279            ch (str): チャンネルID
280            ts (str): メッセージのタイムスタンプ
281
282        """
283        if not all([icon, ch, ts]):
284            logging.warning("deficiency: ts=%s, ch=%s, icon=%s", ts, ch, icon)
285            return
286
287        try:
288            res = self.api.appclient.reactions_remove(
289                channel=ch,
290                name=icon,
291                timestamp=ts,
292            )
293            logging.debug("ch=%s, ts=%s, icon=%s, %s", ch, ts, icon, res.validate())  # type: ignore[no-untyped-call]
294        except self.slack_api_error as err:
295            match cast(dict[str, Any], err.response).get("error"):
296                case "no_reaction":
297                    pass
298                case "message_not_found":
299                    pass
300                case "channel_not_found":
301                    pass
302                case _:
303                    logging.error("slack_api_error'(%s): %s", self.slack_api_error, err)
304                    logging.error("ch=%s, ts=%s, icon=%s", ch, ts, icon)
305
306    def pickup_score(self) -> list["MessageParserProtocol"]:
307        """
308        過去ログからスコア記録を検索して返す
309
310        Returns:
311            list[MessageParserProtocol]: 検索した結果
312
313        """
314        # ゲーム結果の抽出
315        score_matches: list["MessageParserProtocol"] = []
316        for keyword in g.cfg.rule.keyword_mapping.keys():
317            for match in self.get_messages(keyword):
318                if validator.check_score(match):
319                    if match.ignore_user:  # 除外ユーザからのポストは破棄
320                        logging.info("skip ignore user: %s", match.data.user_id)
321                        continue
322                    if match.data.text != match.data.text.replace(keyword, ""):  # 検索結果にキーワードが含まれているか
323                        score_matches.append(match)
324                        logging.debug("found: keyword=%s, %s", keyword, match.data)
325
326        # イベント詳細取得
327        if score_matches:
328            return self.get_message_details(score_matches)
329        return score_matches
330
331    def pickup_remarks(self) -> list["MessageParserProtocol"]:
332        """
333        slackログからメモを検索して返す
334
335        Returns:
336            list[MessageParserProtocol]: 検索した結果
337
338        """
339        remarks_matches: list["MessageParserProtocol"] = []
340
341        # メモの抽出
342        for match in self.get_messages(g.cfg.rule.remarks_words):
343            if match.ignore_user:  # 除外ユーザからのポストは破棄
344                logging.info("skip ignore user: %s", match.data.user_id)
345                continue
346            if match.keyword in g.cfg.rule.remarks_words:
347                remarks_matches.append(match)
348
349        # イベント詳細取得
350        if remarks_matches:
351            return self.get_message_details(remarks_matches)
352        return remarks_matches
353
354    def post_processing(self, m: "MessageParserProtocol") -> None:
355        """
356        後処理
357
358        Args:
359            m (MessageParserProtocol): メッセージデータ
360
361        """
362
363        # リアクション文字取得
364        def _resolve_reaction(name: str, fallback: str) -> Any:
365            section_name = f"slack_{m.data.channel_id}"
366            if channel_config := g.params.channel_config:
367                if value := lookup.get_config_value(
368                    config_file=channel_config,
369                    section=section_name,
370                    name=name,
371                    val_type=str,
372                ):
373                    return value
374
375            if value := lookup.get_config_value(
376                config_file=g.cfg.config_file,
377                section=section_name,
378                name=name,
379                val_type=str,
380            ):
381                return value
382
383            return lookup.get_config_value(
384                config_file=g.cfg.config_file,
385                section="slack",
386                name=name,
387                val_type=str,
388                fallback=fallback,
389            )
390
391        self.conf.reaction_ok = str(_resolve_reaction("reaction_ok", self.conf.reaction_ok))
392        self.conf.reaction_ng = str(_resolve_reaction("reaction_ng", self.conf.reaction_ng))
393
394        # リアクション処理
395        match m.status.action:
396            case ActionStatus.NOTHING:
397                return
398            case ActionStatus.CHANGE:
399                for ts in m.status.target_ts:
400                    reaction_data = self.reaction_status(ch=m.data.channel_id, ts=ts)
401                    if m.status.reaction:  # NGを外してOKを付ける
402                        if not reaction_data.get("ok"):
403                            self.reaction_append(icon=self.conf.reaction_ok, ch=m.data.channel_id, ts=ts)
404                        if reaction_data.get("ng"):
405                            self.reaction_remove(icon=self.conf.reaction_ng, ch=m.data.channel_id, ts=ts)
406                    else:  # OKを外してNGを付ける
407                        if reaction_data.get("ok"):
408                            self.reaction_remove(icon=self.conf.reaction_ok, ch=m.data.channel_id, ts=ts)
409                        if not reaction_data.get("ng"):
410                            self.reaction_append(icon=self.conf.reaction_ng, ch=m.data.channel_id, ts=ts)
411            case ActionStatus.DELETE:
412                for ts in m.status.target_ts:
413                    self.reaction_remove(icon=self.conf.reaction_ok, ch=m.data.channel_id, ts=ts)
414                    self.reaction_remove(icon=self.conf.reaction_ng, ch=m.data.channel_id, ts=ts)
415                m.status.reset()
class SvcFunctions(integrations.base.interface.FunctionsInterface):
 26class SvcFunctions(FunctionsInterface):
 27    """slack専用関数"""
 28
 29    def __init__(self, api: "AdapterAPI", conf: "SvcConfig"):
 30        super().__init__()
 31
 32        try:
 33            from slack_sdk.errors import SlackApiError
 34
 35            self.slack_api_error = SlackApiError
 36        except ModuleNotFoundError as err:
 37            raise ModuleNotFoundError(err.msg) from None
 38
 39        self.api = api
 40        """API操作オブジェクト"""
 41        self.conf = conf
 42        """個別設定"""
 43
 44    def get_messages(self, words: Union[str, list[str]]) -> list["MessageParserProtocol"]:
 45        """
 46        slackログからメッセージを検索して返す
 47
 48        Args:
 49            words (Union[str, list[str]]): 検索するワード
 50
 51        Returns:
 52            list[MessageParserProtocol]: 検索した結果
 53
 54        """
 55        g.adapter = cast("ServiceAdapter", g.adapter)
 56        if isinstance(words, list):
 57            words = " ".join(words)
 58
 59        # 検索クエリ
 60        after = ExtDt(days=-self.conf.search_after, hours=g.cfg.setting.time_adjust).format(Format.YMD, Delimiter.HYPHEN)
 61        channel = " ".join([f"in:{x}" for x in self.conf.search_channel])
 62        query = f"{words} {channel} after:{after}"
 63        logging.info("query=%s, check_db=%s", query, g.cfg.setting.database_file)
 64
 65        # データ取得
 66        response = self.api.webclient.search_messages(query=query, sort="timestamp", sort_dir="asc", count=100)
 67        matches = response["messages"]["matches"]  # 1ページ目
 68        for p in range(2, response["messages"]["paging"]["pages"] + 1):
 69            response = self.api.webclient.search_messages(query=query, sort="timestamp", sort_dir="asc", count=100, page=p)
 70            matches += response["messages"]["matches"]  # 2ページ目以降
 71
 72        # 必要なデータだけ辞書に格納
 73        data: list["MessageParserProtocol"] = []
 74        for x in matches:
 75            if isinstance(x, dict):
 76                work_m = cast("MessageParserProtocol", g.adapter.parser())
 77                work_m.parser(x)
 78                data.append(work_m)
 79
 80        return data
 81
 82    def get_message_details(self, matches: list["MessageParserProtocol"]) -> list["MessageParserProtocol"]:
 83        """
 84        メッセージ詳細情報取得
 85
 86        Args:
 87            matches (list[MessageParserProtocol]): 対象データ
 88
 89        Returns:
 90            list[MessageParserProtocol]: 詳細情報追加データ
 91
 92        """
 93        new_matches: list["MessageParserProtocol"] = []
 94
 95        # 詳細情報取得
 96        for key in matches:
 97            conversations = self.api.appclient.conversations_replies(channel=key.data.channel_id, ts=key.data.event_ts)
 98            if msg := conversations.get("messages"):
 99                res = cast(dict[str, Any], msg[0])
100            else:
101                continue
102
103            if res:
104                # 各種時間取得
105                key.data.event_ts = str(res.get("ts", "0"))  # イベント発生時間
106                key.data.thread_ts = str(res.get("thread_ts", "0"))  # スレッドの先頭
107                key.data.edited_ts = str(cast(dict[str, Any], res.get("edited", {})).get("ts", "0"))  # 編集時間
108                # リアクション取得
109                key.data.reaction_ok, key.data.reaction_ng = self.get_reactions_list(res)
110
111            new_matches.append(key)
112
113        return new_matches
114
115    def get_conversations(self, m: "MessageParserProtocol") -> dict[str, Any]:
116        """
117        スレッド情報の取得
118
119        Args:
120            m (MessageParserProtocol): メッセージデータ
121
122        Returns:
123            dict[str, Any]: API response
124
125        """
126        try:
127            res = self.api.appclient.conversations_replies(channel=m.data.channel_id, ts=m.data.event_ts)
128            logging.trace(res.validate())  # type: ignore
129            return cast(dict[str, Any], res)
130        except self.slack_api_error as err:
131            logging.error("slack_api_error: %s", err)
132            return {}
133
134    def get_reactions_list(self, msg: dict[str, Any]) -> tuple[list[str], list[str]]:
135        """
136        botが付けたリアクションを取得
137
138        Args:
139            msg (dict[str, Any]): メッセージ内容
140
141        Returns:
142            tuple[list,list]:
143            - reaction_ok: okが付いているメッセージのタイムスタンプ
144            - reaction_ng: ngが付いているメッセージのタイムスタンプ
145
146        """
147        reaction_ok: list[str] = []
148        reaction_ng: list[str] = []
149
150        if msg.get("reactions"):
151            for reactions in msg.get("reactions", {}):
152                if isinstance(reactions, dict) and self.conf.bot_id in reactions.get("users", []):
153                    match reactions.get("name"):
154                        case self.conf.reaction_ok:
155                            reaction_ok.append(msg.get("ts", ""))
156                        case self.conf.reaction_ng:
157                            reaction_ng.append(msg.get("ts", ""))
158
159        return (reaction_ok, reaction_ng)
160
161    def get_channel_id(self) -> str:
162        """
163        チャンネルIDを取得する
164
165        Returns:
166            str: チャンネルID
167
168        """
169        channel_id = ""
170
171        try:
172            response = self.api.webclient.search_messages(
173                query=f"in:{self.conf.search_channel}",
174                count=1,
175            )
176            messages: dict[str, Any] = response.get("messages", {})
177            if messages.get("matches"):
178                channel = messages["matches"][0]["channel"]
179                if isinstance(self.conf.search_channel, str):
180                    if channel["name"] in self.conf.search_channel:
181                        channel_id = channel["id"]
182                else:
183                    channel_id = channel["id"]
184        except self.slack_api_error as err:
185            logging.error("slack_api_error: %s", err)
186
187        return channel_id
188
189    def get_dm_channel_id(self, user_id: str) -> str:
190        """
191        DMのチャンネルIDを取得する
192
193        Args:
194            user_id (str): DMの相手
195
196        Returns:
197            str: チャンネルID
198
199        """
200        channel_id = ""
201
202        try:
203            response = self.api.appclient.conversations_open(users=[user_id])
204            channel_id = response["channel"]["id"]
205        except self.slack_api_error as err:
206            logging.error("slack_api_error: %s", err)
207
208        return channel_id
209
210    def reaction_status(self, ch: str, ts: str) -> dict[str, list[str]]:
211        """
212        botが付けたリアクションの種類を返す
213
214        Args:
215            ch (str): チャンネルID
216            ts (str): メッセージのタイムスタンプ
217
218        Returns:
219            dict[str, list[str]]: リアクション
220            - str: "oK" or "ng"
221            - list[str]: タイムスタンプ
222
223        """
224        icon: dict[str, list[str]] = {
225            "ok": [],
226            "ng": [],
227        }
228
229        try:  # 削除済みメッセージはエラーになるので潰す
230            res = self.api.appclient.reactions_get(channel=ch, timestamp=ts)
231            logging.trace(res.validate())  # type: ignore
232        except self.slack_api_error:
233            return icon
234
235        if reactions := cast(dict[str, Any], res["message"]).get("reactions"):
236            for reaction in cast(list[dict[str, Any]], reactions):
237                if reaction.get("name") == self.conf.reaction_ok and self.conf.bot_id in reaction["users"]:
238                    icon["ok"].append(str(res["message"]["ts"]))
239                if reaction.get("name") == self.conf.reaction_ng and self.conf.bot_id in reaction["users"]:
240                    icon["ng"].append(str(res["message"]["ts"]))
241
242        logging.debug("ch=%s, ts=%s, icon=%s", ch, ts, icon)
243        return icon
244
245    def reaction_append(self, icon: str, ch: str, ts: str) -> None:
246        """
247        リアクション追加
248
249        Args:
250            icon (str): リアクション文字
251            ch (str): チャンネルID
252            ts (str): メッセージのタイムスタンプ
253
254        """
255        if not all([icon, ch, ts]):
256            logging.warning("deficiency: ts=%s, ch=%s, icon=%s", ts, ch, icon)
257            return
258
259        try:
260            res: SlackResponse = self.api.appclient.reactions_add(
261                channel=str(ch),
262                name=icon,
263                timestamp=str(ts),
264            )
265            logging.debug("ts=%s, ch=%s, icon=%s, %s", ts, ch, icon, res.validate())  # type: ignore[no-untyped-call]
266        except self.slack_api_error as err:
267            match cast(dict[str, Any], err.response).get("error"):
268                case "already_reacted":
269                    pass
270                case _:
271                    logging.error("slack_api_error: %s", err)
272                    logging.error("ts=%s, ch=%s, icon=%s", ts, ch, icon)
273
274    def reaction_remove(self, icon: str, ch: str, ts: str) -> None:
275        """
276        リアクション削除
277
278        Args:
279            icon (str): リアクション文字
280            ch (str): チャンネルID
281            ts (str): メッセージのタイムスタンプ
282
283        """
284        if not all([icon, ch, ts]):
285            logging.warning("deficiency: ts=%s, ch=%s, icon=%s", ts, ch, icon)
286            return
287
288        try:
289            res = self.api.appclient.reactions_remove(
290                channel=ch,
291                name=icon,
292                timestamp=ts,
293            )
294            logging.debug("ch=%s, ts=%s, icon=%s, %s", ch, ts, icon, res.validate())  # type: ignore[no-untyped-call]
295        except self.slack_api_error as err:
296            match cast(dict[str, Any], err.response).get("error"):
297                case "no_reaction":
298                    pass
299                case "message_not_found":
300                    pass
301                case "channel_not_found":
302                    pass
303                case _:
304                    logging.error("slack_api_error'(%s): %s", self.slack_api_error, err)
305                    logging.error("ch=%s, ts=%s, icon=%s", ch, ts, icon)
306
307    def pickup_score(self) -> list["MessageParserProtocol"]:
308        """
309        過去ログからスコア記録を検索して返す
310
311        Returns:
312            list[MessageParserProtocol]: 検索した結果
313
314        """
315        # ゲーム結果の抽出
316        score_matches: list["MessageParserProtocol"] = []
317        for keyword in g.cfg.rule.keyword_mapping.keys():
318            for match in self.get_messages(keyword):
319                if validator.check_score(match):
320                    if match.ignore_user:  # 除外ユーザからのポストは破棄
321                        logging.info("skip ignore user: %s", match.data.user_id)
322                        continue
323                    if match.data.text != match.data.text.replace(keyword, ""):  # 検索結果にキーワードが含まれているか
324                        score_matches.append(match)
325                        logging.debug("found: keyword=%s, %s", keyword, match.data)
326
327        # イベント詳細取得
328        if score_matches:
329            return self.get_message_details(score_matches)
330        return score_matches
331
332    def pickup_remarks(self) -> list["MessageParserProtocol"]:
333        """
334        slackログからメモを検索して返す
335
336        Returns:
337            list[MessageParserProtocol]: 検索した結果
338
339        """
340        remarks_matches: list["MessageParserProtocol"] = []
341
342        # メモの抽出
343        for match in self.get_messages(g.cfg.rule.remarks_words):
344            if match.ignore_user:  # 除外ユーザからのポストは破棄
345                logging.info("skip ignore user: %s", match.data.user_id)
346                continue
347            if match.keyword in g.cfg.rule.remarks_words:
348                remarks_matches.append(match)
349
350        # イベント詳細取得
351        if remarks_matches:
352            return self.get_message_details(remarks_matches)
353        return remarks_matches
354
355    def post_processing(self, m: "MessageParserProtocol") -> None:
356        """
357        後処理
358
359        Args:
360            m (MessageParserProtocol): メッセージデータ
361
362        """
363
364        # リアクション文字取得
365        def _resolve_reaction(name: str, fallback: str) -> Any:
366            section_name = f"slack_{m.data.channel_id}"
367            if channel_config := g.params.channel_config:
368                if value := lookup.get_config_value(
369                    config_file=channel_config,
370                    section=section_name,
371                    name=name,
372                    val_type=str,
373                ):
374                    return value
375
376            if value := lookup.get_config_value(
377                config_file=g.cfg.config_file,
378                section=section_name,
379                name=name,
380                val_type=str,
381            ):
382                return value
383
384            return lookup.get_config_value(
385                config_file=g.cfg.config_file,
386                section="slack",
387                name=name,
388                val_type=str,
389                fallback=fallback,
390            )
391
392        self.conf.reaction_ok = str(_resolve_reaction("reaction_ok", self.conf.reaction_ok))
393        self.conf.reaction_ng = str(_resolve_reaction("reaction_ng", self.conf.reaction_ng))
394
395        # リアクション処理
396        match m.status.action:
397            case ActionStatus.NOTHING:
398                return
399            case ActionStatus.CHANGE:
400                for ts in m.status.target_ts:
401                    reaction_data = self.reaction_status(ch=m.data.channel_id, ts=ts)
402                    if m.status.reaction:  # NGを外してOKを付ける
403                        if not reaction_data.get("ok"):
404                            self.reaction_append(icon=self.conf.reaction_ok, ch=m.data.channel_id, ts=ts)
405                        if reaction_data.get("ng"):
406                            self.reaction_remove(icon=self.conf.reaction_ng, ch=m.data.channel_id, ts=ts)
407                    else:  # OKを外してNGを付ける
408                        if reaction_data.get("ok"):
409                            self.reaction_remove(icon=self.conf.reaction_ok, ch=m.data.channel_id, ts=ts)
410                        if not reaction_data.get("ng"):
411                            self.reaction_append(icon=self.conf.reaction_ng, ch=m.data.channel_id, ts=ts)
412            case ActionStatus.DELETE:
413                for ts in m.status.target_ts:
414                    self.reaction_remove(icon=self.conf.reaction_ok, ch=m.data.channel_id, ts=ts)
415                    self.reaction_remove(icon=self.conf.reaction_ng, ch=m.data.channel_id, ts=ts)
416                m.status.reset()

slack専用関数

29    def __init__(self, api: "AdapterAPI", conf: "SvcConfig"):
30        super().__init__()
31
32        try:
33            from slack_sdk.errors import SlackApiError
34
35            self.slack_api_error = SlackApiError
36        except ModuleNotFoundError as err:
37            raise ModuleNotFoundError(err.msg) from None
38
39        self.api = api
40        """API操作オブジェクト"""
41        self.conf = conf
42        """個別設定"""
api

API操作オブジェクト

conf

個別設定

def get_messages( self, words: str | list[str]) -> list[integrations.protocols.MessageParserProtocol]:
44    def get_messages(self, words: Union[str, list[str]]) -> list["MessageParserProtocol"]:
45        """
46        slackログからメッセージを検索して返す
47
48        Args:
49            words (Union[str, list[str]]): 検索するワード
50
51        Returns:
52            list[MessageParserProtocol]: 検索した結果
53
54        """
55        g.adapter = cast("ServiceAdapter", g.adapter)
56        if isinstance(words, list):
57            words = " ".join(words)
58
59        # 検索クエリ
60        after = ExtDt(days=-self.conf.search_after, hours=g.cfg.setting.time_adjust).format(Format.YMD, Delimiter.HYPHEN)
61        channel = " ".join([f"in:{x}" for x in self.conf.search_channel])
62        query = f"{words} {channel} after:{after}"
63        logging.info("query=%s, check_db=%s", query, g.cfg.setting.database_file)
64
65        # データ取得
66        response = self.api.webclient.search_messages(query=query, sort="timestamp", sort_dir="asc", count=100)
67        matches = response["messages"]["matches"]  # 1ページ目
68        for p in range(2, response["messages"]["paging"]["pages"] + 1):
69            response = self.api.webclient.search_messages(query=query, sort="timestamp", sort_dir="asc", count=100, page=p)
70            matches += response["messages"]["matches"]  # 2ページ目以降
71
72        # 必要なデータだけ辞書に格納
73        data: list["MessageParserProtocol"] = []
74        for x in matches:
75            if isinstance(x, dict):
76                work_m = cast("MessageParserProtocol", g.adapter.parser())
77                work_m.parser(x)
78                data.append(work_m)
79
80        return data

slackログからメッセージを検索して返す

Arguments:
  • words (Union[str, list[str]]): 検索するワード
Returns:

list[MessageParserProtocol]: 検索した結果

def get_message_details( self, matches: list[integrations.protocols.MessageParserProtocol]) -> list[integrations.protocols.MessageParserProtocol]:
 82    def get_message_details(self, matches: list["MessageParserProtocol"]) -> list["MessageParserProtocol"]:
 83        """
 84        メッセージ詳細情報取得
 85
 86        Args:
 87            matches (list[MessageParserProtocol]): 対象データ
 88
 89        Returns:
 90            list[MessageParserProtocol]: 詳細情報追加データ
 91
 92        """
 93        new_matches: list["MessageParserProtocol"] = []
 94
 95        # 詳細情報取得
 96        for key in matches:
 97            conversations = self.api.appclient.conversations_replies(channel=key.data.channel_id, ts=key.data.event_ts)
 98            if msg := conversations.get("messages"):
 99                res = cast(dict[str, Any], msg[0])
100            else:
101                continue
102
103            if res:
104                # 各種時間取得
105                key.data.event_ts = str(res.get("ts", "0"))  # イベント発生時間
106                key.data.thread_ts = str(res.get("thread_ts", "0"))  # スレッドの先頭
107                key.data.edited_ts = str(cast(dict[str, Any], res.get("edited", {})).get("ts", "0"))  # 編集時間
108                # リアクション取得
109                key.data.reaction_ok, key.data.reaction_ng = self.get_reactions_list(res)
110
111            new_matches.append(key)
112
113        return new_matches

メッセージ詳細情報取得

Arguments:
  • matches (list[MessageParserProtocol]): 対象データ
Returns:

list[MessageParserProtocol]: 詳細情報追加データ

def get_conversations( self, m: integrations.protocols.MessageParserProtocol) -> dict[str, typing.Any]:
115    def get_conversations(self, m: "MessageParserProtocol") -> dict[str, Any]:
116        """
117        スレッド情報の取得
118
119        Args:
120            m (MessageParserProtocol): メッセージデータ
121
122        Returns:
123            dict[str, Any]: API response
124
125        """
126        try:
127            res = self.api.appclient.conversations_replies(channel=m.data.channel_id, ts=m.data.event_ts)
128            logging.trace(res.validate())  # type: ignore
129            return cast(dict[str, Any], res)
130        except self.slack_api_error as err:
131            logging.error("slack_api_error: %s", err)
132            return {}

スレッド情報の取得

Arguments:
  • m (MessageParserProtocol): メッセージデータ
Returns:

dict[str, Any]: API response

def get_reactions_list(self, msg: dict[str, typing.Any]) -> tuple[list[str], list[str]]:
134    def get_reactions_list(self, msg: dict[str, Any]) -> tuple[list[str], list[str]]:
135        """
136        botが付けたリアクションを取得
137
138        Args:
139            msg (dict[str, Any]): メッセージ内容
140
141        Returns:
142            tuple[list,list]:
143            - reaction_ok: okが付いているメッセージのタイムスタンプ
144            - reaction_ng: ngが付いているメッセージのタイムスタンプ
145
146        """
147        reaction_ok: list[str] = []
148        reaction_ng: list[str] = []
149
150        if msg.get("reactions"):
151            for reactions in msg.get("reactions", {}):
152                if isinstance(reactions, dict) and self.conf.bot_id in reactions.get("users", []):
153                    match reactions.get("name"):
154                        case self.conf.reaction_ok:
155                            reaction_ok.append(msg.get("ts", ""))
156                        case self.conf.reaction_ng:
157                            reaction_ng.append(msg.get("ts", ""))
158
159        return (reaction_ok, reaction_ng)

botが付けたリアクションを取得

Arguments:
  • msg (dict[str, Any]): メッセージ内容
Returns:

tuple[list,list]:

  • reaction_ok: okが付いているメッセージのタイムスタンプ
  • reaction_ng: ngが付いているメッセージのタイムスタンプ
def get_channel_id(self) -> str:
161    def get_channel_id(self) -> str:
162        """
163        チャンネルIDを取得する
164
165        Returns:
166            str: チャンネルID
167
168        """
169        channel_id = ""
170
171        try:
172            response = self.api.webclient.search_messages(
173                query=f"in:{self.conf.search_channel}",
174                count=1,
175            )
176            messages: dict[str, Any] = response.get("messages", {})
177            if messages.get("matches"):
178                channel = messages["matches"][0]["channel"]
179                if isinstance(self.conf.search_channel, str):
180                    if channel["name"] in self.conf.search_channel:
181                        channel_id = channel["id"]
182                else:
183                    channel_id = channel["id"]
184        except self.slack_api_error as err:
185            logging.error("slack_api_error: %s", err)
186
187        return channel_id

チャンネルIDを取得する

Returns:

str: チャンネルID

def get_dm_channel_id(self, user_id: str) -> str:
189    def get_dm_channel_id(self, user_id: str) -> str:
190        """
191        DMのチャンネルIDを取得する
192
193        Args:
194            user_id (str): DMの相手
195
196        Returns:
197            str: チャンネルID
198
199        """
200        channel_id = ""
201
202        try:
203            response = self.api.appclient.conversations_open(users=[user_id])
204            channel_id = response["channel"]["id"]
205        except self.slack_api_error as err:
206            logging.error("slack_api_error: %s", err)
207
208        return channel_id

DMのチャンネルIDを取得する

Arguments:
  • user_id (str): DMの相手
Returns:

str: チャンネルID

def reaction_status(self, ch: str, ts: str) -> dict[str, list[str]]:
210    def reaction_status(self, ch: str, ts: str) -> dict[str, list[str]]:
211        """
212        botが付けたリアクションの種類を返す
213
214        Args:
215            ch (str): チャンネルID
216            ts (str): メッセージのタイムスタンプ
217
218        Returns:
219            dict[str, list[str]]: リアクション
220            - str: "oK" or "ng"
221            - list[str]: タイムスタンプ
222
223        """
224        icon: dict[str, list[str]] = {
225            "ok": [],
226            "ng": [],
227        }
228
229        try:  # 削除済みメッセージはエラーになるので潰す
230            res = self.api.appclient.reactions_get(channel=ch, timestamp=ts)
231            logging.trace(res.validate())  # type: ignore
232        except self.slack_api_error:
233            return icon
234
235        if reactions := cast(dict[str, Any], res["message"]).get("reactions"):
236            for reaction in cast(list[dict[str, Any]], reactions):
237                if reaction.get("name") == self.conf.reaction_ok and self.conf.bot_id in reaction["users"]:
238                    icon["ok"].append(str(res["message"]["ts"]))
239                if reaction.get("name") == self.conf.reaction_ng and self.conf.bot_id in reaction["users"]:
240                    icon["ng"].append(str(res["message"]["ts"]))
241
242        logging.debug("ch=%s, ts=%s, icon=%s", ch, ts, icon)
243        return icon

botが付けたリアクションの種類を返す

Arguments:
  • ch (str): チャンネルID
  • ts (str): メッセージのタイムスタンプ
Returns:

dict[str, list[str]]: リアクション

  • str: "oK" or "ng"
  • list[str]: タイムスタンプ
def reaction_append(self, icon: str, ch: str, ts: str) -> None:
245    def reaction_append(self, icon: str, ch: str, ts: str) -> None:
246        """
247        リアクション追加
248
249        Args:
250            icon (str): リアクション文字
251            ch (str): チャンネルID
252            ts (str): メッセージのタイムスタンプ
253
254        """
255        if not all([icon, ch, ts]):
256            logging.warning("deficiency: ts=%s, ch=%s, icon=%s", ts, ch, icon)
257            return
258
259        try:
260            res: SlackResponse = self.api.appclient.reactions_add(
261                channel=str(ch),
262                name=icon,
263                timestamp=str(ts),
264            )
265            logging.debug("ts=%s, ch=%s, icon=%s, %s", ts, ch, icon, res.validate())  # type: ignore[no-untyped-call]
266        except self.slack_api_error as err:
267            match cast(dict[str, Any], err.response).get("error"):
268                case "already_reacted":
269                    pass
270                case _:
271                    logging.error("slack_api_error: %s", err)
272                    logging.error("ts=%s, ch=%s, icon=%s", ts, ch, icon)

リアクション追加

Arguments:
  • icon (str): リアクション文字
  • ch (str): チャンネルID
  • ts (str): メッセージのタイムスタンプ
def reaction_remove(self, icon: str, ch: str, ts: str) -> None:
274    def reaction_remove(self, icon: str, ch: str, ts: str) -> None:
275        """
276        リアクション削除
277
278        Args:
279            icon (str): リアクション文字
280            ch (str): チャンネルID
281            ts (str): メッセージのタイムスタンプ
282
283        """
284        if not all([icon, ch, ts]):
285            logging.warning("deficiency: ts=%s, ch=%s, icon=%s", ts, ch, icon)
286            return
287
288        try:
289            res = self.api.appclient.reactions_remove(
290                channel=ch,
291                name=icon,
292                timestamp=ts,
293            )
294            logging.debug("ch=%s, ts=%s, icon=%s, %s", ch, ts, icon, res.validate())  # type: ignore[no-untyped-call]
295        except self.slack_api_error as err:
296            match cast(dict[str, Any], err.response).get("error"):
297                case "no_reaction":
298                    pass
299                case "message_not_found":
300                    pass
301                case "channel_not_found":
302                    pass
303                case _:
304                    logging.error("slack_api_error'(%s): %s", self.slack_api_error, err)
305                    logging.error("ch=%s, ts=%s, icon=%s", ch, ts, icon)

リアクション削除

Arguments:
  • icon (str): リアクション文字
  • ch (str): チャンネルID
  • ts (str): メッセージのタイムスタンプ
def pickup_score(self) -> list[integrations.protocols.MessageParserProtocol]:
307    def pickup_score(self) -> list["MessageParserProtocol"]:
308        """
309        過去ログからスコア記録を検索して返す
310
311        Returns:
312            list[MessageParserProtocol]: 検索した結果
313
314        """
315        # ゲーム結果の抽出
316        score_matches: list["MessageParserProtocol"] = []
317        for keyword in g.cfg.rule.keyword_mapping.keys():
318            for match in self.get_messages(keyword):
319                if validator.check_score(match):
320                    if match.ignore_user:  # 除外ユーザからのポストは破棄
321                        logging.info("skip ignore user: %s", match.data.user_id)
322                        continue
323                    if match.data.text != match.data.text.replace(keyword, ""):  # 検索結果にキーワードが含まれているか
324                        score_matches.append(match)
325                        logging.debug("found: keyword=%s, %s", keyword, match.data)
326
327        # イベント詳細取得
328        if score_matches:
329            return self.get_message_details(score_matches)
330        return score_matches

過去ログからスコア記録を検索して返す

Returns:

list[MessageParserProtocol]: 検索した結果

def pickup_remarks(self) -> list[integrations.protocols.MessageParserProtocol]:
332    def pickup_remarks(self) -> list["MessageParserProtocol"]:
333        """
334        slackログからメモを検索して返す
335
336        Returns:
337            list[MessageParserProtocol]: 検索した結果
338
339        """
340        remarks_matches: list["MessageParserProtocol"] = []
341
342        # メモの抽出
343        for match in self.get_messages(g.cfg.rule.remarks_words):
344            if match.ignore_user:  # 除外ユーザからのポストは破棄
345                logging.info("skip ignore user: %s", match.data.user_id)
346                continue
347            if match.keyword in g.cfg.rule.remarks_words:
348                remarks_matches.append(match)
349
350        # イベント詳細取得
351        if remarks_matches:
352            return self.get_message_details(remarks_matches)
353        return remarks_matches

slackログからメモを検索して返す

Returns:

list[MessageParserProtocol]: 検索した結果

def post_processing(self, m: integrations.protocols.MessageParserProtocol) -> None:
355    def post_processing(self, m: "MessageParserProtocol") -> None:
356        """
357        後処理
358
359        Args:
360            m (MessageParserProtocol): メッセージデータ
361
362        """
363
364        # リアクション文字取得
365        def _resolve_reaction(name: str, fallback: str) -> Any:
366            section_name = f"slack_{m.data.channel_id}"
367            if channel_config := g.params.channel_config:
368                if value := lookup.get_config_value(
369                    config_file=channel_config,
370                    section=section_name,
371                    name=name,
372                    val_type=str,
373                ):
374                    return value
375
376            if value := lookup.get_config_value(
377                config_file=g.cfg.config_file,
378                section=section_name,
379                name=name,
380                val_type=str,
381            ):
382                return value
383
384            return lookup.get_config_value(
385                config_file=g.cfg.config_file,
386                section="slack",
387                name=name,
388                val_type=str,
389                fallback=fallback,
390            )
391
392        self.conf.reaction_ok = str(_resolve_reaction("reaction_ok", self.conf.reaction_ok))
393        self.conf.reaction_ng = str(_resolve_reaction("reaction_ng", self.conf.reaction_ng))
394
395        # リアクション処理
396        match m.status.action:
397            case ActionStatus.NOTHING:
398                return
399            case ActionStatus.CHANGE:
400                for ts in m.status.target_ts:
401                    reaction_data = self.reaction_status(ch=m.data.channel_id, ts=ts)
402                    if m.status.reaction:  # NGを外してOKを付ける
403                        if not reaction_data.get("ok"):
404                            self.reaction_append(icon=self.conf.reaction_ok, ch=m.data.channel_id, ts=ts)
405                        if reaction_data.get("ng"):
406                            self.reaction_remove(icon=self.conf.reaction_ng, ch=m.data.channel_id, ts=ts)
407                    else:  # OKを外してNGを付ける
408                        if reaction_data.get("ok"):
409                            self.reaction_remove(icon=self.conf.reaction_ok, ch=m.data.channel_id, ts=ts)
410                        if not reaction_data.get("ng"):
411                            self.reaction_append(icon=self.conf.reaction_ng, ch=m.data.channel_id, ts=ts)
412            case ActionStatus.DELETE:
413                for ts in m.status.target_ts:
414                    self.reaction_remove(icon=self.conf.reaction_ok, ch=m.data.channel_id, ts=ts)
415                    self.reaction_remove(icon=self.conf.reaction_ng, ch=m.data.channel_id, ts=ts)
416                m.status.reset()

後処理

Arguments:
  • m (MessageParserProtocol): メッセージデータ